From 8c750980c17df7d70769bb39b85679ca315fa12f Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 29 Sep 2026 02:32:51 +0800 Subject: [PATCH] fix(sync): detach CloudKit operations from delegate callbacks --- FrameworkTests/CloudSyncLifecycleTests.swift | 75 ++++++++++++++++++- .../Cloud/CloudSyncLifecycle.swift | 8 +- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/FrameworkTests/CloudSyncLifecycleTests.swift b/FrameworkTests/CloudSyncLifecycleTests.swift index 57a0d857..bd59fa6c 100644 --- a/FrameworkTests/CloudSyncLifecycleTests.swift +++ b/FrameworkTests/CloudSyncLifecycleTests.swift @@ -8,6 +8,71 @@ import Testing @Suite @MainActor struct CloudSyncLifecycleTests { + @Test(arguments: ["start", "retry", "localChanges"]) + func synchronizationDoesNotInheritDelegateTaskContext(trigger: String) async throws { + let fixture = LifecycleFixture() + defer { fixture.driver.pause() } + let engine = fixture.network.nextEngine + if trigger != "start" { + fixture.driver.start() + await fixture.driver.operation?.value + } + var fetchContexts: [Bool] = [] + var sendContexts: [Bool] = [] + engine.fetchBody = { fetchContexts.append(LifecycleDelegateContext.isHandlingEvent) } + engine.sendBody = { sendContexts.append(LifecycleDelegateContext.isHandlingEvent) } + + LifecycleDelegateContext.$isHandlingEvent.withValue(true) { + switch trigger { + case "start": fixture.driver.start() + case "retry": fixture.driver.retry() + default: + fixture.driver.handle( + .fetchedRecordZoneChanges(records: [], deletions: []), engine: engine, + receiveRemoteRecords: { _, _ in + fixture.journal.pending.append("host-identity-change") + fixture.driver.noteLocalChanges(["host-identity-change"]) + }, handleSentChanges: { _ in }) + } + } + let operation = try #require(fixture.driver.operation) + await operation.value + + #expect(fetchContexts == (trigger == "localChanges" ? [] : [false])) + #expect(sendContexts == [false]) + #expect(fixture.driver.isReadyToSend) + if trigger == "localChanges" { + #expect(engine.queued.contains("host-identity-change")) + } + } + + @Test + func accountChangeCancelsEngineOutsideDelegateTaskContext() async { + let fixture = LifecycleFixture() + fixture.driver.start() + await fixture.driver.operation?.value + let engine = fixture.network.nextEngine + let gate = LifecycleGate() + var cancellationContext: Bool? + engine.cancelBody = { + cancellationContext = LifecycleDelegateContext.isHandlingEvent + try? await gate.suspend() + } + + LifecycleDelegateContext.$isHandlingEvent.withValue(true) { + fixture.driver.handle( + .accountChange(.signOut(previousUser: .init(recordName: "current-user"))), engine: engine, + receiveRemoteRecords: { _, _ in }, handleSentChanges: { _ in }) + } + await gate.waitUntilEntered() + gate.resume() + + #expect(cancellationContext == false) + #expect(engine.cancelCount == 1) + #expect(fixture.driver.engine == nil) + #expect(fixture.driver.status == .signInRequired) + } + @Test func mixedSaveAndDeleteBatchesShareTheRequestLimitAndRejectUnjournaledDeletes() async throws { let fixture = LifecycleFixture() @@ -680,6 +745,7 @@ final class LifecycleEngine: CloudSyncEngineSession { var cancelCount = 0 var fetchBody: () async throws -> Void = {} var sendBody: () async throws -> Void = {} + var cancelBody: () async -> Void = {} func fetch(in zoneID: CKRecordZone.ID) async throws { fetchCount += 1 try await fetchBody() @@ -688,7 +754,10 @@ final class LifecycleEngine: CloudSyncEngineSession { sendCount += 1 try await sendBody() } - func cancel() async { cancelCount += 1 } + func cancel() async { + cancelCount += 1 + await cancelBody() + } func removePending(_ recordNames: Set, in zoneID: CKRecordZone.ID) { pendingChanges.removeAll { change in switch change { @@ -734,6 +803,10 @@ final class LifecycleEngine: CloudSyncEngineSession { } } +private enum LifecycleDelegateContext { + @TaskLocal static var isHandlingEvent = false +} + @MainActor private final class LifecycleGate { private var continuation: CheckedContinuation? diff --git a/Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift b/Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift index b4f39f4d..c26a540c 100644 --- a/Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift +++ b/Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift @@ -295,7 +295,10 @@ final class CloudSyncLifecycle { guard operation == nil else { return } let id = UUID() operationID = id - operation = Task { [weak self] in + // Delegate callbacks can schedule follow-up sync work. A regular Task inherits + // CloudKit's task-local callback context, making fetch/send trap even after + // the callback returns. Detach that context while retaining MainActor isolation. + operation = Task.detached { @MainActor @Sendable [weak self] in defer { if self?.operationID == id { self?.operation = nil } } do { try await body() } catch is CancellationError {} catch { guard let self, self.operationID == id else { return } @@ -681,7 +684,8 @@ extension CloudSyncLifecycle { inventorySeen.removeAll() let activeEngine = engine engine = nil - if let activeEngine { Task { await activeEngine.cancel() } } + // Account changes and failures can retire an engine from its delegate, too. + if let activeEngine { Task.detached { @MainActor @Sendable in await activeEngine.cancel() } } } func handleZoneDeletion() throws {