summaryrefslogtreecommitdiff
path: root/DomainDig/IntegrationService.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-22 21:43:42 -0500
committerChristian Cleberg <[email protected]>2026-07-22 23:23:11 -0500
commit872583eca8b4e7ae6ef85917604d4a5257b9d125 (patch)
tree0df88e0621e195f6adf5d0c67615f2099759a530 /DomainDig/IntegrationService.swift
parentd3af0e7d51c2bac5de801bfbbed96a105c442e64 (diff)
downloaddomain-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.swift74
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() {