Swift concurrency¶
Loom builds with SWIFT_STRICT_CONCURRENCY: complete on Swift 6. Every value that crosses an actor boundary is Sendable. Here's how the codebase deals with it.
Default isolation¶
- App-level state (workspace layout, agent registry, live tasks, settings) is
@MainActor. SwiftUI views read these directly; mutations land on the main actor. - Pure data types (
LocalEndpoint,LLMMessage,LLMEvent,KanbanCard) are structs / enums markedSendable. They cross actors freely. - HTTP providers (
AnthropicProvider,OllamaProvider,OpenAICompatibleProvider) are structs. Thestream(...)method returns anAsyncThrowingStreamwhose continuation is closed-over by aTask; cancellation flows throughcontinuation.onTermination.
Subprocess providers¶
ClaudeCodeProvider is @MainActor final class — it owns mutable state (activeProcess, hasLaunchedSession, sessionID). The actual subprocess work is dispatched off main via Task.detached(priority: .userInitiated) { ... } inside send(...), with a tuple return type that's already Sendable.
Streaming¶
AsyncThrowingStream<LLMEvent, Error> is the streaming primitive. Its continuation is @unchecked Sendable (Apple's contract; we trust it). Each provider builds a stream like:
AsyncThrowingStream { continuation in
let task = Task {
do {
try await runStream(..., continuation: continuation)
continuation.yield(.done)
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
Cancelation is two-way:
- The caller drops the stream →
onTerminationfires → inner Task cancels → URLSession'sbytes(for:)throwsCancellationError. - The inner Task hits an HTTP error → it calls
continuation.finish(throwing:)→ the caller'sfor try awaitrethrows.
URLSession.bytes(for:)¶
The streaming body iterator. Crucially, it propagates Task.cancel() into the underlying URLSessionDataTask, so we get clean teardown without manually tracking the task. We do call try Task.checkCancellation() inside the inner loop as belt-and-suspenders.
Static parsing helpers¶
Where parsing is pure, the function is marked nonisolated static so it can run off any actor and be unit-tested in isolation. Example: AgentRegistry.parseClaudeAgentsList(_:).
nonisolated(unsafe) — avoided¶
Loom does not use nonisolated(unsafe) to silence concurrency warnings. Anything that's tempting becomes a @MainActor access, a Sendable struct, or a Task.detached.
SwiftData on the main actor¶
All ModelContext access is @MainActor. The schema (Workspace, KanbanBoard, KanbanColumn, KanbanCard, IdeaNote) is on the main actor. We don't fan out to background contexts — the data volume doesn't warrant it.