From ae7374fff96fdb4a4964b0853213513e25531e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 1 Nov 2026 07:56:32 +0100 Subject: [PATCH 1/5] fix(ios): end a bridge creation that outlives the snapshot source close A capture can pass the manager's closed checks and then pause inside the host while it makes its bridge session. Close marked the source closed and stopped only the sessions it had already published, so the paused creation resumed, started a helper, published it, and nobody stopped it again. Close now adopts the turn and the creation underway: it ends them and waits for each to stop the process and connection it started, and refuses queued requests instead of leaving them behind a bridge being stopped. --- .../src/snapshot-source/lifecycle.test.ts | 156 +++++++++- .../src/snapshot-source/lifecycle.ts | 286 +++++++++++++----- 2 files changed, 368 insertions(+), 74 deletions(-) diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index 8f4785c109..9ccd85f3de 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -376,6 +376,100 @@ test('the manager rejects a cached target replaced before acquisition begins', a await manager.close(); }); +test('a close during the bridge directory setup leaves no bridge and refuses the capture', async () => { + const paused = createPause(); + const fixture = createLifecycleFixture({ pauseAtDirectorySetup: paused.wait }); + const manager = new SnapshotBridgeManager(fixture.host); + const request = manager.request({ + target, + bridge, + limits, + maxDepth: 10, + deadline: deadline(undefined, 2000), + }); + await waitForFixture( + () => fixture.directorySetups.length === 1, + 'fixture never reached the bridge directory setup', + ); + + const closing = manager.close(); + paused.release(); + + await assert.rejects(request, isClosedSourceFailure); + await closing; + // The creation was refused before it started its helper: nothing was left to reap. + assert.equal(fixture.processes.length, 0); + await assert.rejects( + manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline(undefined, 2000) }), + isClosedSourceFailure, + ); + assert.equal(fixture.processes.length, 0); +}); + +test('a close while the bridge is connecting stops the helper it started and refuses the capture', async () => { + const paused = createPause(); + const fixture = createLifecycleFixture({ pauseAtConnect: paused.wait }); + const manager = new SnapshotBridgeManager(fixture.host); + const request = manager.request({ + target, + bridge, + limits, + maxDepth: 10, + deadline: deadline(undefined, 2000), + }); + await waitForFixture(() => fixture.connects.length === 1, 'fixture never reached the connect'); + + const closing = manager.close(); + paused.release(); + + await assert.rejects(request, isClosedSourceFailure); + await closing; + // The helper and its connection were already started, so the refused creation had to stop both. + assert.equal(fixture.processes.length, 1); + assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']); + assert.equal(fixture.processes[0]!.alive, false); + assert.equal(fixture.sockets.length, 1); + assert.equal(fixture.sockets[0]!.destroyed, true); + await manager.close(); + assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']); +}); + +test('a close refuses a queued capture instead of letting it wait for a stopped bridge', async () => { + const paused = createPause(); + const fixture = createLifecycleFixture({ pauseAtConnect: paused.wait }); + const manager = new SnapshotBridgeManager(fixture.host); + const creating = manager.request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 2000 }, + maxDepth: 10, + deadline: deadline(undefined, 2000), + }); + await waitForFixture(() => fixture.connects.length === 1, 'fixture never reached the connect'); + const queued = manager.request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 2000 }, + maxDepth: 10, + deadline: deadline(undefined, 2000), + }); + const closedAt = Date.now(); + + const closing = manager.close(); + + // The queued capture is refused by the close itself: waiting for the turn would keep it behind a + // creation the close has already ended. + await assert.rejects(queued, isClosedSourceFailure); + assert.ok(Date.now() - closedAt < 100); + assert.equal(fixture.sockets.length, 0); + paused.release(); + + await assert.rejects(creating, isClosedSourceFailure); + await closing; + assert.equal(fixture.processes.length, 1); + assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']); +}); + test('the manager rejects a tree when the target process changes during acquisition', async () => { const fixture = createLifecycleFixture({ targetStartTimes: ['start-1', 'start-2'] }); const manager = new SnapshotBridgeManager(fixture.host); @@ -408,6 +502,10 @@ type LifecycleFixture = { diagnostics: Array[0]>; socketPaths: string[]; startedTargets: Array[0]>; + /** The directory each bridge session setup asked the host to create, in order. */ + directorySetups: string[]; + /** The socket each bridge connect attempt was for, in order, including one that has not returned. */ + connects: string[]; }; function deadline(signal?: AbortSignal, timeoutMs = limits.maxDurationMs) { @@ -415,13 +513,41 @@ function deadline(signal?: AbortSignal, timeoutMs = limits.maxDurationMs) { } async function waitForDispatch(fixture: LifecycleFixture): Promise { + await waitForFixture( + () => fixture.sockets[0]?.writes === 1, + 'Fixture request was not dispatched', + ); +} + +async function waitForFixture(condition: () => boolean, message: string): Promise { const startedAt = Date.now(); - while (fixture.sockets[0]?.writes !== 1) { - if (Date.now() - startedAt >= 1000) throw new Error('Fixture request was not dispatched'); + while (!condition()) { + if (Date.now() - startedAt >= 1000) throw new Error(message); await new Promise((resolve) => setTimeout(resolve, 1)); } } +function createPause(): Readonly<{ wait: () => Promise; release: () => void }> { + let release!: () => void; + const resumed = new Promise((resolve) => { + release = resolve; + }); + return { + wait: async () => { + await resumed; + }, + release, + }; +} + +function isClosedSourceFailure(error: unknown): boolean { + return ( + error instanceof SnapshotSourceError && + error.failureKind === 'unsupported' && + error.failureCode === 'source-closed' + ); +} + function createLifecycleFixture( options: { connectDelayMs?: number; @@ -430,6 +556,10 @@ function createLifecycleFixture( responseGeneration?: string; responseErrorKind?: string; targetStartTimes?: Array; + /** Holds the bridge's directory setup open so a test can close the manager inside it. */ + pauseAtDirectorySetup?: () => Promise; + /** Holds a bridge connect open so a test can close the manager inside it. */ + pauseAtConnect?: () => Promise; } = {}, ): LifecycleFixture { const processes: FakeProcess[] = []; @@ -437,10 +567,17 @@ function createLifecycleFixture( const diagnostics: LifecycleFixture['diagnostics'] = []; const socketPaths: string[] = []; const startedTargets: LifecycleFixture['startedTargets'] = []; + const directorySetups: string[] = []; + const connects: string[] = []; const realHost = createSnapshotSourceHost(createLiveTestHostEnvironment()); const host: SnapshotSourceHost = { ...realHost, emitDiagnostic: (event) => diagnostics.push(event), + ensureDirectory: async (directoryPath) => { + directorySetups.push(directoryPath); + if (options.pauseAtDirectorySetup) await options.pauseAtDirectorySetup(); + await realHost.ensureDirectory(directoryPath); + }, readTargetProcessStartTime: async () => options.targetStartTimes?.shift() ?? 'target-start', start: (startedTarget, _bridgePath, socketPath) => { startedTargets.push(startedTarget); @@ -449,7 +586,9 @@ function createLifecycleFixture( processes.push(process); return process; }, - connect: async (_socketPath, connectOptions) => { + connect: async (socketPath, connectOptions) => { + connects.push(socketPath); + if (options.pauseAtConnect) await options.pauseAtConnect(); if (options.connectDelayMs) { await new Promise((resolve, reject) => { const finish = (error?: SnapshotSourceError) => { @@ -476,7 +615,16 @@ function createLifecycleFixture( return socket; }, }; - return { host, processes, sockets, diagnostics, socketPaths, startedTargets }; + return { + host, + processes, + sockets, + diagnostics, + socketPaths, + startedTargets, + directorySetups, + connects, + }; } class FakeProcess implements SnapshotSourceProcess { diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 223ea44db2..44179171bb 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -33,6 +33,34 @@ type BridgeSession = { socket?: SnapshotSourceSocket; }; +/** + * Work this manager owns and so has to be able to end: one request's turn on a Simulator, or the + * session creation running inside that turn. `close()` aborts `interrupt` and then waits for + * `settled`, which is what makes a closed source hold no bridge rather than promise one: the abort + * tells the work to stop, and the wait proves it did. + */ +type OwnedWork = Readonly<{ + /** Ends the work: a turn must not start its capture, a creation must not publish its session. */ + readonly interrupt: AbortController; + readonly settled: Promise; +}>; + +/** + * What the manager holds for one Simulator, and the one place that says who may stop a bridge. + * + * `creating` says a request is inside the host making the session, and so owns any helper and + * connection it starts; `live` says a session is published and the manager owns it. A creation + * publishes only when it succeeds, so one phase names one owner and a creation that a close refuses + * still stops what it started. A creation clears its own entry, including after a close, because only + * it knows how far it got. + */ +type BridgeEntry = + | Readonly<{ phase: 'creating' }> + | Readonly<{ + phase: 'live'; + session: BridgeSession; + }>; + type SnapshotBridgeRequest = Readonly<{ target: SnapshotSourceTarget; bridge: SnapshotSourceBridgeBinary; @@ -43,8 +71,14 @@ type SnapshotBridgeRequest = Readonly<{ }>; export class SnapshotBridgeManager { - private readonly sessions = new Map(); + private readonly entries = new Map(); private readonly requestQueues = new Map>(); + /** + * Every turn and creation underway. The queue names only the newest request for a Simulator, so this + * is how `close()` reaches a creation, and a capture already in flight behind it, from the moment + * either registers — before either has touched the host. + */ + private readonly activeWork = new Set(); private readonly ownerId = randomUUID(); private closed = false; private readonly host: SnapshotSourceHost; @@ -54,21 +88,25 @@ export class SnapshotBridgeManager { } async request(input: SnapshotBridgeRequest): Promise { - if (this.closed) throw snapshotSourceError('unsupported', 'source-closed'); - return await this.withSimulatorLock(input.target.simulator.udid, input.deadline, () => - this.requestInSimulator(input), + if (this.closed) throw closedSourceRefusal(); + return await this.withSimulatorLock(input.target.simulator.udid, input.deadline, (turn) => + this.requestInSimulator(input, turn), ); } - private async requestInSimulator(input: SnapshotBridgeRequest): Promise { - if (this.closed) throw snapshotSourceError('unsupported', 'source-closed'); + private async requestInSimulator( + input: SnapshotBridgeRequest, + turn: OwnedWork, + ): Promise { + if (refusedByClose(this.closed, turn)) throw closedSourceRefusal(); const deadline = input.deadline; remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'); - const previousSession = this.sessions.get(input.target.simulator.udid); - const session = await this.ensureSession(input, deadline); + const udid = input.target.simulator.udid; + const previousSession = liveSession(this.entries.get(udid)); + const session = await this.ensureSession(udid, input, deadline, turn); try { const targetStartTime = await this.readTargetStartTime(input.target, deadline); - const envelope = await this.exchange(session, input, deadline); + const envelope = await this.exchange(session, input, deadline, turn); await this.assertTargetStillCurrent(input.target, targetStartTime, deadline); return envelope; } catch (error) { @@ -97,116 +135,202 @@ export class SnapshotBridgeManager { private async withSimulatorLock( udid: string, deadline: SnapshotSourceDeadline, - action: () => Promise, + action: (turn: OwnedWork) => Promise, ): Promise { const previous = this.requestQueues.get(udid) ?? Promise.resolve(); - let release!: () => void; + let releaseTurn!: () => void; const turnFinished = new Promise((resolve) => { - release = resolve; + releaseTurn = resolve; }); + const turn: OwnedWork = { interrupt: new AbortController(), settled: turnFinished }; + // Registered before the first await, so it happens in the same synchronous step as the `closed` + // check in `request()`: a turn can never join the active set after `close()` has read it. + this.activeWork.add(turn); const current = previous.then(() => turnFinished); this.requestQueues.set(udid, current); try { - await waitForSimulatorTurn(previous, deadline); - return await action(); + await waitForSimulatorTurn(previous, deadline, turn.interrupt.signal); + if (refusedByClose(this.closed, turn)) throw closedSourceRefusal(); + return await action(turn); } finally { - release(); + releaseTurn(); + this.activeWork.delete(turn); if (this.requestQueues.get(udid) === current) this.requestQueues.delete(udid); } } + /** + * Ends every bridge this manager owns: the sessions it published, the creations still inside the + * host, and the requests queued behind them. + * + * The `closed` checks a request makes cannot see a close that lands while it is inside the host, so + * close adopts the underway work: it ends each one and waits for it to stop the helper and the + * connection it did start. Otherwise a creation resuming after the published sessions were collected + * would start a helper nobody has since published, and no later close would know to stop it — the + * process and its socket would outlive the source. + * + * A queued request is refused rather than left waiting for the turn of a capture whose bridge is + * being stopped. A capture already dispatched to the bridge is not rescued: its bridge stops with + * the rest, and close waits for that capture to settle before it returns. + */ async close(): Promise { if (this.closed) return; this.closed = true; - const sessions = [...this.sessions.values()]; - this.sessions.clear(); - await Promise.all(sessions.map(async (session) => await this.dispose(session, true))); + const adopted = [...this.activeWork]; + this.requestQueues.clear(); + for (const work of adopted) work.interrupt.abort(); + const sessions: BridgeSession[] = []; + for (const [udid, entry] of this.entries) { + if (entry.phase !== 'live') continue; + this.entries.delete(udid); + sessions.push(entry.session); + } + await Promise.all([ + ...sessions.map(async (session) => { + await this.dispose(session, true); + }), + ...adopted.map(async (work) => { + await work.settled; + }), + ]); } private async ensureSession( + udid: string, input: SnapshotBridgeRequest, deadline: SnapshotSourceDeadline, + turn: OwnedWork, ): Promise { - const key = input.target.simulator.udid; - const existing = this.sessions.get(key); - if (existing && existing.bridgePath === input.bridge.path && existing.process.isAlive()) { - if (!existing.socket || existing.socket.destroyed) { - existing.socket = await this.connectUntilReady(existing, deadline); + const existing = liveSession(this.entries.get(udid)); + if (existing) { + if (existing.bridgePath === input.bridge.path && existing.process.isAlive()) { + if (!existing.socket || existing.socket.destroyed) { + existing.socket = await this.connectUntilReady(existing, deadline, turn.interrupt.signal); + } + return existing; } - return existing; + await this.removeSession(existing, true); } - if (existing) await this.removeSession(existing, true); - const socketPath = snapshotSourceSocketPath( - this.host, - input.target.simulator.udid, - this.ownerId, - ); - await this.host.ensureDirectory(path.dirname(socketPath)); - await this.host.remove(socketPath); - const bridgeProcess = this.host.start(input.target.simulator, input.bridge.path, socketPath, { - signal: deadline.signal, + const socketPath = snapshotSourceSocketPath(this.host, udid, this.ownerId); + let resolveSettled!: () => void; + const settled = new Promise((resolve) => { + resolveSettled = resolve; }); - const session: BridgeSession = { - udid: input.target.simulator.udid, - bridgePath: input.bridge.path, - socketPath, - process: bridgeProcess, - }; - this.sessions.set(key, session); + const creation: OwnedWork = { interrupt: new AbortController(), settled }; + const entry: BridgeEntry = { phase: 'creating' }; + // Registered before the first await, in the same synchronous step as the refusal check below: from + // here the creation is the only owner of this Simulator's bridge, and of whatever it starts. + this.entries.set(udid, entry); + this.activeWork.add(creation); try { - session.socket = await this.connectUntilReady(session, deadline); - return session; - } catch (error) { - await this.removeSession(session, true); - throw asSnapshotSourceError(error); + if (refusedByClose(this.closed, creation)) throw closedSourceRefusal(); + await this.host.ensureDirectory(path.dirname(socketPath)); + await this.host.remove(socketPath); + // The host held this call, so a close that landed inside it is the manager's own refusal rather + // than a host failure: the creation started nothing, so it has nothing to stop. + if (refusedByClose(this.closed, creation)) throw closedSourceRefusal(); + const session: BridgeSession = { + udid, + bridgePath: input.bridge.path, + socketPath, + process: this.host.start(input.target.simulator, input.bridge.path, socketPath, { + signal: deadline.signal, + }), + }; + try { + session.socket = await this.connectUntilReady(session, deadline, creation.interrupt.signal); + if (refusedByClose(this.closed, creation)) throw closedSourceRefusal(); + this.entries.set(udid, { phase: 'live', session }); + return session; + } catch (error) { + await this.removeSession(session, true); + throw asSnapshotSourceError(error); + } + } finally { + resolveSettled(); + this.activeWork.delete(creation); + if (this.entries.get(udid) === entry) this.entries.delete(udid); } } private async connectUntilReady( session: BridgeSession, deadline: SnapshotSourceDeadline, + stop: AbortSignal, ): Promise { - let lastError: unknown; while (true) { + if (stop.aborted) throw closedSourceRefusal(); const remainingMs = remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline'); if (!session.process.isAlive()) { throw await bridgeProcessExited(this.host, session.process); } + let socket: SnapshotSourceSocket; try { - return await this.host.connect(session.socketPath, { - signal: deadline.signal, - timeoutMs: Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs), - }); + socket = await this.connectOnce(session, remainingMs, deadline); } catch (error) { - lastError = error; - if (error instanceof SnapshotSourceError && error.failureKind === 'cancelled') { - throw error; - } - const delayMs = Math.min( - CONNECT_RETRY_DELAY_MS, - remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline'), - ); - try { - await waitForSnapshotSourceDelay(deadline, delayMs, 'bridge-connect-deadline'); - } catch (sleepError) { - throw asSnapshotSourceError(sleepError); - } + await this.awaitConnectRetry(deadline, stop, error); + continue; } - if (lastError instanceof SnapshotSourceError && lastError.failureKind === 'timeout') { - throw lastError; + // A connect stops early only because a close owns this work, and a close ends a session it + // published. A refused caller would never hand this connection to anyone, so it ends here. + if (stop.aborted) { + socket.destroy(); + throw closedSourceRefusal(); } + return socket; } } + private async connectOnce( + session: BridgeSession, + remainingMs: number, + deadline: SnapshotSourceDeadline, + ): Promise { + return await this.host.connect(session.socketPath, { + signal: deadline.signal, + timeoutMs: Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs), + }); + } + + /** + * Waits out the retry after a connect the bridge did not answer. Throws the failure that ends the + * wait instead: the close that owns this work, the caller's cancellation, or the attempt's own + * timeout. The close is checked before the timeout, so a close that ended the wait is never reported + * as the connect timeout that happened to come with it. + */ + private async awaitConnectRetry( + deadline: SnapshotSourceDeadline, + stop: AbortSignal, + error: unknown, + ): Promise { + if (stop.aborted) throw closedSourceRefusal(); + const refused = error instanceof SnapshotSourceError ? error : undefined; + if (refused?.failureKind === 'cancelled') throw refused; + const delayMs = Math.min( + CONNECT_RETRY_DELAY_MS, + remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline'), + ); + try { + await waitForSnapshotSourceDelay(deadline, delayMs, 'bridge-connect-deadline', stop); + } catch (sleepError) { + throw asSnapshotSourceError(sleepError); + } + if (stop.aborted) throw closedSourceRefusal(); + if (refused?.failureKind === 'timeout') throw refused; + } + private async exchange( session: BridgeSession, input: SnapshotBridgeRequest, deadline: SnapshotSourceDeadline, + turn: OwnedWork, ): Promise { if (!session.socket || session.socket.destroyed) { - session.socket = await this.connectUntilReady(session, deadline); + session.socket = await this.connectUntilReady(session, deadline, turn.interrupt.signal); } + // Nothing is sent once a close owns this turn: the bridge it would reach is being stopped. + if (refusedByClose(this.closed, turn)) throw closedSourceRefusal(); const requestId = randomUUID(); const request = createSnapshotBridgeDescribeRequest({ requestId, @@ -277,7 +401,8 @@ export class SnapshotBridgeManager { } private async removeSession(session: BridgeSession, stopProcess: boolean): Promise { - if (this.sessions.get(session.udid) === session) this.sessions.delete(session.udid); + const entry = this.entries.get(session.udid); + if (entry?.phase === 'live' && entry.session === session) this.entries.delete(session.udid); await this.dispose(session, stopProcess); } @@ -296,15 +421,36 @@ export class SnapshotBridgeManager { } } +function liveSession(entry: BridgeEntry | undefined): BridgeSession | undefined { + return entry?.phase === 'live' ? entry.session : undefined; +} + +/** Whether a close owns this source while `work` was underway, so `work` must not go on. */ +function refusedByClose(closed: boolean, work: OwnedWork): boolean { + return closed || work.interrupt.signal.aborted; +} + +/** + * The refusal for work a closed manager could not serve. It keeps the typed reason a closed source + * already answers with: the manager ended, which says nothing about the app generation the capture + * was reading. + */ +function closedSourceRefusal(): SnapshotSourceError { + return snapshotSourceError('unsupported', 'source-closed'); +} + async function waitForSimulatorTurn( previous: Promise, deadline: SnapshotSourceDeadline, + stop: AbortSignal, ): Promise { const timeoutMs = remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'); await Promise.race([ previous, - waitForSnapshotSourceDelay(deadline, timeoutMs, 'bridge-request-deadline').then(() => { - throw snapshotSourceError('timeout', 'bridge-request-deadline'); + waitForSnapshotSourceDelay(deadline, timeoutMs, 'bridge-request-deadline', stop).then(() => { + // Only a spent budget is a timeout. A wait that ended because a close owns this turn is + // refused by the caller, which reports the close instead. + if (!stop.aborted) throw snapshotSourceError('timeout', 'bridge-request-deadline'); }), ]); } From 4f3d27e8168622ca485d0110e7f69e12f0a40b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 1 Nov 2026 09:10:01 +0100 Subject: [PATCH 2/5] fix(ios): let a close answer the bridge exit status it cannot wait for The connect loop asked the helper why it had gone away before it checked whether a close owned the work. The host settles that exit status when the child's own pipes close, so a descendant holding an inherited pipe open can keep the read pending indefinitely, and a close that landed there was left waiting on a question nothing could answer. The creation also reported the crash rather than the close that already owned it. The close now answers that read: the refused creation stops what it started and names the close, while the status read nobody waits on still records the exit if it ever settles. --- .../src/snapshot-source/lifecycle.test.ts | 95 +++++++++++++++++-- .../src/snapshot-source/lifecycle.ts | 29 +++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index 9ccd85f3de..6ad994b770 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -470,6 +470,44 @@ test('a close refuses a queued capture instead of letting it wait for a stopped assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']); }); +test('a close while the helper exit status stays unread ends the capture instead of waiting for it', async () => { + const fixture = createLifecycleFixture({ helperGoneAtConnect: true }); + const manager = new SnapshotBridgeManager(fixture.host); + const request = manager.request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 2000 }, + maxDepth: 10, + deadline: deadline(undefined, 2000), + }); + await waitForFixture( + () => fixture.processes[0]?.exitStatusReads === 1, + 'the manager never asked for the helper exit status', + ); + + // Settled before the close: this capture is work the close has to wait for. + const outcome = request.then( + () => ({ refused: false as const }), + (error: unknown) => ({ refused: true as const, error }), + ); + + const closed = await settleWithin(manager.close(), 500); + + assert.equal(closed, 'settled', 'close waited for an exit status that never arrived'); + const settled = await outcome; + assert.equal(settled.refused, true); + assert.ok(settled.refused && isClosedSourceFailure(settled.error)); + // The helper was already gone, so the refused creation had nothing to stop, and its refusal names + // the close rather than a crash whose outcome the manager never learned. + assert.deepEqual(fixture.processes[0]!.signals, []); + assert.equal(fixture.connects.length, 1); + assert.equal(fixture.sockets.length, 0); + assert.equal( + fixture.diagnostics.some((event) => event.phase === 'ios.snapshot-source.bridge-process-exit'), + false, + ); +}); + test('the manager rejects a tree when the target process changes during acquisition', async () => { const fixture = createLifecycleFixture({ targetStartTimes: ['start-1', 'start-2'] }); const manager = new SnapshotBridgeManager(fixture.host); @@ -519,6 +557,24 @@ async function waitForDispatch(fixture: LifecycleFixture): Promise { ); } +/** Whether `pending` finishes on its own inside `graceMs`, so a test can name a wait it must not need. */ +async function settleWithin( + pending: Promise, + graceMs: number, +): Promise<'settled' | 'pending'> { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + pending.then(() => 'settled' as const), + new Promise<'pending'>((resolve) => { + timer = setTimeout(() => resolve('pending'), graceMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + async function waitForFixture(condition: () => boolean, message: string): Promise { const startedAt = Date.now(); while (!condition()) { @@ -560,6 +616,11 @@ function createLifecycleFixture( pauseAtDirectorySetup?: () => Promise; /** Holds a bridge connect open so a test can close the manager inside it. */ pauseAtConnect?: () => Promise; + /** + * Takes the helper away without an exit status as the bridge connect is attempted, so the connect + * fails and the manager is left reading a status that never arrives. + */ + helperGoneAtConnect?: boolean; } = {}, ): LifecycleFixture { const processes: FakeProcess[] = []; @@ -589,6 +650,12 @@ function createLifecycleFixture( connect: async (socketPath, connectOptions) => { connects.push(socketPath); if (options.pauseAtConnect) await options.pauseAtConnect(); + if (options.helperGoneAtConnect) { + // The manager already saw the helper start, so this is a launch that died without ever + // reporting its status: the socket it would have answered on is gone with it. + processes[0]?.exitWithoutStatus(); + throw snapshotSourceError('transport-failure', 'bridge-closed-before-connect'); + } if (options.connectDelayMs) { await new Promise((resolve, reject) => { const finish = (error?: SnapshotSourceError) => { @@ -630,14 +697,20 @@ function createLifecycleFixture( class FakeProcess implements SnapshotSourceProcess { alive = true; signals: NodeJS.Signals[] = []; - readonly wait: Promise<{ stdout: string; stderr: string; exitCode: number }>; + /** How often the manager asked for the exit status, counting one read still awaiting a result. */ + exitStatusReads = 0; private readonly processId: number; - private resolveWait!: (result: { stdout: string; stderr: string; exitCode: number }) => void; + private readonly exitStatus: Promise<{ stdout: string; stderr: string; exitCode: number }>; + private resolveExitStatus!: (result: { + stdout: string; + stderr: string; + exitCode: number; + }) => void; constructor(pid: number) { this.processId = pid; - this.wait = new Promise((resolve) => { - this.resolveWait = resolve; + this.exitStatus = new Promise((resolve) => { + this.resolveExitStatus = resolve; }); } @@ -645,6 +718,11 @@ class FakeProcess implements SnapshotSourceProcess { return this.processId; } + get wait(): Promise<{ stdout: string; stderr: string; exitCode: number }> { + this.exitStatusReads += 1; + return this.exitStatus; + } + isAlive(): boolean { return this.alive; } @@ -652,12 +730,17 @@ class FakeProcess implements SnapshotSourceProcess { signal(signal: NodeJS.Signals): void { this.signals.push(signal); this.alive = false; - this.resolveWait({ stdout: '', stderr: '', exitCode: signal === 'SIGKILL' ? 137 : 0 }); + this.resolveExitStatus({ stdout: '', stderr: '', exitCode: signal === 'SIGKILL' ? 137 : 0 }); } crash(): void { this.alive = false; - this.resolveWait({ stdout: '', stderr: 'crashed', exitCode: 1 }); + this.resolveExitStatus({ stdout: '', stderr: 'crashed', exitCode: 1 }); + } + + /** Goes away without ever reporting its status, the way a helper with inherited pipes can. */ + exitWithoutStatus(): void { + this.alive = false; } readLog(): string { diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 44179171bb..ed7c71a17e 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -263,7 +263,7 @@ export class SnapshotBridgeManager { if (stop.aborted) throw closedSourceRefusal(); const remainingMs = remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline'); if (!session.process.isAlive()) { - throw await bridgeProcessExited(this.host, session.process); + throw await this.bridgeExitOrClose(session, stop); } let socket: SnapshotSourceSocket; try { @@ -293,6 +293,33 @@ export class SnapshotBridgeManager { }); } + /** + * Why the helper is gone, unless a close ends that question first. + * + * The exit status is not free to wait for: the host settles it when the child's own pipes close, and a + * descendant that inherited one can hold it pending indefinitely. A close cannot make that answer + * arrive, so the close answers here: the refused creation goes on to stop what it started and names the + * close, while the read nobody waits on still records the exit if it ever settles. + */ + private async bridgeExitOrClose( + session: BridgeSession, + stop: AbortSignal, + ): Promise { + let refuse!: () => void; + const refusal = new Promise((resolve) => { + // Resolves rather than rejects: a close that arrives after the exit status did must never leave a + // rejection nobody owns. + refuse = () => resolve(closedSourceRefusal()); + }); + if (stop.aborted) refuse(); + else stop.addEventListener('abort', refuse, { once: true }); + try { + return await Promise.race([bridgeProcessExited(this.host, session.process), refusal]); + } finally { + stop.removeEventListener('abort', refuse); + } + } + /** * Waits out the retry after a connect the bridge did not answer. Throws the failure that ends the * wait instead: the close that owns this work, the caller's cancellation, or the attempt's own From 349d69c26839119c8831f20fa32b703778081fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 1 Nov 2026 09:39:07 +0100 Subject: [PATCH 3/5] fix(ios): keep a bridge exit status that answered before the close The connect loop raced the close against its own typed conversion of the helper's exit status, and that conversion takes a turn of its own. A close landing in that turn overwrote an answer the helper had already given: the capture reported the close's refusal even though the manager had been told the helper crashed. The race now subscribes to the raw status, so the close wins only while it is still pending. A status read that fails counts as an answer too: the manager knows the helper is not coming back. --- .../src/snapshot-source/lifecycle.test.ts | 74 ++++++++++++++++++- .../src/snapshot-source/lifecycle.ts | 30 ++++++-- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index 6ad994b770..408972ca61 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -508,6 +508,62 @@ test('a close while the helper exit status stays unread ends the capture instead ); }); +test('an exit status that answers before the close still reports the helper crash', async () => { + const fixture = createLifecycleFixture({ helperGoneAtConnect: true }); + const helper = () => fixture.processes[0]!; + const manager = new SnapshotBridgeManager(fixture.host); + const request = manager.request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 2000 }, + maxDepth: 10, + deadline: deadline(undefined, 2000), + }); + await waitForFixture( + () => helper()?.exitStatusReads === 1, + 'the manager never asked for the helper exit status', + ); + + // One synchronous turn: the status answers, and the close arrives right after it. The status had + // already answered, so the capture reports the crash the manager was told about, not the close. + helper().crash(); + const closing = manager.close(); + + await assert.rejects(request, isHelperCrashFailure); + await closing; + assert.deepEqual(helper().signals, []); + const exitDiagnostic = fixture.diagnostics.filter( + (event) => event.phase === 'ios.snapshot-source.bridge-process-exit', + ); + assert.equal(exitDiagnostic.length, 1); + assert.equal(exitDiagnostic[0]?.data?.exitCode, 1); +}); + +test('a status read that fails before the close still reports the helper crash', async () => { + const fixture = createLifecycleFixture({ helperGoneAtConnect: true }); + const helper = () => fixture.processes[0]!; + const manager = new SnapshotBridgeManager(fixture.host); + const request = manager.request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 2000 }, + maxDepth: 10, + deadline: deadline(undefined, 2000), + }); + await waitForFixture( + () => helper()?.exitStatusReads === 1, + 'the manager never asked for the helper exit status', + ); + + // A failing status read is an answer too: the manager knows the helper is not coming back. + helper().exitWithFailedStatus(); + const closing = manager.close(); + + await assert.rejects(request, isHelperCrashFailure); + await closing; + assert.deepEqual(helper().signals, []); +}); + test('the manager rejects a tree when the target process changes during acquisition', async () => { const fixture = createLifecycleFixture({ targetStartTimes: ['start-1', 'start-2'] }); const manager = new SnapshotBridgeManager(fixture.host); @@ -604,6 +660,14 @@ function isClosedSourceFailure(error: unknown): boolean { ); } +function isHelperCrashFailure(error: unknown): boolean { + return ( + error instanceof SnapshotSourceError && + error.failureKind === 'process-crash' && + error.failureCode === 'bridge-exited' + ); +} + function createLifecycleFixture( options: { connectDelayMs?: number; @@ -706,11 +770,13 @@ class FakeProcess implements SnapshotSourceProcess { stderr: string; exitCode: number; }) => void; + private rejectExitStatus!: (error: Error) => void; constructor(pid: number) { this.processId = pid; - this.exitStatus = new Promise((resolve) => { + this.exitStatus = new Promise((resolve, reject) => { this.resolveExitStatus = resolve; + this.rejectExitStatus = reject; }); } @@ -743,6 +809,12 @@ class FakeProcess implements SnapshotSourceProcess { this.alive = false; } + /** Goes away with the status read itself failing, the way a launch that never started can. */ + exitWithFailedStatus(): void { + this.alive = false; + this.rejectExitStatus(new Error('helper launch failed')); + } + readLog(): string { return 'fixture log'; } diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index ed7c71a17e..c2602b5548 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -300,26 +300,46 @@ export class SnapshotBridgeManager { * descendant that inherited one can hold it pending indefinitely. A close cannot make that answer * arrive, so the close answers here: the refused creation goes on to stop what it started and names the * close, while the read nobody waits on still records the exit if it ever settles. + * + * The close only wins while the status is still pending, so the race subscribes to the raw status and + * not to its typed conversion: converting takes a turn of its own, and a close landing in that turn + * must not overwrite an answer the helper already gave. */ private async bridgeExitOrClose( session: BridgeSession, stop: AbortSignal, ): Promise { let refuse!: () => void; - const refusal = new Promise((resolve) => { - // Resolves rather than rejects: a close that arrives after the exit status did must never leave a - // rejection nobody owns. - refuse = () => resolve(closedSourceRefusal()); + const refusal = new Promise<'closed'>((resolve) => { + refuse = () => resolve('closed'); }); if (stop.aborted) refuse(); else stop.addEventListener('abort', refuse, { once: true }); try { - return await Promise.race([bridgeProcessExited(this.host, session.process), refusal]); + if (!(await this.exitStatusOrClose(session.process, refusal))) { + throw closedSourceRefusal(); + } + return await bridgeProcessExited(this.host, session.process); } finally { stop.removeEventListener('abort', refuse); } } + /** + * Whether the helper answered the status question first — either outcome counts, and a rejected status + * is an answer too: `bridgeProcessExited` reads the winner and keeps its typed failure. + */ + private async exitStatusOrClose( + bridgeProcess: SnapshotSourceProcess, + refusal: Promise<'closed'>, + ): Promise { + try { + return (await Promise.race([bridgeProcess.wait, refusal])) !== 'closed'; + } catch { + return true; + } + } + /** * Waits out the retry after a connect the bridge did not answer. Throws the failure that ends the * wait instead: the close that owns this work, the caller's cancellation, or the attempt's own From c62f0c8d62bcfd4f4720d8edf61bcca5aecbf8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 1 Nov 2026 12:16:13 +0100 Subject: [PATCH 4/5] fix(ios): let the exit-status wait answer the request budget and keep its late diagnosis The close race owned the helper exit-status question alone, so a capture whose helper died without ever reporting its status had nothing to end the wait: it ran past its own budget and answered only once a close arrived. The wait now races the request's remaining budget too, and a spent budget or a close that stops waiting leaves the read behind as an observer, so a status that answers late is still diagnosed. The connect loop keeps its inline connect; the queued-capture refusal is now proven by its order rather than a clock bound. --- .../src/snapshot-source/lifecycle.test.ts | 112 +++++++++++++++++- .../src/snapshot-source/lifecycle.ts | 79 ++++++------ 2 files changed, 150 insertions(+), 41 deletions(-) diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index 408972ca61..939f46c6e2 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -453,14 +453,13 @@ test('a close refuses a queued capture instead of letting it wait for a stopped maxDepth: 10, deadline: deadline(undefined, 2000), }); - const closedAt = Date.now(); - const closing = manager.close(); - // The queued capture is refused by the close itself: waiting for the turn would keep it behind a - // creation the close has already ended. + // The queued capture is refused by the close itself, while the creation is still held at the connect: + // if the refusal ever waited for that creation, this await would never settle and the test would time + // out, so the order is a causal proof and needs no clock bound. await assert.rejects(queued, isClosedSourceFailure); - assert.ok(Date.now() - closedAt < 100); + assert.equal(fixture.connects.length, 1); assert.equal(fixture.sockets.length, 0); paused.release(); @@ -564,6 +563,103 @@ test('a status read that fails before the close still reports the helper crash', assert.deepEqual(helper().signals, []); }); +test('a capture whose exit status stays unread ends at its own deadline without a close', async () => { + const fixture = createLifecycleFixture({ helperGoneAtConnect: true }); + const helper = () => fixture.processes[0]!; + const manager = new SnapshotBridgeManager(fixture.host); + + // A 120 ms budget: the helper dies without a status as the connect is attempted, and the exit read + // that follows has only this request's own budget to end it. No close arrives, so a wait raced + // against the close alone never settles and this request never answers. + const failure = await manager + .request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 2000 }, + maxDepth: 10, + deadline: deadline(undefined, 120), + }) + .then( + () => undefined, + (error: unknown) => error, + ); + + assert.ok( + failure instanceof SnapshotSourceError && + failure.failureKind === 'timeout' && + failure.failureCode === 'bridge-exit-deadline', + 'the exit wait did not answer the request budget it was given', + ); + assert.equal(helper().exitStatusReads >= 1, true); + // A status that never answered fabricated no exit diagnostic. + assert.equal(exitDiagnostics(fixture).length, 0); + await manager.close(); +}); + +test('a close that ends the exit wait still records the crash once the helper status answers', async () => { + const fixture = createLifecycleFixture({ helperGoneAtConnect: true }); + const helper = () => fixture.processes[0]!; + const manager = new SnapshotBridgeManager(fixture.host); + const request = manager.request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 2000 }, + maxDepth: 10, + deadline: deadline(undefined, 2000), + }); + await waitForFixture( + () => helper()?.exitStatusReads === 1, + 'the manager never asked for the helper exit status', + ); + + const closed = await settleWithin(manager.close(), 500); + assert.equal(closed, 'settled', 'close waited for an exit status that never arrived'); + await assert.rejects(request, isClosedSourceFailure); + // The close did not learn the outcome, so nothing is diagnosed yet: the crash belongs to the status + // read the manager left behind, which is what records it when the helper finally answers. + assert.equal(exitDiagnostics(fixture).length, 0); + + helper().crash(); + await waitForFixture( + () => exitDiagnostics(fixture).length === 1, + 'the status read left behind by the close never diagnosed the exit', + ); + assert.equal(exitDiagnostics(fixture)[0]?.data?.exitCode, 1); + await manager.close(); +}); + +test('a close during a dispatched capture waits only for the capture deadline the helper died on', async () => { + // The helper answers 2000 ms after the write; the request budget is 150 ms, and the helper goes away + // without a status while the capture is on the way. The capture's own deadline is what ends it: close + // adopts that turn, so close is bounded by the same budget rather than by the status that never answers. + const fixture = createLifecycleFixture({ responseDelayMs: 2000 }); + const manager = new SnapshotBridgeManager(fixture.host); + const request = manager.request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 2000 }, + maxDepth: 10, + deadline: deadline(undefined, 150), + }); + await waitForDispatch(fixture); + fixture.processes[0]!.exitWithoutStatus(); + + const closing = manager.close(); + assert.equal( + await settleWithin(closing, 800), + 'settled', + 'close waited past the deadline of the capture it adopted', + ); + await assert.rejects( + request, + (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout', + ); + // The helper was already gone, so there was nothing to signal, and a status that never answered + // fabricated no exit diagnostic. + assert.deepEqual(fixture.processes[0]!.signals, []); + assert.equal(exitDiagnostics(fixture).length, 0); +}); + test('the manager rejects a tree when the target process changes during acquisition', async () => { const fixture = createLifecycleFixture({ targetStartTimes: ['start-1', 'start-2'] }); const manager = new SnapshotBridgeManager(fixture.host); @@ -668,6 +764,12 @@ function isHelperCrashFailure(error: unknown): boolean { ); } +function exitDiagnostics(fixture: LifecycleFixture): LifecycleFixture['diagnostics'] { + return fixture.diagnostics.filter( + (event) => event.phase === 'ios.snapshot-source.bridge-process-exit', + ); +} + function createLifecycleFixture( options: { connectDelayMs?: number; diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index c2602b5548..3d51936f7d 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -263,11 +263,14 @@ export class SnapshotBridgeManager { if (stop.aborted) throw closedSourceRefusal(); const remainingMs = remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline'); if (!session.process.isAlive()) { - throw await this.bridgeExitOrClose(session, stop); + throw await this.bridgeExitOrClose(session, deadline, stop); } let socket: SnapshotSourceSocket; try { - socket = await this.connectOnce(session, remainingMs, deadline); + socket = await this.host.connect(session.socketPath, { + signal: deadline.signal, + timeoutMs: Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs), + }); } catch (error) { await this.awaitConnectRetry(deadline, stop, error); continue; @@ -282,61 +285,65 @@ export class SnapshotBridgeManager { } } - private async connectOnce( - session: BridgeSession, - remainingMs: number, - deadline: SnapshotSourceDeadline, - ): Promise { - return await this.host.connect(session.socketPath, { - signal: deadline.signal, - timeoutMs: Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs), - }); - } - /** - * Why the helper is gone, unless a close ends that question first. + * Why the helper is gone, unless the close or the request's own budget ends that question first. * * The exit status is not free to wait for: the host settles it when the child's own pipes close, and a - * descendant that inherited one can hold it pending indefinitely. A close cannot make that answer - * arrive, so the close answers here: the refused creation goes on to stop what it started and names the - * close, while the read nobody waits on still records the exit if it ever settles. + * descendant that inherited one can hold it pending indefinitely. Neither a close nor a spent budget can + * make that answer arrive, so each answers here and the refused work goes on to stop what it started. + * Whoever stops waiting leaves the read behind as an observer, so a status that answers late is still + * diagnosed. * - * The close only wins while the status is still pending, so the race subscribes to the raw status and - * not to its typed conversion: converting takes a turn of its own, and a close landing in that turn - * must not overwrite an answer the helper already gave. + * The close and the budget win only while the status is still pending, so the race subscribes to the raw + * status and not to its typed conversion: converting takes a turn of its own, and an answer landing in + * that turn must not be overwritten. */ private async bridgeExitOrClose( session: BridgeSession, + deadline: SnapshotSourceDeadline, stop: AbortSignal, ): Promise { + // Read the budget before anything is subscribed: a deadline that is already spent ends the question + // here, with no listener and no timer to take back. + const remainingMs = remainingSnapshotSourceMs(deadline, 'bridge-exit-deadline'); let refuse!: () => void; const refusal = new Promise<'closed'>((resolve) => { refuse = () => resolve('closed'); }); if (stop.aborted) refuse(); else stop.addEventListener('abort', refuse, { once: true }); + const stopWaiting = new AbortController(); + const budget = waitForSnapshotSourceDelay( + deadline, + remainingMs, + 'bridge-exit-deadline', + stopWaiting.signal, + ).then( + () => 'timeout' as const, + (error: unknown) => ({ cancelled: asSnapshotSourceError(error) }), + ); try { - if (!(await this.exitStatusOrClose(session.process, refusal))) { + // The raw status keeps its place in the race: its typed conversion takes a turn of its own, and an + // answer landing in that turn must not lose. Only the budget is pre-tagged, and a rejected status + // is an answer too — `bridgeProcessExited` reads the winner and keeps its typed failure. + const answer = await Promise.race([session.process.wait, refusal, budget]).catch( + () => 'status' as const, + ); + if (answer === 'closed') { + // The close stops waiting on the status, but the read stays subscribed as an observer: a helper + // that died on its own keeps its exit code and bounded log if the status ever answers. + void bridgeProcessExited(this.host, session.process).catch(() => {}); throw closedSourceRefusal(); } + if (answer === 'timeout') { + void bridgeProcessExited(this.host, session.process).catch(() => {}); + throw snapshotSourceError('timeout', 'bridge-exit-deadline'); + } + if (typeof answer === 'object' && 'cancelled' in answer) throw answer.cancelled; return await bridgeProcessExited(this.host, session.process); } finally { stop.removeEventListener('abort', refuse); - } - } - - /** - * Whether the helper answered the status question first — either outcome counts, and a rejected status - * is an answer too: `bridgeProcessExited` reads the winner and keeps its typed failure. - */ - private async exitStatusOrClose( - bridgeProcess: SnapshotSourceProcess, - refusal: Promise<'closed'>, - ): Promise { - try { - return (await Promise.race([bridgeProcess.wait, refusal])) !== 'closed'; - } catch { - return true; + stopWaiting.abort(); } } From 893e41ac957f01306fb8a7f7d34308404ec3dac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 1 Nov 2026 22:13:04 +0100 Subject: [PATCH 5/5] fix(ios): retain bridge exit diagnostics after cancellation --- .../src/snapshot-source/lifecycle.test.ts | 96 ++++++++++--------- .../src/snapshot-source/lifecycle.ts | 54 ++++------- 2 files changed, 66 insertions(+), 84 deletions(-) diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index 939f46c6e2..56b9b6dff0 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -480,7 +480,7 @@ test('a close while the helper exit status stays unread ends the capture instead deadline: deadline(undefined, 2000), }); await waitForFixture( - () => fixture.processes[0]?.exitStatusReads === 1, + () => (fixture.processes[0]?.exitStatusReads ?? 0) >= 1, 'the manager never asked for the helper exit status', ); @@ -519,12 +519,10 @@ test('an exit status that answers before the close still reports the helper cras deadline: deadline(undefined, 2000), }); await waitForFixture( - () => helper()?.exitStatusReads === 1, + () => helper()?.exitStatusReads >= 1, 'the manager never asked for the helper exit status', ); - // One synchronous turn: the status answers, and the close arrives right after it. The status had - // already answered, so the capture reports the crash the manager was told about, not the close. helper().crash(); const closing = manager.close(); @@ -550,11 +548,10 @@ test('a status read that fails before the close still reports the helper crash', deadline: deadline(undefined, 2000), }); await waitForFixture( - () => helper()?.exitStatusReads === 1, + () => helper()?.exitStatusReads >= 1, 'the manager never asked for the helper exit status', ); - // A failing status read is an answer too: the manager knows the helper is not coming back. helper().exitWithFailedStatus(); const closing = manager.close(); @@ -568,9 +565,6 @@ test('a capture whose exit status stays unread ends at its own deadline without const helper = () => fixture.processes[0]!; const manager = new SnapshotBridgeManager(fixture.host); - // A 120 ms budget: the helper dies without a status as the connect is attempted, and the exit read - // that follows has only this request's own budget to end it. No close arrives, so a wait raced - // against the close alone never settles and this request never answers. const failure = await manager .request({ target, @@ -591,47 +585,54 @@ test('a capture whose exit status stays unread ends at its own deadline without 'the exit wait did not answer the request budget it was given', ); assert.equal(helper().exitStatusReads >= 1, true); - // A status that never answered fabricated no exit diagnostic. assert.equal(exitDiagnostics(fixture).length, 0); await manager.close(); }); -test('a close that ends the exit wait still records the crash once the helper status answers', async () => { - const fixture = createLifecycleFixture({ helperGoneAtConnect: true }); - const helper = () => fixture.processes[0]!; - const manager = new SnapshotBridgeManager(fixture.host); - const request = manager.request({ - target, - bridge, - limits: { ...limits, maxDurationMs: 2000 }, - maxDepth: 10, - deadline: deadline(undefined, 2000), - }); - await waitForFixture( - () => helper()?.exitStatusReads === 1, - 'the manager never asked for the helper exit status', - ); - - const closed = await settleWithin(manager.close(), 500); - assert.equal(closed, 'settled', 'close waited for an exit status that never arrived'); - await assert.rejects(request, isClosedSourceFailure); - // The close did not learn the outcome, so nothing is diagnosed yet: the crash belongs to the status - // read the manager left behind, which is what records it when the helper finally answers. - assert.equal(exitDiagnostics(fixture).length, 0); - - helper().crash(); - await waitForFixture( - () => exitDiagnostics(fixture).length === 1, - 'the status read left behind by the close never diagnosed the exit', - ); - assert.equal(exitDiagnostics(fixture)[0]?.data?.exitCode, 1); - await manager.close(); -}); +test.each(['close', 'cancel'] as const)( + 'a %s that ends the exit wait still diagnoses a late exit', + async (ending) => { + const fixture = createLifecycleFixture({ helperGoneAtConnect: true }); + const helper = () => fixture.processes[0]!; + const manager = new SnapshotBridgeManager(fixture.host); + const cancellation = new AbortController(); + const request = manager.request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 2000 }, + maxDepth: 10, + deadline: deadline(cancellation.signal, 2000), + }); + await waitForFixture( + () => helper()?.exitStatusReads >= 1, + 'the manager never asked for the helper exit status', + ); + if (ending === 'close') { + assert.equal(await settleWithin(manager.close(), 500), 'settled'); + await assert.rejects(request, isClosedSourceFailure); + } else { + cancellation.abort(); + await assert.rejects( + request, + (error: unknown) => + error instanceof SnapshotSourceError && + error.failureKind === 'cancelled' && + error.failureCode === 'abort-signal', + ); + } + assert.equal(exitDiagnostics(fixture).length, 0); + helper().crash(); + await waitForFixture( + () => exitDiagnostics(fixture).length === 1, + `the status read left behind by the ${ending} never diagnosed the exit`, + ); + assert.equal(exitDiagnostics(fixture)[0]?.data?.exitCode, 1); + assert.equal(exitDiagnostics(fixture)[0]?.data?.stderr, 'fixture log'); + await manager.close(); + }, +); test('a close during a dispatched capture waits only for the capture deadline the helper died on', async () => { - // The helper answers 2000 ms after the write; the request budget is 150 ms, and the helper goes away - // without a status while the capture is on the way. The capture's own deadline is what ends it: close - // adopts that turn, so close is bounded by the same budget rather than by the status that never answers. const fixture = createLifecycleFixture({ responseDelayMs: 2000 }); const manager = new SnapshotBridgeManager(fixture.host); const request = manager.request({ @@ -652,10 +653,11 @@ test('a close during a dispatched capture waits only for the capture deadline th ); await assert.rejects( request, - (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout', + (error: unknown) => + error instanceof SnapshotSourceError && + error.failureKind === 'timeout' && + error.failureCode === 'bridge-request-deadline', ); - // The helper was already gone, so there was nothing to signal, and a status that never answered - // fabricated no exit diagnostic. assert.deepEqual(fixture.processes[0]!.signals, []); assert.equal(exitDiagnostics(fixture).length, 0); }); diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 3d51936f7d..a352de3330 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -186,9 +186,7 @@ export class SnapshotBridgeManager { sessions.push(entry.session); } await Promise.all([ - ...sessions.map(async (session) => { - await this.dispose(session, true); - }), + ...sessions.map((session) => this.dispose(session, true)), ...adopted.map(async (work) => { await work.settled; }), @@ -306,43 +304,25 @@ export class SnapshotBridgeManager { // Read the budget before anything is subscribed: a deadline that is already spent ends the question // here, with no listener and no timer to take back. const remainingMs = remainingSnapshotSourceMs(deadline, 'bridge-exit-deadline'); - let refuse!: () => void; - const refusal = new Promise<'closed'>((resolve) => { - refuse = () => resolve('closed'); - }); - if (stop.aborted) refuse(); - else stop.addEventListener('abort', refuse, { once: true }); + const exited = bridgeProcessExited(this.host, session.process); const stopWaiting = new AbortController(); - const budget = waitForSnapshotSourceDelay( - deadline, - remainingMs, - 'bridge-exit-deadline', - stopWaiting.signal, - ).then( - () => 'timeout' as const, - (error: unknown) => ({ cancelled: asSnapshotSourceError(error) }), - ); try { - // The raw status keeps its place in the race: its typed conversion takes a turn of its own, and an - // answer landing in that turn must not lose. Only the budget is pre-tagged, and a rejected status - // is an answer too — `bridgeProcessExited` reads the winner and keeps its typed failure. - const answer = await Promise.race([session.process.wait, refusal, budget]).catch( - () => 'status' as const, - ); - if (answer === 'closed') { - // The close stops waiting on the status, but the read stays subscribed as an observer: a helper - // that died on its own keeps its exit code and bounded log if the status ever answers. - void bridgeProcessExited(this.host, session.process).catch(() => {}); - throw closedSourceRefusal(); - } - if (answer === 'timeout') { - void bridgeProcessExited(this.host, session.process).catch(() => {}); - throw snapshotSourceError('timeout', 'bridge-exit-deadline'); - } - if (typeof answer === 'object' && 'cancelled' in answer) throw answer.cancelled; - return await bridgeProcessExited(this.host, session.process); + const answer = await Promise.race([ + session.process.wait, + waitForSnapshotSourceDelay( + deadline, + remainingMs, + 'bridge-exit-deadline', + AbortSignal.any([stop, stopWaiting.signal]), + ), + ]).catch((error: unknown) => { + if (error instanceof SnapshotSourceError) throw error; + return 'status' as const; + }); + if (answer !== undefined) return await exited; + if (stop.aborted) throw closedSourceRefusal(); + throw snapshotSourceError('timeout', 'bridge-exit-deadline'); } finally { - stop.removeEventListener('abort', refuse); stopWaiting.abort(); } }