From 2ab7a9b46e963440afd35b1cf73abb4884f6eda1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:54:56 -0700 Subject: [PATCH] fix: make teardown journal appends constant size --- config/reliability-gates.jsonc | 4 +- src/main/ipc/pty.ts | 7 ++ src/main/persistence.test.ts | 65 +++++++++++++++++++ src/main/persistence.ts | 33 ++++++++-- .../ssh/ssh-relay-generation-reattach.test.ts | 5 +- .../ssh-relay-session-terminal-error.test.ts | 1 + src/main/ssh/ssh-relay-session.test.ts | 1 + src/main/ssh/ssh-relay-session.ts | 2 + src/main/terminal-teardown-intent-records.ts | 20 +++++- src/main/terminal-teardown-intent-snapshot.ts | 42 ++++++++---- 10 files changed, 155 insertions(+), 25 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index d9ab0c4fbc2..edb75f99ece 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3344,13 +3344,13 @@ "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "ssh"], - "coverageNotes": "A macOS unit harness loads the installed patched node-pty WindowsTerminal prototype without constructing native state, proving pre-ready kill/destroy dispatch immediately. A relay artifact harness reproduces vanilla npm node-pty retaining one deferred kill with zero native close calls, then proves both Windows relay artifacts hash, ship, apply, include the local ConPTY console-list fallback, and idempotently verify the fix. Relay tests simulate win32 shutdown, repeated stale-spawn native failure, one-shot ownership, and retry after native failure without deleting the only handle before close is proven. Relay boot UUIDs scope recycled pty-N counters through initial spawn, durable-lease persistence, reattach, and SSH target-id migration; mismatched generations expire before raw attach, while pane identity protects legacy leases. Main-process retry owners persist local daemon and remote-runtime teardown intent across renderer and app restarts, coalesce by exact provider identity, use one shared one-shot timer per owner, and fence SSH relay plus remote-runtime generation reuse before close. Local Windows shutdown retains provider records and listeners after native throw unless onExit independently proves death. A disconnected SSH provider rejects shutdown without tombstoning its attachable lease. Paired/mobile headless close refuses to depersist a terminal until the controller accepts durable retry ownership. Project removal keeps local or remote ownership until provider-verified shutdown and successful VM cleanup; runtime repo removal drains pre-spawn trust/team/command preparation plus create-first worktree creation, headless, renderer-backed mobile, renderer split, and selector-less renderer terminal admissions before fresh ownership/liveness snapshots, stop, and metadata mutation. Desktop and duplicate-host repo removal route through the same transaction; legacy worktree creation, renderer PTY spawn, and every local/SSH/folder worktree removal outcome—including Windows stale registration and orphan-compatible fallback—are fenced and verified before destructive mutation or metadata purge. Hidden local/SSH and remote-runtime background-launch cleanup retain exact PTY/handle retry ownership with bounded one-shot scheduling. Crash-durable teardown intent uses an O(1) revisioned append journal, compacted to one checkpoint after a successful full-state save, instead of synchronously serializing every retained owner for each mutation. Targeted terminal.stop probes skip unrelated sessions. The same relay contract serves paired web/mobile clients; no live Windows ConPTY or Windows-over-SSH run is recorded yet.", + "coverageNotes": "A macOS unit harness loads the installed patched node-pty WindowsTerminal prototype without constructing native state, proving pre-ready kill/destroy dispatch immediately. A relay artifact harness reproduces vanilla npm node-pty retaining one deferred kill with zero native close calls, then proves both Windows relay artifacts hash, ship, apply, include the local ConPTY console-list fallback, and idempotently verify the fix. Relay tests simulate win32 shutdown, repeated stale-spawn native failure, one-shot ownership, and retry after native failure without deleting the only handle before close is proven. Relay boot UUIDs scope recycled pty-N counters through initial spawn, durable-lease persistence, reattach, and SSH target-id migration; mismatched generations expire before raw attach and release their exact in-memory retry owner, while pane identity protects legacy leases. Main-process retry owners persist local daemon and remote-runtime teardown intent across renderer and app restarts, coalesce by exact provider identity, use one shared one-shot timer per owner, and fence SSH relay plus remote-runtime generation reuse before close. Local Windows shutdown retains provider records and listeners after native throw unless onExit independently proves death. A disconnected SSH provider rejects shutdown without tombstoning its attachable lease. Paired/mobile headless close refuses to depersist a terminal until the controller accepts durable retry ownership. Project removal keeps local or remote ownership until provider-verified shutdown and successful VM cleanup; runtime repo removal drains pre-spawn trust/team/command preparation plus create-first worktree creation, headless, renderer-backed mobile, renderer split, and selector-less renderer terminal admissions before fresh ownership/liveness snapshots, stop, and metadata mutation. Desktop and duplicate-host repo removal route through the same transaction; legacy worktree creation, renderer PTY spawn, and every local/SSH/folder worktree removal outcome—including Windows stale registration and orphan-compatible fallback—are fenced and verified before destructive mutation or metadata purge. Hidden local/SSH and remote-runtime background-launch cleanup retain exact PTY/handle retry ownership with bounded one-shot scheduling. Crash-durable teardown intent uses an O(1) revisioned append journal with exact SSH set/remove records, compacted to one checkpoint after a successful full-state save, instead of synchronously serializing every retained owner for each mutation. Targeted terminal.stop probes skip unrelated sessions. The same relay contract serves paired web/mobile clients; no live Windows ConPTY or Windows-over-SSH run is recorded yet.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8260", "https://github.com/stablyai/orca/pull/8279" ], "invariant": "A Windows PTY closed before its first output executes exactly one no-argument node-pty shutdown immediately; Orca never deletes ownership without accepted native shutdown or an observed exit, and a later retry can target only the same relay generation and pane while project removal fences both worktree and terminal creation.", - "oracle": "Load the installed patched WindowsTerminal prototype in a pre-ready state and assert kill and destroy each close the terminal and native agent once with zero deferred work, while signal arguments still throw. Build both Windows SSH relay artifacts, reproduce vanilla npm node-pty with close=0/native-kill=0/deferred=1, apply the shipped patch, and assert close=1/native-kill=1/deferred=0, AttachConsole failure falls back to the shell pid, and the artifact version derives from all patch bytes. Assert remote npm install executes patch enforcement before its runtime probe and never finalizes a failed patch. In local and relay providers, assert native throws retain exact ownership/listeners and retry succeeds, while a synchronous onExit is accepted as independent death proof; provider absence must retain the SSH lease and reject shutdown. Persist relay generation with every durable lease, expire mismatches before attach, and preserve generation through target migration. Reject local daemon and remote-runtime teardown, reload the durable store, wait for the startup provider before replay, replace an in-flight provider, and prove the same caller re-drives the replacement before settling, stronger coalesced shutdown options persist, and capped retries drain exact identities with zero final retry timers; persisted close intent for an already-removed runtime must drain without an unhandled rejection or retry wake loop. A generationless SSH id must not mutate or tombstone a generation-qualified replacement lease. Paired/mobile headless close must preserve persisted UI state when controller ownership is rejected. Create and clear 65 local plus 65 remote-runtime owners, reload immediately at both boundaries, and assert 260 O(1) journal records stay below 100 KiB with a 1 KiB per-record cap, synchronous full-store flush count remains zero, a torn final append preserves all complete records, the next mutation first rewrites an atomic recovered checkpoint, and the next full-state save compacts the journal to one revision-fenced checkpoint; an older valid sidecar must lose to a newer full-store fallback revision. Replace SSH relay and remote-runtime generations while a retry is retained and prove the new owner is never closed. Start pre-spawn trust/team preparation plus worktree and terminal creation surfaces before whole-project and host-scoped project removal and assert removal performs no ownership/liveness snapshot until they publish; host-scoped liveness must query only its selected provider so an unrelated SSH outage cannot veto removal. Removal-first and failed/concurrent admissions must reject or release without deadlock. Start a real worktree removal, pause its preflight, and assert a late renderer terminal admission is rejected; assert every successful registered, orphaned, leftover, already-missing, and stale-registration removal path verifies shutdown before mutation or metadata purge; assert desktop repo removal, worktree create/remove, and PTY spawn handlers all enter their runtime-owned transactions before mutation or provider work.", + "oracle": "Load the installed patched WindowsTerminal prototype in a pre-ready state and assert kill and destroy each close the terminal and native agent once with zero deferred work, while signal arguments still throw. Build both Windows SSH relay artifacts, reproduce vanilla npm node-pty with close=0/native-kill=0/deferred=1, apply the shipped patch, and assert close=1/native-kill=1/deferred=0, AttachConsole failure falls back to the shell pid, and the artifact version derives from all patch bytes. Assert remote npm install executes patch enforcement before its runtime probe and never finalizes a failed patch. In local and relay providers, assert native throws retain exact ownership/listeners and retry succeeds, while a synchronous onExit is accepted as independent death proof; provider absence must retain the SSH lease and reject shutdown. Persist relay generation with every durable lease, expire mismatches before attach while releasing their exact retry owner, and preserve generation through target migration. Reject local daemon and remote-runtime teardown, reload the durable store, wait for the startup provider before replay, replace an in-flight provider, and prove the same caller re-drives the replacement before settling, stronger coalesced shutdown options persist, and capped retries drain exact identities with zero final retry timers; persisted close intent for an already-removed runtime must drain without an unhandled rejection or retry wake loop. A generationless SSH id must not mutate or tombstone a generation-qualified replacement lease. Paired/mobile headless close must preserve persisted UI state when controller ownership is rejected. Create and clear 65 local plus 65 remote-runtime owners, reload immediately at both boundaries, and assert 260 O(1) journal records stay below 100 KiB with a 1 KiB per-record cap; repeat the same byte and record bounds for 50 generation-qualified SSH owners. Synchronous full-store flush count must remain zero, both a partial JSON tail and a complete record missing its newline must preserve all complete records, the next mutation must first rewrite an atomic recovered checkpoint, and the next full-state save must compact the journal to one revision-fenced checkpoint; an older valid sidecar must lose to a newer full-store fallback revision. Replace SSH relay and remote-runtime generations while a retry is retained and prove the new owner is never closed. Start pre-spawn trust/team preparation plus worktree and terminal creation surfaces before whole-project and host-scoped project removal and assert removal performs no ownership/liveness snapshot until they publish; host-scoped liveness must query only its selected provider so an unrelated SSH outage cannot veto removal. Removal-first and failed/concurrent admissions must reject or release without deadlock. Start a real worktree removal, pause its preflight, and assert a late renderer terminal admission is rejected; assert every successful registered, orphaned, leftover, already-missing, and stale-registration removal path verifies shutdown before mutation or metadata purge; assert desktop repo removal, worktree create/remove, and PTY spawn handlers all enter their runtime-owned transactions before mutation or provider work.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 324a602d49d..9920da584d2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1338,6 +1338,13 @@ export function releasePendingSshShutdownsForTarget(connectionId: string): void scheduleRetainedShutdownRetries() } +export function releasePendingSshShutdown(id: string): void { + pendingSshShutdownRetries.delete(id) + // Why: generation expiry proves this exact remote process is unreachable; + // its retained owner must not keep waking against the replacement relay. + scheduleRetainedShutdownRetries() +} + function retryPersistedSshShutdowns(connectionId: string, provider: IPtyProvider): void { const store = activePtyStore if (!store || typeof store.getSshRemotePtyLeases !== 'function') { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index e97d1319d0c..9fa18691183 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -8974,6 +8974,71 @@ describe('Store', () => { secondReload.flush() }) + it('rewrites a checkpoint before appending after a complete record loses its newline', async () => { + const store = await createStore() + store.upsertPendingLocalPtyShutdown({ ptyId: 'local-a', requestedAt: 1 }) + writeFileSync( + terminalTeardownIntentFile(), + readFileSync(terminalTeardownIntentFile(), 'utf-8').trimEnd(), + 'utf-8' + ) + + const reloaded = await createStore() + expect(reloaded.getPendingLocalPtyShutdowns()).toEqual([{ ptyId: 'local-a', requestedAt: 1 }]) + reloaded.upsertPendingLocalPtyShutdown({ ptyId: 'local-b', requestedAt: 2 }) + + const secondReload = await createStore() + expect(secondReload.getPendingLocalPtyShutdowns()).toEqual([ + { ptyId: 'local-a', requestedAt: 1 }, + { ptyId: 'local-b', requestedAt: 2 } + ]) + expect(readTerminalTeardownIntentJournal()).toEqual([ + expect.objectContaining({ kind: 'checkpoint', revision: 2 }) + ]) + store.flush() + reloaded.flush() + secondReload.flush() + }) + + it('keeps the 50-PTY SSH shutdown journal within the constant-record budget', async () => { + const store = await createStore() + const owners = Array.from({ length: 50 }, (_, index) => ({ + targetId: `target-${index}-${'t'.repeat(36)}`, + relayInstanceId: `boot-${index}-${'g'.repeat(36)}`, + ptyId: `pty-${index}-${'p'.repeat(36)}` + })) + for (const owner of owners) { + store.upsertSshRemotePtyLease({ ...owner, state: 'attached' }) + } + for (const owner of owners) { + expect( + store.markSshRemotePtyShutdownRequested( + owner.targetId, + `ssh:${owner.targetId}@@${owner.relayInstanceId}@@${owner.ptyId}` + ) + ).toBe(true) + } + + const raw = readFileSync(terminalTeardownIntentFile(), 'utf-8') + const records = readTerminalTeardownIntentJournal() + const shutdownRecords = records.filter( + (record) => record.kind === 'ssh-set' && record.shutdownRequestedAt !== undefined + ) + expect(shutdownRecords).toHaveLength(50) + expect(Math.max(...records.map((record) => JSON.stringify(record).length))).toBeLessThan(1_024) + expect(raw.length).toBeLessThan(100_000) + + const reloaded = await createStore() + expect( + owners.filter( + (owner) => + reloaded.getSshRemotePtyLeases(owner.targetId)[0]?.shutdownRequestedAt !== undefined + ) + ).toHaveLength(50) + store.flush() + reloaded.flush() + }) + it('ignores an older sidecar after the full-store fallback persisted a newer revision', async () => { const store = await createStore() store.upsertPendingLocalPtyShutdown({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 23e1d93cda7..0a59718db9a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -6459,6 +6459,20 @@ export class Store { } } + private sshTeardownIntentMutationForLease( + lease: Pick + ): TerminalTeardownIntentMutation { + return { + kind: 'ssh-set', + targetId: lease.targetId, + ptyId: lease.ptyId, + ...(lease.relayInstanceId ? { relayInstanceId: lease.relayInstanceId } : {}), + ...(lease.shutdownRequestedAt === undefined + ? {} + : { shutdownRequestedAt: lease.shutdownRequestedAt }) + } + } + upsertSshRemotePtyLease( lease: Omit & Partial> @@ -6497,7 +6511,7 @@ export class Store { this.state.sshRemotePtyLeases.push(next) } this.flush() - this.persistTerminalTeardownIntents({ kind: 'ssh-replace' }) + this.persistTerminalTeardownIntents(this.sshTeardownIntentMutationForLease(next)) } markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { @@ -6536,7 +6550,7 @@ export class Store { : false if (changed || bindingsChanged) { if (shouldClearBindings) { - this.persistTerminalTeardownIntents({ kind: 'ssh-replace' }) + this.persistTerminalTeardownIntents({ kind: 'ssh-remove-target', targetId }) } else { this.scheduleSave() } @@ -6569,7 +6583,7 @@ export class Store { const bindingsChanged = shouldClearBindings && this.clearSshRemotePtyBindingsForLeases(targetId, [lease]) if (bindingsChanged || intentChanged) { - this.persistTerminalTeardownIntents({ kind: 'ssh-replace' }) + this.persistTerminalTeardownIntents(this.sshTeardownIntentMutationForLease(lease)) } return true } @@ -6583,7 +6597,7 @@ export class Store { } if (shouldClearBindings) { this.clearSshRemotePtyBindingsForLeases(targetId, [lease]) - this.persistTerminalTeardownIntents({ kind: 'ssh-replace' }) + this.persistTerminalTeardownIntents(this.sshTeardownIntentMutationForLease(lease)) } else { this.scheduleSave() } @@ -6604,7 +6618,7 @@ export class Store { } lease.shutdownRequestedAt = Date.now() lease.updatedAt = lease.shutdownRequestedAt - this.persistTerminalTeardownIntents({ kind: 'ssh-replace' }) + this.persistTerminalTeardownIntents(this.sshTeardownIntentMutationForLease(lease)) return true } @@ -6626,7 +6640,12 @@ export class Store { lease.relayInstanceId !== requestedGeneration ) if (this.state.sshRemotePtyLeases.length !== before) { - this.persistTerminalTeardownIntents({ kind: 'ssh-replace' }) + this.persistTerminalTeardownIntents({ + kind: 'ssh-set', + targetId, + ptyId: relayPtyId, + ...(requestedGeneration ? { relayInstanceId: requestedGeneration } : {}) + }) } } @@ -6638,7 +6657,7 @@ export class Store { (lease) => lease.targetId !== targetId ) if (this.state.sshRemotePtyLeases.length !== before) { - this.persistTerminalTeardownIntents({ kind: 'ssh-replace' }) + this.persistTerminalTeardownIntents({ kind: 'ssh-remove-target', targetId }) } } diff --git a/src/main/ssh/ssh-relay-generation-reattach.test.ts b/src/main/ssh/ssh-relay-generation-reattach.test.ts index f47841a8498..210ca3af436 100644 --- a/src/main/ssh/ssh-relay-generation-reattach.test.ts +++ b/src/main/ssh/ssh-relay-generation-reattach.test.ts @@ -45,6 +45,7 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), + releasePendingSshShutdown: vi.fn(), setPtyOwnership: vi.fn(), answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) })) @@ -60,7 +61,8 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ unregisterSshGitProvider: vi.fn() })) -const { getSshPtyProvider, getPtyIdsForConnection, setPtyOwnership } = await import('../ipc/pty') +const { getSshPtyProvider, getPtyIdsForConnection, releasePendingSshShutdown, setPtyOwnership } = + await import('../ipc/pty') describe('SSH relay generation reattach', () => { beforeEach(() => { @@ -125,6 +127,7 @@ describe('SSH relay generation reattach', () => { 'ssh:target-1@@relay-old@@pty-recycled', 'expired' ) + expect(releasePendingSshShutdown).toHaveBeenCalledWith('ssh:target-1@@relay-old@@pty-recycled') expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { id: 'ssh:target-1@@relay-old@@pty-recycled', code: -1 diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index 0fab723fbb0..0fc9e964cfb 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -58,6 +58,7 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), + releasePendingSshShutdown: vi.fn(), setPtyOwnership: vi.fn(), answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) })) diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 54ed124dd72..76d9f436318 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -73,6 +73,7 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), + releasePendingSshShutdown: vi.fn(), setPtyOwnership: vi.fn(), answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) })) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index aadbbe45bb1..2215d984292 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -41,6 +41,7 @@ import { clearPtyOwnershipForConnection, clearProviderPtyState, deletePtyOwnership, + releasePendingSshShutdown, setPtyOwnership, answerStartupTerminalColorQueriesForPty } from '../ipc/pty' @@ -1200,6 +1201,7 @@ export class SshRelaySession { } private expireReattachPty(_relayPtyId: string, appPtyId: string): void { + releasePendingSshShutdown(appPtyId) clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) this.forwardedReattachReplayByPty.delete(appPtyId) diff --git a/src/main/terminal-teardown-intent-records.ts b/src/main/terminal-teardown-intent-records.ts index 58a52e360e7..b60932af778 100644 --- a/src/main/terminal-teardown-intent-records.ts +++ b/src/main/terminal-teardown-intent-records.ts @@ -70,7 +70,16 @@ export const terminalTeardownIntentJournalRecordSchema = z.discriminatedUnion('k .extend({ kind: z.literal('runtime-remove-environment'), environmentId: nonEmptyString }) .strict(), journalBaseSchema - .extend({ kind: z.literal('ssh-replace'), pendingSshPtyShutdowns: z.array(sshShutdownSchema) }) + .extend({ + kind: z.literal('ssh-set'), + targetId: nonEmptyString, + ptyId: nonEmptyString, + relayInstanceId: nonEmptyString.optional(), + shutdownRequestedAt: timestamp.optional() + }) + .strict(), + journalBaseSchema + .extend({ kind: z.literal('ssh-remove-target'), targetId: nonEmptyString }) .strict() ]) @@ -84,4 +93,11 @@ export type TerminalTeardownIntentMutation = | { kind: 'runtime-upsert'; request: PersistedRuntimeTerminalClose } | { kind: 'runtime-remove'; environmentId: string; handle: string } | { kind: 'runtime-remove-environment'; environmentId: string } - | { kind: 'ssh-replace' } + | { + kind: 'ssh-set' + targetId: string + ptyId: string + relayInstanceId?: string + shutdownRequestedAt?: number + } + | { kind: 'ssh-remove-target'; targetId: string } diff --git a/src/main/terminal-teardown-intent-snapshot.ts b/src/main/terminal-teardown-intent-snapshot.ts index 28fabdaf4bf..297383330e0 100644 --- a/src/main/terminal-teardown-intent-snapshot.ts +++ b/src/main/terminal-teardown-intent-snapshot.ts @@ -72,13 +72,15 @@ function readRecords(dataFile: string): { } const journal = terminalTeardownIntentJournalRecordSchema.safeParse(parsed) if (journal.success) { - return { records: [journal.data], journalReady: true } + // Why: appendFileSync needs a record delimiter. A crash after the JSON + // bytes but before the newline must recover through an atomic checkpoint. + return { records: [journal.data], journalReady: raw.endsWith('\n') } } } catch { // Multi-record journals are newline-delimited rather than one JSON value. } const records: TerminalTeardownIntentJournalRecord[] = [] - let appendable = true + let appendable = raw.endsWith('\n') for (const line of raw.split('\n')) { if (!line.trim()) { continue @@ -180,8 +182,30 @@ function applyJournalRecord( (entry) => entry.environmentId !== record.environmentId ) return - case 'ssh-replace': - applySshShutdowns(state, record.pendingSshPtyShutdowns) + case 'ssh-set': { + const lease = state.sshRemotePtyLeases?.find( + (candidate) => + candidate.targetId === record.targetId && + candidate.ptyId === record.ptyId && + candidate.relayInstanceId === record.relayInstanceId + ) + if (!lease) { + return + } + if (record.shutdownRequestedAt === undefined) { + delete lease.shutdownRequestedAt + } else { + lease.shutdownRequestedAt = record.shutdownRequestedAt + lease.updatedAt = Math.max(lease.updatedAt, record.shutdownRequestedAt) + } + return + } + case 'ssh-remove-target': + for (const lease of state.sshRemotePtyLeases ?? []) { + if (lease.targetId === record.targetId) { + delete lease.shutdownRequestedAt + } + } } } @@ -250,14 +274,6 @@ export function appendTerminalTeardownIntentMutation( return } const revision = state.terminalTeardownIntentRevision ?? 0 - const record: TerminalTeardownIntentJournalRecord = - mutation.kind === 'ssh-replace' - ? { - version: 2, - revision, - kind: 'ssh-replace', - pendingSshPtyShutdowns: pendingSshShutdowns(state) - } - : { version: 2, revision, ...mutation } + const record: TerminalTeardownIntentJournalRecord = { version: 2, revision, ...mutation } appendFileSync(getSnapshotFile(dataFile), `${JSON.stringify(record)}\n`, 'utf-8') }