diff --git a/packages/host-kit/src/internal/host-process-liveness.test.ts b/packages/host-kit/src/internal/host-process-liveness.test.ts index 3a259150eb..cc090f0f02 100644 --- a/packages/host-kit/src/internal/host-process-liveness.test.ts +++ b/packages/host-kit/src/internal/host-process-liveness.test.ts @@ -70,6 +70,7 @@ test('reads many process identities from one ps snapshot', () => { startTime: 'Mon Aug 10 20:01:00 2026', }); assert.equal(mockRunCmdSync.mock.calls.length, 1); + assert.equal(mockRunCmdSync.mock.calls[0]?.[0], '/bin/ps'); assert.deepEqual(mockRunCmdSync.mock.calls[0]?.[1], [ '-p', '4242,4343', @@ -129,5 +130,6 @@ test('the ownership read answers the zombie question from the state field', asyn command: 'y', zombie: true, }); + assert.equal(mockRunCmd.mock.calls[0]?.[0], '/bin/ps'); assert.equal(mockRunCmd.mock.calls[0]?.[2]?.timeoutMs, 1_000); }); diff --git a/packages/host-kit/src/internal/host-process-windows.test.ts b/packages/host-kit/src/internal/host-process-windows.test.ts new file mode 100644 index 0000000000..3173e24cdc --- /dev/null +++ b/packages/host-kit/src/internal/host-process-windows.test.ts @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test, vi } from 'vitest'; + +const { mockRunCmdSync, mockRunCmd } = vi.hoisted(() => ({ + mockRunCmdSync: vi.fn(), + mockRunCmd: vi.fn(), +})); + +vi.mock('./exec.ts', async () => { + const actual = await vi.importActual('./exec.ts'); + return { ...actual, runCmd: mockRunCmd, runCmdSync: mockRunCmdSync }; +}); + +import { + isProcessZombie, + listHostProcesses, + readHostProcessIdentityObservations, + readProcessCommand, + readProcessIdentityFacts, + readProcessStartTime, +} from './host-process.ts'; +import { classifyOwnerLiveness } from './owner-identity.ts'; + +const DAEMON_START = '20261008123456789012'; +const DAEMON_PID = 4242; +// One CIM row as a Windows host answers it: pid|ppid|creation|command. +const DAEMON_ROW = `${DAEMON_PID}|4321|${DAEMON_START}|C:\\Program Files\\node.exe C:\\app\\dist\\src\\internal\\daemon.js`; + +function windowsPlatform(run: () => T): T { + const previous = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + try { + return run(); + } finally { + if (previous) Object.defineProperty(process, 'platform', previous); + } +} + +function cimAnswers(stdout: string, exitCode = 0): void { + mockRunCmdSync.mockReturnValue({ stdout, stderr: '', exitCode }); +} + +beforeEach(() => { + mockRunCmdSync.mockReset(); + mockRunCmd.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('the Windows start-time read asks CIM for the process birth, not for lstart', () => { + windowsPlatform(() => { + cimAnswers(`${DAEMON_ROW}\r\n`); + assert.equal(readProcessStartTime(DAEMON_PID), DAEMON_START); + }); + const call = mockRunCmdSync.mock.calls[0]!; + assert.equal(call[0], 'powershell.exe'); + const command = String(call[1][3]); + assert.match(command, /Get-CimInstance -ClassName Win32_Process -Filter 'ProcessId=4242'/); + assert.match(command, /CreationDate/); + assert.match(command, /CommandLine/); +}); + +test('the Windows command read answers the daemon-identity pattern from CommandLine', () => { + windowsPlatform(() => { + cimAnswers(`${DAEMON_ROW}\r\n`); + assert.equal( + readProcessCommand(DAEMON_PID), + String.raw`C:\Program Files\node.exe C:\app\dist\src\internal\daemon.js`, + ); + }); +}); + +test('the Windows host answers the zombie question without a process-table probe', () => { + // A terminated Windows process leaves the CIM table rather than lingering in + // it, so probing state per record could never reveal a zombie. The answer is + // false with zero queries. + windowsPlatform(() => { + assert.equal(isProcessZombie(DAEMON_PID), false); + assert.equal(isProcessZombie(4343), false); + }); + assert.equal(mockRunCmdSync.mock.calls.length, 0); +}); + +test('a CIM query that names no row for the pid is unknown, not death', () => { + windowsPlatform(() => { + cimAnswers(''); + assert.equal(readProcessStartTime(DAEMON_PID), null); + assert.equal(readProcessCommand(DAEMON_PID), null); + assert.equal(isProcessZombie(DAEMON_PID), false); + }); +}); + +test('a failed or thrown CIM query is unknown evidence, as a failed ps is', () => { + windowsPlatform(() => { + cimAnswers('', 1); + assert.equal(readProcessStartTime(DAEMON_PID), null); + mockRunCmdSync.mockImplementation(() => { + throw new Error('powershell.exe timed out'); + }); + assert.equal(readProcessStartTime(DAEMON_PID), null); + }); +}); + +test('the CIM row body never lets one null field raise, and pins the invariant culture', () => { + windowsPlatform(() => { + // The System Idle Process has no CreationDate. Formatting that row must + // print an empty stamp, not raise: a terminating error mid-pipeline exits + // 1 and blanks the ENTIRE snapshot, turning one odd process into unknown + // evidence for every pid. + cimAnswers(`0|0||System Idle Process\r\n4343|4321||cmd.exe\r\n${DAEMON_ROW}\r\n`); + const observations = readHostProcessIdentityObservations([DAEMON_PID, 4343]); + assert.deepEqual(observations.get(DAEMON_PID), { state: 'R', startTime: DAEMON_START }); + assert.equal( + observations.get(4343), + undefined, + 'a null-birth row is unknown, not a false birth', + ); + }); + const command = String(mockRunCmdSync.mock.calls[0]![1][3]); + assert.match(command, /if \(\$_\.CreationDate\)/, 'null birth prints empty instead of raising'); + assert.match( + command, + /CultureInfo\]::InvariantCulture/, + 'custom format follows the host calendar', + ); +}); + +test('the Windows batch snapshot answers one observation query for every selected pid', () => { + windowsPlatform(() => { + cimAnswers( + `${DAEMON_ROW}\r\n4343|4321|20261008123500000000|C:\\Windows\\System32\\cmd.exe\r\n`, + ); + const observations = readHostProcessIdentityObservations([DAEMON_PID, 4343, DAEMON_PID]); + assert.deepEqual(observations.get(DAEMON_PID), { + state: 'R', + startTime: DAEMON_START, + }); + assert.equal(observations.get(4343)?.startTime, '20261008123500000000'); + }); + assert.equal(mockRunCmdSync.mock.calls.length, 1); + assert.match(String(mockRunCmdSync.mock.calls[0]![1][3]), /ProcessId=4242 OR ProcessId=4343/); +}); + +test('the Windows ownership read answers all three facts from one CIM query', async () => { + mockRunCmd.mockResolvedValue({ stdout: `${DAEMON_ROW}\r\n`, stderr: '', exitCode: 0 }); + const facts = await windowsPlatform(() => readProcessIdentityFacts(DAEMON_PID, 5_000)); + assert.deepEqual(facts, { + startTime: DAEMON_START, + command: 'C:\\Program Files\\node.exe C:\\app\\dist\\src\\internal\\daemon.js', + zombie: false, + }); + assert.equal(mockRunCmd.mock.calls.length, 1); + assert.equal(mockRunCmd.mock.calls[0]![0], 'powershell.exe'); + assert.equal(mockRunCmd.mock.calls[0]![2]?.timeoutMs, 5_000); +}); + +test('the Windows ownership read budgets PowerShell startup over the ps default', async () => { + mockRunCmd.mockResolvedValue({ stdout: DAEMON_ROW, stderr: '', exitCode: 0 }); + await windowsPlatform(() => readProcessIdentityFacts(DAEMON_PID)); + assert.equal(mockRunCmd.mock.calls[0]![2]?.timeoutMs, 2_000); +}); + +test('the Windows ownership read reports an unanswered host as unknown, not as absence', async () => { + mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + assert.deepEqual(await windowsPlatform(() => readProcessIdentityFacts(DAEMON_PID, 5_000)), { + startTime: null, + command: null, + zombie: null, + }); + mockRunCmd.mockRejectedValue(new Error('powershell.exe timed out after 5000ms')); + assert.deepEqual(await windowsPlatform(() => readProcessIdentityFacts(DAEMON_PID, 5_000)), { + startTime: null, + command: null, + zombie: null, + }); +}); + +test('the Windows process list parses CIM rows through the injected command runner', async () => { + const calls: Array<{ cmd: string; args: string[]; timeoutMs: number | undefined }> = []; + const processes = await windowsPlatform(() => + listHostProcesses({ + timeoutMs: 1_234, + runCommand: async (cmd, args, options) => { + calls.push({ cmd, args, timeoutMs: options.timeoutMs }); + return { + stdout: `\uFEFF0|0||System Idle Process\r\n${DAEMON_ROW}\r\n0|0||\r\nnot a row\r\n`, + stderr: '', + exitCode: 0, + }; + }, + }), + ); + assert.equal(calls.length, 1); + assert.equal(calls[0]!.cmd, 'powershell.exe'); + assert.match( + String(calls[0]!.args[3]), + /^\[Console\]::OutputEncoding=\[System\.Text\.Encoding\]::UTF8; Get-CimInstance -ClassName Win32_Process \|/, + ); + assert.equal(calls[0]!.timeoutMs, 2_000); + assert.deepEqual(processes, [ + { + pid: DAEMON_PID, + ppid: 4321, + command: 'C:\\Program Files\\node.exe C:\\app\\dist\\src\\internal\\daemon.js', + }, + ]); +}); + +test('a live Windows owner matches its recorded CIM birth instead of staying unproven', () => { + // This is the #3291 failure shape: with no Windows identity facts the owner + // is recorded without a start time and every later ownership check stays + // fail-closed while the daemon listens. With the CIM birth recorded, the + // same checks prove the lifetime of this still-alive pid. + windowsPlatform(() => { + cimAnswers(`${process.pid}|4321|${DAEMON_START}|\r\n`); + assert.equal(readProcessStartTime(process.pid), DAEMON_START); + assert.equal( + classifyOwnerLiveness({ owner: { pid: process.pid, startTime: DAEMON_START } }), + 'live', + 'a listening daemon with a CIM-recorded birth must classify as live', + ); + // The same pid answering a different birth proves PID reuse, which the + // null-start Windows failure could never report. + cimAnswers(`${process.pid}|4321|20270101000000000000|\r\n`); + assert.equal( + classifyOwnerLiveness({ owner: { pid: process.pid, startTime: DAEMON_START } }), + 'owner-process-reused', + ); + }); +}); diff --git a/packages/host-kit/src/internal/host-process.ts b/packages/host-kit/src/internal/host-process.ts index f292b5065a..595546eb28 100644 --- a/packages/host-kit/src/internal/host-process.ts +++ b/packages/host-kit/src/internal/host-process.ts @@ -3,7 +3,14 @@ import { runCmd, runCmdSync } from './exec.ts'; import { sleep } from './retry.ts'; const PS_TIMEOUT_MS = 1_000; -const HOST_PS_COMMAND = process.platform === 'win32' ? 'ps' : '/bin/ps'; +// The one binary behind every POSIX probe: pinning it keeps a hostile PATH from +// substituting another `ps`. Windows hosts branch to the CIM query at every call +// site before this constant is consulted, so it names no Windows tool. +const HOST_PS_COMMAND = '/bin/ps'; +// PowerShell pays a fixed startup cost before the CIM query even runs, so every +// Windows process-table read gets at least this budget instead of the `ps` one. +const WINDOWS_PS_TIMEOUT_MS = 2_000; +const WINDOWS_PROCESS_TOOL = 'powershell.exe'; export type HostProcessInfo = { pid: number; @@ -101,13 +108,17 @@ export function isProcessGroupAlive(pid: number): boolean { /** * Reads who owns a pid, off the event loop, with a budget the caller names. The three fields * are read concurrently so a loaded host pays one budget for the set rather than one per - * field. Use this rather than the synchronous probes when the answer decides whether a - * process is signaled: waiting is reversible and signaling the wrong process is not. + * field; on Windows one CIM query answers the whole set. Use this rather than the synchronous + * probes when the answer decides whether a process is signaled: waiting is reversible and + * signaling the wrong process is not. */ export async function readProcessIdentityFacts( pid: number, timeoutMs = PS_TIMEOUT_MS, ): Promise { + if (isWindowsHostPlatform()) { + return await readWindowsProcessIdentityFacts(pid, timeoutMs); + } const [startTime, command, state] = await Promise.all([ readProcessFieldAsync(pid, 'lstart=', timeoutMs), readProcessFieldAsync(pid, 'command=', timeoutMs), @@ -124,10 +135,11 @@ export function readProcessCommand(pid: number): string | null { return readProcessField(pid, 'command='); } -// A zombie passes kill(pid, 0) and still reports its original lstart, so both -// isProcessAlive and the start-time identity check read it as live; only the -// process state exposes that it already terminated. +// A terminated Windows process leaves the CIM table rather than lingering in it +// unreaped, so no live pid on that host can be a zombie: the question is answered +// without a process-table probe, and exit proof stays liveness-and-lifetime. export function isProcessZombie(pid: number): boolean { + if (isWindowsHostPlatform()) return false; return readProcessField(pid, 'state=')?.startsWith('Z') ?? false; } @@ -137,11 +149,25 @@ export function readHostProcessIdentityObservations( const observations = new Map(); const selected = uniquePositivePids(pids); if (selected.length === 0) return observations; + if (isWindowsHostPlatform()) { + for (const row of readWindowsProcessRows(selected, WINDOWS_PS_TIMEOUT_MS)) { + if (row.startTime === null) continue; + // A live row is never a zombie: a terminated Windows process leaves the + // CIM table, it does not linger in it unreaped, so no row state exposes + // one. Exit proof stays liveness-and-lifetime, as on every other platform. + observations.set(row.pid, { state: 'R', startTime: row.startTime }); + } + return observations; + } try { - const result = runCmdSync('ps', ['-p', selected.join(','), '-o', 'pid=,state=,lstart='], { - allowFailure: true, - timeoutMs: PS_TIMEOUT_MS, - }); + const result = runCmdSync( + HOST_PS_COMMAND, + ['-p', selected.join(','), '-o', 'pid=,state=,lstart='], + { + allowFailure: true, + timeoutMs: PS_TIMEOUT_MS, + }, + ); if (result.exitCode !== 0) return observations; for (const line of result.stdout.split('\n')) { const match = /^\s*(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line); @@ -160,6 +186,7 @@ type HostProcessField = 'lstart=' | 'command=' | 'state='; function readProcessField(pid: number, field: HostProcessField): string | null { if (!Number.isInteger(pid) || pid <= 0) return null; + if (isWindowsHostPlatform()) return readWindowsProcessField(pid, field); try { const result = runCmdSync(HOST_PS_COMMAND, ['-p', String(pid), '-o', field], { allowFailure: true, @@ -194,23 +221,176 @@ function processFieldValue(result: ExecResult): string | null { return value.length > 0 ? value : null; } -export function parseHostProcessList(stdout: string): HostProcessInfo[] { - const processes: HostProcessInfo[] = []; - for (const line of stdout.split('\n')) { - const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line); +/** + * Walks process-table stdout line by line, keeping rows whose first two + * capture groups are a positive pid and an optional positive ppid. Every + * process-table format (POSIX `ps`, Windows CIM) feeds its own row pattern + * and row builder through this single walk. + */ +function parseProcessTableRows( + stdout: string, + rowPattern: RegExp, + build: (match: RegExpExecArray, pid: number, ppid: number | undefined) => T, +): T[] { + const rows: T[] = []; + for (const line of stdout.replace(/^\uFEFF/, '').split('\n')) { + const match = rowPattern.exec(line); if (!match) continue; const pid = Number.parseInt(match[1]!, 10); - const ppid = Number.parseInt(match[2]!, 10); - const command = match[3]!; if (!Number.isInteger(pid) || pid <= 0) continue; - processes.push({ pid, ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : undefined, command }); + const ppid = Number.parseInt(match[2]!, 10); + rows.push(build(match, pid, Number.isInteger(ppid) && ppid > 0 ? ppid : undefined)); + } + return rows; +} + +export function parseHostProcessList(stdout: string): HostProcessInfo[] { + return parseProcessTableRows(stdout, /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/, (match, pid, ppid) => ({ + pid, + ppid, + command: match[3]!, + })); +} + +/* -------------------------------------------------------------------------- */ +/* Windows process-identity branch (#3291) */ +/* */ +/* Windows hosts have no `ps`. The same identity facts come from one */ +/* CIM (`Win32_Process`) query through PowerShell: `CreationDate` is the */ +/* birth time proven against PID reuse, `CommandLine` is the command the */ +/* daemon-identity patterns match, and a terminated process leaves the table */ +/* entirely instead of lingering as a zombie, so a live row is never `Z`. */ +/* A failed or unanswered query is unknown evidence, exactly like a failed */ +/* `ps`, and every caller stays fail-closed on it. */ +/* -------------------------------------------------------------------------- */ + +type WindowsProcessRow = Readonly<{ + pid: number; + ppid?: number; + startTime: string | null; + command: string | null; +}>; + +function isWindowsHostPlatform(): boolean { + return hostPlatform() === 'win32'; +} + +// Rows are `pid|ppid|creation|command`. The creation stamp is formatted with +// the invariant culture, so the value recorded in daemon.json compares equal +// on every read of the same process lifetime regardless of host locale. The +// row rule is that a null field prints empty and never raises: the CIM table +// carries rows without a CreationDate (the System Idle Process), and a +// terminating error mid-pipeline would exit 1 and blank the whole snapshot +// instead of that one row. Newlines inside a command line are flattened so +// one process is one row; the command may still contain `|`, which only ever +// lands in the final capture group. +const WINDOWS_PROCESS_ROW_BODY = + ' | ForEach-Object {' + + ' $birth = if ($_.CreationDate) {' + + " $_.CreationDate.ToUniversalTime().ToString('yyyyMMddHHmmssfffffff'," + + ' [System.Globalization.CultureInfo]::InvariantCulture)' + + " } else { '' };" + + " '{0}|{1}|{2}|{3}' -f $_.ProcessId, $_.ParentProcessId, $birth," + + String.raw` ($_.CommandLine -replace '[\r\n]+', ' ') }`; + +function windowsProcessQueryArgs(pids: readonly number[]): string[] { + const source = + pids.length > 0 + ? `Get-CimInstance -ClassName Win32_Process -Filter '${pids + .map((pid) => `ProcessId=${pid}`) + .join(' OR ')}'` + : 'Get-CimInstance -ClassName Win32_Process'; + // PowerShell writes redirected stdout in the OEM console code page, which would + // mojibake any non-ASCII command line the identity check later compares + // byte-for-byte; the console is pinned to UTF-8 so the pipe matches the + // UTF-8 decode Node applies. + const command = `[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; ${source}${WINDOWS_PROCESS_ROW_BODY}`; + return ['-NoProfile', '-NonInteractive', '-Command', command]; +} + +function parseWindowsProcessRows(stdout: string): WindowsProcessRow[] { + return parseProcessTableRows( + stdout, + /^(\d+)\|(\d+)\|([^|]*)\|(.*)\r?$/, + (match, pid, ppid): WindowsProcessRow => ({ + pid, + ...(ppid !== undefined ? { ppid } : {}), + startTime: match[3]!.length > 0 ? match[3]! : null, + command: match[4]!.length > 0 ? match[4]! : null, + }), + ); +} + +function readWindowsProcessRows(pids: readonly number[], timeoutMs: number): WindowsProcessRow[] { + try { + const result = runCmdSync(WINDOWS_PROCESS_TOOL, windowsProcessQueryArgs(pids), { + allowFailure: true, + timeoutMs, + }); + if (result.exitCode !== 0) return []; + return parseWindowsProcessRows(result.stdout); + } catch { + // A failed CIM snapshot is unknown evidence; callers remain fail-closed. + return []; } - return processes; +} + +function readWindowsProcessField(pid: number, field: HostProcessField): string | null { + const row = readWindowsProcessRows([pid], WINDOWS_PS_TIMEOUT_MS).find( + (candidate) => candidate.pid === pid, + ); + if (!row) return null; + if (field === 'lstart=') return row.startTime; + if (field === 'command=') return row.command; + return 'R'; +} + +function windowsBudgetMs(timeoutMs: number): number { + return Math.max(timeoutMs, WINDOWS_PS_TIMEOUT_MS); +} + +async function readWindowsProcessIdentityFacts( + pid: number, + timeoutMs: number, +): Promise { + if (!Number.isInteger(pid) || pid <= 0) return { startTime: null, command: null, zombie: null }; + try { + const result = await runCmd(WINDOWS_PROCESS_TOOL, windowsProcessQueryArgs([pid]), { + allowFailure: true, + timeoutMs: windowsBudgetMs(timeoutMs), + }); + if (result.exitCode !== 0) return { startTime: null, command: null, zombie: null }; + const row = parseWindowsProcessRows(result.stdout).find((candidate) => candidate.pid === pid); + if (!row) return { startTime: null, command: null, zombie: null }; + return { startTime: row.startTime, command: row.command, zombie: false }; + } catch { + return { startTime: null, command: null, zombie: null }; + } +} + +async function listWindowsHostProcesses( + options: ListHostProcessesOptions, +): Promise { + const result = await (options.runCommand ?? runCmd)( + WINDOWS_PROCESS_TOOL, + windowsProcessQueryArgs([]), + { + allowFailure: true, + timeoutMs: windowsBudgetMs(options.timeoutMs), + }, + ); + if (result.exitCode !== 0) return []; + return parseWindowsProcessRows(result.stdout).map((row) => ({ + pid: row.pid, + ...(row.ppid !== undefined ? { ppid: row.ppid } : {}), + command: row.command ?? '', + })); } export async function listHostProcesses( options: ListHostProcessesOptions, ): Promise { + if (isWindowsHostPlatform()) return await listWindowsHostProcesses(options); const result = await (options.runCommand ?? runCmd)( options.runCommand ? 'ps' : HOST_PS_COMMAND, ['-ax', '-o', 'pid=,ppid=,command='], diff --git a/packages/host-kit/src/internal/owned-process-reaper.ts b/packages/host-kit/src/internal/owned-process-reaper.ts index af73e000a0..d168eac360 100644 --- a/packages/host-kit/src/internal/owned-process-reaper.ts +++ b/packages/host-kit/src/internal/owned-process-reaper.ts @@ -1,10 +1,9 @@ import type { OwnedProcessRecord } from '@agent-device/contracts/platform-runtime-host'; import { isProcessAlive, - isProcessZombie, - readProcessCommand, - readProcessStartTime, + readProcessIdentityFacts, waitForProcessExit, + type HostProcessIdentityFacts, } from './host-process.ts'; import type { OwnedProcessRecordRead, OwnedProcessRecordStore } from './owned-process-record.ts'; @@ -163,17 +162,22 @@ async function reapOwnedProcess( record: OwnedProcessRecord, policy: Pick, ): Promise<'terminated' | 'missing' | 'ownership-lost'> { - const initial = inspectOwnedProcess(record); + const initial = await inspectOwnedProcess(record); if (initial !== undefined) return initial; signalBestEffort(record.pid, initialSignalFor(record)); return await escalateOwnedProcess(record, policy); } -function inspectOwnedProcess(record: OwnedProcessRecord): 'missing' | 'ownership-lost' | undefined { - if (record.pid === process.pid || !isProcessAlive(record.pid) || isProcessZombie(record.pid)) { - return 'missing'; - } - return matchesRecord(record) ? undefined : 'ownership-lost'; +async function inspectOwnedProcess( + record: OwnedProcessRecord, +): Promise<'missing' | 'ownership-lost' | undefined> { + if (record.pid === process.pid || !isProcessAlive(record.pid)) return 'missing'; + // One off-loop identity read answers the whole decision. Per-field probes + // would spend a fresh process-tool startup on every record on hosts where + // each read spawns a query tool rather than one cheap `ps`. + const facts = await readProcessIdentityFacts(record.pid); + if (facts.zombie) return 'missing'; + return matchesFacts(record, facts) ? undefined : 'ownership-lost'; } function initialSignalFor(record: OwnedProcessRecord): NodeJS.Signals { @@ -197,15 +201,14 @@ async function waitForOwnedProcessExit( timeoutMs: number, ): Promise<'exited' | 'owned-alive' | 'ownership-lost'> { await waitForProcessExit(record.pid, timeoutMs); - if (!isProcessAlive(record.pid) || isProcessZombie(record.pid)) return 'exited'; - return matchesRecord(record) ? 'owned-alive' : 'ownership-lost'; + if (!isProcessAlive(record.pid)) return 'exited'; + const facts = await readProcessIdentityFacts(record.pid); + if (facts.zombie) return 'exited'; + return matchesFacts(record, facts) ? 'owned-alive' : 'ownership-lost'; } -function matchesRecord(record: OwnedProcessRecord): boolean { - return ( - readProcessStartTime(record.pid) === record.startTime && - readProcessCommand(record.pid) === record.command - ); +function matchesFacts(record: OwnedProcessRecord, facts: HostProcessIdentityFacts): boolean { + return facts.startTime === record.startTime && facts.command === record.command; } function signalBestEffort(pid: number, signal: NodeJS.Signals): void { diff --git a/packages/host-kit/src/internal/owned-process-record.test.ts b/packages/host-kit/src/internal/owned-process-record.test.ts index 38b651b5d6..026db6b3b4 100644 --- a/packages/host-kit/src/internal/owned-process-record.test.ts +++ b/packages/host-kit/src/internal/owned-process-record.test.ts @@ -8,15 +8,22 @@ const processState = vi.hoisted(() => ({ alive: new Set(), starts: new Map(), commands: new Map(), + zombiePids: new Set(), waitExits: true, })); vi.mock('./host-process.ts', async (importOriginal) => ({ ...(await importOriginal()), isProcessAlive: (pid: number) => processState.alive.has(pid), - isProcessZombie: () => false, + isProcessZombie: (pid: number) => processState.zombiePids.has(pid), readProcessStartTime: (pid: number) => processState.starts.get(pid) ?? null, readProcessCommand: (pid: number) => processState.commands.get(pid) ?? null, + readProcessIdentityFacts: (pid: number) => + Promise.resolve({ + startTime: processState.starts.get(pid) ?? null, + command: processState.commands.get(pid) ?? null, + zombie: processState.zombiePids.has(pid) ? true : processState.alive.has(pid) ? false : null, + }), waitForProcessExit: async (pid: number) => { if (processState.waitExits) processState.alive.delete(pid); return true; @@ -30,6 +37,7 @@ beforeEach(() => { processState.alive.clear(); processState.starts.clear(); processState.commands.clear(); + processState.zombiePids.clear(); processState.waitExits = true; }); @@ -158,6 +166,42 @@ test('startup interrupts an exact simctl recorder before clearing its record', a } }); +test('startup treats a zombie identity as missing without signaling it', async () => { + // The reaper asks one identity read the zombie question, and a zombie has + // already terminated: it is cleaned as missing, never signaled. Before this + // case the fixture could seed zombiePids but never did, so every facts.zombie + // branch ran uncovered. + const stateDir = mkdtempForTestSync('agent-device-owned-process-zombie-reap-'); + const store = createOwnedProcessRecordStore({ + stateDir, + sessionsDir: path.join(stateDir, 'sessions'), + resolveSessionDir: (sessionId) => path.join(stateDir, 'sessions', sessionId), + }); + const record = { + pid: 303, + startTime: 'start-303', + command: 'command-303', + purpose: 'managed-web-browser', + }; + processState.alive.add(record.pid); + processState.zombiePids.add(record.pid); + processState.starts.set(record.pid, record.startTime); + processState.commands.set(record.pid, record.command); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + + try { + store.replace({ kind: 'daemon' }, [record]); + + const summary = await reapOwnedProcessRecordsAtStartup(store); + + assert.deepEqual(summary.missingPids, [record.pid]); + assert.equal(killSpy.mock.calls.length, 0); + assert.equal(store.read().length, 0); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + test('startup retains a record when the pid identity was reused', async () => { const stateDir = mkdtempForTestSync('agent-device-owned-process-reuse-'); const store = createOwnedProcessRecordStore({ diff --git a/packages/host-kit/src/internal/owner-identity-liveness.test.ts b/packages/host-kit/src/internal/owner-identity-liveness.test.ts index 511da64644..412d3e81af 100644 --- a/packages/host-kit/src/internal/owner-identity-liveness.test.ts +++ b/packages/host-kit/src/internal/owner-identity-liveness.test.ts @@ -11,6 +11,21 @@ vi.mock('./host-process.ts', () => ({ isProcessAlive: mockIsProcessAlive, isProcessZombie: mockIsProcessZombie, readProcessStartTime: mockReadProcessStartTime, + // The classification consumes one snapshot, so the fixture answers it from + // the same state as the per-field probes and drops rows whose start time the + // host could not read. + readHostProcessIdentityObservations: (pids: Iterable) => { + const observations = new Map(); + for (const pid of pids) { + const startTime = mockReadProcessStartTime(pid); + if (startTime === null) continue; + observations.set(pid, { + state: mockIsProcessZombie(pid) ? 'ZN' : 'Ss', + startTime, + }); + } + return observations; + }, })); import { classifyOwnerLiveness, classifyOwnerLivenessFromObservation } from './owner-identity.ts'; @@ -69,3 +84,11 @@ test('a pid outside the native range is unknown without a liveness probe', () => assert.equal(mockIsProcessZombie.mock.calls.length, 0); assert.equal(mockReadProcessStartTime.mock.calls.length, 0); }); + +test('the snapshot path probes liveness for the owner pid exactly once', () => { + // The guards were once duplicated across the snapshot entry and the shared + // judge, paying two kill(pid, 0) per poll on the very path whose single + // snapshot exists to stop double probing. + assert.equal(classifyOwnerLiveness({ owner: { pid: OWNER_PID, startTime: 'start-a' } }), 'live'); + assert.equal(mockIsProcessAlive.mock.calls.length, 1); +}); diff --git a/packages/host-kit/src/internal/owner-identity.ts b/packages/host-kit/src/internal/owner-identity.ts index 552a030ea7..e555a87163 100644 --- a/packages/host-kit/src/internal/owner-identity.ts +++ b/packages/host-kit/src/internal/owner-identity.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { isProcessAlive, isProcessZombie, + readHostProcessIdentityObservations, readProcessStartTime, type HostProcessIdentityObservation, } from './host-process.ts'; @@ -69,7 +70,15 @@ export function classifyOwnerLiveness(params: { owner: Pick; stateDir?: string; }): OwnerLiveness { - return classifyOwnerLivenessFromObservation(params); + // One process-table snapshot answers the whole judgment. State and start time + // read as two probes double the cost of every lock-ownership poll on hosts + // where a probe starts a fresh process tool, and one snapshot also judges both + // facts at the same instant instead of stitching two separate host answers. + const guarded = guardOwnerPid(params.owner); + if (guarded) return guarded; + const observation = + readHostProcessIdentityObservations([params.owner.pid]).get(params.owner.pid) ?? null; + return classifyLiveOwnerFromObservation(params, observation); } export function classifyOwnerLivenessFromObservation( @@ -79,24 +88,63 @@ export function classifyOwnerLivenessFromObservation( }, observation?: HostProcessIdentityObservation | null, ): OwnerLiveness { - const { owner, stateDir } = params; + // The snapshot was taken before this call, so the guards run again here: a + // claim whose owner died between snapshot and judgment must read dead. + const guarded = guardOwnerPid(params.owner); + if (guarded) return guarded; + return classifyLiveOwnerFromObservation(params, observation); +} + +/** The verdicts provable from the pid alone; undefined means the caller may probe on. */ +function guardOwnerPid(owner: Pick): OwnerLiveness | undefined { if (!isProcessPid(owner.pid)) return 'unknown'; if (!isProcessAlive(owner.pid)) return 'owner-process-dead'; - if (observation !== undefined ? observation?.state.startsWith('Z') : isProcessZombie(owner.pid)) { - return 'owner-process-dead'; - } - if (owner.startTime) { - const currentStartTime = - observation !== undefined - ? (observation?.startTime ?? null) - : readProcessStartTime(owner.pid); - if (currentStartTime !== null && currentStartTime !== owner.startTime) { - return 'owner-process-reused'; - } - } + return undefined; +} + +/** Judges an owner the caller has established as a live, valid pid; probes no liveness. */ +function classifyLiveOwnerFromObservation( + params: { + owner: Pick; + stateDir?: string; + }, + observation?: HostProcessIdentityObservation | null, +): OwnerLiveness { + const { owner, stateDir } = params; + if (hostSaysZombie(owner, observation)) return 'owner-process-dead'; + if (owner.startTime && provesPidReused(owner, observation)) return 'owner-process-reused'; return stateDir ? classifyOwnerStateDirectory(stateDir) : 'live'; } +/** + * Whether the host calls this owner a zombie. An `undefined` observation means + * no snapshot was taken and the field probe answers; a `null` observation is a + * completed snapshot that did not list the pid, which is unknown evidence and + * never a zombie claim. + */ +function hostSaysZombie( + owner: Pick, + observation: HostProcessIdentityObservation | null | undefined, +): boolean { + if (observation === undefined) return isProcessZombie(owner.pid); + return observation?.state.startsWith('Z') ?? false; +} + +/** + * Whether the recorded birth time proves the pid changed hands. Only a + * readable current start time that differs from the record proves reuse; an + * `undefined` observation falls back to the field probe, and a host that did + * not answer is unknown evidence, never proof. + */ +function provesPidReused( + owner: Pick, + observation: HostProcessIdentityObservation | null | undefined, +): boolean { + const currentStartTime = + observation === undefined ? readProcessStartTime(owner.pid) : (observation?.startTime ?? null); + return currentStartTime !== null && currentStartTime !== owner.startTime; +} + function classifyOwnerStateDirectory(stateDir: string): OwnerLiveness { try { fs.statSync(stateDir); diff --git a/packages/host-kit/src/internal/process-lock-owner-liveness.test.ts b/packages/host-kit/src/internal/process-lock-owner-liveness.test.ts new file mode 100644 index 0000000000..72818bf6f4 --- /dev/null +++ b/packages/host-kit/src/internal/process-lock-owner-liveness.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; + +const { zombiePids, processProbe } = vi.hoisted(() => ({ + zombiePids: new Set(), + processProbe: { observe: undefined as (() => void) | undefined }, +})); + +vi.mock('./host-process.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isProcessZombie: (pid: number) => { + processProbe.observe?.(); + return zombiePids.has(pid); + }, + readProcessStartTime: (pid: number) => { + processProbe.observe?.(); + return actual.readProcessStartTime(pid); + }, + // Classification judges from one snapshot; the fixture answers it from the + // same zombie state and real start times as the per-field probes. + readHostProcessIdentityObservations: (pids: Iterable) => { + const observations = new Map(); + for (const pid of pids) { + processProbe.observe?.(); + const startTime = actual.readProcessStartTime(pid); + if (!zombiePids.has(pid) && startTime === null) continue; + observations.set(pid, { + state: zombiePids.has(pid) ? 'ZN' : 'Ss', + startTime: startTime ?? 'zombie-snapshot', + }); + } + return observations; + }, + }; +}); + +import { acquireProcessLock, tryAcquireProcessLock } from './process-lock.ts'; +import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; +import { currentProcessOwner, writeLockOwnerFixture } from './process-lock.fixtures.ts'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempForTestSync('agent-device-process-lock-liveness-'); +}); + +afterEach(() => { + processProbe.observe = undefined; + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('acquireProcessLock reclaims locks owned by zombie processes', async () => { + const lockDirPath = path.join(tmpDir, 'zombie.lock'); + fs.mkdirSync(lockDirPath); + // The owner passes kill(pid, 0) and matches its recorded start time; only + // the zombie state reveals it already terminated. + fs.writeFileSync(path.join(lockDirPath, 'owner.json'), JSON.stringify(currentProcessOwner())); + zombiePids.add(process.pid); + + try { + const release = await acquireProcessLock({ + lockDirPath, + owner: currentProcessOwner(), + timeoutMs: 3_000, + pollMs: 1, + }); + await release(); + assert.equal(fs.existsSync(lockDirPath), false); + } finally { + zombiePids.delete(process.pid); + } +}); + +test('acquireProcessLock never steals a null-start-time lock from an alive pid', async () => { + const lockDirPath = path.join(tmpDir, 'null-start.lock'); + // An acquiredAtMs far older than this process simulates what a wall-clock + // step makes a live null-start owner look like; age is not proof of death, + // so the waiter must time out instead of reclaiming the held lock. + writeLockOwnerFixture(lockDirPath, { + pid: process.pid, + startTime: null, + acquiredAtMs: Date.now() - 365 * 24 * 60 * 60_000, + }); + + await assert.rejects( + () => + acquireProcessLock({ + lockDirPath, + owner: currentProcessOwner(), + timeoutMs: 50, + pollMs: 1, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.ownerLiveness, 'live'); + return true; + }, + ); +}); + +test('a contender judges a live owner without holding the guard', async () => { + const lockDirPath = path.join(tmpDir, 'probed-outside-guard.lock'); + const mutexPath = path.join(tmpDir, 'probed-outside-guard.reclaim.lock'); + writeLockOwnerFixture(lockDirPath, { + pid: process.ppid, + startTime: null, + acquiredAtMs: Date.now(), + claimToken: 'live-rival', + }); + let probes = 0; + let probesUnderGuard = 0; + processProbe.observe = () => { + probes += 1; + if (fs.existsSync(mutexPath)) probesUnderGuard += 1; + }; + + const attempt = tryAcquireProcessLock({ lockDirPath, owner: currentProcessOwner() }); + + assert.equal(attempt.status, 'busy'); + assert.ok(probes > 0, 'the live owner was never probed'); + assert.equal(probesUnderGuard, 0); + assert.equal(fs.existsSync(mutexPath), false); +}); diff --git a/packages/host-kit/src/internal/process-lock.test.ts b/packages/host-kit/src/internal/process-lock.test.ts index 0e277c3235..33e8dc211a 100644 --- a/packages/host-kit/src/internal/process-lock.test.ts +++ b/packages/host-kit/src/internal/process-lock.test.ts @@ -5,26 +5,6 @@ import { afterEach, beforeEach, test, vi } from 'vitest'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import * as diagnostics from './diagnostics.ts'; -const { zombiePids, processProbe } = vi.hoisted(() => ({ - zombiePids: new Set(), - processProbe: { observe: undefined as (() => void) | undefined }, -})); - -vi.mock('./host-process.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isProcessZombie: (pid: number) => { - processProbe.observe?.(); - return zombiePids.has(pid); - }, - readProcessStartTime: (pid: number) => { - processProbe.observe?.(); - return actual.readProcessStartTime(pid); - }, - }; -}); - import { acquireProcessLock, acquireProcessLockAcquisition, @@ -54,7 +34,6 @@ beforeEach(() => { }); afterEach(() => { - processProbe.observe = undefined; fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -87,55 +66,6 @@ test('acquireProcessLock reclaims locks owned by dead processes', async () => { assert.equal(fs.existsSync(lockDirPath), false); }); -test('acquireProcessLock reclaims locks owned by zombie processes', async () => { - const lockDirPath = path.join(tmpDir, 'zombie.lock'); - fs.mkdirSync(lockDirPath); - // The owner passes kill(pid, 0) and matches its recorded start time; only - // the zombie state reveals it already terminated. - fs.writeFileSync(path.join(lockDirPath, 'owner.json'), JSON.stringify(currentProcessOwner())); - zombiePids.add(process.pid); - - try { - const release = await acquireProcessLock({ - lockDirPath, - owner: currentProcessOwner(), - timeoutMs: 3_000, - pollMs: 1, - }); - await release(); - assert.equal(fs.existsSync(lockDirPath), false); - } finally { - zombiePids.delete(process.pid); - } -}); - -test('acquireProcessLock never steals a null-start-time lock from an alive pid', async () => { - const lockDirPath = path.join(tmpDir, 'null-start.lock'); - // An acquiredAtMs far older than this process simulates what a wall-clock - // step makes a live null-start owner look like; age is not proof of death, - // so the waiter must time out instead of reclaiming the held lock. - writeLockOwnerFixture(lockDirPath, { - pid: process.pid, - startTime: null, - acquiredAtMs: Date.now() - 365 * 24 * 60 * 60_000, - }); - - await assert.rejects( - () => - acquireProcessLock({ - lockDirPath, - owner: currentProcessOwner(), - timeoutMs: 50, - pollMs: 1, - }), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.details?.ownerLiveness, 'live'); - return true; - }, - ); -}); - test('acquireProcessLock reports live lock owner details on timeout', async () => { const lockDirPath = path.join(tmpDir, 'busy.lock'); const owner = currentProcessOwner(); @@ -974,27 +904,3 @@ test('release waits for a guard another process holds for a filesystem step', as assert.equal(next.status, 'acquired'); if (next.status === 'acquired') await next.acquisition.release(); }); - -test('a contender judges a live owner without holding the guard', async () => { - const lockDirPath = path.join(tmpDir, 'probed-outside-guard.lock'); - const mutexPath = path.join(tmpDir, 'probed-outside-guard.reclaim.lock'); - writeLockOwnerFixture(lockDirPath, { - pid: process.ppid, - startTime: null, - acquiredAtMs: Date.now(), - claimToken: 'live-rival', - }); - let probes = 0; - let probesUnderGuard = 0; - processProbe.observe = () => { - probes += 1; - if (fs.existsSync(mutexPath)) probesUnderGuard += 1; - }; - - const attempt = tryAcquireProcessLock({ lockDirPath, owner: currentProcessOwner() }); - - assert.equal(attempt.status, 'busy'); - assert.ok(probes > 0, 'the live owner was never probed'); - assert.equal(probesUnderGuard, 0); - assert.equal(fs.existsSync(mutexPath), false); -}); diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index 439b1a0a6c..f504445479 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -91,6 +91,117 @@ test('publication truncates the log the daemon is already appending to', async ( await owner.finish(report); }); +function onWindowsHost(run: () => Promise): Promise { + const previous = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + return run().finally(() => { + if (previous) Object.defineProperty(process, 'platform', previous); + }); +} + +// Windows refuses ftruncate on a handle opened append-only, and closing an +// append handle to reopen one drops the inode pin. These tests pin the +// platform split: an existing log is emptied through one pinned handle, and +// the Windows host rule (EPERM on an append handle, enforced here at the fs +// seam) is answered by an `'r+'` handle instead of a create/reopen window +// on the common path (#3291). + +// Emulates the Windows host rule at the fs seam: ftruncate through an +// append-only handle raises EPERM as NTFS would, while every other open and +// truncate passes through. Records the flags each log open used so each test +// can pin the exact handle sequence. +function spyWindowsLogHandleRules(logPath: string): string[] { + const flagsByDescriptor = new Map(); + const logOpenFlags: string[] = []; + const realOpen = fs.openSync; + vi.spyOn(fs, 'openSync').mockImplementation((( + target: fs.PathLike, + flags: fs.OpenMode, + mode?: fs.Mode, + ) => { + if (String(target) === logPath) logOpenFlags.push(String(flags)); + const descriptor = realOpen(target, flags, mode); + flagsByDescriptor.set(descriptor, String(flags)); + return descriptor; + }) as typeof fs.openSync); + const realTruncate = fs.ftruncateSync; + vi.spyOn(fs, 'ftruncateSync').mockImplementation(((descriptor: number, len?: number) => { + if (flagsByDescriptor.get(descriptor) === 'a') { + throw Object.assign(new Error('EPERM: operation not permitted, ftruncate'), { + code: 'EPERM', + }); + } + realTruncate(descriptor, len); + }) as typeof fs.ftruncateSync); + return logOpenFlags; +} + +test('a Windows host empties an existing log through one pinned write handle', async () => { + const { paths, owner } = await acquire(); + fs.writeFileSync(paths.logPath, 'previous run\n'); + const logOpenFlags = spyWindowsLogHandleRules(paths.logPath); + await onWindowsHost(async () => { + try { + owner.publish(fields); + assert.equal(fs.readFileSync(paths.logPath, 'utf8'), ''); + // No append handle and no second open of the path: one r+ handle + // carried the inode from the truncation through to its close. + assert.deepEqual(logOpenFlags, ['r+']); + } finally { + vi.restoreAllMocks(); + } + }); + await owner.finish(report); +}); + +test('a missing Windows-host log is created through append and emptied through a write handle', async () => { + const { paths, owner } = await acquire(); + const logOpenFlags = spyWindowsLogHandleRules(paths.logPath); + await onWindowsHost(async () => { + try { + owner.publish(fields); + assert.equal(fs.readFileSync(paths.logPath, 'utf8'), ''); + // The append handle is confined to the file-missing path: the failed + // 'r+' probe, the creation through 'a', then one write handle that + // carries the truncation. + assert.deepEqual(logOpenFlags, ['r+', 'a', 'r+']); + } finally { + vi.restoreAllMocks(); + } + }); + await owner.finish(report); +}); + +test('a log removed between creation and the write-handle reopen fails startup loudly', async () => { + const { paths, owner } = await acquire(); + const realOpen = fs.openSync; + let created = false; + vi.spyOn(fs, 'openSync').mockImplementation((( + target: fs.PathLike, + flags: fs.OpenMode, + mode?: fs.Mode, + ) => { + // A rotation that removes the just-created log before the write handle + // reopens it surfaces as this startup failure, never a silent unemptied log. + if (String(target) === paths.logPath) { + if (String(flags) === 'a') created = true; + else if (created && String(flags) === 'r+') fs.rmSync(paths.logPath, { force: true }); + } + return realOpen(target, flags, mode); + }) as typeof fs.openSync); + await onWindowsHost(async () => { + try { + assert.throws( + () => owner.publish(fields), + (error: unknown) => (error as NodeJS.ErrnoException).code === 'ENOENT', + ); + } finally { + vi.restoreAllMocks(); + } + }); + await owner.finish(report); +}); + for (const [pid, startTime, reason] of [ [999_999_999, 'successor-start', 'replaced'], [process.pid, 'recycled-start', 'replaced'], diff --git a/src/daemon-registration-owner.ts b/src/daemon-registration-owner.ts index 3547a4b99b..b353cb7a06 100644 --- a/src/daemon-registration-owner.ts +++ b/src/daemon-registration-owner.ts @@ -167,8 +167,19 @@ function writeShutdownReport( } } -/** The daemon's stdout and stderr append to this inode, so it is emptied in place, never replaced. */ +/** + * The daemon's stdout and stderr append to this inode, so it is emptied in place, never + * replaced. On POSIX the append handle that creates the file also permits truncation, and + * keeping that one handle open for the whole operation is what pins the inode. A Windows + * handle opened append-only (`'a'`) rejects `ftruncate` with EPERM, so that host empties the + * log through a read-write handle instead (#3291). A failure at any step propagates: an + * unreadable log must fail startup loudly. + */ function truncateDaemonLog(logPath: string): void { + if (process.platform === 'win32') { + truncateDaemonLogOnWindowsHost(logPath); + return; + } const descriptor = fs.openSync(logPath, 'a', 0o600); try { fs.ftruncateSync(descriptor, 0); @@ -177,6 +188,28 @@ function truncateDaemonLog(logPath: string): void { } } +/** + * `ftruncate` on Windows requires a handle opened for writing beyond append. An existing log + * is opened `'r+'` directly, so no window exists between handles on the common path; a + * missing log is first created through `'a'` and then reopened, and a creation race against + * that reopen surfaces as this startup failing rather than a silently unemptied log. + */ +function truncateDaemonLogOnWindowsHost(logPath: string): void { + let descriptor: number; + try { + descriptor = fs.openSync(logPath, 'r+', 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + fs.closeSync(fs.openSync(logPath, 'a', 0o600)); + descriptor = fs.openSync(logPath, 'r+', 0o600); + } + try { + fs.ftruncateSync(descriptor, 0); + } finally { + fs.closeSync(descriptor); + } +} + declare const privateReplayState: unique symbol; export type OwnedReplayStateDir = Readonly<{ paths: Readonly;