From aa50c6d988b5e6172dca597fe18924e0eb108dd9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:47:49 -0700 Subject: [PATCH] fix(terminal): reject exited daemon admissions --- config/reliability-gates.jsonc | 18 +++- src/main/daemon/daemon-pty-adapter.test.ts | 71 ++++++++++++++- src/main/daemon/daemon-pty-adapter.ts | 52 ++++++++--- src/main/daemon/daemon-server.test.ts | 91 ++++++++++++++++++- src/main/daemon/daemon-server.ts | 7 +- .../daemon/daemon-session-exit-fence.test.ts | 2 + src/main/daemon/daemon-session-exit-fence.ts | 4 + 7 files changed, 223 insertions(+), 22 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 1b87f923df7..cd203933e4f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3668,6 +3668,8 @@ "a protocol-v23 exit from an older immutable session generation is rejected without probing", "a replacement-generation exit emitted before its create reply is deferred and finalized after admission", "an older alive readback cannot erase a newer deferred replacement-generation exit", + "a synchronous generation-qualified exit rejects the dead spawn result before callers can republish ownership", + "100 failed admissions with unknown readback and no published owner release every exit-fence map", "authoritative list absence clears active ownership and every exit-fence map" ] }, @@ -3675,7 +3677,7 @@ "file": "src/main/daemon/daemon-session-exit-fence.test.ts", "assertions": [ "same-id admission completion invalidates a stale absent-list snapshot and recognizes the older immutable generation", - "generation mismatch classification is deferred while same-id admission can still publish a replacement generation", + "generation mismatch classification is deferred while same-id admission is observably active and can still publish a replacement generation", "2,000 finalized session identities leave zero admission, revision, generation, or pending-exit entries", "an admission finishing after dispose-style clear cannot publish generation state or recreate any map" ] @@ -3685,7 +3687,8 @@ "assertions": [ "one immutable protocol-v23 generation crosses create, list, size, and exit and changes on same-id replacement", "100 synchronous buffered-output and exit subscriptions leave zero daemon stream routes or host sessions", - "failed attach rollback restores only the prior stream-route owner" + "failed attach rollback restores only a still-active prior stream-route owner", + "two overlapping failed attaches retain zero routes while an older failure cannot dislodge a newer successful route" ] }, { @@ -4218,6 +4221,15 @@ "result": "passed", "durationSeconds": 9.98, "summary": "1451 tests passed: 100 synchronous buffered-output/exit subscriptions retained zero daemon routes or sessions, replacement exits before create reply finalized after admission, and older alive readbacks preserved newer deferred exits." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/daemon-session-exit-fence.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 13.21, + "summary": "1455 tests passed: synchronous pre-reply exit rejected the dead spawn result, 100 ownerless unknown exits released every fence map, overlapping failed routes retained zero owners, and an older failure preserved the newer successful route." } ], "runtimeBudget": { @@ -4230,7 +4242,7 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The built relay artifact harness records the vanilla npm dependency at close=0, native-kill=0, deferred=1 and the shipped patched artifact at close=1, native-kill=1, deferred=0. Accepted local, daemon, and relay kills now remain provider-visible until onExit or conservative OS liveness proves physical exit; Windows retries stay signal-free and only native throws reissue the one-shot close. Same-id exits that race daemon or fallback replacement spawn admission are deferred, then suppressed only when the exact child provider proves the replacement live; overlapping same-id spawn is rejected before provider work can overwrite that proof. Injected daemon native failures propagate without reaping the live session, while ESRCH after accepted or throwing native close reaps the exact local/relay owner even when node-pty omits onExit. Whole-process daemon and relay drains retain listeners and retry ownership until exit proof or the bounded process-exit deadline, and an older 5-second graceful timer is replaced by the shutdown cadence. RPC drain rejection is contained before explicit detached-process exit. Stale relay spawns retain one timer beyond the eighth failure, graceful fallback throws stay below relay-global uncaughtException, and a standalone cold 51-request relay admission deterministically creates 50 live PTYs plus one cap rejection through one shared addon load. Protocol-v23 generations plus epoch-bound admission publication reject old exits, stale absence, and post-clear completion; authoritative absence releases every map. Stream routes are published before subscription, deleted only by their exact callback owner, and restored on failed attach only while that route remains current; 100 synchronous output/exit cycles retain zero routes or sessions. Replacement-generation exits arriving before their create reply stay pending through admission, and an older alive readback cannot erase that newer exit. The former 65-id renderer oracle proved the oldest still-live owner was dropped; the exact 1,024-id tombstone oracle evicts only the oldest id, recovers its successor, and probes none of 100 unrelated later registrations. A disconnected SSH provider retains its lease without a synthetic exit; 50 concurrent accepted SSH owners share one provider inventory, bulk teardown preserves relay generation and pane identity in the durable owner, and target removal preserves metadata unless every PTY is exit-proved before the grace-zero relay is force-stopped. A real worktree-removal preflight deterministically rejects a late matching-host admission while allowing an unrelated host removal; fallback cleanup no longer erases state after unproved shutdown." + "evidence": "The built relay artifact harness records the vanilla npm dependency at close=0, native-kill=0, deferred=1 and the shipped patched artifact at close=1, native-kill=1, deferred=0. Accepted local, daemon, and relay kills now remain provider-visible until onExit or conservative OS liveness proves physical exit; Windows retries stay signal-free and only native throws reissue the one-shot close. Same-id exits that race daemon or fallback replacement spawn admission are deferred, then suppressed only when the exact child provider proves the replacement live; overlapping same-id spawn is rejected before provider work can overwrite that proof. Injected daemon native failures propagate without reaping the live session, while ESRCH after accepted or throwing native close reaps the exact local/relay owner even when node-pty omits onExit. Whole-process daemon and relay drains retain listeners and retry ownership until exit proof or the bounded process-exit deadline, and an older 5-second graceful timer is replaced by the shutdown cadence. RPC drain rejection is contained before explicit detached-process exit. Stale relay spawns retain one timer beyond the eighth failure, graceful fallback throws stay below relay-global uncaughtException, and a standalone cold 51-request relay admission deterministically creates 50 live PTYs plus one cap rejection through one shared addon load. Protocol-v23 generations plus epoch-bound admission publication reject old exits, stale absence, and post-clear completion; authoritative absence releases every map. Stream routes are published before subscription, invalidated by their own exit/failure even when not current, and restored on failed attach only while the predecessor remains active; 100 synchronous output/exit cycles and two overlapping failures retain zero routes or sessions, while an older failure cannot dislodge a newer successful route. Replacement-generation exits are queued before cross-socket readback while admission is active, older alive readbacks cannot erase them, and authoritative reconciliation rejects a dead pre-reply spawn result before caller ownership can be republished. One hundred failed admissions with unknown readback and no published owner release every fence map. The former 65-id renderer oracle proved the oldest still-live owner was dropped; the exact 1,024-id tombstone oracle evicts only the oldest id, recovers its successor, and probes none of 100 unrelated later registrations. A disconnected SSH provider retains its lease without a synthetic exit; 50 concurrent accepted SSH owners share one provider inventory, bulk teardown preserves relay generation and pane identity in the durable owner, and target removal preserves metadata unless every PTY is exit-proved before the grace-zero relay is force-stopped. A real worktree-removal preflight deterministically rejects a late matching-host admission while allowing an unrelated host removal; fallback cleanup no longer erases state after unproved shutdown." }, "performanceBudget": { "required": true, diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index b8863468e62..d71d1584f72 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -31,7 +31,10 @@ function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-adapter-test-')) } -function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { +function createMockSubprocess( + dataOnSubscribe?: string, + exitOnSubscribe?: number +): SubprocessHandle & { pause: ReturnType void>> resume: ReturnType void>> _simulateData: (data: string) => void @@ -59,6 +62,9 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { }, onExit(cb) { onExitCb = cb + if (exitOnSubscribe !== undefined) { + cb(exitOnSubscribe) + } }, dispose: vi.fn(), _simulateData(data: string) { @@ -96,9 +102,11 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { command?: string } | null let subprocessDataOnSubscribe: string | undefined + let subprocessExitOnSubscribe: number | undefined beforeEach(async () => { subprocessDataOnSubscribe = undefined + subprocessExitOnSubscribe = undefined dir = createTestDir() socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') @@ -108,7 +116,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { tokenPath, spawnSubprocess: (opts) => { lastSpawnOpts = opts - lastSubprocess = createMockSubprocess(subprocessDataOnSubscribe) + lastSubprocess = createMockSubprocess(subprocessDataOnSubscribe, subprocessExitOnSubscribe) return lastSubprocess } }) @@ -133,6 +141,32 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(typeof result.id).toBe('string') }) + it('rejects a session that exits synchronously before its create reply publishes', async () => { + subprocessExitOnSubscribe = 0 + + await expect( + adapter.spawn({ sessionId: 'synchronous-admission-exit', cols: 80, rows: 24 }) + ).rejects.toThrow('Daemon PTY exited during admission') + + const daemon = server as unknown as { + host: { listSessions(): unknown[] } + streamRouteBySessionId: Map + } + const internals = adapter as unknown as { + exitFence: Record< + 'revisions' | 'admissions' | 'sessionGenerations' | 'pendingExits', + Map + > + } + expect(adapter.hasPty('synchronous-admission-exit')).toBe(false) + expect(daemon.host.listSessions()).toHaveLength(0) + expect(daemon.streamRouteBySessionId.size).toBe(0) + expect(internals.exitFence.revisions.size).toBe(0) + expect(internals.exitFence.admissions.size).toBe(0) + expect(internals.exitFence.sessionGenerations.size).toBe(0) + expect(internals.exitFence.pendingExits.size).toBe(0) + }) + it('fails closed when a preserved legacy session has no observable pane identity', async () => { const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 21 }) const internals = legacy as unknown as { @@ -606,7 +640,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const internals = adapter as unknown as { readAppliedSize(sessionId: string): Promise<{ status: 'alive' | 'absent' | 'unknown' }> handleExitEvent(sessionId: string, code: number, sessionGeneration?: string): Promise - reconcilePendingExitAfterAdmission(sessionId: string): Promise + reconcilePendingExitAfterAdmission(sessionId: string): Promise exitFence: { beginAdmission(sessionId: string): { complete(): void @@ -653,7 +687,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { sessionGeneration?: string }> handleExitEvent(sessionId: string, code: number, sessionGeneration?: string): Promise - reconcilePendingExitAfterAdmission(sessionId: string): Promise + reconcilePendingExitAfterAdmission(sessionId: string): Promise exitFence: { beginAdmission(sessionId: string): { complete(): void } rememberGeneration( @@ -689,6 +723,35 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(exits).toEqual([{ id, code: 11 }]) }) + it('releases unknown deferred exits after failed admissions publish no owner', async () => { + const internals = adapter as unknown as { + readAppliedSize(sessionId: string): Promise<{ status: 'alive' | 'absent' | 'unknown' }> + reconcilePendingExitAfterAdmission(sessionId: string): Promise + exitFence: { + beginAdmission(sessionId: string): { complete(): void } + defer(sessionId: string, exit: { code: number }): void + revisions: Map + admissions: Map + sessionGenerations: Map + pendingExits: Map + } + } + vi.spyOn(internals, 'readAppliedSize').mockResolvedValue({ status: 'unknown' }) + + for (let index = 0; index < 100; index++) { + const sessionId = `failed-admission-${index}` + const admission = internals.exitFence.beginAdmission(sessionId) + internals.exitFence.defer(sessionId, { code: index }) + admission.complete() + await expect(internals.reconcilePendingExitAfterAdmission(sessionId)).resolves.toBe(true) + } + + expect(internals.exitFence.revisions.size).toBe(0) + expect(internals.exitFence.admissions.size).toBe(0) + expect(internals.exitFence.sessionGenerations.size).toBe(0) + expect(internals.exitFence.pendingExits.size).toBe(0) + }) + it('retains an unknown exit until authoritative listing reconciles it', async () => { const exits: { id: string; code: number }[] = [] adapter.onExit((payload) => exits.push(payload)) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 4c5d4d1ec19..ed4df71f82e 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -203,12 +203,15 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() } const admission = this.exitFence.beginAdmission(sessionId) + let result: PtySpawnResult try { - return await this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, admission)) - } finally { - admission.complete() - await this.reconcilePendingExitAfterAdmission(sessionId) + result = await this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, admission)) + } catch (error) { + await this.finishAdmission(sessionId, admission, false) + throw error } + await this.finishAdmission(sessionId, admission, true) + return result } private async doSpawn( @@ -509,10 +512,11 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!this.exitFence.rememberGeneration(id, result.sessionGeneration, admission)) { throw new Error(`Daemon PTY admission superseded for "${id}"`) } - } finally { - admission.complete() - await this.reconcilePendingExitAfterAdmission(id) + } catch (error) { + await this.finishAdmission(id, admission, false) + throw error } + await this.finishAdmission(id, admission, true) } hasPty(id: string): boolean { @@ -1473,6 +1477,13 @@ export class DaemonPtyAdapter implements IPtyProvider { if (this.exitFence.isStaleGeneration(sessionId, sessionGeneration)) { return } + const exit: PendingDaemonExit = { code, ...(sessionGeneration ? { sessionGeneration } : {}) } + if (this.exitFence.isAdmissionActive(sessionId)) { + // Why before any readback await: create replies and stream exits use + // separate sockets, so admission completion must see the queued exit. + this.exitFence.defer(sessionId, exit) + return + } const fenceSnapshot = this.exitFence.snapshot(sessionId) // Why: control and stream sockets can reorder a replacement create reply // ahead of the old process's queued exit. Targeted liveness proof prevents @@ -1486,7 +1497,6 @@ export class DaemonPtyAdapter implements IPtyProvider { this.exitFence.clearPending(sessionId) return } - const exit: PendingDaemonExit = { code, ...(sessionGeneration ? { sessionGeneration } : {}) } if (!this.exitFence.isStable(sessionId, fenceSnapshot)) { this.exitFence.defer(sessionId, exit) // Why: admission may have completed while the old readback was in @@ -1534,22 +1544,42 @@ export class DaemonPtyAdapter implements IPtyProvider { } } - private async reconcilePendingExitAfterAdmission(sessionId: string): Promise { + private async reconcilePendingExitAfterAdmission(sessionId: string): Promise { const pending = this.exitFence.getPending(sessionId) if (!pending || this.exitFence.isStaleGeneration(sessionId, pending.sessionGeneration)) { this.exitFence.clearPending(sessionId) - return + return false } const fenceSnapshot = this.exitFence.snapshot(sessionId) const readback = await this.readAppliedSize(sessionId) if (!this.exitFence.isStable(sessionId, fenceSnapshot)) { - return + return false } if (readback.status === 'alive') { this.exitFence.rememberGeneration(sessionId, readback.sessionGeneration) this.exitFence.clearPending(sessionId) + return false } else if (readback.status === 'absent') { this.finalizeExitEvent(sessionId, pending.code) + return true + } else if (!this.activeSessionIds.has(sessionId)) { + // Why: the exit event is authoritative for an admission that never + // published ownership; an inconclusive probe must not retain fence maps. + this.finalizeExitEvent(sessionId, pending.code) + return true + } + return false + } + + private async finishAdmission( + sessionId: string, + admission: DaemonSessionAdmission, + rejectIfExited: boolean + ): Promise { + admission.complete() + const exited = await this.reconcilePendingExitAfterAdmission(sessionId) + if (rejectIfExited && exited) { + throw new Error(`Daemon PTY exited during admission for "${sessionId}"`) } } } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index c6304e9eda4..78f888cf497 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -51,7 +51,7 @@ function createMockSubprocess(): SubprocessHandle & { type DaemonServerPrivate = { server: Server | null host: TerminalHost - streamRouteBySessionId: Map + streamRouteBySessionId: Map clients: Map< string, { @@ -217,7 +217,7 @@ describe('DaemonServer', () => { } }) const daemon = server as unknown as DaemonServerPrivate - const previousRoute = { clientId: 'previous-client' } + const previousRoute = { clientId: 'previous-client', active: true } daemon.streamRouteBySessionId.set('failed-attach', previousRoute) await expect( @@ -231,6 +231,93 @@ describe('DaemonServer', () => { expect(daemon.streamRouteBySessionId.get('failed-attach')).toBe(previousRoute) }) + it('does not resurrect an older failed route when overlapping attaches both fail', async () => { + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + const daemon = server as unknown as DaemonServerPrivate + let rejectOlder!: (reason: unknown) => void + let rejectNewer!: (reason: unknown) => void + const olderResult = new Promise((_resolve, reject) => { + rejectOlder = reject + }) + const newerResult = new Promise((_resolve, reject) => { + rejectNewer = reject + }) + vi.spyOn(daemon.host, 'createOrAttach') + .mockReturnValueOnce(olderResult) + .mockReturnValueOnce(newerResult) + + const olderRequest = daemon.routeRequest('older-client', { + id: 'older-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-failure', cols: 80, rows: 24 } + }) + const newerRequest = daemon.routeRequest('newer-client', { + id: 'newer-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-failure', cols: 80, rows: 24 } + }) + + rejectOlder(new Error('older failed')) + await expect(olderRequest).rejects.toThrow('older failed') + expect(daemon.streamRouteBySessionId.get('overlapping-failure')).toMatchObject({ + clientId: 'newer-client', + active: true + }) + + rejectNewer(new Error('newer failed')) + await expect(newerRequest).rejects.toThrow('newer failed') + expect(daemon.streamRouteBySessionId.has('overlapping-failure')).toBe(false) + expect(daemon.host.listSessions()).toHaveLength(0) + }) + + it('keeps a newer successful route when an overlapping older attach fails', async () => { + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + const daemon = server as unknown as DaemonServerPrivate + let rejectOlder!: (reason: unknown) => void + const olderResult = new Promise((_resolve, reject) => { + rejectOlder = reject + }) + vi.spyOn(daemon.host, 'createOrAttach') + .mockReturnValueOnce(olderResult) + .mockResolvedValueOnce({ + isNew: true, + snapshot: null, + pid: 55555, + shellState: 'ready', + sessionGeneration: 'newer-generation', + attachToken: Symbol('newer-attach') + }) + + const olderRequest = daemon.routeRequest('older-client', { + id: 'older-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-success', cols: 80, rows: 24 } + }) + const newerRequest = daemon.routeRequest('newer-client', { + id: 'newer-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-success', cols: 80, rows: 24 } + }) + + rejectOlder(new Error('older failed')) + await expect(olderRequest).rejects.toThrow('older failed') + await expect(newerRequest).resolves.toMatchObject({ + sessionGeneration: 'newer-generation' + }) + expect(daemon.streamRouteBySessionId.get('overlapping-success')).toMatchObject({ + clientId: 'newer-client', + active: true + }) + }) + it('carries one immutable session generation through create, list, size, and exit', async () => { let subprocess: ReturnType server = new DaemonServer({ diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 4a40899a690..40e81a352d0 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -56,6 +56,7 @@ type ConnectedClient = { type SessionStreamRoute = { clientId: string + active: boolean } export class DaemonServer { @@ -350,7 +351,7 @@ export class DaemonServer { switch (request.type) { case 'createOrAttach': { const p = request.payload - const streamRoute: SessionStreamRoute = { clientId } + const streamRoute: SessionStreamRoute = { clientId, active: true } const previousStreamRoute = this.streamRouteBySessionId.get(p.sessionId) // Why before host subscription: node-pty may flush data and exit // synchronously, so routing ownership must exist before callbacks run. @@ -408,6 +409,7 @@ export class DaemonServer { sessionIdSuffix: p.sessionId.slice(-10) }) this.transientFactRelay.onSessionExit(p.sessionId) + streamRoute.active = false if (this.streamRouteBySessionId.get(p.sessionId) === streamRoute) { this.streamRouteBySessionId.delete(p.sessionId) } @@ -416,10 +418,11 @@ export class DaemonServer { } }) const result = await pendingResult.catch((error: unknown) => { + streamRoute.active = false // Why identity-fenced rollback: a failed older attach must not // erase a newer concurrent route for the reused session id. if (this.streamRouteBySessionId.get(p.sessionId) === streamRoute) { - if (previousStreamRoute) { + if (previousStreamRoute?.active) { this.streamRouteBySessionId.set(p.sessionId, previousStreamRoute) } else { this.streamRouteBySessionId.delete(p.sessionId) diff --git a/src/main/daemon/daemon-session-exit-fence.test.ts b/src/main/daemon/daemon-session-exit-fence.test.ts index 8bf8ded0b7e..4c38bffd338 100644 --- a/src/main/daemon/daemon-session-exit-fence.test.ts +++ b/src/main/daemon/daemon-session-exit-fence.test.ts @@ -25,10 +25,12 @@ describe('DaemonSessionExitFence', () => { fence.rememberGeneration('same-id', 'old-generation') const admission = fence.beginAdmission('same-id') + expect(fence.isAdmissionActive('same-id')).toBe(true) expect(fence.isStaleGeneration('same-id', 'replacement-generation')).toBe(false) fence.rememberGeneration('same-id', 'replacement-generation', admission) admission.complete() + expect(fence.isAdmissionActive('same-id')).toBe(false) expect(fence.isStaleGeneration('same-id', 'old-generation')).toBe(true) }) diff --git a/src/main/daemon/daemon-session-exit-fence.ts b/src/main/daemon/daemon-session-exit-fence.ts index d62157752fa..9b620c8975f 100644 --- a/src/main/daemon/daemon-session-exit-fence.ts +++ b/src/main/daemon/daemon-session-exit-fence.ts @@ -74,6 +74,10 @@ export class DaemonSessionExitFence { ) } + isAdmissionActive(sessionId: string): boolean { + return this.admissions.has(sessionId) + } + snapshot(sessionId: string): number | undefined { return this.revisions.get(sessionId) }