mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
fix(terminal): fence synchronous daemon exits
This commit is contained in:
@@ -3493,6 +3493,7 @@
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-environments.test.ts src/main/runtime/rpc/terminal-close-generation.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ipc/ssh.test.ts src/renderer/src/store/slices/worktrees.test.ts",
|
||||
"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/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/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"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/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"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",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-exit-fence.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-process-exit-proof.test.ts src/main/runtime/worktree-teardown.test.ts"
|
||||
],
|
||||
@@ -3665,6 +3666,8 @@
|
||||
"preserved pre-v22 sessions fail closed when restart cannot recover pane identity for attach or shutdown",
|
||||
"a stale daemon list started before same-id replacement completion cannot finalize the old exit",
|
||||
"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",
|
||||
"authoritative list absence clears active ownership and every exit-fence map"
|
||||
]
|
||||
},
|
||||
@@ -3672,6 +3675,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",
|
||||
"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"
|
||||
]
|
||||
@@ -3679,7 +3683,9 @@
|
||||
{
|
||||
"file": "src/main/daemon/daemon-server.test.ts",
|
||||
"assertions": [
|
||||
"one immutable protocol-v23 generation crosses create, list, size, and exit and changes on same-id replacement"
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -4203,6 +4209,15 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 9.06,
|
||||
"summary": "1443 tests passed: two-phase Session startup delivered synchronous buffered BOOT output and exit to the published owner/client, reaped the exact map entry, and allowed a clean same-ID replacement."
|
||||
},
|
||||
{
|
||||
"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": 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."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -4215,7 +4230,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. 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, 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."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
|
||||
@@ -599,6 +599,96 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(exits).toEqual([])
|
||||
})
|
||||
|
||||
it('retains a replacement exit emitted before admission publishes its generation', async () => {
|
||||
const exits: { id: string; code: number }[] = []
|
||||
adapter.onExit((payload) => exits.push(payload))
|
||||
const { id } = await adapter.spawn({ sessionId: 'pre-reply-exit', cols: 80, rows: 24 })
|
||||
const internals = adapter as unknown as {
|
||||
readAppliedSize(sessionId: string): Promise<{ status: 'alive' | 'absent' | 'unknown' }>
|
||||
handleExitEvent(sessionId: string, code: number, sessionGeneration?: string): Promise<void>
|
||||
reconcilePendingExitAfterAdmission(sessionId: string): Promise<void>
|
||||
exitFence: {
|
||||
beginAdmission(sessionId: string): {
|
||||
complete(): void
|
||||
}
|
||||
rememberGeneration(
|
||||
sessionId: string,
|
||||
generation: string,
|
||||
admission?: { complete(): void }
|
||||
): boolean
|
||||
getPending(sessionId: string): { code: number; sessionGeneration?: string } | undefined
|
||||
}
|
||||
}
|
||||
const admission = internals.exitFence.beginAdmission(id)
|
||||
vi.spyOn(internals, 'readAppliedSize').mockResolvedValue({ status: 'absent' })
|
||||
|
||||
await internals.handleExitEvent(id, 9, 'replacement-generation')
|
||||
|
||||
expect(internals.exitFence.getPending(id)).toEqual({
|
||||
code: 9,
|
||||
sessionGeneration: 'replacement-generation'
|
||||
})
|
||||
internals.exitFence.rememberGeneration(id, 'replacement-generation', admission)
|
||||
admission.complete()
|
||||
await internals.reconcilePendingExitAfterAdmission(id)
|
||||
|
||||
expect(adapter.hasPty(id)).toBe(false)
|
||||
expect(exits).toEqual([{ id, code: 9 }])
|
||||
})
|
||||
|
||||
it('does not let an older alive readback erase a newer deferred exit', async () => {
|
||||
const exits: { id: string; code: number }[] = []
|
||||
adapter.onExit((payload) => exits.push(payload))
|
||||
const { id } = await adapter.spawn({ sessionId: 'overlapping-readback', cols: 80, rows: 24 })
|
||||
let resolveOlderReadback!: (value: { status: 'alive'; sessionGeneration: string }) => void
|
||||
const olderReadback = new Promise<{
|
||||
status: 'alive'
|
||||
sessionGeneration: string
|
||||
}>((resolve) => {
|
||||
resolveOlderReadback = resolve
|
||||
})
|
||||
const internals = adapter as unknown as {
|
||||
readAppliedSize(sessionId: string): Promise<{
|
||||
status: 'alive' | 'absent' | 'unknown'
|
||||
sessionGeneration?: string
|
||||
}>
|
||||
handleExitEvent(sessionId: string, code: number, sessionGeneration?: string): Promise<void>
|
||||
reconcilePendingExitAfterAdmission(sessionId: string): Promise<void>
|
||||
exitFence: {
|
||||
beginAdmission(sessionId: string): { complete(): void }
|
||||
rememberGeneration(
|
||||
sessionId: string,
|
||||
generation: string,
|
||||
admission?: { complete(): void }
|
||||
): boolean
|
||||
getPending(sessionId: string): { code: number; sessionGeneration?: string } | undefined
|
||||
}
|
||||
}
|
||||
const readback = vi
|
||||
.spyOn(internals, 'readAppliedSize')
|
||||
.mockImplementationOnce(() => olderReadback)
|
||||
.mockResolvedValue({ status: 'unknown' })
|
||||
const olderExit = internals.handleExitEvent(id, 0)
|
||||
await waitFor(() => readback.mock.calls.length === 1)
|
||||
|
||||
const admission = internals.exitFence.beginAdmission(id)
|
||||
await internals.handleExitEvent(id, 11, 'replacement-generation')
|
||||
resolveOlderReadback({ status: 'alive', sessionGeneration: 'old-generation' })
|
||||
await olderExit
|
||||
|
||||
expect(internals.exitFence.getPending(id)).toEqual({
|
||||
code: 11,
|
||||
sessionGeneration: 'replacement-generation'
|
||||
})
|
||||
internals.exitFence.rememberGeneration(id, 'replacement-generation', admission)
|
||||
admission.complete()
|
||||
readback.mockResolvedValue({ status: 'absent' })
|
||||
await internals.reconcilePendingExitAfterAdmission(id)
|
||||
|
||||
expect(adapter.hasPty(id)).toBe(false)
|
||||
expect(exits).toEqual([{ id, code: 11 }])
|
||||
})
|
||||
|
||||
it('retains an unknown exit until authoritative listing reconciles it', async () => {
|
||||
const exits: { id: string; code: number }[] = []
|
||||
adapter.onExit((payload) => exits.push(payload))
|
||||
|
||||
@@ -1479,9 +1479,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// that stale exit from deleting the replacement's same-id ownership.
|
||||
const readback = await this.readAppliedSize(sessionId)
|
||||
if (readback.status === 'alive') {
|
||||
if (this.exitFence.isStable(sessionId, fenceSnapshot)) {
|
||||
this.exitFence.rememberGeneration(sessionId, readback.sessionGeneration)
|
||||
if (!this.exitFence.isStable(sessionId, fenceSnapshot)) {
|
||||
return
|
||||
}
|
||||
this.exitFence.rememberGeneration(sessionId, readback.sessionGeneration)
|
||||
this.exitFence.clearPending(sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
type DaemonServerPrivate = {
|
||||
server: Server | null
|
||||
host: TerminalHost
|
||||
streamRouteBySessionId: Map<string, { clientId: string }>
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
@@ -181,6 +182,55 @@ describe('DaemonServer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retain routes when subscription flushes output and exit synchronously', async () => {
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => {
|
||||
const subprocess = createMockSubprocess()
|
||||
subprocess.onData = vi.fn((callback) => callback('BOOT'))
|
||||
subprocess.onExit = vi.fn((callback) => callback(0))
|
||||
return subprocess
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
const c = await connectClient()
|
||||
const daemon = server as unknown as DaemonServerPrivate
|
||||
|
||||
for (let index = 0; index < 100; index++) {
|
||||
await c.request('createOrAttach', {
|
||||
sessionId: `synchronous-exit-${index}`,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
expect(daemon.streamRouteBySessionId.size).toBe(0)
|
||||
expect(daemon.host.listSessions()).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the prior route when a new attach fails before taking ownership', async () => {
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => {
|
||||
throw new Error('spawn failed')
|
||||
}
|
||||
})
|
||||
const daemon = server as unknown as DaemonServerPrivate
|
||||
const previousRoute = { clientId: 'previous-client' }
|
||||
daemon.streamRouteBySessionId.set('failed-attach', previousRoute)
|
||||
|
||||
await expect(
|
||||
daemon.routeRequest('replacement-client', {
|
||||
id: 'failed-request',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'failed-attach', cols: 80, rows: 24 }
|
||||
})
|
||||
).rejects.toThrow('spawn failed')
|
||||
|
||||
expect(daemon.streamRouteBySessionId.get('failed-attach')).toBe(previousRoute)
|
||||
})
|
||||
|
||||
it('carries one immutable session generation through create, list, size, and exit', async () => {
|
||||
let subprocess: ReturnType<typeof createMockSubprocess>
|
||||
server = new DaemonServer({
|
||||
|
||||
@@ -54,6 +54,10 @@ type ConnectedClient = {
|
||||
streamSocket: Socket | null
|
||||
}
|
||||
|
||||
type SessionStreamRoute = {
|
||||
clientId: string
|
||||
}
|
||||
|
||||
export class DaemonServer {
|
||||
private server: Server | null = null
|
||||
private token: string
|
||||
@@ -86,9 +90,9 @@ export class DaemonServer {
|
||||
// them (a fact jumping the queue could arrive after the reveal snapshot
|
||||
// that already reflects it).
|
||||
private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => {
|
||||
const clientId = this.streamClientIdBySessionId.get(sessionId)
|
||||
if (clientId) {
|
||||
this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, {
|
||||
const route = this.streamRouteBySessionId.get(sessionId)
|
||||
if (route) {
|
||||
this.streamDataBatcher.enqueueControlEvent(route.clientId, sessionId, {
|
||||
type: 'event',
|
||||
event: 'transientFact',
|
||||
sessionId,
|
||||
@@ -96,7 +100,7 @@ export class DaemonServer {
|
||||
})
|
||||
}
|
||||
})
|
||||
private streamClientIdBySessionId = new Map<string, string>()
|
||||
private streamRouteBySessionId = new Map<string, SessionStreamRoute>()
|
||||
private lastInputAtBySessionId = new Map<string, number>()
|
||||
private stopStreamBacklogProbe: () => void = () => {}
|
||||
|
||||
@@ -346,7 +350,12 @@ export class DaemonServer {
|
||||
switch (request.type) {
|
||||
case 'createOrAttach': {
|
||||
const p = request.payload
|
||||
const result = await this.host.createOrAttach({
|
||||
const streamRoute: SessionStreamRoute = { clientId }
|
||||
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.
|
||||
this.streamRouteBySessionId.set(p.sessionId, streamRoute)
|
||||
const pendingResult = this.host.createOrAttach({
|
||||
sessionId: p.sessionId,
|
||||
cols: p.cols,
|
||||
rows: p.rows,
|
||||
@@ -399,12 +408,25 @@ export class DaemonServer {
|
||||
sessionIdSuffix: p.sessionId.slice(-10)
|
||||
})
|
||||
this.transientFactRelay.onSessionExit(p.sessionId)
|
||||
this.streamClientIdBySessionId.delete(p.sessionId)
|
||||
if (this.streamRouteBySessionId.get(p.sessionId) === streamRoute) {
|
||||
this.streamRouteBySessionId.delete(p.sessionId)
|
||||
}
|
||||
this.lastInputAtBySessionId.delete(p.sessionId)
|
||||
}
|
||||
}
|
||||
})
|
||||
this.streamClientIdBySessionId.set(p.sessionId, clientId)
|
||||
const result = await pendingResult.catch((error: unknown) => {
|
||||
// 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) {
|
||||
this.streamRouteBySessionId.set(p.sessionId, previousStreamRoute)
|
||||
} else {
|
||||
this.streamRouteBySessionId.delete(p.sessionId)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
})
|
||||
// Why an attach-time marker: the adapter resyncs the background set on
|
||||
// a fresh connection, which can precede this attach — main's scan
|
||||
// suppression must still start at the head of the new stream.
|
||||
@@ -485,8 +507,8 @@ export class DaemonServer {
|
||||
this.host.getPartialEscapeTailAnsi(sessionId)
|
||||
)
|
||||
}
|
||||
const streamClientId = this.streamClientIdBySessionId.get(sessionId)
|
||||
if (!streamClientId) {
|
||||
const streamRoute = this.streamRouteBySessionId.get(sessionId)
|
||||
if (!streamRoute) {
|
||||
// Not attached yet — the attach-time marker covers the handoff.
|
||||
return {}
|
||||
}
|
||||
@@ -496,7 +518,7 @@ export class DaemonServer {
|
||||
// and the normal flush/drain loop delivers them within milliseconds
|
||||
// (bounded ≤ the keep-tail drop cap), in order, ahead of the marker.
|
||||
const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId)
|
||||
this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
|
||||
this.streamDataBatcher.enqueueControlEvent(streamRoute.clientId, sessionId, {
|
||||
type: 'event',
|
||||
event: 'sessionBackgroundMarker',
|
||||
sessionId,
|
||||
|
||||
@@ -20,6 +20,18 @@ describe('DaemonSessionExitFence', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('defers generation mismatch classification while admission is active', () => {
|
||||
const fence = new DaemonSessionExitFence()
|
||||
fence.rememberGeneration('same-id', 'old-generation')
|
||||
|
||||
const admission = fence.beginAdmission('same-id')
|
||||
expect(fence.isStaleGeneration('same-id', 'replacement-generation')).toBe(false)
|
||||
|
||||
fence.rememberGeneration('same-id', 'replacement-generation', admission)
|
||||
admission.complete()
|
||||
expect(fence.isStaleGeneration('same-id', 'old-generation')).toBe(true)
|
||||
})
|
||||
|
||||
it('releases every per-session map after repeated exit finalization', () => {
|
||||
const fence = new DaemonSessionExitFence()
|
||||
for (let i = 0; i < 2_000; i++) {
|
||||
|
||||
@@ -64,7 +64,14 @@ export class DaemonSessionExitFence {
|
||||
|
||||
isStaleGeneration(sessionId: string, generation: string | undefined): boolean {
|
||||
const current = this.sessionGenerations.get(sessionId)
|
||||
return generation !== undefined && current !== undefined && generation !== current
|
||||
// Why: a replacement can exit before its create reply publishes the new
|
||||
// generation; admission completion decides whether that queued exit won.
|
||||
return (
|
||||
!this.admissions.has(sessionId) &&
|
||||
generation !== undefined &&
|
||||
current !== undefined &&
|
||||
generation !== current
|
||||
)
|
||||
}
|
||||
|
||||
snapshot(sessionId: string): number | undefined {
|
||||
|
||||
Reference in New Issue
Block a user