diff --git a/Sources/CoreMLLLM/CoreMLLLM.swift b/Sources/CoreMLLLM/CoreMLLLM.swift index cd4f295..90cfb5d 100644 --- a/Sources/CoreMLLLM/CoreMLLLM.swift +++ b/Sources/CoreMLLLM/CoreMLLLM.swift @@ -1003,7 +1003,13 @@ public final class CoreMLLLM: @unchecked Sendable { } return AsyncStream { continuation in - Task(priority: decodePriority) { + // T4 cancellation: AsyncStream does NOT auto-cancel the producer + // task when the consumer breaks out of `for await`. Bind a + // handle so onTermination can call cancel(). Combined with + // Task.checkCancellation() at chunk and decode-step boundaries, + // a user-initiated cancel (or stream deinit) stops generation + // promptly instead of running to maxDecode. + let genTask = Task(priority: decodePriority) { do { let IMAGE_TOKEN_ID = 258880 let AUDIO_TOKEN_ID = 258881 @@ -1077,6 +1083,7 @@ public final class CoreMLLLM: @unchecked Sendable { engine.currentPosition = prefillLen for step in prefillLen..= ctxLimit { break } + // T4: stop the loop the moment the consumer + // unsubscribes — wired via continuation.onTermination + // → genTask.cancel() below. + try Task.checkCancellation() let useEagle = (eagleSpec != nil) && didFirstDecode && engine.canSpeculate @@ -1343,11 +1355,16 @@ public final class CoreMLLLM: @unchecked Sendable { pos += 1 } } + } catch is CancellationError { + // T4: user-cancelled. No log spam — this is expected. } catch { print("[CoreMLLLM] Error: \(error)") } continuation.finish() } + continuation.onTermination = { @Sendable _ in + genTask.cancel() + } } }