fix(sync): detach CloudKit operations from delegate callbacks

This commit is contained in:
Kaede Akino committed 2026-09-29 02:32:51 +08:00
1 parent 6dbf80b22c
commit 8c750980c1
2 files changed
+80 -3

No files matched your search

+74 -1
View File
@@ -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<String>, 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<Void, any Error>?
@@ -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 {