fix(macos): wait for the current reconnect snapshot (#116550)

* fix(macos): isolate snapshot waiter timeouts

* chore: leave changelog to release generation

* test(macos): wait for voice ear expiry
This commit is contained in:
Vincent Koc
2026-07-31 06:31:40 +08:00
committed by GitHub
parent dc34274397
commit 60bf2bd1ad
3 changed files with 58 additions and 8 deletions

View File

@@ -23,7 +23,10 @@ struct AppStateVoiceEarsTests {
#expect(state.earBoostActive)
try await Task.sleep(for: .milliseconds(60))
let deadline = ContinuousClock.now + .seconds(1)
while state.earBoostActive, ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
}
#expect(!state.earBoostActive)
}
}

View File

@@ -160,7 +160,7 @@ public actor GatewayNodeSession {
private var serverMethods: Set<String>?
private var serverCapabilities: Set<GatewayServerCapability>?
private var mainSessionKey: String?
private var snapshotWaiters: [CheckedContinuation<Bool, Never>] = []
private var snapshotWaiters: [UUID: CheckedContinuation<Bool, Never>] = [:]
private var snapshotReadyWaiters: [CheckedContinuation<Bool, Never>] = []
// `computer.act` is not safe to repeat after a response is lost. Keep recent
// in-flight/results on the long-lived node session so a channel reconnect can
@@ -979,12 +979,13 @@ extension GatewayNodeSession {
return true
}
let clamped = max(0, timeoutMs)
let waiterID = UUID()
return await withCheckedContinuation { cont in
self.snapshotWaiters.append(cont)
self.snapshotWaiters[waiterID] = cont
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(clamped) * 1_000_000)
await self.timeoutSnapshotWaiters()
await self.timeoutSnapshotWaiter(id: waiterID)
}
}
}
@@ -998,14 +999,16 @@ extension GatewayNodeSession {
}
}
private func timeoutSnapshotWaiters() {
guard !self.snapshotReceived else { return }
self.drainSnapshotWaiters(returning: false)
private func timeoutSnapshotWaiter(id: UUID) {
guard !self.snapshotReceived,
let waiter = self.snapshotWaiters.removeValue(forKey: id)
else { return }
waiter.resume(returning: false)
}
private func drainSnapshotWaiters(returning value: Bool) {
if !self.snapshotWaiters.isEmpty {
let waiters = self.snapshotWaiters
let waiters = self.snapshotWaiters.values
self.snapshotWaiters.removeAll()
for waiter in waiters {
waiter.resume(returning: value)
@@ -1332,6 +1335,26 @@ extension GatewayNodeSession {
self.broadcastServerEvent(event)
}
// periphery:ignore - package tests reproduce a stale timeout across route reset.
func _test_waitForSnapshot(timeoutMs: Int) async -> Bool {
await self.waitForSnapshot(timeoutMs: timeoutMs)
}
// periphery:ignore - package tests complete snapshot waits without a live socket.
func _test_markSnapshotReceived() {
self.markSnapshotReceived()
}
// periphery:ignore - package tests reproduce route replacement snapshot state.
func _test_resetConnectionState() {
self.resetConnectionState()
}
// periphery:ignore - package tests wait until a snapshot continuation is registered.
func _test_snapshotWaiterCount() -> Int {
self.snapshotWaiters.count
}
#endif
private func cancelActiveInvokes(

View File

@@ -1320,6 +1320,30 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func `completed snapshot timeout cannot release a later route waiter`() async throws {
let gateway = GatewayNodeSession()
let firstWait = Task {
await gateway._test_waitForSnapshot(timeoutMs: 1000)
}
try await waitUntil("initial snapshot waiter registered") {
await gateway._test_snapshotWaiterCount() == 1
}
await gateway._test_markSnapshotReceived()
#expect(await firstWait.value)
await gateway._test_resetConnectionState()
let replacementWait = Task {
await gateway._test_waitForSnapshot(timeoutMs: 3000)
}
try await waitUntil("replacement snapshot waiter registered") {
await gateway._test_snapshotWaiterCount() == 1
}
try await Task.sleep(nanoseconds: 1_200_000_000)
await gateway._test_markSnapshotReceived()
#expect(await replacementWait.value)
}
@Test
func `concurrent replacements wait for route invalidation before installing a channel`() async throws {
let session = FakeGatewayWebSocketSession()