diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index 8f4785c109..56b9b6dff0 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -376,6 +376,292 @@ 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 closing = manager.close(); + + // 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.equal(fixture.connects.length, 1); + 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('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 ?? 0) >= 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('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', + ); + + 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', + ); + + helper().exitWithFailedStatus(); + const closing = manager.close(); + + await assert.rejects(request, isHelperCrashFailure); + await closing; + 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); + + 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); + assert.equal(exitDiagnostics(fixture).length, 0); + 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 () => { + 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' && + error.failureCode === 'bridge-request-deadline', + ); + 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); @@ -408,6 +694,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 +705,73 @@ 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', + ); +} + +/** 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 (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 isHelperCrashFailure(error: unknown): boolean { + return ( + error instanceof SnapshotSourceError && + error.failureKind === 'process-crash' && + error.failureCode === 'bridge-exited' + ); +} + +function exitDiagnostics(fixture: LifecycleFixture): LifecycleFixture['diagnostics'] { + return fixture.diagnostics.filter( + (event) => event.phase === 'ios.snapshot-source.bridge-process-exit', + ); +} + function createLifecycleFixture( options: { connectDelayMs?: number; @@ -430,6 +780,15 @@ 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; + /** + * 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[] = []; @@ -437,10 +796,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 +815,15 @@ 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.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) => { @@ -476,20 +850,37 @@ function createLifecycleFixture( return socket; }, }; - return { host, processes, sockets, diagnostics, socketPaths, startedTargets }; + return { + host, + processes, + sockets, + diagnostics, + socketPaths, + startedTargets, + directorySetups, + connects, + }; } 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; + private rejectExitStatus!: (error: Error) => void; constructor(pid: number) { this.processId = pid; - this.wait = new Promise((resolve) => { - this.resolveWait = resolve; + this.exitStatus = new Promise((resolve, reject) => { + this.resolveExitStatus = resolve; + this.rejectExitStatus = reject; }); } @@ -497,6 +888,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; } @@ -504,12 +900,23 @@ 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; + } + + /** 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 { diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 223ea44db2..a352de3330 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,236 @@ 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((session) => 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); + throw await this.bridgeExitOrClose(session, deadline, stop); } + let socket: SnapshotSourceSocket; try { - return await this.host.connect(session.socketPath, { + socket = await this.host.connect(session.socketPath, { signal: deadline.signal, timeoutMs: Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs), }); } 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; + } + } + + /** + * 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. 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 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'); + const exited = bridgeProcessExited(this.host, session.process); + const stopWaiting = new AbortController(); + try { + 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 { + stopWaiting.abort(); } } + /** + * 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 +435,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 +455,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'); }), ]); }