diff options
| author | Christian Cleberg <[email protected]> | 2026-07-22 21:43:42 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-22 23:23:11 -0500 |
| commit | 872583eca8b4e7ae6ef85917604d4a5257b9d125 (patch) | |
| tree | 0df88e0621e195f6adf5d0c67615f2099759a530 /DomainDig/IntegrationService.swift | |
| parent | d3af0e7d51c2bac5de801bfbbed96a105c442e64 (diff) | |
| download | domain-dig-872583eca8b4e7ae6ef85917604d4a5257b9d125.tar.gz domain-dig-872583eca8b4e7ae6ef85917604d4a5257b9d125.tar.bz2 domain-dig-872583eca8b4e7ae6ef85917604d4a5257b9d125.zip | |
fix: adopt Swift 6 language mode; resolve all concurrency issues (#27)
All three product targets (app, widget, share extension) now build under
SWIFT_VERSION = 6.0 with zero errors and zero warnings. The UITests
target stays on 5.0: XCTestCase's nonisolated setUp/init overrides
conflict with the target's MainActor default isolation under 6, and test
tooling is not shipping code.
The original seven diagnostics, plus the layers Swift 6 mode surfaced
once those cleared:
- SMTPChannel is an actor. It was implicitly MainActor while running its
receive loop on a background queue, so parsedLines/lineWaiters/
receiveBuffer were declared main-actor-protected and mutated off it —
concurrent mutation while resuming a CheckedContinuation can
double-resume, which traps. The actor serialises all state; Network
callbacks hop in via Task. The start() continuation also gains an
OSAllocatedUnfairLock resume-once guard: the state handler can fire
.ready and later .failed, and resuming twice was a pre-existing trap of
the same family.
- CachedLookupResult is nonisolated (a value pair built inside actor
LookupRuntime cannot have a MainActor-bound memberwise init) with
conditional Sendable — opting out of MainActor isolation also opted out
of the implicit Sendable that globally-isolated types get.
- PortScanService.printableBanner is nonisolated: a pure transformation
called from the connection's queue.
- SweepActivityController stores the activity's Sendable id instead of
the non-Sendable Activity, re-resolving via Activity.activities inside
each fire-and-forget task, so nothing non-Sendable crosses isolation.
- App Intents' static title/description/openAppWhenRun become lets
(get-only protocol requirements; static var is shared mutable global
state), and the summary helpers are @MainActor to match the model
properties they read and the perform() implementations that call them.
- ExternalDataService's ISO8601DateFormatter is nonisolated(unsafe),
citing Apple's documented thread-safety, rather than risking a parser
behaviour change by switching APIs with no test coverage.
- TaskMetricsDelegate.metrics is nonisolated(unsafe): written on the
session's delegate queue, read only after the request completes, and
URLSession guarantees didFinishCollecting precedes task completion.
- The share extension extracts the host via async/withCheckedContinuation
instead of sending a non-Sendable completion into loadItem's @Sendable
handler; Task inherits the view controller's MainActor so the manual
DispatchQueue.main hop goes too.
Validated: clean Swift 6 build of all product targets, and the full
enforced 11-test audit suite green on the floor runtime — Swift 6's
runtime isolation checks ran the app through every screen without a
trap.
Diffstat (limited to 'DomainDig/IntegrationService.swift')
| -rw-r--r-- | DomainDig/IntegrationService.swift | 74 |
1 files changed, 49 insertions, 25 deletions
diff --git a/DomainDig/IntegrationService.swift b/DomainDig/IntegrationService.swift index de24c23..090f564 100644 --- a/DomainDig/IntegrationService.swift +++ b/DomainDig/IntegrationService.swift @@ -1,5 +1,6 @@ import Foundation import Network +import os import Observation import Security @@ -749,11 +750,17 @@ private enum SMTPClient { try await channel.sendRaw(body + "\r\n.\r\n") _ = try await channel.readResponse(expecting: [250]) _ = try await channel.sendCommand("QUIT", expecting: [221]) - channel.cancel() + await channel.cancel() } } -private final class SMTPChannel { +/// An actor, not a MainActor class. The previous shape inherited the project's +/// MainActor default while running its receive loop on a background dispatch +/// queue, so `parsedLines`/`lineWaiters`/`receiveBuffer` were declared +/// main-actor-protected and mutated off it — concurrent mutation while resuming +/// a `CheckedContinuation` can double-resume, which traps. The actor serialises +/// all of it and forces the Network callbacks to hop in explicitly. (Issue #27.) +private actor SMTPChannel { private let connection: NWConnection private var parsedLines: [String] = [] private var lineWaiters: [CheckedContinuation<String, Error>] = [] @@ -765,25 +772,35 @@ private final class SMTPChannel { func start() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in - connection.stateUpdateHandler = { [weak self] state in + // The state handler can fire `.ready` and later `.failed` (or + // `.failed` twice); resuming a continuation twice traps. The lock + // also keeps the closure Sendable-clean without touching actor state + // from the connection's queue. + let hasResumed = OSAllocatedUnfairLock(initialState: false) + connection.stateUpdateHandler = { state in + let isFirst: () -> Bool = { + hasResumed.withLock { resumed in + if resumed { return false } + resumed = true + return true + } + } switch state { case .ready: - self?.scheduleReceiveLoop() - continuation.resume() + if isFirst() { continuation.resume() } case .failed(let error): - continuation.resume(throwing: error) + if isFirst() { continuation.resume(throwing: error) } + case .cancelled: + if isFirst() { continuation.resume(throwing: IntegrationError.streamClosed) } default: break } } connection.start(queue: .global(qos: .utility)) } - } - - private func scheduleReceiveLoop() { - DispatchQueue.global(qos: .utility).async { [weak self] in - self?.startReceiveLoop() - } + // Started from the actor once the connection is ready, replacing the old + // dispatch-queue hop. TCP buffers anything that arrives in the gap. + startReceiveLoop() } func cancel() { @@ -845,26 +862,33 @@ private final class SMTPChannel { } private func startReceiveLoop() { + // The completion runs on the connection's queue; hop back onto the + // actor before touching any state. connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in guard let self else { return } - - if let error { - self.failWaiters(with: error) - return + Task { + await self.handleReceive(data: data, isComplete: isComplete, error: error) } + } + } - if let data, !data.isEmpty { - self.receiveBuffer.append(data) - self.flushBuffer() - } + private func handleReceive(data: Data?, isComplete: Bool, error: Error?) { + if let error { + failWaiters(with: error) + return + } - if isComplete { - self.failWaiters(with: IntegrationError.streamClosed) - return - } + if let data, !data.isEmpty { + receiveBuffer.append(data) + flushBuffer() + } - self.startReceiveLoop() + if isComplete { + failWaiters(with: IntegrationError.streamClosed) + return } + + startReceiveLoop() } private func flushBuffer() { |
