diff --git a/CoreAILabCore/AppleModels/Diffusion/AppleDiffusionPipelineEngine.swift b/CoreAILabCore/AppleModels/Diffusion/AppleDiffusionPipelineEngine.swift index 0507794..29f3011 100644 --- a/CoreAILabCore/AppleModels/Diffusion/AppleDiffusionPipelineEngine.swift +++ b/CoreAILabCore/AppleModels/Diffusion/AppleDiffusionPipelineEngine.swift @@ -2,6 +2,9 @@ import CoreAIDiffusionPipeline import Foundation actor AppleDiffusionPipelineEngine: AppleDiffusionGenerating { + // Safe: all stored properties are immutable after init, and the class is + // only stored on this actor; deinit merely balances the security-scoped + // access started in init, so there is no shared mutable state to race on. private final class ScopedResourceLease: @unchecked Sendable { let url: URL private let isAccessing: Bool diff --git a/CoreAILabCore/AppleModels/ObjectDetection/AppleObjectDetectorEngine.swift b/CoreAILabCore/AppleModels/ObjectDetection/AppleObjectDetectorEngine.swift index 7c4e37f..6b023ab 100644 --- a/CoreAILabCore/AppleModels/ObjectDetection/AppleObjectDetectorEngine.swift +++ b/CoreAILabCore/AppleModels/ObjectDetection/AppleObjectDetectorEngine.swift @@ -37,9 +37,11 @@ actor AppleObjectDetectorEngine: AppleObjectDetecting { } } -// ObjectDetector is a value wrapper around Core AI runtime handles, and its -// inference function supports concurrent calls. Keep that unchecked boundary -// local instead of adding a retroactive conformance to Apple's public type. +// Safe: ObjectDetector is a value wrapper around Core AI runtime handles, and +// its inference function supports concurrent calls; instances are additionally +// confined to AppleObjectDetectorEngine's actor-isolated methods. Keep that +// unchecked boundary local instead of adding a retroactive conformance to +// Apple's public type. private struct SendableObjectDetector: @unchecked Sendable { let value: ObjectDetector } diff --git a/CoreAILabCore/AppleModels/Segmentation/AppleImageSegmenterEngine.swift b/CoreAILabCore/AppleModels/Segmentation/AppleImageSegmenterEngine.swift index 0ae7e9c..9e134e8 100644 --- a/CoreAILabCore/AppleModels/Segmentation/AppleImageSegmenterEngine.swift +++ b/CoreAILabCore/AppleModels/Segmentation/AppleImageSegmenterEngine.swift @@ -72,6 +72,9 @@ actor AppleImageSegmenterEngine: AppleImageSegmenting { } } +// Safe: ImageSegmenter is not Sendable, but every instance is created and +// queried exclusively from AppleImageSegmenterEngine's actor-isolated methods, +// so the wrapped value never crosses an isolation boundary concurrently. private struct SendableImageSegmenter: @unchecked Sendable { let value: ImageSegmenter } diff --git a/CoreAILabCore/Chatterbox/ChatterboxSampling.swift b/CoreAILabCore/Chatterbox/ChatterboxSampling.swift index 2122e04..33c7f2a 100644 --- a/CoreAILabCore/Chatterbox/ChatterboxSampling.swift +++ b/CoreAILabCore/Chatterbox/ChatterboxSampling.swift @@ -1,37 +1,62 @@ import Foundation +import os +/// A deterministic SplitMix64-based generator whose mutable state is guarded by +/// an unfair lock, which is what makes the `@unchecked Sendable` conformance +/// sound. Draw order still determines the sequence, so callers that need +/// reproducibility must serialize their draws (the Chatterbox engine does, by +/// confining each generator to a single generation pass). final class ChatterboxRandomGenerator: @unchecked Sendable, RandomNumberGenerator { - private var state: UInt64 - private var spareNormal: Double? + private struct State { + var state: UInt64 + var spareNormal: Double? + } + + private let protectedState: OSAllocatedUnfairLock init(seed: UInt64) { - state = seed + protectedState = OSAllocatedUnfairLock( + initialState: State(state: seed, spareNormal: nil) + ) } func next() -> UInt64 { - state &+= 0x9E3779B97F4A7C15 - var value = state - value = (value ^ (value >> 30)) &* 0xBF58476D1CE4E5B9 - value = (value ^ (value >> 27)) &* 0x94D049BB133111EB - return value ^ (value >> 31) + protectedState.withLock { Self.nextValue(&$0) } } func nextUnitDouble() -> Double { - Double(next() >> 11) * 0x1.0p-53 + protectedState.withLock { Self.nextUnitDouble(&$0) } } func nextNormal() -> Double { - if let spareNormal { - self.spareNormal = nil - return spareNormal + protectedState.withLock { state in + if let spareNormal = state.spareNormal { + state.spareNormal = nil + return spareNormal + } + + let first = max( + Self.nextUnitDouble(&state), + Double.leastNonzeroMagnitude + ) + let second = Self.nextUnitDouble(&state) + let magnitude = sqrt(-2 * log(first)) + let angle = 2 * Double.pi * second + state.spareNormal = magnitude * sin(angle) + return magnitude * cos(angle) } + } + + private static func nextValue(_ state: inout State) -> UInt64 { + state.state &+= 0x9E3779B97F4A7C15 + var value = state.state + value = (value ^ (value >> 30)) &* 0xBF58476D1CE4E5B9 + value = (value ^ (value >> 27)) &* 0x94D049BB133111EB + return value ^ (value >> 31) + } - let first = max(nextUnitDouble(), Double.leastNonzeroMagnitude) - let second = nextUnitDouble() - let magnitude = sqrt(-2 * log(first)) - let angle = 2 * Double.pi * second - spareNormal = magnitude * sin(angle) - return magnitude * cos(angle) + private static func nextUnitDouble(_ state: inout State) -> Double { + Double(nextValue(&state) >> 11) * 0x1.0p-53 } } diff --git a/CoreAILabCore/Conversion/CoreAIConversionProcessRunner.swift b/CoreAILabCore/Conversion/CoreAIConversionProcessRunner.swift index 317ac35..e954d59 100644 --- a/CoreAILabCore/Conversion/CoreAIConversionProcessRunner.swift +++ b/CoreAILabCore/Conversion/CoreAIConversionProcessRunner.swift @@ -126,14 +126,38 @@ actor CoreAIConversionProcessRunner { private func stop(_ process: Process) async { guard process.isRunning else { return } + process.interrupt() - try? await Task.sleep(for: .seconds(1)) - if process.isRunning { - process.terminate() - } - if process.isRunning { - process.waitUntilExit() + if await waitForExit(of: process, within: .seconds(1)) { return } + + process.terminate() + if await waitForExit(of: process, within: .seconds(1)) { return } + + kill(process.processIdentifier, SIGKILL) + _ = await waitForExit(of: process, within: .seconds(1)) + } + + /// Waits for the process to exit without blocking the actor. Polling is + /// used because `execute` already owns the process's `terminationHandler`, + /// so installing a second handler here would break the termination stream. + /// Returns `false` as soon as the grace period lapses (or the surrounding + /// task is cancelled) so the caller can escalate to the next signal. + private func waitForExit( + of process: Process, + within gracePeriod: Duration + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: gracePeriod) + while process.isRunning { + guard clock.now < deadline else { return false } + do { + try await Task.sleep(for: .milliseconds(50)) + } catch { + // Cancelled: skip the remaining grace period and escalate. + return !process.isRunning + } } + return true } private func childEnvironment(for executableURL: URL) -> [String: String] {