Skip to main content
3Nsofts logo3Nsofts
Foundation ModelsUpdated ·

LanguageModelSession CancellationError: Stop Tasks and Streams Safely

Updated
Read time
7 min read
Level
Intermediate
Platform
Foundation Models, Swift structured concurrency, SwiftUI task lifecycle

Implementation Notes

  • ~/ What broke: Cancelled or superseded requests overwrite current UI state, and appended stream snapshots duplicate text.
  • ~/ What to do: Check cancellation before publishing, replace snapshot content, and await completion before reusing a session.
LanguageModelSession CancellationErrorFoundation Models cancellationSwift Task cancellationFoundation Models streaming errorSwiftUI task lifecycle

Quick answer

Treat cancellation as a stop request, check it before publishing a result, and keep it separate from recoverable model failures. For a text stream, assign snapshot.content to the displayed output; snapshots contain the response so far and are not text deltas to append.

Task.cancel() marks a task as cancelled. It does not synchronously stop all work, guarantee an exception at every suspension point, or prove that a session is ready for another request. Use Task.checkCancellation() at your own boundaries and wait for an old operation to finish before reusing its session.

Rebuilding with Xcode 27? Use the error-handling migration guide for the changed model, session, and parsing error types. The cancellation principles here apply independently of those renamed errors.

Find the owner that requested cancellation

A SwiftUI .task is tied to a view lifecycle; .task(id:) also replaces work when its ID changes. By contrast, creating an unstructured Task { ... } inside a button action does not automatically bind its lifetime to that view. Store its handle and cancel it explicitly when that is the desired behavior.

Other common owners include a Stop button, a search request replaced by newer input, or a structured parent operation that was cancelled. Log the request identifier and cancellation reason at the owner. An error observed at session.respond(to:) identifies where cancellation surfaced, not necessarily where it originated.

Swift's task cancellation documentation explains the cooperative model. Task.isCancelled reads a flag; Task.checkCancellation() throws when that flag is set. Neither creates an automatic retry policy.

Handle a single response without publishing stale output

The following helper accepts an already-owned session. Its caller must ensure that the session has no other active request. It propagates cancellation so the UI owner can decide whether to show an idle or stopped state.

import Foundation
import FoundationModels

@available(iOS 26.0, macOS 26.0, *)
@MainActor
func respondUnlessCancelled(
    session: LanguageModelSession,
    prompt: String
) async throws -> String {
    try Task.checkCancellation()
    do {
        let response = try await session.respond(to: prompt)
        try Task.checkCancellation()
        return response.content
    } catch {
        if error is CancellationError || Task.isCancelled {
            throw CancellationError()
        }
        throw error
    }
}

If the task is cancelled at the same time another error occurs, this helper deliberately gives cancellation precedence. An application that needs diagnostic information about the underlying error can record a sanitized error category before returning the cancellation result.

At the UI boundary, catch cancellation separately. Do not let an old request set a replacement request's loading state to idle. Compare a request identifier before every UI mutation, including error handling and cleanup.

Stream snapshots instead of accumulating deltas

For text generation, the current snapshot replaces the previous display text. Appending each snapshot would repeat the prefix. Apple's Foundation Models introduction explains snapshot streaming and why it differs from a token-delta API.

@available(iOS 26.0, macOS 26.0, *)
@MainActor
func streamUnlessCancelled(
    session: LanguageModelSession,
    prompt: String,
    onSnapshot: @MainActor (String) -> Void
) async throws {
    try Task.checkCancellation()
    do {
        for try await snapshot in session.streamResponse(to: prompt) {
            try Task.checkCancellation()
            onSnapshot(snapshot.content)
        }
        // A sequence can finish without throwing. Check before marking complete.
        try Task.checkCancellation()
    } catch {
        if error is CancellationError || Task.isCancelled {
            throw CancellationError()
        }
        throw error
    }
}

Choose the partial-output policy before implementing Stop. A writing assistant may retain an unfinished draft with a stopped indicator. A classification feature should not treat incomplete structured output as a validated result. Cancellation and completion need distinct UI states even when both leave visible text behind.

Cancel, await completion, then reuse the session

Putting a session inside an actor protects access to isolated state. It does not make a whole async operation indivisible: another actor call can run while the first call is suspended at await. Cancelling one task and immediately starting another on the same session can therefore still overlap requests.

For a stateful conversation, use one owner with this sequence:

  1. Capture the active task handle and request cancellation.
  2. Await that task's completion before submitting another request on the session.
  3. Recheck whether the pending prompt is still the newest request.
  4. Start that request and associate all output and cleanup with its identifier.
  5. Keep input or a queue policy explicit while cancellation is in progress.

A debounced search field may replace a pending prompt while the current request stops. A chat interface may instead disable submission until the current turn ends. These are different product policies; neither should rely on actor isolation alone to serialize inference.

A new session is appropriate for an intentionally independent one-shot operation. Reusing a session preserves conversation context, so do not replace it blindly as a cancellation workaround. Inspect and test the conversation behavior you intend to preserve.

Distinguish cancellation from availability and model errors

The examples assume that the model is available. Gate the feature using the availability guide before inference. For other failures, use the generation-error reference and the SDK-specific migration guide above.

Do not retry automatically after cancellation. A new explicit user action may start a new request, but that is distinct from retrying work the user has stopped. Likewise, not every non-cancellation error is retryable: a context limit or unsupported input needs a different response from a temporary failure.

Test the transitions that usually get missed

  • Cancel before inference starts; no output should be published.
  • Stop during streaming; retained text should match the latest snapshot without duplicated prefixes.
  • Navigate away from a .task-owned feature; no stale error UI should appear.
  • Replace input rapidly; only the newest request may update the current screen.
  • Cancel as a response completes; verify the chosen completion-versus-stop policy.
  • Trigger a genuine non-cancellation failure; verify that it remains visible through the error path.
  • Reuse a conversation after cancellation; verify transcript behavior and absence of overlapping requests on supported OS versions.

The Swift helpers can be compiler-checked without running a model. Actual cancellation timing, model availability, and transcript behavior require runtime tests on supported devices. Keep that distinction in your integration checklist.

For help reviewing task ownership across a complete feature, see on-device AI integration.

Authoritative References