diff --git a/src/main/daemon/daemon-health-identity.ts b/src/main/daemon/daemon-health-identity.ts index 44a1c02978e..d9add78663f 100644 --- a/src/main/daemon/daemon-health-identity.ts +++ b/src/main/daemon/daemon-health-identity.ts @@ -1,13 +1,5 @@ -/** What a `ptySpawnHealth` reply proves about this daemon; every field is optional on the wire. */ -export function readDaemonHealthIdentity(): { - coverage: 'pty-spawn' | 'handshake' - runtimeKind: 'node' - runtimeVersion: string -} { - return { - // Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there. - coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn', - runtimeKind: 'node', - runtimeVersion: process.version - } +/** What a `ptySpawnHealth` reply proves on this platform. */ +export function ptySpawnHealthPlatformCoverage(): 'pty-spawn' | 'handshake' { + // Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there. + return process.platform === 'win32' ? 'handshake' : 'pty-spawn' } diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 5b00b7c91e5..d0d2d2f0334 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -106,11 +106,9 @@ describe('daemon health', () => { try { await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy') await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true) - await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({ + await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toEqual({ verdict: 'healthy', - coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn', - runtimeKind: 'node', - runtimeVersion: process.version + coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn' }) expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(3) } finally { diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index cc035c26a08..04cf322b893 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs' import { connect, type Socket } from 'node:net' import { encodeNdjson } from './ndjson' +import { ptySpawnHealthPlatformCoverage } from './daemon-health-identity' import { PROTOCOL_VERSION, type HelloMessage, @@ -24,24 +25,6 @@ export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-u export type DaemonHealthCheck = { verdict: DaemonHealth coverage: 'pty-spawn' | 'handshake' - /** Optional runtime proof from newer daemons; absent on mixed-version peers. */ - runtimeKind?: 'node' - runtimeVersion?: string -} - -function readRuntimeIdentity( - payload: unknown -): Pick { - if (typeof payload !== 'object' || payload === null) { - return {} - } - const runtimeKind = 'runtimeKind' in payload ? payload.runtimeKind : undefined - const runtimeVersion = 'runtimeVersion' in payload ? payload.runtimeVersion : undefined - // Why drop other kinds: only a Node daemon reports here; anything else is an unknown peer. - return { - ...(runtimeKind === 'node' ? { runtimeKind } : {}), - ...(typeof runtimeVersion === 'string' && runtimeVersion.length > 0 ? { runtimeVersion } : {}) - } } function readPtySpawnHealthCoverage( @@ -62,7 +45,7 @@ export function checkDaemonHealthWithCoverage( return new Promise((resolve) => { // Older Windows daemons answered this RPC without spawning; an absent optional coverage // field must preserve that weaker meaning during adoption. - const fallbackCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' + const fallbackCoverage = ptySpawnHealthPlatformCoverage() const resolveVerdict = (verdict: DaemonHealth): void => resolve({ verdict, coverage: fallbackCoverage }) if (process.env[E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV] === '1') { @@ -148,11 +131,9 @@ export function checkDaemonHealthWithCoverage( } if (message.id === 'health-1') { - const identity = readRuntimeIdentity(message.payload) settle({ verdict: message.ok === true ? 'healthy' : 'pty-spawn-unhealthy', - coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage), - ...identity + coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage) }) return } diff --git a/src/main/daemon/daemon-launched-child-identity.test.ts b/src/main/daemon/daemon-launched-child-identity.test.ts index 9e6a623e5ce..c34a53c2e14 100644 --- a/src/main/daemon/daemon-launched-child-identity.test.ts +++ b/src/main/daemon/daemon-launched-child-identity.test.ts @@ -103,6 +103,22 @@ describe('launchDaemonChild identity', () => { }) }) +describe('launchDaemonChild startup budget', () => { + it('waits for readiness past the default 10 s when the caller allows it', async () => { + vi.useFakeTimers() + const child = fakeDaemonChild(4242) + spawnDaemonChildProcessMock.mockReturnValue(child) + try { + const launch = launchDaemonChild({ ...LAUNCH_OPTIONS, startupTimeoutMs: 30_000 }) + await vi.advanceTimersByTimeAsync(20_000) + child.emit('message', { type: 'ready', pid: 4242, startedAtMs: 1_000_000 }) + await expect(launch).resolves.toMatchObject({ identity: { pid: 4242 } }) + } finally { + vi.useRealTimers() + } + }) +}) + describe('launchDaemonChild durable-scope fallback', () => { it('retries once without cgroup isolation when the scoped attempt fails', async () => { isDurableDaemonScopeSupportedMock.mockReturnValue(true) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 887241e2daa..1d069b70aa5 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -14,6 +14,8 @@ export type DaemonChildSpawnOptions = { pidPath: string launchNonce: string macosLoginSessionWatch: boolean + /** Defaults to 10 s; a cold first exec on Windows can need longer. */ + startupTimeoutMs?: number } function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] { diff --git a/src/main/daemon/daemon-launched-child.ts b/src/main/daemon/daemon-launched-child.ts index f5082cdc230..9ec02b88c02 100644 --- a/src/main/daemon/daemon-launched-child.ts +++ b/src/main/daemon/daemon-launched-child.ts @@ -12,6 +12,7 @@ import { unlinkOwnedDaemonPidFile } from './daemon-spawner' const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000 const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000 const STARTUP_STDERR_MAX_BYTES = 8192 +const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 10_000 export class DaemonEndpointUnavailableError extends Error { constructor( @@ -187,7 +188,7 @@ async function launchDaemonChildAttempt( timer = setTimeout(() => { void fail(new Error('Daemon startup timed out')) - }, 10000) + }, options.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS) child.on('message', onReadyMessage) child.on('error', onStartupError) diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index 80a77fe5f88..1aa811c1d5d 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -52,9 +52,11 @@ function createPreservedDaemonHandle( return handle } +export type DaemonLaunchPolicy = { macosLoginSessionWatch?: boolean; startupTimeoutMs?: number } + export function createOutOfProcessLauncher( runtimeDir: string, - macosLoginSessionWatch = false + { macosLoginSessionWatch = false, startupTimeoutMs }: DaemonLaunchPolicy = {} ): DaemonLauncher { return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => { const entryPath = getDaemonEntryPath() @@ -132,7 +134,8 @@ export function createOutOfProcessLauncher( tokenPath, pidPath, launchNonce, - macosLoginSessionWatch + macosLoginSessionWatch, + startupTimeoutMs }) } catch (error) { if (!(error instanceof DaemonEndpointUnavailableError) || error.reason !== 'occupied') { diff --git a/src/main/daemon/daemon-process-inspection.test.ts b/src/main/daemon/daemon-process-inspection.test.ts index 41ea4e660b9..3d0c8309fd7 100644 --- a/src/main/daemon/daemon-process-inspection.test.ts +++ b/src/main/daemon/daemon-process-inspection.test.ts @@ -33,41 +33,40 @@ describe('daemon process inspection', () => { expect(runCommand).not.toHaveBeenCalled() }) - it('asks PowerShell to report a failed CIM query instead of an absent process', async () => { - const runCommand = vi.fn( - async (_file: string, _args: string[], _timeoutMs: number) => - '{"status":"present","cmd":"daemon","start":1}' - ) + it('reads the Windows process from the process table, not a PowerShell spawn', async () => { + const runCommand = vi.fn() + const readProcessTable = vi.fn(async () => [ + { pid: 42, ppid: 1, name: 'node.exe', command: 'daemon', creationTimeMs: 7 } + ]) - await queryWindowsProcess(42, { runCommand }) - - const script = runCommand.mock.calls[0]?.[1].at(-1) ?? '' - expect(script).toContain("$ErrorActionPreference = 'Stop'") - expect(script).toMatch(/catch \{[^}]*query_failed/) + await expect(queryWindowsProcess(42, { runCommand, readProcessTable })).resolves.toEqual({ + status: 'present', + commandLine: 'daemon', + startedAtMs: 7 + }) + expect(runCommand).not.toHaveBeenCalled() }) - it('keeps a failed CIM query indeterminate instead of proving the process gone', async () => { - const runCommand = vi.fn(async () => '{"status":"query_failed"}') + it('keeps an unreadable process table indeterminate instead of proving the process gone', async () => { + const readProcessTable = vi.fn(async () => { + throw new Error('windows process table is unreadable') + }) - await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({ + await expect(queryWindowsProcess(42, { readProcessTable })).resolves.toEqual({ status: 'unavailable' }) }) - it('never reads a probe result without a success marker as proof of absence', async () => { - const runCommand = vi.fn(async () => '{"exists":false}') + it('reports absence only from a table that was read and lacks the PID', async () => { + const readProcessTable = vi.fn(async () => [ + { pid: 7, ppid: 1, name: 'other.exe', command: '' } + ]) - await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({ - status: 'unavailable' + await expect(queryWindowsProcess(42, { readProcessTable })).resolves.toEqual({ + status: 'missing' }) }) - it('reports absence only from a CIM query that ran and found nothing', async () => { - const runCommand = vi.fn(async () => '{"status":"missing"}') - - await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({ status: 'missing' }) - }) - it('reads the macOS start time through an async spawn', async () => { const runCommand = vi.fn(async () => 'Sat Jan 1 00:00:00 2028\n') @@ -158,14 +157,14 @@ describe('daemon process inspection', () => { }) it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])( - 'rejects unsafe Windows pid %s before command interpolation', + 'rejects unsafe Windows pid %s before reading the process table', async (pid) => { - const runCommand = vi.fn() + const readProcessTable = vi.fn(async () => []) - await expect(queryWindowsProcess(pid, { runCommand })).resolves.toEqual({ + await expect(queryWindowsProcess(pid, { readProcessTable })).resolves.toEqual({ status: 'unavailable' }) - expect(runCommand).not.toHaveBeenCalled() + expect(readProcessTable).not.toHaveBeenCalled() } ) }) diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts index 4f40090bfee..9f52625e3a6 100644 --- a/src/main/daemon/daemon-process-inspection.ts +++ b/src/main/daemon/daemon-process-inspection.ts @@ -8,6 +8,11 @@ import type { ProcessSignalEvidence, WindowsProcessEvidence } from './daemon-incarnation-evidence-types' +import { + readWindowsProcessCreationTime, + readWindowsProcessTableFresh, + type WindowsProcessRow +} from '../windows/windows-process-table' const execFileAsync = promisify(execFile) @@ -16,6 +21,7 @@ type InspectionCommandRunner = (file: string, args: string[], timeoutMs: number) export type DaemonProcessInspectionDependencies = { readTextFile?: (path: string) => Promise runCommand?: InspectionCommandRunner + readProcessTable?: () => Promise } export function inspectProcessSignal(pid: number): ProcessSignalEvidence { @@ -33,6 +39,12 @@ export function inspectProcessSignal(pid: number): ProcessSignalEvidence { } } +/** EPERM counts as alive: it proves some process holds the PID. */ +export function isProcessAlive(pid: number): boolean { + const signal = inspectProcessSignal(pid) + return signal === 'occupied' || signal === 'permission_denied' +} + export function inspectProcessLiveness(pid: number): ProcessLivenessVerdict { const signal = inspectProcessSignal(pid) switch (signal) { @@ -94,9 +106,7 @@ export async function readProcessCommandLine( } } -// Why: Get-CimInstance errors (Winmgmt down, corrupt WMI repository, access denied) are -// non-terminating and exit 0 with an empty $p, which is indistinguishable from "no such -// process" β€” so the script reports query failure explicitly instead of asserting absence. +// Only a table that was read and lacks the PID proves absence; the reader rejects a truncated one. export async function queryWindowsProcess( pid: number, dependencies: DaemonProcessInspectionDependencies = {} @@ -104,45 +114,21 @@ export async function queryWindowsProcess( if (!Number.isSafeInteger(pid) || pid <= 0) { return { status: 'unavailable' } } - const runCommand = dependencies.runCommand ?? runInspectionCommand + let rows: WindowsProcessRow[] try { - const stdout = await runCommand( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - `$ErrorActionPreference = 'Stop'; ` + - `try { $p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" } ` + - `catch { @{ status = 'query_failed' } | ConvertTo-Json -Compress; exit 0 }; ` + - `if (!$p) { @{ status = 'missing' } | ConvertTo-Json -Compress; exit 0 }; ` + - `$start = $null; if ($p.CreationDate) { ` + - `$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` + - `@{ status = 'present'; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress` - ], - 3_000 - ) - const parsed = JSON.parse(stdout.trim()) as { - status?: unknown - cmd?: unknown - start?: unknown - } - // Only a query that ran and found nothing proves absence; anything else stays indeterminate. - if (parsed.status === 'missing') { - return { status: 'missing' } - } - if (parsed.status !== 'present') { - return { status: 'unavailable' } - } - return { - status: 'present', - commandLine: typeof parsed.cmd === 'string' && parsed.cmd ? parsed.cmd : null, - startedAtMs: - typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null - } + rows = await (dependencies.readProcessTable ?? readWindowsProcessTableFresh)() } catch { return { status: 'unavailable' } } + const row = rows.find((candidate) => candidate.pid === pid) + if (!row) { + return { status: 'missing' } + } + return { + status: 'present', + commandLine: row.command || null, + startedAtMs: row.creationTimeMs ?? readWindowsProcessCreationTime(pid) + } } // Why: the sync procfs helper in daemon-process-start-time spawns getconf per call; CLK_TCK is fixed for @@ -219,8 +205,6 @@ async function runInspectionCommand( args: string[], timeoutMs: number ): Promise { - // powershell.exe is console-subsystem: without this it flashes a conhost and - // steals foreground on every inspection (#10488). const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', timeout: timeoutMs, @@ -229,6 +213,6 @@ async function runInspectionCommand( return stdout } -function hasErrorCode(error: unknown, code: string): boolean { +export function hasErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index c1d73d95a27..9d9a1b5c3e1 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -19,7 +19,8 @@ import { } from './daemon-launch-paths' import { attributeNextDaemonReplacement, - createOutOfProcessLauncher + createOutOfProcessLauncher, + type DaemonLaunchPolicy } from './daemon-out-of-process-launcher' import type { DaemonProvider } from './daemon-provider-routing' import { installDaemonProvider } from './daemon-provider-state' @@ -46,7 +47,7 @@ function logDaemonMilestone(event: string, details: Record = {} export async function initDaemonPtyProvider( signal?: AbortSignal, - options: { macosLoginSessionWatch?: boolean } = {} + options: DaemonLaunchPolicy = {} ): Promise { logDaemonMilestone('daemon-init-start') // Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout. @@ -58,7 +59,7 @@ export async function initDaemonPtyProvider( const newSpawner = new DaemonSpawner({ runtimeDir, - launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false) + launcher: createOutOfProcessLauncher(runtimeDir, options) }) // Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner. diff --git a/src/main/daemon/daemon-pty-event-subscriptions.ts b/src/main/daemon/daemon-pty-event-subscriptions.ts index f007a734b40..d874bde8b48 100644 --- a/src/main/daemon/daemon-pty-event-subscriptions.ts +++ b/src/main/daemon/daemon-pty-event-subscriptions.ts @@ -121,6 +121,12 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent private async finishIdleRetirementRequest(): Promise { try { await this.client.ensureConnected() + } catch { + // Nothing was asked of the daemon, so nothing can be retiring. + this.reopenAfterRefusedIdleRetirement() + return { state: 'unverifiable' } + } + try { const result = await this.client.request('shutdownIfIdle', undefined) if (result.retiring) { this.idleRetirementState = 'retiring' @@ -148,7 +154,12 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent /** Reopens admission an idle-retirement attempt fenced without retiring the daemon. */ releaseIdleRetirementFence(): void { - if (this.idleRetirementState !== 'retiring' && !this.idleRetirementPromise) { + // An unverifiable attempt may have been accepted, so it stays fenced like a retiring one. + if ( + this.idleRetirementState !== 'retiring' && + this.idleRetirementState !== 'unverifiable' && + !this.idleRetirementPromise + ) { this.reopenAfterRefusedIdleRetirement() } } diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index e7d04f5851e..681ce2c4dd9 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -26,11 +26,16 @@ import { assertDaemonRecoverySpawnAdmission } from './daemon-recovery-spawn-admi import { addWslEnvKeys } from '../wsl-env' export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { - async spawn(opts: PtySpawnOptions): Promise { + // Checked again in doSpawn: retirement can close admission while spawn awaits. + private assertSpawnAdmission(opts: PtySpawnOptions): void { assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts) if (this.idleRetirementAdmissionClosed) { throw new Error('Terminal daemon is decommissioning') } + } + + async spawn(opts: PtySpawnOptions): Promise { + this.assertSpawnAdmission(opts) const spawnOpts = this.withHistoryIsolation(opts) const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) const operation: PendingDaemonSpawnOperation = { @@ -110,10 +115,7 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { operation: PendingDaemonSpawnOperation, historyRecovery: HistoryRecoveryContext ): Promise { - assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts) - if (this.idleRetirementAdmissionClosed) { - throw new Error('Terminal daemon is decommissioning') - } + this.assertSpawnAdmission(opts) if ( opts.agentSessionEnsure && this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 651eaf36ab6..1184517c6dc 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -10,7 +10,7 @@ import type { DaemonSessionBackgroundRouting } from './daemon-session-background import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import type { DaemonTerminalAdmission } from './daemon-terminal-admission' -import { readDaemonHealthIdentity } from './daemon-health-identity' +import { ptySpawnHealthPlatformCoverage } from './daemon-health-identity' import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import type { TerminalHost } from './terminal-host' import { SessionNotFoundError, type DaemonRequest } from './types' @@ -157,7 +157,7 @@ export class DaemonRequestRouter { return { health: await readCurrentProcessMacSystemResolverHealth() } case 'ptySpawnHealth': await this.options.ptySpawnHealthCheck() - return { healthy: true, ...readDaemonHealthIdentity() } + return { healthy: true, coverage: ptySpawnHealthPlatformCoverage() } case 'shutdown': return this.shutdown(clientId, request.id, request.payload.killSessions) } diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 7d6f825b3f7..f10715e8711 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -118,20 +118,6 @@ describe('DaemonStreamDataBatcher', () => { }) }) - it('preserves PTY incarnation identity through stream serialization', () => { - const { batcher, streamSocket } = createBatcher() - - batcher.enqueue('client-1', 'session-1', 'output', { - flushImmediately: true, - incarnationId: 'incarnation-1' - }) - - expect(JSON.parse(String(streamSocket.write.mock.calls[0]?.[0]))).toMatchObject({ - event: 'data', - payload: { data: 'output', incarnationId: 'incarnation-1' } - }) - }) - it('keeps large pending output batched even when an interactive redraw follows', () => { vi.useFakeTimers() try { diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 202d59c4009..a562a60ee9d 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -246,8 +246,7 @@ export class DaemonStreamDataBatcher { this.maxLineBytes, sliceSequenceChars, entry.seq, - entry.transformed, - entry.incarnationId + entry.transformed ) } this.updateBackpressure(clientId, batch) diff --git a/src/main/daemon/daemon-stream-data-entry.ts b/src/main/daemon/daemon-stream-data-entry.ts index e4159aaa88a..017d21fde8c 100644 --- a/src/main/daemon/daemon-stream-data-entry.ts +++ b/src/main/daemon/daemon-stream-data-entry.ts @@ -2,7 +2,6 @@ import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' import { writeStreamDataEvents } from './daemon-stream-data-split' import { encodeNdjson } from './ndjson' import { accountDaemonStreamEntry } from './daemon-stream-entry-accounting' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type DaemonStreamEnqueueOptions = { flushImmediately?: boolean @@ -10,7 +9,6 @@ export type DaemonStreamEnqueueOptions = { rawLength?: number transformed?: boolean seq?: number - incarnationId?: PtyIncarnationId } export function appendDaemonStreamData( @@ -25,8 +23,7 @@ export function appendDaemonStreamData( last?.sessionId === sessionId && !last.control && !last.transformed && - options.transformed !== true && - last.incarnationId === options.incarnationId + options.transformed !== true ) { last.data += data const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length @@ -42,8 +39,7 @@ export function appendDaemonStreamData( ? {} : { sequenceChars: options.rawLength }), ...(options.transformed ? { transformed: true } : {}), - ...(options.seq === undefined ? {} : { seq: options.seq }), - ...(options.incarnationId === undefined ? {} : { incarnationId: options.incarnationId }) + ...(options.seq === undefined ? {} : { seq: options.seq }) }) ) } @@ -95,8 +91,7 @@ export function flushDaemonStreamSession( maxLineBytes, entry.sequenceChars ?? entry.data.length, entry.seq, - entry.transformed, - entry.incarnationId + entry.transformed ) } } diff --git a/src/main/daemon/daemon-stream-data-split.test.ts b/src/main/daemon/daemon-stream-data-split.test.ts index 2fe9983252a..48cf8cbc80e 100644 --- a/src/main/daemon/daemon-stream-data-split.test.ts +++ b/src/main/daemon/daemon-stream-data-split.test.ts @@ -18,8 +18,7 @@ function write( rawLength = data.length, seq?: number, transformed = false, - sessionId = 'session-1', - incarnationId?: string + sessionId = 'session-1' ): string[] { const lines: string[] = [] const socket: Pick = { @@ -28,16 +27,7 @@ function write( return true }) } - writeStreamDataEvents( - socket, - sessionId, - data, - maxLineBytes, - rawLength, - seq, - transformed, - incarnationId - ) + writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed) return lines } @@ -94,15 +84,6 @@ describe('writeStreamDataEvents serialization budget', () => { expect(encodeNdjson).toHaveBeenCalledTimes(1) }) - it('retains incarnation metadata in the single-encode fast path', () => { - const line = encodeStreamDataEvent('session-1', 'Γ©πŸ™', undefined, undefined, false, 'epoch-1') - vi.mocked(encodeNdjson).mockClear() - expect( - write('Γ©πŸ™', Buffer.byteLength(line), 3, undefined, false, 'session-1', 'epoch-1') - ).toEqual([line]) - expect(encodeNdjson).toHaveBeenCalledTimes(1) - }) - it('does not add a duplicate full-data sizing probe to oversized writes', () => { const data = 'πŸ™\x1b[0m'.repeat(100) const expected = previousWrites(data, 160) @@ -121,24 +102,6 @@ describe('writeStreamDataEvents serialization budget', () => { }) describe('writeStreamDataEvents wire parity', () => { - it.each([undefined, 9000])('budgets incarnation metadata on oversized writes (seq=%s)', (seq) => { - const data = 'πŸ™Γ©δΈ­\x1b[0m"\\\n'.repeat(100) - const incarnationId = 'epoch-'.repeat(16) - const lines = write(data, 384, data.length, seq, false, 'session-1', incarnationId) - expect(lines.length).toBeGreaterThan(1) - let consumed = 0 - const chunks = lines.map((line) => { - expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(384) - const { payload } = JSON.parse(line) - expect(payload.incarnationId).toBe(incarnationId) - expect(typeof payload.data).toBe('string') - consumed += payload.data.length - expect(payload.seq).toBe(seq === undefined ? undefined : seq - data.length + consumed) - return payload.data - }) - expect(chunks.join('')).toBe(data) - }) - it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => { const payloads = [ '', diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts index b644d971e4b..c31c4437b84 100644 --- a/src/main/daemon/daemon-stream-data-split.ts +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -5,15 +5,13 @@ */ import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan' import { encodeNdjson } from './ndjson' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' export function encodeStreamDataEvent( sessionId: string, data: string, rawLength?: number, seq?: number, - transformed?: boolean, - incarnationId?: PtyIncarnationId + transformed?: boolean ): string { return encodeNdjson({ type: 'event', @@ -21,7 +19,6 @@ export function encodeStreamDataEvent( sessionId, payload: { data, - ...(incarnationId === undefined ? {} : { incarnationId }), ...(seq === undefined ? {} : { seq }), ...(rawLength === undefined ? {} : { rawLength }), ...(rawLength === undefined ? {} : { sequenceChars: rawLength }), @@ -30,16 +27,8 @@ export function encodeStreamDataEvent( }) } -function streamDataEventLineBytes( - sessionId: string, - data: string, - rawLength?: number, - incarnationId?: PtyIncarnationId -): number { - return Buffer.byteLength( - encodeStreamDataEvent(sessionId, data, rawLength, undefined, false, incarnationId), - 'utf8' - ) +function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number { + return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8') } function isHighSurrogate(value: number): boolean { @@ -90,28 +79,20 @@ export function splitStreamDataForNdjson( sessionId: string, data: string, maxLineBytes: number, - sequenceChars?: number, - incarnationId?: PtyIncarnationId + sequenceChars?: number ): string[] { - if (streamDataEventLineBytes(sessionId, data, sequenceChars, incarnationId) <= maxLineBytes) { + if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) { return [data] } - return splitOversizedStreamDataForNdjson( - sessionId, - data, - maxLineBytes, - sequenceChars, - incarnationId - ) + return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars) } function splitOversizedStreamDataForNdjson( sessionId: string, data: string, maxLineBytes: number, - sequenceChars?: number, - incarnationId?: PtyIncarnationId + sequenceChars?: number ): string[] { const chunks: string[] = [] let start = 0 @@ -129,8 +110,7 @@ function splitOversizedStreamDataForNdjson( } if ( - streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars, incarnationId) <= - maxLineBytes + streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes ) { best = mid low = rawMid + 1 @@ -154,36 +134,28 @@ export function writeStreamDataEvents( maxLineBytes: number, rawLength = data.length, seq?: number, - transformed = false, - incarnationId?: PtyIncarnationId + transformed = false ): void { const explicitRawLength = rawLength === data.length ? undefined : rawLength if (transformed) { - streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true, incarnationId)) + streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true)) return } const carriesMetadata = explicitRawLength !== undefined || seq !== undefined let chunks: string[] if (!carriesMetadata) { - const line = encodeStreamDataEvent(sessionId, data, undefined, undefined, false, incarnationId) + const line = encodeStreamDataEvent(sessionId, data) if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) { streamSocket.write(line) return } - chunks = splitOversizedStreamDataForNdjson( - sessionId, - data, - maxLineBytes, - undefined, - incarnationId - ) + chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes) } else { chunks = splitStreamDataForNdjson( sessionId, data, Math.max(1, maxLineBytes - 96), - explicitRawLength, - incarnationId + explicitRawLength ) } let consumed = 0 @@ -191,8 +163,6 @@ export function writeStreamDataEvents( consumed += chunk.length const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed) const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined - streamSocket.write( - encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq, false, incarnationId) - ) + streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq)) } } diff --git a/src/main/daemon/daemon-stream-incarnation.test.ts b/src/main/daemon/daemon-stream-incarnation.test.ts deleted file mode 100644 index e52a49c444a..00000000000 --- a/src/main/daemon/daemon-stream-incarnation.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { appendDaemonStreamData, flushDaemonStreamSession } from './daemon-stream-data-entry' -import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' -import { SessionOutputPlane } from './session-output-plane' - -function batch(): PendingStreamDataBatch { - return { - timer: null, - queue: [], - queuedChars: 0, - queuedCharsBySession: new Map(), - queuedMetadataBytesBySession: new Map(), - droppableQueuedSessionIds: new Set() - } -} - -describe('daemon stream incarnation ids', () => { - it('never merges output from two incarnations into one queued entry', () => { - const pending = batch() - appendDaemonStreamData(pending, 'session-1', 'old', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'more', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'new', { incarnationId: 'incarnation-2' }) - - expect(pending.queue.map(({ data, incarnationId }) => ({ data, incarnationId }))).toEqual([ - { data: 'oldmore', incarnationId: 'incarnation-1' }, - { data: 'new', incarnationId: 'incarnation-2' } - ]) - }) - - it('writes the id on per-session flushes and omits it when absent', () => { - const pending = batch() - appendDaemonStreamData(pending, 'session-1', 'tagged', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'legacy', {}) - const lines: string[] = [] - flushDaemonStreamSession(pending, 'session-1', 64 * 1024, (line) => lines.push(line)) - - const payloads = lines.map((line) => JSON.parse(line).payload) - expect(payloads).toEqual([ - { data: 'tagged', incarnationId: 'incarnation-1' }, - { data: 'legacy' } - ]) - }) - - it('hands the session incarnation only to identity-aware clients', () => { - const plane = new SessionOutputPlane({ cols: 80, rows: 24, incarnationId: 'incarnation-1' }) - const legacy = vi.fn() - const aware = vi.fn() - const unused = vi.fn() - plane.attachClient({ onData: legacy, onExit: vi.fn() }) - plane.attachClient({ onData: unused, onDataWithIncarnation: aware, onExit: vi.fn() }) - - plane.emit({ data: 'hi', rawStartSeq: 0, rawEndSeq: 2, transformed: false }) - - expect(legacy).toHaveBeenCalledWith('hi') - expect(aware).toHaveBeenCalledWith('hi', undefined, undefined, undefined, 'incarnation-1') - expect(unused).not.toHaveBeenCalled() - plane.disposeEmulator() - }) -}) diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts index 5fcfeed09a0..7bbd20047fe 100644 --- a/src/main/daemon/daemon-stream-keep-tail-drop.ts +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -14,7 +14,6 @@ import { accountDaemonStreamEntry, releaseDaemonStreamEntry } from './daemon-stream-entry-accounting' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' // A control entry carries a whole pre-shaped stream event (background marker, // data gap, transient fact) that must ride at its exact position in the @@ -28,7 +27,6 @@ export type StreamQueueEntry = { sequenceChars?: number seq?: number transformed?: boolean - incarnationId?: PtyIncarnationId control?: DaemonEvent retainedBytes?: number } diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 984cbc87dec..f02cbdb9205 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -173,32 +173,23 @@ export class DaemonTerminalAdmission { clientId: string, sessionId: () => string ): CreateOrAttachOptions['streamClient'] { - const onData = ( - data: string, - rawLength = data.length, - transformed = false, - seq?: number, - incarnationId?: string - ): void => { - const routedSessionId = sessionId() - this.options.transientFactRelay.onSessionData(routedSessionId, data) - const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) - const isInteractiveOutput = - data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS - this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, - rawLength, - transformed, - seq, - ...(incarnationId === undefined ? {} : { incarnationId }) - }) - } return { - onData, - onDataWithIncarnation: onData, + onData: (data, rawLength = data.length, transformed = false, seq) => { + const routedSessionId = sessionId() + this.options.transientFactRelay.onSessionData(routedSessionId, data) + const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) + const isInteractiveOutput = + data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS + this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, + rawLength, + transformed, + seq + }) + }, onExit: (code, incarnationId, cause) => { const routedSessionId = sessionId() this.options.log.log('session-exited', { diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts index 819b9c72259..f67d2e15b92 100644 --- a/src/main/daemon/session-output-pipeline.ts +++ b/src/main/daemon/session-output-pipeline.ts @@ -1,7 +1,6 @@ import { SessionOutputPlane } from './session-output-plane' import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import type { SubprocessHandle } from './session-subprocess-handle' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' /** The session's ordered output pipeline: the recovery barrier feeding the * output plane. Built together because the barrier's owner is what the @@ -12,7 +11,6 @@ export function createSessionOutputPipeline(opts: { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined - incarnationId?: PtyIncarnationId | undefined subprocess: SubprocessHandle isAlive: () => boolean }): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { @@ -24,7 +22,6 @@ export function createSessionOutputPipeline(opts: { scrollback: opts.scrollback, wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, - incarnationId: opts.incarnationId, getTerminalOwner: () => barrier?.getOwner() }) const recoveryBarrier = new TerminalShellRecoveryBarrier({ diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index d994fdf4b48..04b3f773bda 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -5,7 +5,6 @@ import { normalizePtySize } from './daemon-pty-size' import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types' import type { TerminalOwner } from '../../shared/terminal-owner' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { SubprocessHandle } from './session-subprocess-handle' import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' @@ -16,14 +15,6 @@ const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024 export type AttachedClient = { token: symbol onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - /** Identity-bearing callback used by mutation-aware consumers; legacy clients keep the old shape. */ - onDataWithIncarnation?: ( - data: string, - rawLength: number | undefined, - transformed: boolean | undefined, - seq: number | undefined, - incarnationId: PtyIncarnationId - ) => void onExit: (code: number, incarnationId: string, cause?: TerminalExitCause) => void } @@ -33,7 +24,6 @@ export type SessionOutputPlaneOptions = { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined - incarnationId?: PtyIncarnationId | undefined /** Read from the recovery barrier at snapshot time; the barrier scans bytes * before this plane receives them, so its owner never lags the emulator. */ getTerminalOwner?: (() => TerminalOwner | undefined) | undefined @@ -45,7 +35,6 @@ export class SessionOutputPlane { readonly historySeeded: boolean | undefined private readonly emulator: HeadlessEmulator private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined - private readonly incarnationId: PtyIncarnationId | undefined private attachedClients: AttachedClient[] = [] private pendingOutputRecords: PendingOutputRecord[] = [] private pendingOutputBytes = 0 @@ -75,7 +64,6 @@ export class SessionOutputPlane { ? undefined : opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk)) this.readTerminalOwner = opts.getTerminalOwner - this.incarnationId = opts.incarnationId } get responderParser(): HeadlessEmulator['responderParser'] { @@ -209,7 +197,9 @@ export class SessionOutputPlane { return } this.record({ kind: 'output', data: pending }) - this.broadcastData(pending, 0, true, this._outputSequence) + for (const client of this.attachedClients) { + client.onData(pending, 0, true, this._outputSequence) + } } emit(emission: PtyIngressEmission): void { @@ -226,24 +216,9 @@ export class SessionOutputPlane { } // Broadcast to attached clients - if (emission.transformed || rawLength !== data.length) { - this.broadcastData(data, rawLength, true, this._outputSequence) - } else { - this.broadcastData(data) - } - } - - private broadcastData( - data: string, - rawLength?: number, - transformed?: boolean, - seq?: number - ): void { for (const client of this.attachedClients) { - if (client.onDataWithIncarnation && this.incarnationId) { - client.onDataWithIncarnation(data, rawLength, transformed, seq, this.incarnationId) - } else if (transformed || rawLength !== undefined || seq !== undefined) { - client.onData(data, rawLength, transformed, seq) + if (emission.transformed || rawLength !== data.length) { + client.onData(data, rawLength, true, this._outputSequence) } else { client.onData(data) } diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7c63dbd1d3f..7d113665292 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -12,7 +12,12 @@ import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import { PtyStartupIngress } from '../../shared/pty-startup-ingress' -import type * as SessionProtocol from './types' +import type { + SessionState, + ShellReadyState, + TakePendingOutputResult, + TerminalSnapshot +} from './types' import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' @@ -23,7 +28,7 @@ export class Session { readonly launchAgent: TuiAgent | null readonly wslDistro: string | null readonly processNameIsSpawnFile: boolean - private _state: SessionProtocol.SessionState = 'running' + private _state: SessionState = 'running' private _exitCode: number | null = null private _disposed = false private subprocess: SubprocessHandle @@ -50,8 +55,7 @@ export class Session { wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, subprocess: this.subprocess, - isAlive: () => !this._disposed && this._state !== 'exited', - incarnationId: this.incarnationId + isAlive: () => !this._disposed && this._state !== 'exited' }) this.output = pipeline.output this.recoveryBarrier = pipeline.recoveryBarrier @@ -91,11 +95,11 @@ export class Session { this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) } - get state(): SessionProtocol.SessionState { + get state(): SessionState { return this._state } - get shellState(): SessionProtocol.ShellReadyState { + get shellState(): ShellReadyState { return this.shellReady.state } @@ -212,7 +216,7 @@ export class Session { this.producerPause.release({ resume: true }) } - getSnapshot(opts: { scrollbackRows?: number } = {}): SessionProtocol.TerminalSnapshot | null { + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { this.startupIngress.snapshotBarrier() return this.output.getSnapshot(opts) } @@ -228,7 +232,7 @@ export class Session { takePendingOutput( includeSnapshot: boolean, opts: { teardownSnapshot?: boolean } = {} - ): SessionProtocol.TakePendingOutputResult | null { + ): TakePendingOutputResult | null { if (this._disposed) { return null } diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 654df23af81..9f6e6fbec1b 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -37,13 +37,6 @@ export type CreateOrAttachOptions = { } streamClient: { onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - onDataWithIncarnation?: ( - data: string, - rawLength: number | undefined, - transformed: boolean | undefined, - seq: number | undefined, - incarnationId: PtyIncarnationId - ) => void onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void } /** Lets the daemon route output under the adopted owner's canonical id before diff --git a/src/main/orcad/orcad-artifact-cache-retention.test.ts b/src/main/orcad/orcad-artifact-cache-retention.test.ts index 3ce9291c12f..2533d2764e2 100644 --- a/src/main/orcad/orcad-artifact-cache-retention.test.ts +++ b/src/main/orcad/orcad-artifact-cache-retention.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { ORCAD_LOCK_FILE_NAME } from './orcad-instance-lock' import { liveLocalOrcadServeVersion, + pruneDesktopOrcadArtifactCache, pruneOrcadArtifactCache } from './orcad-artifact-cache-retention' @@ -94,4 +95,27 @@ describe('orcad artifact cache retention', () => { lock('desktop') expect(liveLocalOrcadServeVersion(userData)).toBeNull() }) + + it('keeps both the selected slot and the one a running orcad serve still uses', async () => { + const userData = cacheRoot() + const root = join(userData, 'orcad-artifacts') + const running = slot(root, 'linux-x64-glibc', '0.1.0+old', 90) + const selected = slot(root, 'linux-x64-glibc', '0.4.0+new', 1) + const others = [2, 3, 4].map((age) => slot(root, 'linux-x64-glibc', `v${age}`, age)) + writeFileSync( + join(userData, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ + pid: process.pid, + startedAtMs: null, + nonce: 'nonce', + identity: 'uid', + version: '0.1.0+old', + acquiredAt: new Date().toISOString(), + role: 'orcad' + }) + ) + expect(await pruneDesktopOrcadArtifactCache(userData, ['0.4.0+new'])).toEqual([others[2]]) + expect(existsSync(running)).toBe(true) + expect(existsSync(selected)).toBe(true) + }) }) diff --git a/src/main/orcad/orcad-artifact-cache-retention.ts b/src/main/orcad/orcad-artifact-cache-retention.ts index bb9038fee0d..193c8d78169 100644 --- a/src/main/orcad/orcad-artifact-cache-retention.ts +++ b/src/main/orcad/orcad-artifact-cache-retention.ts @@ -1,7 +1,6 @@ /** - * Bounds the desktop's `/orcad-artifacts` slot cache, the way VS Code bounds its - * server downloads: per target, keep the most recently used slots and evict the rest at - * startup. The cache survives uninstall (it lives in userData); this is what keeps it small. + * Bounds the desktop's `/orcad-artifacts` slot cache: per target, keep the most + * recently used slots and evict the rest at startup. The cache survives uninstall (it lives in userData); this is what keeps it small. * * Never evicted: a slot this process materialized (an SSH deploy may be reading it) and the * slot a live local orcad serve runs from, named by the profile's instance lock. @@ -11,6 +10,7 @@ import { join } from 'node:path' import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import { ORCAD_LOCK_FILE_NAME, readOrcadInstanceLockRecord } from './orcad-instance-lock' import { materializedOrcadArtifactVersions } from '../ssh/orcad-artifact-materializer' +import { isProcessAlive } from '../daemon/daemon-process-inspection' /** The in-use slot plus the two most recent others (the newest three when none is in use). */ export const ORCAD_ARTIFACT_CACHE_KEEP = 3 @@ -68,32 +68,30 @@ export async function pruneOrcadArtifactCache( return removed } -/** The desktop's startup pass over its own `/orcad-artifacts`. */ -export function pruneDesktopOrcadArtifactCache(userDataPath: string): Promise { +/** A pass over `/orcad-artifacts` that never evicts a slot something still runs from. */ +export function pruneDesktopOrcadArtifactCache( + userDataPath: string, + alsoInUse: readonly string[] = [] +): Promise { const live = liveLocalOrcadServeVersion(userDataPath) return pruneOrcadArtifactCache(join(userDataPath, 'orcad-artifacts'), { - inUseVersions: new Set([...materializedOrcadArtifactVersions(), ...(live ? [live] : [])]) + inUseVersions: new Set([ + ...materializedOrcadArtifactVersions(), + ...(live ? [live] : []), + ...alsoInUse + ]) }) } /** The slot version a live local orcad serve runs from, or null when none holds the profile. */ export function liveLocalOrcadServeVersion( userDataPath: string, - isAlive: (pid: number) => boolean = processIsAlive + isAlive: (pid: number) => boolean = isProcessAlive ): string | null { const record = readOrcadInstanceLockRecord(join(userDataPath, ORCAD_LOCK_FILE_NAME)) return record && record.role !== 'desktop' && isAlive(record.pid) ? record.version : null } -function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return error instanceof Error && 'code' in error && error.code === 'EPERM' - } -} - async function listNames(directory: string): Promise { try { return await readdir(directory) diff --git a/src/main/orcad/orcad-completed-stop-receipt.ts b/src/main/orcad/orcad-completed-stop-receipt.ts index aa89cce87d5..7f155df696b 100644 --- a/src/main/orcad/orcad-completed-stop-receipt.ts +++ b/src/main/orcad/orcad-completed-stop-receipt.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path' import type { z } from 'zod' import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' import { writeDurableSecureJsonFile } from '../../shared/secure-file' +import { hasErrorCode } from '../daemon/daemon-process-inspection' import { ORCAD_STOP_RECEIPTS_DIRNAME, OrcadCompletedStopReceiptSchema, @@ -40,7 +41,7 @@ export function readOrcadStopReceipt { }) expect(unanswered.releaseFence).toHaveBeenCalledOnce() }) + + it('reopens admission again once a timed-out attempt is refused late', async () => { + let answer: (result: DaemonIdleRetirementResult) => void = () => {} + const late = ports(() => new Promise((resolve) => (answer = resolve))) + await retireOrcadDaemonIfIdle(late) + expect(late.releaseFence).toHaveBeenCalledOnce() + answer({ state: 'busy', liveSessions: 1 }) + await vi.waitFor(() => expect(late.releaseFence).toHaveBeenCalledTimes(2)) + }) + + it('keeps the fence when a timed-out attempt turns out to retire the daemon', async () => { + let answer: (result: DaemonIdleRetirementResult) => void = () => {} + const late = ports(() => new Promise((resolve) => (answer = resolve))) + await retireOrcadDaemonIfIdle(late) + answer({ state: 'retiring' }) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(late.releaseFence).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/orcad/orcad-daemon-retirement.ts b/src/main/orcad/orcad-daemon-retirement.ts index c5f0bb3936f..6310cafd5d9 100644 --- a/src/main/orcad/orcad-daemon-retirement.ts +++ b/src/main/orcad/orcad-daemon-retirement.ts @@ -11,8 +11,8 @@ import { requestIdleDaemonRetirement } from '../daemon/daemon-init' import type { OrcadDaemonRetirementVerdict } from '../../shared/orcad-stop-request' +import { ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS } from './orcad-stop-deadlines' -export const ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS = 5_000 export type OrcadDaemonRetirement = { retirement: OrcadDaemonRetirementVerdict @@ -48,11 +48,12 @@ export async function retireOrcadDaemonIfIdle( ports: Partial = {} ): Promise { const { request, releaseFence, countLiveSessions, timeoutMs } = { ...DEFAULT_PORTS, ...ports } - const result = await withTimeout( - request().catch(() => ({ state: 'unverifiable' as const })), - timeoutMs, - { state: 'timed-out' as const } - ) + const attempt = request().catch(() => ({ state: 'unverifiable' as const })) + const result = await withTimeout(attempt, timeoutMs, { state: 'timed-out' as const }) + if (result.state === 'timed-out') { + // The fence cannot reopen while the attempt is pending; reopen it once a late refusal lands. + void attempt.then((late) => late.state !== 'retiring' && releaseFence()) + } if (result.state === 'retiring') { return { retirement: 'retired', liveSessions: 0, reason: null } } diff --git a/src/main/orcad/orcad-daemon-supervision.test.ts b/src/main/orcad/orcad-daemon-supervision.test.ts index 19616c9d5ef..ba5cd989ad9 100644 --- a/src/main/orcad/orcad-daemon-supervision.test.ts +++ b/src/main/orcad/orcad-daemon-supervision.test.ts @@ -56,6 +56,20 @@ describe('startOrcadDaemon', () => { }) }) + it('gives a cold Windows daemon a longer startup budget', async () => { + await startOrcadDaemon('win32') + expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, { + macosLoginSessionWatch: false, + startupTimeoutMs: 30_000 + }) + }) + + it('retries the daemon spawn once before falling back to in-process terminals', async () => { + initDaemonPtyProviderMock.mockRejectedValueOnce(new Error('Daemon startup timed out')) + await expect(startOrcadDaemon()).resolves.toEqual({ state: 'live', pid: 4242 }) + expect(initDaemonPtyProviderMock).toHaveBeenCalledTimes(2) + }) + it('reports degraded when fresh terminals would fall back to the local provider', async () => { daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) const result = await startOrcadDaemon() @@ -71,6 +85,7 @@ describe('startOrcadDaemon', () => { state: 'unavailable', reason: 'node-pty is missing' }) + expect(initDaemonPtyProviderMock).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/orcad/orcad-daemon-supervision.ts b/src/main/orcad/orcad-daemon-supervision.ts index d35b03048d3..d0fa08cfbe9 100644 --- a/src/main/orcad/orcad-daemon-supervision.ts +++ b/src/main/orcad/orcad-daemon-supervision.ts @@ -18,6 +18,12 @@ import { readDaemonPidRecord } from '../daemon/daemon-init' +const WINDOWS_DAEMON_STARTUP_TIMEOUT_MS = 30_000 + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export type OrcadDaemonStartup = | { state: 'live'; pid: number | null } | { state: 'degraded'; reason: string } @@ -31,13 +37,25 @@ export type OrcadDaemonStartup = * β€” `daemonOwnsFreshPersistentPtys()` is what the runtime reads for that, and it answers * false here without any extra bookkeeping. */ -export async function startOrcadDaemon(): Promise { +export async function startOrcadDaemon( + platform: NodeJS.Platform = process.platform +): Promise { + // Why no login-session watch: that retires the daemon when the spawning macOS GUI login + // session dies. An orcad daemon must survive its SSH session ending β€” that is the point. + const policy = { + macosLoginSessionWatch: false, + // A freshly uploaded node.exe plus conpty can take well over 10 s on its first, AV-scanned exec. + ...(platform === 'win32' ? { startupTimeoutMs: WINDOWS_DAEMON_STARTUP_TIMEOUT_MS } : {}) + } try { - // Why no login-session watch: that retires the daemon when the spawning macOS GUI login - // session dies. An orcad daemon must survive its SSH session ending β€” that is the point. - await initDaemonPtyProvider(undefined, { macosLoginSessionWatch: false }) + await initDaemonPtyProvider(undefined, policy).catch((error: unknown) => { + // One warm retry: activation refuses a daemonless candidate, so a cold-start miss would + // otherwise leave a first deploy serving nothing. + console.warn(`[orcad] The terminal daemon did not start (${errorMessage(error)}); retrying`) + return initDaemonPtyProvider(undefined, policy) + }) } catch (error) { - const reason = error instanceof Error ? error.message : String(error) + const reason = errorMessage(error) console.error( `[orcad] The terminal daemon did not start: ${reason}\n` + '[orcad] Terminals will run in-process and WILL NOT survive an orcad restart.' diff --git a/src/main/orcad/orcad-instance-lock.test.ts b/src/main/orcad/orcad-instance-lock.test.ts index 2c5071a6874..7724d54c9e7 100644 --- a/src/main/orcad/orcad-instance-lock.test.ts +++ b/src/main/orcad/orcad-instance-lock.test.ts @@ -2,10 +2,12 @@ import { chmodSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, renameSync, rmSync, statSync, + utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -146,21 +148,36 @@ describe('acquireOrcadInstanceLock', () => { expect(JSON.parse(readFileSync(lock.path, 'utf8')).nonce).toBe('successor') }) - it.each([ + const garbledLocks = [ + ['nothing (a torn write)', ''], ['invalid JSON', '{'], ['an incomplete record', JSON.stringify({ pid: 424242, identity: 'uid-1000' })], ['an invalid pid', JSON.stringify(persistedRecord({ pid: -1 }))], ['an invalid start time', JSON.stringify({ ...persistedRecord(), startedAtMs: 'yesterday' })] - ])('fails closed when the existing lock contains %s', (_label, contents) => { + ] + + it.each(garbledLocks)('leaves a lock still being written alone: %s', (_label, contents) => { const root = makeRoot() writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), contents) expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) + expect.objectContaining({ code: 'orcad_instance_lock_held' }) ) expect(readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')).toBe(contents) }) + it.each(garbledLocks)('reclaims an abandoned lock containing %s', (_label, contents) => { + const root = makeRoot() + const lockPath = join(root, ORCAD_LOCK_FILE_NAME) + writeFileSync(lockPath, contents) + const longAgo = new Date(Date.now() - 60_000) + utimesSync(lockPath, longAgo, longAgo) + + const lock = acquireOrcadInstanceLock(root, hooks()) + expect(JSON.parse(readFileSync(lockPath, 'utf8')).nonce).toBe(lock.record.nonce) + expect(readdirSync(root).sort()).toEqual([ORCAD_LOCK_FILE_NAME]) + }) + it('fails closed when the existing lock is not a regular file', () => { const root = makeRoot() mkdirSync(join(root, ORCAD_LOCK_FILE_NAME)) diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts index 1c8cbb88387..9f2a7d1e8bf 100644 --- a/src/main/orcad/orcad-instance-lock.ts +++ b/src/main/orcad/orcad-instance-lock.ts @@ -13,7 +13,7 @@ * it says nothing about the daemon, which is what makes a non-destructive restart possible. */ import { randomUUID } from 'node:crypto' -import { linkSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { linkSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { userInfo } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -28,9 +28,12 @@ import { type OrcadDataRootPrivacyHooks } from './orcad-data-root-privacy' import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' +import { hasErrorCode, isProcessAlive } from '../daemon/daemon-process-inspection' export const ORCAD_LOCK_FILE_NAME = 'orcad.lock' const MAX_ORCAD_LOCK_BYTES = 64 * 1024 +// A writer finishes in milliseconds; a garbled record older than this has no live writer. +const GARBLED_LOCK_GRACE_MS = 10_000 export { OrcadInstanceLockError, type OrcadInstanceLockCode } from './orcad-data-root-privacy' @@ -81,19 +84,6 @@ function defaultIdentity(): string { : String(process.getuid?.() ?? 'unknown') } -function defaultProcessIsAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return isErrorCode(error, 'EPERM') - } -} - -function isErrorCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code -} - /** `null` for anything that is not a complete record; never a reason to treat a lock as free. */ export function parseOrcadInstanceLockRecord(content: string): OrcadLockRecord | null { try { @@ -115,7 +105,7 @@ export function acquireOrcadInstanceLock( hooks: OrcadInstanceLockHooks = {} ): OrcadInstanceLock { const identity = (hooks.identity ?? defaultIdentity)() - const isAlive = hooks.processIsAlive ?? defaultProcessIsAlive + const isAlive = hooks.processIsAlive ?? isProcessAlive const readStartedAt = hooks.startedAtMs ?? readOrcadProcessStartedAtMs const matchesStartTime = hooks.startTimeMatches ?? orcadProcessStartTimeMatches @@ -144,18 +134,23 @@ export function acquireOrcadInstanceLock( } const serialized = JSON.stringify(record) + // Staged then hard-linked: the canonical path only ever holds a complete record, never a torn one. const publish = (): boolean => { + const staged = `${lockPath}.staged-${process.pid}-${randomUUID()}` try { - writeFileSync(lockPath, serialized, { flag: 'wx', mode: 0o600 }) + writeFileSync(staged, serialized, { flag: 'wx', mode: 0o600 }) + linkSync(staged, lockPath) return true } catch (error) { - if (isErrorCode(error, 'EEXIST')) { + if (hasErrorCode(error, 'EEXIST')) { return false } throw new OrcadInstanceLockError( 'orcad_data_root_unusable', `Cannot write the orcad instance lock ${lockPath}: ${(error as Error).message}` ) + } finally { + unlinkQuietly(staged) } } @@ -163,16 +158,28 @@ export function acquireOrcadInstanceLock( return makeLock(lockPath, record) } - const existing = parseOrcadInstanceLockRecord(readBoundedLockFile(lockPath) ?? '') - if (!existing) { - // Why fail closed: an unreadable record proves nothing about its holder having exited. + const existingContents = readBoundedLockFile(lockPath) + if (existingContents === null) { + // Why fail closed: a record we cannot read proves nothing about its holder having exited. throw new OrcadInstanceLockError( 'orcad_instance_lock_unreadable', - `The orcad instance lock at ${lockPath} is unreadable, malformed, or larger than ` + + `The orcad instance lock at ${lockPath} is unreadable or larger than ` + `${MAX_ORCAD_LOCK_BYTES} bytes. Refusing to reclaim it without proof that its holder ` + 'has exited. Stop orcad and remove the stale lock manually.' ) } + const existing = parseOrcadInstanceLockRecord(existingContents) + if (!existing) { + // Only a torn write (an older build, or a crash mid-write) leaves this; once it has aged + // past any live writer, no owner can be behind it. + if (garbledLockIsAbandoned(lockPath)) { + return reclaimAndPublish(lockPath, dataRoot, existingContents, publish, record) + } + throw new OrcadInstanceLockError( + 'orcad_instance_lock_held', + `The orcad instance lock at ${lockPath} is still being written by another process. Retry.` + ) + } if (existing.identity !== identity) { throw new OrcadInstanceLockError( 'orcad_instance_lock_foreign_identity', @@ -189,9 +196,21 @@ export function acquireOrcadInstanceLock( 'before starting another, or use a different ORCA_USER_DATA.' ) } - // Why rename-and-then-publish rather than unlink-and-write: rename claims one exact - // directory entry, so a replacement written between our read and our write stays at the - // canonical path and wins β€” we never delete a record we did not inspect. + return reclaimAndPublish(lockPath, dataRoot, existingContents, publish, record) +} + +/** + * Why rename-and-then-publish rather than unlink-and-write: rename claims one exact directory + * entry, so a replacement written between our read and our write stays at the canonical path + * and wins β€” we never delete a record we did not inspect. + */ +function reclaimAndPublish( + lockPath: string, + dataRoot: string, + inspectedContents: string, + publish: () => boolean, + record: OrcadLockRecord +): OrcadInstanceLock { const claimPath = `${lockPath}.stale-${process.pid}-${randomUUID()}` try { renameSync(lockPath, claimPath) @@ -204,33 +223,42 @@ export function acquireOrcadInstanceLock( } // A contender may have replaced the entry after the liveness check; never displace its record. const claimedContents = readBoundedLockFile(claimPath) - if (parseOrcadInstanceLockRecord(claimedContents ?? '')?.nonce !== existing.nonce) { + if (claimedContents !== inspectedContents) { restoreDisplacedLock(claimPath, lockPath, claimedContents) throw new OrcadInstanceLockError( 'orcad_instance_lock_held', `The orcad instance lock at ${lockPath} changed while reclaiming a stale record.` ) } - if (!publish()) { + const published = publish() + // A uniquely named claim is inert either way. + unlinkQuietly(claimPath) + if (!published) { // Someone else claimed it first. Their record is authoritative; ours is not. - try { - unlinkSync(claimPath) - } catch { - // A uniquely named claim is inert. - } throw new OrcadInstanceLockError( 'orcad_instance_lock_held', `Another orcad took the data root ${dataRoot} while this one was reclaiming a stale lock.` ) } - try { - unlinkSync(claimPath) - } catch { - // The canonical record is authoritative; the claim is inert. - } return makeLock(lockPath, record) } +function garbledLockIsAbandoned(lockPath: string): boolean { + try { + return Date.now() - statSync(lockPath).mtimeMs > GARBLED_LOCK_GRACE_MS + } catch { + return false + } +} + +function unlinkQuietly(path: string): void { + try { + unlinkSync(path) + } catch { + // Already gone, or inert under its unique name. + } +} + function describeLockHolder(record: OrcadLockRecord): string { return record.role === 'desktop' ? 'The Orca desktop app' : 'Another orcad' } diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index ffcec0224a3..b59988e69e5 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -5,6 +5,7 @@ import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request' import { acquireOrcadInstanceLock } from './orcad-instance-lock' import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime' import { resolveOrcadExitCode } from './orcad-exit-code' +import { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-stop-deadlines' import { acquireProfileStateRuntimeAdmission, type ProfileStateRuntimeAdmission @@ -21,7 +22,7 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promi } } -export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 +export { ORCAD_SHUTDOWN_DEADLINE_MS } /** * A launcher and its child can both receive the same process-group or service stop signal. diff --git a/src/main/orcad/orcad-local-serve-selection-entry.ts b/src/main/orcad/orcad-local-serve-selection-entry.ts index 1cee48d08cf..e99f1532331 100644 --- a/src/main/orcad/orcad-local-serve-selection-entry.ts +++ b/src/main/orcad/orcad-local-serve-selection-entry.ts @@ -10,7 +10,7 @@ import { ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS } from '../../shared/orcad-local-serve-selection' import { selectServeRuntime } from './orcad-local-serve-selection' -import { pruneOrcadArtifactCache } from './orcad-artifact-cache-retention' +import { pruneDesktopOrcadArtifactCache } from './orcad-artifact-cache-retention' function flagValue(argv: readonly string[], flag: string): string | null { const index = argv.indexOf(flag) @@ -34,9 +34,10 @@ async function run(argv: readonly string[]): Promise { usesMacUpdateHandoff: argv.includes(FLAGS.macUpdateHandoff) }) // orcad serve never starts the desktop's startup pass, so the slot cache is bounded here too. - await pruneOrcadArtifactCache(join(userDataPath, 'orcad-artifacts'), { - inUseVersions: new Set(selection.kind === 'orcad' ? [selection.version] : []) - }).catch(() => []) + await pruneDesktopOrcadArtifactCache( + userDataPath, + selection.kind === 'orcad' ? [selection.version] : [] + ).catch(() => []) process.stdout.write(`${formatServeRuntimeSelection(selection)}\n`, () => process.exit(0)) } diff --git a/src/main/orcad/orcad-managed-stop-completion.ts b/src/main/orcad/orcad-managed-stop-completion.ts index 57760dfcec6..818cc9eebac 100644 --- a/src/main/orcad/orcad-managed-stop-completion.ts +++ b/src/main/orcad/orcad-managed-stop-completion.ts @@ -19,6 +19,7 @@ import { startTimesWithinTolerance } from '../daemon/daemon-process-start-time' import { readOrcadProcessStartedAtMs } from './orcad-process-start-time' +import { hasErrorCode, inspectProcessSignal } from '../daemon/daemon-process-inspection' import { persistOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt' import { readOrcadManagedStopDecision } from './orcad-managed-stop-decision' import { @@ -26,6 +27,10 @@ import { orcadManagedStopRequestPath, readOrcadManagedStopRequest } from './orcad-managed-stop-request' +import { + ORCAD_STOP_COMPLETION_POLL_ATTEMPTS, + ORCAD_STOP_COMPLETION_POLL_MS +} from './orcad-stop-deadlines' export type OrcadProcessProbe = (pid: number) => 'alive' | 'missing' | 'unverifiable' @@ -38,14 +43,9 @@ export type OrcadManagedStopCompletionOptions = { } function defaultProbe(pid: number): ReturnType { - try { - process.kill(pid, 0) - return 'alive' - } catch (error) { - const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null - // EPERM proves some process holds the PID; it cannot prove ours exited. - return code === 'ESRCH' ? 'missing' : 'unverifiable' - } + const signal = inspectProcessSignal(pid) + // EPERM proves some process holds the PID; it cannot prove ours exited. + return signal === 'occupied' ? 'alive' : signal === 'missing' ? 'missing' : 'unverifiable' } function observeInstance( @@ -75,7 +75,7 @@ export async function completeOrcadManagedStop( options: OrcadManagedStopCompletionOptions = {} ): Promise { const request = OrcadManagedStopRequestSchema.parse(input) - const attempts = options.attempts ?? 80 + const attempts = options.attempts ?? ORCAD_STOP_COMPLETION_POLL_ATTEMPTS if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 240) { throw new Error('orcad_managed_stop_invalid_attempts') } @@ -103,7 +103,7 @@ export async function completeOrcadManagedStop( throw new Error('orcad_managed_stop_request_permissions_unconfirmed') } for (let attempt = 0; attempt < attempts; attempt++) { - await (options.sleep ?? (() => delay(250)))() + await (options.sleep ?? (() => delay(ORCAD_STOP_COMPLETION_POLL_MS)))() verdict = observeInstance(request.instance, options) if (verdict !== 'live') { return verdict === 'exited' ? completed() : verdict @@ -125,6 +125,6 @@ function existingRequestMatches(path: string, request: OrcadManagedStopRequest): try { return JSON.stringify(readOrcadManagedStopRequest(path)) === JSON.stringify(request) } catch (error) { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' + return hasErrorCode(error, 'ENOENT') } } diff --git a/src/main/orcad/orcad-managed-stop-decision.ts b/src/main/orcad/orcad-managed-stop-decision.ts index e6b9d3d029e..a5b61e3d2eb 100644 --- a/src/main/orcad/orcad-managed-stop-decision.ts +++ b/src/main/orcad/orcad-managed-stop-decision.ts @@ -15,13 +15,10 @@ import { type OrcadManagedStopRequest } from '../../shared/orcad-stop-request' import { orcadStopReceiptPath, readOrcadStopReceipt } from './orcad-completed-stop-receipt' +import { hasErrorCode } from '../daemon/daemon-process-inspection' export type OrcadManagedStopDecisionValue = OrcadManagedStopDecision['decision'] -function isAlreadyDecided(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST' -} - export function readOrcadManagedStopDecision( request: OrcadManagedStopRequest ): OrcadManagedStopDecisionValue | null { @@ -50,7 +47,7 @@ export function claimOrcadManagedStopDecision( linkSync(staged, path) return decision } catch (error) { - if (!isAlreadyDecided(error)) { + if (!hasErrorCode(error, 'EEXIST')) { throw error } const standing = readOrcadManagedStopDecision(request) diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts index 24ebce56a72..aec81754e62 100644 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ b/src/main/orcad/orcad-managed-stop.test.ts @@ -22,6 +22,11 @@ import { } from './orcad-completed-stop-receipt' import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' import { runOrcadManagedStopCommand } from './orcad-managed-stop-command' +import { + ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS, + ORCAD_SHUTDOWN_DEADLINE_MS, + ORCAD_STOP_COMPLETION_POLL_MS +} from './orcad-stop-deadlines' const roots: string[] = [] afterEach(() => { @@ -165,6 +170,18 @@ describe('completing a managed stop', () => { expect(await completeOrcadManagedStop(request, options())).toBe('live') }) + it('waits out daemon retirement plus the shutdown deadline before answering live', async () => { + const { request } = running() + let polls = 0 + const exitAfterMs = 2 * ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS + ORCAD_SHUTDOWN_DEADLINE_MS + const verdict = await completeOrcadManagedStop(request, { + ...options({ attempts: undefined }), + sleep: async () => void polls++, + probeProcess: () => (polls * ORCAD_STOP_COMPLETION_POLL_MS >= exitAfterMs ? 'missing' : 'alive') + }) + expect(verdict).toBe('exited') + }) + it('does not address a live process that no longer holds the lock it published', async () => { const { lock, request } = running() writeFileSync(lock.path, JSON.stringify({ ...lock.record, nonce: 'successor' })) diff --git a/src/main/orcad/orcad-stop-deadlines.ts b/src/main/orcad/orcad-stop-deadlines.ts new file mode 100644 index 00000000000..faaf6d1ae56 --- /dev/null +++ b/src/main/orcad/orcad-stop-deadlines.ts @@ -0,0 +1,11 @@ +/** Kept dependency-free: the short-lived stop-completion command imports these too. */ +export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 +export const ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS = 5_000 + +export const ORCAD_STOP_COMPLETION_POLL_MS = 250 +// Retirement (request + session count, each bounded) runs before the shutdown deadline starts. +const STOP_COMPLETION_BUDGET_MS = + 2 * ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS + ORCAD_SHUTDOWN_DEADLINE_MS + 5_000 +export const ORCAD_STOP_COMPLETION_POLL_ATTEMPTS = Math.ceil( + STOP_COMPLETION_BUDGET_MS / ORCAD_STOP_COMPLETION_POLL_MS +) diff --git a/src/main/orcad/orcad-stop-request-listener.test.ts b/src/main/orcad/orcad-stop-request-listener.test.ts index 2563543cb95..dc98966f6c4 100644 --- a/src/main/orcad/orcad-stop-request-listener.test.ts +++ b/src/main/orcad/orcad-stop-request-listener.test.ts @@ -112,6 +112,27 @@ describe('orcad stop-request listeners', () => { expect(existsSync(path)).toBe(true) }) + it('acts once on a managed request written before the listeners were installed', async () => { + const managedStop = managedContext() + writeFileSync( + orcadManagedStopRequestPath(managedStop.instance), + JSON.stringify({ + schemaVersion: 1, + transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', + ...managedStop + }) + ) + const onRequest = vi.fn() + const prepare = vi.fn(async () => {}) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + listen(onRequest, { installRoot: directory(), managedStop, beforeManagedStop: prepare }) + await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(prepare).toHaveBeenCalledOnce() + expect(onRequest).toHaveBeenCalledOnce() + expect(report).not.toHaveBeenCalled() + }) + it('ignores a managed request for another runtime and reports it once', async () => { const managedStop = managedContext() const onRequest = vi.fn() diff --git a/src/main/orcad/orcad-stop-request-listener.ts b/src/main/orcad/orcad-stop-request-listener.ts index 1b09eeed3dc..61e67d66e4e 100644 --- a/src/main/orcad/orcad-stop-request-listener.ts +++ b/src/main/orcad/orcad-stop-request-listener.ts @@ -17,20 +17,17 @@ import { validateOrcadManagedStopRequest } from './orcad-managed-stop-request' import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision' +import { hasErrorCode } from '../daemon/daemon-process-inspection' export type OrcadStopRequestListener = { close(): void } const DEFAULT_POLL_INTERVAL_MS = 1_000 -function isMissingFile(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' -} - -/** `consume` returns true when the request should stop orcad; a missing file throws ENOENT. */ -function listenForRequest( +/** `consume` returns the request that should stop orcad, or null; a missing file throws ENOENT. */ +function listenForRequest( requestPath: string, - consume: (path: string) => boolean, - onRequest: () => void, + consume: (path: string) => T | null, + onRequest: (request: T) => void, pollIntervalMs: number ): OrcadStopRequestListener { let closed = false @@ -39,12 +36,11 @@ function listenForRequest( if (closed) { return } + let request: T | null try { - if (!consume(requestPath)) { - return - } + request = consume(requestPath) } catch (error) { - if (!isMissingFile(error)) { + if (!hasErrorCode(error, 'ENOENT')) { // Polling retries every interval; report each distinct refusal once. const failure = error instanceof Error ? error.message : String(error) if (failure !== lastFailure) { @@ -54,9 +50,12 @@ function listenForRequest( } return } + if (request === null) { + return + } closed = true stop() - onRequest() + onRequest(request) } let watcher: FSWatcher | null = null try { @@ -98,17 +97,24 @@ export function installOrcadStopRequestListeners( } ): OrcadStopRequestListener { const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS - const listeners = [ + // Declared first: a listener's initial check can consume a request and close before returning. + const listeners: OrcadStopRequestListener[] = [] + const close = (): void => { + for (const listener of listeners) { + listener.close() + } + } + listeners.push( listenForRequest( join(options.installRoot, ORCAD_STOP_REQUEST_FILENAME), (path) => { unlinkSync(path) return true }, - onRequest, + () => onRequest(), pollIntervalMs ) - ] + ) const managedStop = options.managedStop if (managedStop) { const prepare = options.beforeManagedStop ?? (async () => {}) @@ -121,6 +127,9 @@ export function installOrcadStopRequestListeners( if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') { throw new Error('orcad_managed_stop_canceled') } + return request + }, + (request) => { close() // Preparation is best effort: whatever it reports, the stop proceeds. void prepare(request) @@ -128,17 +137,10 @@ export function installOrcadStopRequestListeners( console.error('[orcad] managed stop preparation failed:', error) ) .finally(onRequest) - return false }, - onRequest, pollIntervalMs ) ) } - const close = (): void => { - for (const listener of listeners) { - listener.close() - } - } return { close } } diff --git a/src/main/runtime/mobile-session-editor-projection.ts b/src/main/runtime/mobile-session-editor-projection.ts index 8510e6cc47d..a88e633dd41 100644 --- a/src/main/runtime/mobile-session-editor-projection.ts +++ b/src/main/runtime/mobile-session-editor-projection.ts @@ -60,6 +60,8 @@ function editorTab( return { ...common, type: 'file', language: file.language, mode: 'edit' } } +export const HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR = 'editor_tab_has_unsaved_draft' + export function buildHeadlessMobileSessionEditorTabs( worktreeId: string, session: WorkspaceSessionState @@ -73,12 +75,18 @@ export function buildHeadlessMobileSessionEditorTabs( export function retireHeadlessEditorTab( session: WorkspaceSessionState, worktreeId: string, - tab: Pick + tab: Pick, + force = false ): WorkspaceSessionState | null { const files = session.openFilesByWorktree?.[worktreeId] ?? [] - if (!files.some((file) => file.filePath === tab.filePath)) { + const file = files.find((candidate) => candidate.filePath === tab.filePath) + if (!file) { return null } + // No window here can prompt to save, and the draft lives nowhere else. + if (file.dirtyDraftContent !== undefined && !force) { + throw new Error(HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR) + } const unifiedTabId = editorUnifiedTab(session, worktreeId, tab.filePath)?.id ?? tab.id const withoutTab = (ids: readonly string[] | undefined): string[] | undefined => ids?.filter((id) => id !== unifiedTabId) @@ -127,12 +135,13 @@ export type HeadlessEditorRetirementHost = { export function retireHeadlessMobileSessionEditorTab( host: HeadlessEditorRetirementHost, worktreeId: string, - tab: { id: string; type: string; filePath?: string } + tab: { id: string; type: string; filePath?: string }, + force = false ): boolean { const session = host.getWorkspaceSessionForWorktree(worktreeId) const next = session && (tab.type === 'markdown' || tab.type === 'file') && tab.filePath - ? retireHeadlessEditorTab(session, worktreeId, { id: tab.id, filePath: tab.filePath }) + ? retireHeadlessEditorTab(session, worktreeId, { id: tab.id, filePath: tab.filePath }, force) : null if (!next) { return false diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 3afcb4b23b3..2bfeaec2add 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -306,14 +306,12 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU tab, structuredAgentSessionTabCloseCause(options.reason) ) - } else { - if (!this.notifier?.closeSessionTab) { - // Why: a headless host listed this editor from its own session, so it retires it there. - if (!retireHeadlessMobileSessionEditorTab(this, worktreeId, tab)) { - throw new Error('runtime_unavailable') - } - return finishCommittedClose() + } else if (!this.notifier?.closeSessionTab) { + // Why: a headless host listed this editor from its own session, so it retires it there. + if (!retireHeadlessMobileSessionEditorTab(this, worktreeId, tab, options.force)) { + throw new Error('runtime_unavailable') } + } else { await this.notifier.closeSessionTab(tab.id, worktreeId) } return finishCommittedClose() diff --git a/src/main/runtime/orca-runtime-migration-catalog.ts b/src/main/runtime/orca-runtime-migration-catalog.ts index 2a5f6bd3174..3e34bf1a6fa 100644 --- a/src/main/runtime/orca-runtime-migration-catalog.ts +++ b/src/main/runtime/orca-runtime-migration-catalog.ts @@ -2,133 +2,117 @@ import type { OrcadMigrationCatalogAbortResult, OrcadMigrationCatalogState, OrcadMigrationImportResult, - OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' + OrcadMigrationManifest, +} from "../../shared/orcad-migration-manifest"; import type { OrcadMigrationSnapshotChunkRequest, - OrcadMigrationSnapshotChunkResult -} from '../../shared/orcad-migration-scrollback' + OrcadMigrationSnapshotChunkResult, +} from "../../shared/orcad-migration-scrollback"; import { abortStagedOrcadMigrationCatalogDurably, commitStagedOrcadMigrationCatalogDurably, getOrcadMigrationCatalogState, importOrcadMigrationCatalogDurably, - stageOrcadMigrationCatalogDurably -} from './orcad-migration-catalog-import' -import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' + stageOrcadMigrationCatalogDurably, +} from "./orcad-migration-catalog-import"; +import { OrcaRuntimeWithResolveWaiter } from "./orca-runtime-resolve-waiter"; +import type { RuntimeStore } from "./runtime-store-contract"; +import type { Store } from "../persistence"; /** The destination half of a dormant catalog migration; every mutation is flushed before it answers. */ export class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithResolveWaiter { async importOrcadMigrationCatalog( manifest: OrcadMigrationManifest, - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal } = {}, ): Promise { - const store = this.store - const importCatalog = store?.importOrcadMigrationCatalog - const flushPending = store?.flushPendingOrThrowAsync - if (!store || !importCatalog || !flushPending) { - throw new Error('runtime_unavailable') - } return importOrcadMigrationCatalogDurably({ - store: { - importOrcadMigrationCatalog: (input, inputOptions) => - importCatalog.call(store, input, inputOptions), - flushPendingOrThrowAsync: (flushOptions) => flushPending.call(store, flushOptions) - }, + store: this.requireMigrationStore( + "importOrcadMigrationCatalog", + "flushPendingOrThrowAsync", + ), manifest, signal: options.signal, onDurableImport: () => { - this.invalidateResolvedWorktreeCache() - this.notifyReposChanged() - } - }) + this.invalidateResolvedWorktreeCache(); + this.notifyReposChanged(); + }, + }); } async stageOrcadMigrationCatalog( manifest: OrcadMigrationManifest, - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal } = {}, ): Promise { - const store = this.store - const stage = store?.stageOrcadMigrationCatalog - const flush = store?.flushPendingOrThrowAsync - if (!store || !stage || !flush) { - throw new Error('runtime_unavailable') - } return stageOrcadMigrationCatalogDurably({ - store: { - stageOrcadMigrationCatalog: (input, inputOptions) => stage.call(store, input, inputOptions), - flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions) - }, + store: this.requireMigrationStore( + "stageOrcadMigrationCatalog", + "flushPendingOrThrowAsync", + ), manifest, - signal: options.signal - }) + signal: options.signal, + }); } async commitStagedOrcadMigrationCatalog( manifest: OrcadMigrationManifest, - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal } = {}, ): Promise { - const store = this.store - const commit = store?.commitStagedOrcadMigrationCatalog - const flush = store?.flushPendingOrThrowAsync - if (!store || !commit || !flush) { - throw new Error('runtime_unavailable') - } return commitStagedOrcadMigrationCatalogDurably({ - store: { - commitStagedOrcadMigrationCatalog: (input, inputOptions) => - commit.call(store, input, inputOptions), - flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions) - }, + store: this.requireMigrationStore( + "commitStagedOrcadMigrationCatalog", + "flushPendingOrThrowAsync", + ), manifest, signal: options.signal, onDurableCommit: () => { - this.invalidateResolvedWorktreeCache() - this.notifyReposChanged() - } - }) + this.invalidateResolvedWorktreeCache(); + this.notifyReposChanged(); + }, + }); } stageOrcadMigrationSnapshotChunk( - request: OrcadMigrationSnapshotChunkRequest + request: OrcadMigrationSnapshotChunkRequest, ): OrcadMigrationSnapshotChunkResult { - const store = this.store - const stageChunk = store?.stageOrcadMigrationSnapshotChunk - if (!store || !stageChunk) { - throw new Error('runtime_unavailable') - } - return stageChunk.call(store, request) + return this.requireMigrationStore( + "stageOrcadMigrationSnapshotChunk", + ).stageOrcadMigrationSnapshotChunk(request); } async abortStagedOrcadMigrationCatalog( manifest: OrcadMigrationManifest, - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal } = {}, ): Promise { - const store = this.store - const abort = store?.abortStagedOrcadMigrationCatalog - const flush = store?.flushPendingOrThrowAsync - if (!store || !abort || !flush) { - throw new Error('runtime_unavailable') - } return abortStagedOrcadMigrationCatalogDurably({ - store: { - abortStagedOrcadMigrationCatalog: (input) => abort.call(store, input), - flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions) - }, + store: this.requireMigrationStore( + "abortStagedOrcadMigrationCatalog", + "flushPendingOrThrowAsync", + ), manifest, - signal: options.signal - }) + signal: options.signal, + }); } - getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState { - const store = this.store - const readState = store?.getOrcadMigrationCatalogState - if (!store || !readState) { - throw new Error('runtime_unavailable') - } + getOrcadMigrationCatalogState( + manifest: OrcadMigrationManifest, + ): OrcadMigrationCatalogState { return getOrcadMigrationCatalogState({ - store: { getOrcadMigrationCatalogState: (input) => readState.call(store, input) }, - manifest - }) + store: this.requireMigrationStore("getOrcadMigrationCatalogState"), + manifest, + }); + } + + /** A partial store (tests, headless hosts) lacks some optional methods; refuse rather than half-run. */ + private requireMigrationStore( + ...methods: K[] + ): Pick { + const store = this.store; + if ( + !store || + methods.some((method) => typeof store[method] !== "function") + ) { + throw new Error("runtime_unavailable"); + } + return this.requireStore(); } } diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-14.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-14.spec.ts index 0798c3bfd30..672e75abec1 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-14.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-14.spec.ts @@ -97,4 +97,22 @@ describe('editor tabs a headless host persisted', () => { expect(getSession().unifiedTabs?.[TEST_WORKTREE_ID]).toEqual([]) expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) }) + + it('keeps an unsaved draft unless the close is forced', async () => { + const session = sessionWithMigratedEditor() + const [file] = session.openFilesByWorktree?.[TEST_WORKTREE_ID] ?? [] + session.openFilesByWorktree = { [TEST_WORKTREE_ID]: [{ ...file, dirtyDraftContent: 'draft' }] } + const { runtime, getSession } = headlessRuntime(session) + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme') + ).rejects.toThrow('editor_tab_has_unsaved_draft') + expect(getSession().openFilesByWorktree?.[TEST_WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe( + 'draft' + ) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme', { force: true }) + expect(getSession().openFilesByWorktree?.[TEST_WORKTREE_ID]).toEqual([]) + }) }) diff --git a/src/main/runtime/rpc/websocket-transport-limits.ts b/src/main/runtime/rpc/websocket-transport-limits.ts deleted file mode 100644 index e2e0b3d16ce..00000000000 --- a/src/main/runtime/rpc/websocket-transport-limits.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES = 1024 * 1024 -// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs. -export const WEBSOCKET_TRANSPORT_MAX_CONNECTIONS = 128 -// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound. -export const WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS = WEBSOCKET_TRANSPORT_MAX_CONNECTIONS * 2 diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 81e76b1bb09..8ef083ff81a 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -13,11 +13,12 @@ import { type WebSocketMessageHandler } from './node-websocket-lifecycle' import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' -import { - WEBSOCKET_TRANSPORT_MAX_CONNECTIONS, - WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES, - WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS -} from './websocket-transport-limits' + +const WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES = 1024 * 1024 +// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs. +const WEBSOCKET_TRANSPORT_MAX_CONNECTIONS = 128 +// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound. +const WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS = WEBSOCKET_TRANSPORT_MAX_CONNECTIONS * 2 const PRE_AUTH_TIMEOUT_MS = 10_000 diff --git a/src/main/runtime/runtime-worktree-ps-unverifiable-terminals.test.ts b/src/main/runtime/runtime-worktree-ps-unverifiable-terminals.test.ts new file mode 100644 index 00000000000..8696b75ceba --- /dev/null +++ b/src/main/runtime/runtime-worktree-ps-unverifiable-terminals.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import { applyRuntimeWorktreePsUnverifiableTerminals } from './runtime-worktree-ps-unverifiable-terminals' + +describe('unverifiable terminal attribution', () => { + it('follows the host PTY record, not a stale pane, as the live pass does', () => { + const summaries = new Map>([ + ['wt-old', {}], + ['wt-new', {}] + ]) + applyRuntimeWorktreePsUnverifiableTerminals({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the counters this pass writes are read back. + summaries: summaries as Map, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getSummary below ignores the index. + pathIndex: {} as never, + missingIds: new Set(), + countedPtyIds: new Set(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId. + leaves: [{ ptyId: 'pty-1', worktreeId: 'wt-old' } as never], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId. + ptysById: new Map([['pty-1', { ptyId: 'pty-1', worktreeId: 'wt-new' } as never]]), + getLivenessVerdict: () => ({ status: 'unverifiable', reason: 'lost contact' }), + getSummary: (all, _index, _missing, worktreeId) => all.get(worktreeId) ?? null + }) + expect(summaries.get('wt-new')?.unverifiableTerminalCount).toBe(1) + expect(summaries.get('wt-old')?.unverifiableTerminalCount).toBeUndefined() + }) +}) diff --git a/src/main/runtime/runtime-worktree-ps-unverifiable-terminals.ts b/src/main/runtime/runtime-worktree-ps-unverifiable-terminals.ts index 8a4ffed2bba..23ee5dcab07 100644 --- a/src/main/runtime/runtime-worktree-ps-unverifiable-terminals.ts +++ b/src/main/runtime/runtime-worktree-ps-unverifiable-terminals.ts @@ -23,18 +23,16 @@ export function applyRuntimeWorktreePsUnverifiableTerminals(args: { worktreeId: string ) => RuntimeWorktreePsSummary | null }): void { - // The renderer's pane owns the PTY's worktree; the record is the fallback once no pane holds it. + // Like the live pass, the host's PTY record owns the worktree; a pane only fills in a PTY with no record. const ownerByPtyId = new Map() + for (const pty of args.ptysById.values()) { + ownerByPtyId.set(pty.ptyId, pty.worktreeId) + } for (const leaf of args.leaves) { if (leaf.ptyId && !ownerByPtyId.has(leaf.ptyId)) { ownerByPtyId.set(leaf.ptyId, leaf.worktreeId) } } - for (const pty of args.ptysById.values()) { - if (!ownerByPtyId.has(pty.ptyId)) { - ownerByPtyId.set(pty.ptyId, pty.worktreeId) - } - } for (const [ptyId, worktreeId] of ownerByPtyId) { if ( args.countedPtyIds.has(ptyId) || diff --git a/src/main/ssh/orcad-activation-gate.test.ts b/src/main/ssh/orcad-activation-gate.test.ts index e3afdcd142e..43ca270448a 100644 --- a/src/main/ssh/orcad-activation-gate.test.ts +++ b/src/main/ssh/orcad-activation-gate.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { evaluateOrcadActivation } from './orcad-activation-gate' +import { classifyOrcadHostUnavailable } from './orcad-host-unavailable' import type { ServeReadiness } from '../server/serve-readiness' import type { OrcadHealth, TerminalDaemonHealth } from '../orcad/orcad-health' @@ -85,6 +86,8 @@ describe('evaluateOrcadActivation', () => { EXPECTED ) expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_daemon_absent' }) + // A cold daemon start is transient: the host must stay eligible for the next connect's deploy. + expect(classifyOrcadHostUnavailable({ code: 'orcad_activation_daemon_absent' })).toBeNull() }) it('refuses a degraded daemon, whose fresh terminals would not survive a restart', () => { diff --git a/src/main/ssh/orcad-activation-gate.ts b/src/main/ssh/orcad-activation-gate.ts index c9581919117..d514d6858e4 100644 --- a/src/main/ssh/orcad-activation-gate.ts +++ b/src/main/ssh/orcad-activation-gate.ts @@ -105,7 +105,8 @@ export function evaluateOrcadActivation( reason: 'The candidate has no terminal daemon. Every terminal on this host would run in the ' + 'orcad process and die with it, which is the exact regression the daemon exists to ' + - 'prevent. Nothing was activated.' + "prevent. Nothing was activated; orcad.log carries the daemon's startup error, and " + + 'the next connect retries.' } } if (daemon.state === 'degraded') { diff --git a/src/main/startup/windows-app-data-path.test.ts b/src/main/startup/windows-app-data-path.test.ts index 8cbff4f39dd..0304adfe5d1 100644 --- a/src/main/startup/windows-app-data-path.test.ts +++ b/src/main/startup/windows-app-data-path.test.ts @@ -11,7 +11,10 @@ import { const ERROR_MESSAGE = /could not find the Windows roaming AppData folder/ -function createHost(nativeAppData: string | Error): WindowsAppDataPathHost & { +function createHost( + nativeAppData: string | Error, + switches: string[] = [] +): WindowsAppDataPathHost & { calls: string[] paths: Map } { @@ -21,6 +24,7 @@ function createHost(nativeAppData: string | Error): WindowsAppDataPathHost & { calls, paths, getName: () => 'orca', + commandLine: { hasSwitch: (name) => switches.includes(name) }, getPath: (name) => { calls.push(`get:${name}`) const overridden = paths.get(name) @@ -85,6 +89,12 @@ describe('ensureWindowsAppDataPath', () => { expect(host.paths.get('userData')).toBe('C:\\Users\\me\\AppData\\Roaming\\orca') }) + it('leaves userData to an explicit --user-data-dir switch', () => { + const host = createHost('C:\\Users\\me\\AppData\\Roaming', ['user-data-dir']) + ensureWindowsAppDataPath(host, {}, 'win32') + expect(host.calls).toEqual(['get:appData']) + }) + it('creates and sets appData from the environment before userData when the lookup throws', () => { // Why a real dir: the fallback must mkdir a folder that a profile-less session never created. const root = mkdtempSync(join(tmpdir(), 'orca-appdata-')) diff --git a/src/main/startup/windows-app-data-path.ts b/src/main/startup/windows-app-data-path.ts index 04e29d2dfc7..065d07de0cd 100644 --- a/src/main/startup/windows-app-data-path.ts +++ b/src/main/startup/windows-app-data-path.ts @@ -5,6 +5,7 @@ export type WindowsAppDataPathHost = { getPath(name: 'appData'): string setPath(name: 'appData' | 'userData', value: string): void getName(): string + commandLine: { hasSwitch(name: string): boolean } } /** Derives the roaming AppData folder from the environment when the known-folder lookup fails. */ @@ -50,6 +51,10 @@ export function ensureWindowsAppDataPath( mkdirSync(appData, { recursive: true }) host.setPath('appData', appData) } + // Electron already resolved userData from an explicit switch; pinning it would override that. + if (host.commandLine.hasSwitch('user-data-dir')) { + return + } // Same value Electron's own provider computes; setting it skips that provider entirely. host.setPath('userData', win32.join(appData, host.getName())) }