From fd8b8679c2e47c22a5bdbf3871695e2d82010837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 21:30:27 +0200 Subject: [PATCH 1/9] fix(host-kit): read process identity facts from CIM on Windows hosts Windows hosts have no `ps`, so every daemon-ownership start-time and command read answered null and a live daemon could never prove its lifetime (#3291). Add a Windows branch to the same host-process seam the POSIX reads use: one PowerShell CIM query answers start time (CreationDate), command line, and liveness per pid, with a budget that covers PowerShell startup. A failed or unanswered query stays unknown evidence, and a live CIM row is never a zombie because terminated Windows processes leave the table. --- .../src/internal/host-process-windows.test.ts | 201 ++++++++++++++++++ .../host-kit/src/internal/host-process.ts | 151 ++++++++++++- 2 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 packages/host-kit/src/internal/host-process-windows.test.ts 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..348fe316d1 --- /dev/null +++ b/packages/host-kit/src/internal/host-process-windows.test.ts @@ -0,0 +1,201 @@ +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 state read never reports a zombie for a live row', () => { + windowsPlatform(() => { + cimAnswers(`${DAEMON_ROW}\r\n`); + assert.equal(isProcessZombie(DAEMON_PID), false); + }); +}); + +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 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: `\uFEFF${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]), /^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..e7fd8074e5 100644 --- a/packages/host-kit/src/internal/host-process.ts +++ b/packages/host-kit/src/internal/host-process.ts @@ -4,6 +4,10 @@ import { sleep } from './retry.ts'; const PS_TIMEOUT_MS = 1_000; const HOST_PS_COMMAND = process.platform === 'win32' ? 'ps' : '/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 +105,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), @@ -137,6 +145,16 @@ 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, @@ -160,6 +178,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, @@ -208,9 +227,137 @@ export function parseHostProcessList(stdout: string): HostProcessInfo[] { return processes; } +/* -------------------------------------------------------------------------- */ +/* 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` with a culture-invariant UTC creation +// stamp, so the value recorded in daemon.json compares equal on every read of +// the same process lifetime. 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 { '{0}|{1}|{2}|{3}' -f $_.ProcessId, $_.ParentProcessId," + + " $_.CreationDate.ToUniversalTime().ToString('yyyyMMddHHmmssfffffff')," + + 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'; + return ['-NoProfile', '-NonInteractive', '-Command', `${source}${WINDOWS_PROCESS_ROW_BODY}`]; +} + +function parseWindowsProcessRows(stdout: string): WindowsProcessRow[] { + const rows: WindowsProcessRow[] = []; + for (const line of stdout.replace(/^\uFEFF/, '').split('\n')) { + const match = /^(\d+)\|(\d+)\|([^|]*)\|(.*)\r?$/.exec(line); + if (!match) continue; + const pid = Number.parseInt(match[1]!, 10); + const ppid = Number.parseInt(match[2]!, 10); + if (!Number.isInteger(pid) || pid <= 0) continue; + rows.push({ + pid, + ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : undefined, + startTime: match[3]!.length > 0 ? match[3]! : null, + command: match[4]!.length > 0 ? match[4]! : null, + }); + } + return rows; +} + +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 []; + } +} + +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='], From 0743ddba0323b9df983f1d56ee15ef626e29bb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 21:30:27 +0200 Subject: [PATCH 2/9] fix(daemon): empty the daemon log through a handle that permits ftruncate On Windows a handle opened append-only ('a') rejects ftruncate with EPERM, so daemon startup died inside truncateDaemonLog before it could listen (#3291). Create the log with 'a', then truncate through an 'r+' handle; a failing truncation still propagates instead of being swallowed. The regression test pins the Windows handle rule at the fs seam. --- .../daemon-registration-owner.test.ts | 35 +++++++++++++++++++ src/daemon-registration-owner.ts | 11 ++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index 439b1a0a6c..ed3dfe5a5f 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -91,6 +91,41 @@ test('publication truncates the log the daemon is already appending to', async ( await owner.finish(report); }); +// Windows refuses ftruncate on a handle opened append-only. Reproducing that +// host rule at the fs seam proves publication empties the log through a handle +// that permits truncation instead of the one that creates the file (#3291). +test('log truncation empties the file through a handle that permits ftruncate', async () => { + const { paths, owner } = await acquire(); + const flagsByDescriptor = new Map(); + const realOpen = fs.openSync; + vi.spyOn(fs, 'openSync').mockImplementation((( + target: fs.PathLike, + flags: fs.OpenMode, + mode?: fs.Mode, + ) => { + 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); + try { + fs.writeFileSync(paths.logPath, 'previous run\n'); + owner.publish(fields); + assert.equal(fs.readFileSync(paths.logPath, 'utf8'), ''); + } 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..1ce3198531 100644 --- a/src/daemon-registration-owner.ts +++ b/src/daemon-registration-owner.ts @@ -167,9 +167,16 @@ 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. The handle that empties it must permit truncation: on Windows a handle opened + * append-only (`'a'`) rejects `ftruncate` with EPERM, so the file is only created through + * `'a'` and then emptied through an `'r+'` handle, which both platforms permit (#3291). + */ function truncateDaemonLog(logPath: string): void { - const descriptor = fs.openSync(logPath, 'a', 0o600); + const created = fs.openSync(logPath, 'a', 0o600); + fs.closeSync(created); + const descriptor = fs.openSync(logPath, 'r+', 0o600); try { fs.ftruncateSync(descriptor, 0); } finally { From 0d53b5342abecc4252d246c127f052cba7d5b179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 22:50:55 +0200 Subject: [PATCH 3/9] fix(daemon): split daemon-log truncation by platform handle rules Keeping the create-then-reopen on every host dropped the inode pin between handles, so a rotated log turned publication into an ENOENT startup failure or a truncate of the wrong file. POSIX keeps the original single pinned append handle (it permits ftruncate there); only win32 opens the write handle, directly 'r+' for an existing log, with append-creation confined to the missing-file path. A removal inside the remaining creation race still fails startup loudly (PR #3330 review). --- .../daemon-registration-owner.test.ts | 109 ++++++++++++++++-- src/daemon-registration-owner.ts | 38 +++++- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index ed3dfe5a5f..92a7cf3c0d 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -91,18 +91,32 @@ test('publication truncates the log the daemon is already appending to', async ( await owner.finish(report); }); -// Windows refuses ftruncate on a handle opened append-only. Reproducing that -// host rule at the fs seam proves publication empties the log through a handle -// that permits truncation instead of the one that creates the file (#3291). -test('log truncation empties the file through a handle that permits ftruncate', async () => { +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). +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 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) === paths.logPath) logOpenFlags.push(String(flags)); const descriptor = realOpen(target, flags, mode); flagsByDescriptor.set(descriptor, String(flags)); return descriptor; @@ -116,13 +130,86 @@ test('log truncation empties the file through a handle that permits ftruncate', } realTruncate(descriptor, len); }) as typeof fs.ftruncateSync); - try { - fs.writeFileSync(paths.logPath, 'previous run\n'); - owner.publish(fields); - assert.equal(fs.readFileSync(paths.logPath, 'utf8'), ''); - } finally { - vi.restoreAllMocks(); - } + 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 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) === paths.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); + 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); }); diff --git a/src/daemon-registration-owner.ts b/src/daemon-registration-owner.ts index 1ce3198531..b353cb7a06 100644 --- a/src/daemon-registration-owner.ts +++ b/src/daemon-registration-owner.ts @@ -169,14 +169,40 @@ function writeShutdownReport( /** * The daemon's stdout and stderr append to this inode, so it is emptied in place, never - * replaced. The handle that empties it must permit truncation: on Windows a handle opened - * append-only (`'a'`) rejects `ftruncate` with EPERM, so the file is only created through - * `'a'` and then emptied through an `'r+'` handle, which both platforms permit (#3291). + * 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 { - const created = fs.openSync(logPath, 'a', 0o600); - fs.closeSync(created); - const descriptor = fs.openSync(logPath, 'r+', 0o600); + if (process.platform === 'win32') { + truncateDaemonLogOnWindowsHost(logPath); + return; + } + const descriptor = fs.openSync(logPath, 'a', 0o600); + try { + fs.ftruncateSync(descriptor, 0); + } finally { + fs.closeSync(descriptor); + } +} + +/** + * `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 { From 1869aed46b1e3cc7ffd43f05bd4bc4ee633de242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 22:50:55 +0200 Subject: [PATCH 4/9] perf(host-kit): spend one process-table query per identity decision The Windows identity branch made every synchronous probe pay a process-tool startup, and per-record ownership checks issued three of them. The owned- process reaper now answers zombie/startTime/command from one async readProcessIdentityFacts per decision, owner-liveness classification reads one batch snapshot instead of a state probe plus a birth probe, and the Windows host answers the zombie question without any probe because a terminated process leaves the CIM table rather than lingering unreaped. Also pin PowerShell's console to UTF-8: its default OEM code page corrupted non-ASCII CommandLine rows that identity equality later compares byte-wise (PR #3330 review). --- .../src/internal/host-process-windows.test.ts | 13 +++++-- .../host-kit/src/internal/host-process.ts | 14 +++++--- .../src/internal/owned-process-reaper.ts | 35 ++++++++++--------- .../src/internal/owned-process-record.test.ts | 10 +++++- .../internal/owner-identity-liveness.test.ts | 15 ++++++++ .../host-kit/src/internal/owner-identity.ts | 11 +++++- .../src/internal/process-lock.test.ts | 15 ++++++++ 7 files changed, 88 insertions(+), 25 deletions(-) diff --git a/packages/host-kit/src/internal/host-process-windows.test.ts b/packages/host-kit/src/internal/host-process-windows.test.ts index 348fe316d1..5f7f0f0b9b 100644 --- a/packages/host-kit/src/internal/host-process-windows.test.ts +++ b/packages/host-kit/src/internal/host-process-windows.test.ts @@ -72,11 +72,15 @@ test('the Windows command read answers the daemon-identity pattern from CommandL }); }); -test('the Windows state read never reports a zombie for a live row', () => { +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(() => { - cimAnswers(`${DAEMON_ROW}\r\n`); 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', () => { @@ -166,7 +170,10 @@ test('the Windows process list parses CIM rows through the injected command runn ); assert.equal(calls.length, 1); assert.equal(calls[0]!.cmd, 'powershell.exe'); - assert.match(String(calls[0]!.args[3]), /^Get-CimInstance -ClassName Win32_Process \|/); + 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, [ { diff --git a/packages/host-kit/src/internal/host-process.ts b/packages/host-kit/src/internal/host-process.ts index e7fd8074e5..2290d6291d 100644 --- a/packages/host-kit/src/internal/host-process.ts +++ b/packages/host-kit/src/internal/host-process.ts @@ -132,10 +132,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; } @@ -267,7 +268,12 @@ function windowsProcessQueryArgs(pids: readonly number[]): string[] { .map((pid) => `ProcessId=${pid}`) .join(' OR ')}'` : 'Get-CimInstance -ClassName Win32_Process'; - return ['-NoProfile', '-NonInteractive', '-Command', `${source}${WINDOWS_PROCESS_ROW_BODY}`]; + // 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[] { 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..f7780b178e 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; }); 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..f61f3bcba8 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'; diff --git a/packages/host-kit/src/internal/owner-identity.ts b/packages/host-kit/src/internal/owner-identity.ts index 552a030ea7..8736318e88 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. + if (!isProcessPid(params.owner.pid)) return 'unknown'; + if (!isProcessAlive(params.owner.pid)) return 'owner-process-dead'; + const observation = + readHostProcessIdentityObservations([params.owner.pid]).get(params.owner.pid) ?? null; + return classifyOwnerLivenessFromObservation(params, observation); } export function classifyOwnerLivenessFromObservation( diff --git a/packages/host-kit/src/internal/process-lock.test.ts b/packages/host-kit/src/internal/process-lock.test.ts index 0e277c3235..fd009d3516 100644 --- a/packages/host-kit/src/internal/process-lock.test.ts +++ b/packages/host-kit/src/internal/process-lock.test.ts @@ -22,6 +22,21 @@ vi.mock('./host-process.ts', async (importOriginal) => { 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; + }, }; }); From 59323000d789e0e84ae5aad9b9c7a63a34bfcf51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 23:41:53 +0200 Subject: [PATCH 5/9] test(host-kit): split lock-owner liveness cases into their own module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process-lock.test.ts crossed the 1,000-line tripwire once the snapshot-aware fixture landed, and the ratchet refuses growth. The three owner-liveness judgment cases (zombie reclaim, null-start fail-closed, guard-free live judgment) mirror the classification seam, not lock mechanics, so they move — unchanged — to process-lock-owner-liveness.test.ts with the probe fixture they consume. Lock-mechanics cases stay. No assertions changed. --- .../process-lock-owner-liveness.test.ts | 128 ++++++++++++++++++ .../src/internal/process-lock.test.ts | 109 --------------- 2 files changed, 128 insertions(+), 109 deletions(-) create mode 100644 packages/host-kit/src/internal/process-lock-owner-liveness.test.ts 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 fd009d3516..33e8dc211a 100644 --- a/packages/host-kit/src/internal/process-lock.test.ts +++ b/packages/host-kit/src/internal/process-lock.test.ts @@ -5,41 +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); - }, - // 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, acquireProcessLockAcquisition, @@ -69,7 +34,6 @@ beforeEach(() => { }); afterEach(() => { - processProbe.observe = undefined; fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -102,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(); @@ -989,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); -}); From 1d3d2b0a0ef419e10f5e14db81924e3d74a27fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 9 Oct 2026 02:57:32 +0200 Subject: [PATCH 6/9] fix(host-kit): probe /bin/ps from the identity snapshot like every POSIX probe The snapshot predated this PR on PATH 'ps' while HOST_PS_COMMAND pinned the per-field probes to /bin/ps. One binary for every POSIX process-table read restores the trust posture HOST_PS_COMMAND exists for, and its dead win32 arm goes: every Windows caller branches to the CIM query first. Snapshot and facts tests now pin the binary the probe asks for. --- .../src/internal/host-process-liveness.test.ts | 2 ++ packages/host-kit/src/internal/host-process.ts | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) 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.ts b/packages/host-kit/src/internal/host-process.ts index 2290d6291d..0584a4e493 100644 --- a/packages/host-kit/src/internal/host-process.ts +++ b/packages/host-kit/src/internal/host-process.ts @@ -3,7 +3,10 @@ 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; @@ -157,10 +160,14 @@ export function readHostProcessIdentityObservations( 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); From bad805c213d65088aa0bbda08ba1379a79b12c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 9 Oct 2026 02:57:32 +0200 Subject: [PATCH 7/9] test(daemon): share one Windows fs-seam spy between the truncation cases The EPERM-on-append-handle emulation was copied verbatim into the existing and missing log cases. The flags it records still differ per case, so the helper returns them and each test keeps pinning its own handle sequence. --- .../daemon-registration-owner.test.ts | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index 92a7cf3c0d..f504445479 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -105,9 +105,12 @@ function onWindowsHost(run: () => Promise): Promise { // 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). -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'); + +// 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; @@ -116,7 +119,7 @@ test('a Windows host empties an existing log through one pinned write handle', a flags: fs.OpenMode, mode?: fs.Mode, ) => { - if (String(target) === paths.logPath) logOpenFlags.push(String(flags)); + if (String(target) === logPath) logOpenFlags.push(String(flags)); const descriptor = realOpen(target, flags, mode); flagsByDescriptor.set(descriptor, String(flags)); return descriptor; @@ -130,6 +133,13 @@ test('a Windows host empties an existing log through one pinned write handle', a } 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); @@ -146,28 +156,7 @@ test('a Windows host empties an existing log through one pinned write handle', a test('a missing Windows-host log is created through append and emptied through a write handle', async () => { const { paths, owner } = await acquire(); - 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) === paths.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); + const logOpenFlags = spyWindowsLogHandleRules(paths.logPath); await onWindowsHost(async () => { try { owner.publish(fields); From 16ff5d83b93faf59e68cc652ef11f512cc1d678e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 9 Oct 2026 05:13:01 +0200 Subject: [PATCH 8/9] fix(host-kit): keep a null CIM field printing empty and the birth stamp culture-pinned The row body claimed a culture-invariant stamp while ToString took no culture argument, so a host calendar could reformat the same process birth between the daemon's publish and a client's verify. And Formatting a null CreationDate (the System Idle Process row) raises mid-pipeline: exit 1 blanks the whole snapshot rather than that row, turning one odd process into unknown evidence for every pid. The row rule is now what it claims: null fields print empty, never raise; birth is formatted with the invariant culture. Reaper coverage gains the zombie identity it never seeded, and owner-liveness guards collapse into one place so the single snapshot path stops paying two kill(pid, 0) per poll. --- .../src/internal/host-process-windows.test.ts | 26 +++++++++++++- .../host-kit/src/internal/host-process.ts | 18 +++++++--- .../src/internal/owned-process-record.test.ts | 36 +++++++++++++++++++ .../internal/owner-identity-liveness.test.ts | 8 +++++ .../host-kit/src/internal/owner-identity.ts | 28 ++++++++++++--- 5 files changed, 106 insertions(+), 10 deletions(-) diff --git a/packages/host-kit/src/internal/host-process-windows.test.ts b/packages/host-kit/src/internal/host-process-windows.test.ts index 5f7f0f0b9b..3173e24cdc 100644 --- a/packages/host-kit/src/internal/host-process-windows.test.ts +++ b/packages/host-kit/src/internal/host-process-windows.test.ts @@ -103,6 +103,30 @@ test('a failed or thrown CIM query is unknown evidence, as a failed ps is', () = }); }); +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( @@ -161,7 +185,7 @@ test('the Windows process list parses CIM rows through the injected command runn runCommand: async (cmd, args, options) => { calls.push({ cmd, args, timeoutMs: options.timeoutMs }); return { - stdout: `\uFEFF${DAEMON_ROW}\r\n0|0||\r\nnot a row\r\n`, + stdout: `\uFEFF0|0||System Idle Process\r\n${DAEMON_ROW}\r\n0|0||\r\nnot a row\r\n`, stderr: '', exitCode: 0, }; diff --git a/packages/host-kit/src/internal/host-process.ts b/packages/host-kit/src/internal/host-process.ts index 0584a4e493..90abdfabb8 100644 --- a/packages/host-kit/src/internal/host-process.ts +++ b/packages/host-kit/src/internal/host-process.ts @@ -258,14 +258,22 @@ function isWindowsHostPlatform(): boolean { return hostPlatform() === 'win32'; } -// Rows are `pid|ppid|creation|command` with a culture-invariant UTC creation -// stamp, so the value recorded in daemon.json compares equal on every read of -// the same process lifetime. Newlines inside a command line are flattened so +// 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 { '{0}|{1}|{2}|{3}' -f $_.ProcessId, $_.ParentProcessId," + - " $_.CreationDate.ToUniversalTime().ToString('yyyyMMddHHmmssfffffff')," + + ' | 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[] { 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 f7780b178e..026db6b3b4 100644 --- a/packages/host-kit/src/internal/owned-process-record.test.ts +++ b/packages/host-kit/src/internal/owned-process-record.test.ts @@ -166,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 f61f3bcba8..412d3e81af 100644 --- a/packages/host-kit/src/internal/owner-identity-liveness.test.ts +++ b/packages/host-kit/src/internal/owner-identity-liveness.test.ts @@ -84,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 8736318e88..eb60294094 100644 --- a/packages/host-kit/src/internal/owner-identity.ts +++ b/packages/host-kit/src/internal/owner-identity.ts @@ -74,11 +74,11 @@ export function classifyOwnerLiveness(params: { // 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. - if (!isProcessPid(params.owner.pid)) return 'unknown'; - if (!isProcessAlive(params.owner.pid)) return 'owner-process-dead'; + const guarded = guardOwnerPid(params.owner); + if (guarded) return guarded; const observation = readHostProcessIdentityObservations([params.owner.pid]).get(params.owner.pid) ?? null; - return classifyOwnerLivenessFromObservation(params, observation); + return classifyLiveOwnerFromObservation(params, observation); } export function classifyOwnerLivenessFromObservation( @@ -88,9 +88,29 @@ 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'; + 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 (observation !== undefined ? observation?.state.startsWith('Z') : isProcessZombie(owner.pid)) { return 'owner-process-dead'; } From a7821ef58384df8f829a46845d768d059dbebd01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 9 Oct 2026 05:18:56 +0200 Subject: [PATCH 9/9] refactor(host-kit): share the process-table row walk and name the liveness rules The two stdout parsers had grown an identical five-line pid/ppid walk, and the shared liveness judge stacked its probe-mode ternaries past the complexity threshold. Both formats now feed their own pattern and builder through one row walk, and the zombie and PID-reuse questions answer from named rules that own their unknown-evidence semantics. No baseline moved: the findings resolved at their shapes. --- .../host-kit/src/internal/host-process.ts | 52 ++++++++++++------- .../host-kit/src/internal/owner-identity.ts | 43 ++++++++++----- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/packages/host-kit/src/internal/host-process.ts b/packages/host-kit/src/internal/host-process.ts index 90abdfabb8..595546eb28 100644 --- a/packages/host-kit/src/internal/host-process.ts +++ b/packages/host-kit/src/internal/host-process.ts @@ -221,18 +221,35 @@ 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 processes; + 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]!, + })); } /* -------------------------------------------------------------------------- */ @@ -292,21 +309,16 @@ function windowsProcessQueryArgs(pids: readonly number[]): string[] { } function parseWindowsProcessRows(stdout: string): WindowsProcessRow[] { - const rows: WindowsProcessRow[] = []; - for (const line of stdout.replace(/^\uFEFF/, '').split('\n')) { - const match = /^(\d+)\|(\d+)\|([^|]*)\|(.*)\r?$/.exec(line); - if (!match) continue; - const pid = Number.parseInt(match[1]!, 10); - const ppid = Number.parseInt(match[2]!, 10); - if (!Number.isInteger(pid) || pid <= 0) continue; - rows.push({ + return parseProcessTableRows( + stdout, + /^(\d+)\|(\d+)\|([^|]*)\|(.*)\r?$/, + (match, pid, ppid): WindowsProcessRow => ({ pid, - ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : undefined, + ...(ppid !== undefined ? { ppid } : {}), startTime: match[3]!.length > 0 ? match[3]! : null, command: match[4]!.length > 0 ? match[4]! : null, - }); - } - return rows; + }), + ); } function readWindowsProcessRows(pids: readonly number[], timeoutMs: number): WindowsProcessRow[] { diff --git a/packages/host-kit/src/internal/owner-identity.ts b/packages/host-kit/src/internal/owner-identity.ts index eb60294094..e555a87163 100644 --- a/packages/host-kit/src/internal/owner-identity.ts +++ b/packages/host-kit/src/internal/owner-identity.ts @@ -111,21 +111,40 @@ function classifyLiveOwnerFromObservation( observation?: HostProcessIdentityObservation | null, ): OwnerLiveness { const { owner, stateDir } = params; - 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'; - } - } + 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);