From 472c98bfba5e021e2f6b5883baae28f8bd1eaf29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 6 Jul 2026 17:25:27 +0200 Subject: [PATCH] refactor: extract host process primitives --- CONTEXT.md | 4 + fallow-baselines/health.json | 3 +- scripts/clean-daemon.ts | 2 +- src/__tests__/client-metro-companion.test.ts | 4 +- .../client-metro-startup-cleanup.test.ts | 4 +- src/__tests__/client-metro.test.ts | 2 +- src/__tests__/daemon-entrypoint.test.ts | 2 +- src/client/client-companion-tunnel.ts | 2 +- src/daemon/__tests__/daemon-process.test.ts | 18 ++ src/daemon/app-log-process.ts | 2 +- src/daemon/client/daemon-client-metadata.ts | 5 +- src/daemon/client/daemon-client-timeout.ts | 2 +- src/daemon/daemon-process.ts | 55 ++++++ .../handlers/record-trace-ios-simulator.ts | 30 +-- src/daemon/server/server-lifecycle.ts | 4 +- src/metro/client-metro.ts | 2 +- .../core/__tests__/runner-adoption.test.ts | 6 +- .../core/__tests__/runner-session.test.ts | 6 +- .../apple/core/runner/runner-adoption.ts | 2 +- .../apple/core/runner/runner-cache.ts | 2 +- .../apple/core/runner/runner-device-set.ts | 2 +- .../apple/core/runner/runner-disposal.ts | 10 +- .../apple/core/runner/runner-lease.ts | 2 +- .../web/agent-browser-lifecycle.test.ts | 39 ---- src/platforms/web/agent-browser-lifecycle.ts | 102 ++-------- src/platforms/web/agent-browser-tool.ts | 2 +- .../__tests__/daemon-client-lifecycle.test.ts | 2 +- src/utils/__tests__/daemon-client.test.ts | 4 +- src/utils/__tests__/host-process.test.ts | 181 ++++++++++++++++++ src/utils/__tests__/process-identity.test.ts | 41 ---- src/utils/__tests__/process-lock.test.ts | 2 +- src/utils/host-process.ts | 162 ++++++++++++++++ src/utils/process-identity.ts | 112 ----------- src/utils/process-lock.ts | 2 +- src/utils/swift-cache.ts | 2 +- test/integration/smoke-daemon-clean.test.ts | 3 +- test/integration/smoke-daemon-http.test.ts | 2 +- 37 files changed, 489 insertions(+), 338 deletions(-) create mode 100644 src/daemon/__tests__/daemon-process.test.ts create mode 100644 src/daemon/daemon-process.ts create mode 100644 src/utils/__tests__/host-process.test.ts delete mode 100644 src/utils/__tests__/process-identity.test.ts create mode 100644 src/utils/host-process.ts delete mode 100644 src/utils/process-identity.ts diff --git a/CONTEXT.md b/CONTEXT.md index 585d62a7c..738622e58 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -38,6 +38,10 @@ such as `proxy`, cloud bridge, or `limrun`. - Runner/process lease: backend helper mutual-exclusion guard for platform runners or tools; it is not the remote client ownership boundary. +- Host process primitive: low-level host PID helpers in `src/utils/host-process.ts` for liveness, + start-time/command reads, process listing, process-tree expansion, PID de-duplication, and + best-effort signaling. It must not own domain cleanup policy such as browser ownership markers, + runner lease reclamation, daemon takeover checks, or app-log PID metadata verification. - Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints. - Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution. - Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior, distinct from the public command surface and daemon command registry. The Swift runner traits classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the alert command as read-only only for its `get` action. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type. diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index a94f79d52..d4209bf8b 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -636,7 +636,6 @@ "src/cli/parser/args.ts:high impact", "src/daemon/snapshot-presentation/tree.ts:high impact", "src/platforms/apple/core/perf-xml.ts:high impact", - "src/utils/process-identity.ts:high impact", "src/platforms/web/json-utils.ts:high impact", "src/utils/rect-center.ts:high impact", "src/daemon/handlers/session-doctor-output.ts:high impact", @@ -670,4 +669,4 @@ "src/daemon/client/daemon-client-lifecycle.ts:complexity", "src/client/client-companion-tunnel-worker.ts:complexity" ] -} \ No newline at end of file +} diff --git a/scripts/clean-daemon.ts b/scripts/clean-daemon.ts index 24cb30a03..16f15ed54 100644 --- a/scripts/clean-daemon.ts +++ b/scripts/clean-daemon.ts @@ -5,7 +5,7 @@ import { resolveDaemonPaths } from '../src/daemon/config.ts'; import { isAgentDeviceDaemonProcess, stopProcessForTakeover, -} from '../src/utils/process-identity.ts'; +} from '../src/daemon/daemon-process.ts'; import { cleanupRunnerLeasesForOwner } from '../src/platforms/apple/core/runner/runner-lease.ts'; import { runnerLeaseCleanupAdapter } from '../src/platforms/apple/core/runner/runner-disposal.ts'; diff --git a/src/__tests__/client-metro-companion.test.ts b/src/__tests__/client-metro-companion.test.ts index 5bdab58a3..8b8ca9b37 100644 --- a/src/__tests__/client-metro-companion.test.ts +++ b/src/__tests__/client-metro-companion.test.ts @@ -8,7 +8,7 @@ vi.mock('../utils/exec.ts', () => ({ runCmdDetached: vi.fn(), })); -vi.mock('../utils/process-identity.ts', () => ({ +vi.mock('../utils/host-process.ts', () => ({ isProcessAlive: vi.fn(), readProcessCommand: vi.fn(), readProcessStartTime: vi.fn(), @@ -21,7 +21,7 @@ import { readProcessCommand, readProcessStartTime, waitForProcessExit, -} from '../utils/process-identity.ts'; +} from '../utils/host-process.ts'; import { ensureMetroCompanion, stopMetroCompanion } from '../metro/client-metro-companion.ts'; import { ensureReactDevtoolsCompanion } from '../client/client-react-devtools-companion.ts'; diff --git a/src/__tests__/client-metro-startup-cleanup.test.ts b/src/__tests__/client-metro-startup-cleanup.test.ts index 99a08f382..f59614760 100644 --- a/src/__tests__/client-metro-startup-cleanup.test.ts +++ b/src/__tests__/client-metro-startup-cleanup.test.ts @@ -9,12 +9,12 @@ vi.mock('../utils/exec.ts', () => ({ runCmdSync: vi.fn(), })); -vi.mock('../utils/process-identity.ts', () => ({ +vi.mock('../utils/host-process.ts', () => ({ waitForProcessExit: vi.fn(), })); import { runCmdDetached } from '../utils/exec.ts'; -import { waitForProcessExit } from '../utils/process-identity.ts'; +import { waitForProcessExit } from '../utils/host-process.ts'; import { prepareMetroRuntime } from '../metro/client-metro.ts'; afterEach(() => { diff --git a/src/__tests__/client-metro.test.ts b/src/__tests__/client-metro.test.ts index 27be615bd..122068e4f 100644 --- a/src/__tests__/client-metro.test.ts +++ b/src/__tests__/client-metro.test.ts @@ -10,7 +10,7 @@ import os from 'node:os'; import path from 'node:path'; import { prepareMetroRuntime, reloadMetro } from '../metro/client-metro.ts'; import { AppError } from '../kernel/errors.ts'; -import { isProcessAlive, waitForProcessExit } from '../utils/process-identity.ts'; +import { isProcessAlive, waitForProcessExit } from '../utils/host-process.ts'; const TEST_TOKEN = 'agent-device-proxy-test-token'; diff --git a/src/__tests__/daemon-entrypoint.test.ts b/src/__tests__/daemon-entrypoint.test.ts index 5ead02e1b..81279ffa3 100644 --- a/src/__tests__/daemon-entrypoint.test.ts +++ b/src/__tests__/daemon-entrypoint.test.ts @@ -10,7 +10,7 @@ import { trackDownloadableArtifact, } from '../daemon/artifact-tracking.ts'; import { runCmdBackground } from '../utils/exec.ts'; -import { isProcessAlive, waitForProcessExit } from '../utils/process-identity.ts'; +import { isProcessAlive, waitForProcessExit } from '../utils/host-process.ts'; import { waitForHttpOk } from './test-utils/index.ts'; type DaemonInfoFile = { diff --git a/src/client/client-companion-tunnel.ts b/src/client/client-companion-tunnel.ts index bee0b03c8..d4f415543 100644 --- a/src/client/client-companion-tunnel.ts +++ b/src/client/client-companion-tunnel.ts @@ -24,7 +24,7 @@ import { readProcessCommand, readProcessStartTime, waitForProcessExit, -} from '../utils/process-identity.ts'; +} from '../utils/host-process.ts'; const COMPANION_TUNNEL_TERM_TIMEOUT_MS = 1_000; const COMPANION_TUNNEL_KILL_TIMEOUT_MS = 1_000; diff --git a/src/daemon/__tests__/daemon-process.test.ts b/src/daemon/__tests__/daemon-process.test.ts new file mode 100644 index 000000000..c851ac64d --- /dev/null +++ b/src/daemon/__tests__/daemon-process.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { isAgentDeviceDaemonCommand } from '../daemon-process.ts'; + +test('isAgentDeviceDaemonCommand matches expected daemon command', () => { + assert.equal(isAgentDeviceDaemonCommand('node /tmp/agent-device/dist/src/daemon.js'), true); + assert.equal( + isAgentDeviceDaemonCommand('node /tmp/agent-device/dist/src/internal/daemon.js'), + true, + ); + assert.equal( + isAgentDeviceDaemonCommand( + 'node --experimental-strip-types /worktrees/agent-device/src/daemon.ts', + ), + true, + ); + assert.equal(isAgentDeviceDaemonCommand('node -e "setInterval(() => {}, 1000)"'), false); +}); diff --git a/src/daemon/app-log-process.ts b/src/daemon/app-log-process.ts index f9d8513ae..7ef83c7cd 100644 --- a/src/daemon/app-log-process.ts +++ b/src/daemon/app-log-process.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { readProcessCommand, readProcessStartTime } from '../utils/process-identity.ts'; +import { readProcessCommand, readProcessStartTime } from '../utils/host-process.ts'; import type { LogBackend } from './network-log.ts'; import type { ExecResult } from '../utils/exec.ts'; diff --git a/src/daemon/client/daemon-client-metadata.ts b/src/daemon/client/daemon-client-metadata.ts index 02e8fbaf7..1c67f8bb8 100644 --- a/src/daemon/client/daemon-client-metadata.ts +++ b/src/daemon/client/daemon-client-metadata.ts @@ -1,9 +1,6 @@ import fs from 'node:fs'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { - isAgentDeviceDaemonProcess, - stopProcessForTakeover, -} from '../../utils/process-identity.ts'; +import { isAgentDeviceDaemonProcess, stopProcessForTakeover } from '../daemon-process.ts'; import { shellQuote } from '../../utils/shell-quote.ts'; import { resolveDaemonPaths, type DaemonPaths, type DaemonServerMode } from '../config.ts'; diff --git a/src/daemon/client/daemon-client-timeout.ts b/src/daemon/client/daemon-client-timeout.ts index dd1909697..9ac86628c 100644 --- a/src/daemon/client/daemon-client-timeout.ts +++ b/src/daemon/client/daemon-client-timeout.ts @@ -1,7 +1,7 @@ import { AppError } from '../../kernel/errors.ts'; import { runCmdSync } from '../../utils/exec.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { isAgentDeviceDaemonProcess } from '../../utils/process-identity.ts'; +import { isAgentDeviceDaemonProcess } from '../daemon-process.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { resolveCommandTimeoutPolicy } from '../../core/command-descriptor/registry.ts'; import type { DaemonPaths } from '../config.ts'; diff --git a/src/daemon/daemon-process.ts b/src/daemon/daemon-process.ts new file mode 100644 index 000000000..5e130ce29 --- /dev/null +++ b/src/daemon/daemon-process.ts @@ -0,0 +1,55 @@ +import { + isProcessAlive, + readProcessCommand, + readProcessStartTime, + waitForProcessExit, +} from '../utils/host-process.ts'; + +const DAEMON_COMMAND_PATTERNS = [ + /(^|[/\s"'=])dist\/src\/daemon\.js($|[\s"'])/, + /(^|[/\s"'=])dist\/src\/internal\/daemon\.js($|[\s"'])/, + /(^|[/\s"'=])src\/daemon\.ts($|[\s"'])/, +]; + +export function isAgentDeviceDaemonCommand(command: string): boolean { + const normalized = command.toLowerCase().replaceAll('\\', '/'); + if (!normalized.includes('agent-device')) return false; + return DAEMON_COMMAND_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +export function isAgentDeviceDaemonProcess(pid: number, expectedStartTime?: string): boolean { + if (!isProcessAlive(pid)) return false; + if (expectedStartTime) { + const actualStartTime = readProcessStartTime(pid); + if (!actualStartTime || actualStartTime !== expectedStartTime) return false; + } + const command = readProcessCommand(pid); + if (!command) return false; + return isAgentDeviceDaemonCommand(command); +} + +function trySignalProcess(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ESRCH' || code === 'EPERM') return false; + throw err; + } +} + +export async function stopProcessForTakeover( + pid: number, + options: { + termTimeoutMs: number; + killTimeoutMs: number; + expectedStartTime?: string; + }, +): Promise { + if (!isAgentDeviceDaemonProcess(pid, options.expectedStartTime)) return; + if (!trySignalProcess(pid, 'SIGTERM')) return; + if (await waitForProcessExit(pid, options.termTimeoutMs)) return; + if (!trySignalProcess(pid, 'SIGKILL')) return; + await waitForProcessExit(pid, options.killTimeoutMs); +} diff --git a/src/daemon/handlers/record-trace-ios-simulator.ts b/src/daemon/handlers/record-trace-ios-simulator.ts index 6ffa4dfb0..13811b47d 100644 --- a/src/daemon/handlers/record-trace-ios-simulator.ts +++ b/src/daemon/handlers/record-trace-ios-simulator.ts @@ -1,6 +1,7 @@ import { sleep } from '../../utils/timeouts.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { ExecResult } from '../../utils/exec.ts'; +import { signalPidsBestEffort, uniquePositivePids } from '../../utils/host-process.ts'; import { formatRecordTraceError } from '../record-trace-errors.ts'; import type { SessionState } from '../types.ts'; import type { RecordTraceDeps } from './record-trace-types.ts'; @@ -88,8 +89,8 @@ async function signalMatchingIosSimulatorRecorders( return; } - const pids = uniquePositivePids(parseProcessIds(result.stdout)); - const signaled = signalProcessIds(pids, signal); + const pids = uniquePositivePids(parseProcessIds(result.stdout), { excludePid: process.pid }); + const signaled = signalPidsBestEffort(pids, signal); emitDiagnostic({ level: signaled > 0 ? 'warn' : 'debug', @@ -124,8 +125,10 @@ async function signalSessionOwnedIosSimulatorRecorders( } const childResult = await findChildProcessIds(deps, recorderPid, recording.outPath, signal); - const pids = uniquePositivePids([recorderPid, ...childResult.pids]); - const signaled = signalProcessIds(pids, signal); + const pids = uniquePositivePids([recorderPid, ...childResult.pids], { + excludePid: process.pid, + }); + const signaled = signalPidsBestEffort(pids, signal); emitDiagnostic({ level: signaled > 0 ? 'warn' : 'debug', @@ -173,25 +176,6 @@ async function findChildProcessIds( }; } -function uniquePositivePids(values: number[]): number[] { - return Array.from(new Set(values)).filter( - (pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid, - ); -} - -function signalProcessIds(pids: number[], signal: NodeJS.Signals): number { - let signaled = 0; - for (const pid of pids) { - try { - process.kill(pid, signal); - signaled += 1; - } catch { - // Process already exited or cannot be signaled; cleanup remains best-effort. - } - } - return signaled; -} - function parseProcessIds(stdout: string): number[] { return stdout .split(/\s+/) diff --git a/src/daemon/server/server-lifecycle.ts b/src/daemon/server/server-lifecycle.ts index bdfe52d2f..911b0fc7e 100644 --- a/src/daemon/server/server-lifecycle.ts +++ b/src/daemon/server/server-lifecycle.ts @@ -1,8 +1,8 @@ import fs from 'node:fs'; -import { isAgentDeviceDaemonProcess } from '../../utils/process-identity.ts'; +import { isAgentDeviceDaemonProcess } from '../daemon-process.ts'; export { readVersion } from '../../utils/version.ts'; -export { readProcessStartTime } from '../../utils/process-identity.ts'; +export { readProcessStartTime } from '../../utils/host-process.ts'; export { resolveDaemonCodeSignature } from '../code-signature.ts'; export type DaemonLockInfo = { diff --git a/src/metro/client-metro.ts b/src/metro/client-metro.ts index 4dc13df8d..aaac1429d 100644 --- a/src/metro/client-metro.ts +++ b/src/metro/client-metro.ts @@ -12,7 +12,7 @@ import type { import { AppError } from '../kernel/errors.ts'; import { runCmdSync, runCmdDetached } from '../utils/exec.ts'; import { resolveUserPath } from '../utils/path-resolution.ts'; -import { waitForProcessExit } from '../utils/process-identity.ts'; +import { waitForProcessExit } from '../utils/host-process.ts'; import { detectProjectRuntimeKindFromPackageJson, readProjectPackageJson, diff --git a/src/platforms/apple/core/__tests__/runner-adoption.test.ts b/src/platforms/apple/core/__tests__/runner-adoption.test.ts index e6fe3e1d3..e117e9226 100644 --- a/src/platforms/apple/core/__tests__/runner-adoption.test.ts +++ b/src/platforms/apple/core/__tests__/runner-adoption.test.ts @@ -15,7 +15,7 @@ import { tryAdoptRunnerSessionFromLease, } from '../runner/runner-adoption.ts'; import { sendRunnerCommandOnce } from '../runner/runner-transport.ts'; -import { isProcessAlive } from '../../../../utils/process-identity.ts'; +import { isProcessAlive } from '../../../../utils/host-process.ts'; import { resolveExpectedRunnerCacheMetadata, resolveRunnerDerivedPath, @@ -25,8 +25,8 @@ vi.mock('../runner/runner-transport.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, sendRunnerCommandOnce: vi.fn() }; }); -vi.mock('../../../../utils/process-identity.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../utils/host-process.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, isProcessAlive: vi.fn(() => false) }; }); diff --git a/src/platforms/apple/core/__tests__/runner-session.test.ts b/src/platforms/apple/core/__tests__/runner-session.test.ts index 0fca5ca4c..0fc7d541d 100644 --- a/src/platforms/apple/core/__tests__/runner-session.test.ts +++ b/src/platforms/apple/core/__tests__/runner-session.test.ts @@ -60,9 +60,9 @@ vi.mock('../../../../utils/exec.ts', async () => { }; }); -vi.mock('../../../../utils/process-identity.ts', async () => { - const actual = await vi.importActual( - '../../../../utils/process-identity.ts', +vi.mock('../../../../utils/host-process.ts', async () => { + const actual = await vi.importActual( + '../../../../utils/host-process.ts', ); return { ...actual, diff --git a/src/platforms/apple/core/runner/runner-adoption.ts b/src/platforms/apple/core/runner/runner-adoption.ts index c36d1c5df..4350aa55f 100644 --- a/src/platforms/apple/core/runner/runner-adoption.ts +++ b/src/platforms/apple/core/runner/runner-adoption.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { resolveIosSimulatorDeviceSetPath } from '../../../../utils/device-isolation.ts'; import { emitDiagnostic } from '../../../../utils/diagnostics.ts'; -import { isProcessAlive } from '../../../../utils/process-identity.ts'; +import { isProcessAlive } from '../../../../utils/host-process.ts'; import { parseBooleanLiteral } from '../../../../utils/source-value.ts'; import type { DeviceInfo } from '../../../../kernel/device.ts'; import type { ExecResult } from '../../../../utils/exec.ts'; diff --git a/src/platforms/apple/core/runner/runner-cache.ts b/src/platforms/apple/core/runner/runner-cache.ts index 4f815d1d3..832577abc 100644 --- a/src/platforms/apple/core/runner/runner-cache.ts +++ b/src/platforms/apple/core/runner/runner-cache.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '../../../../kernel/errors.ts'; import { emitDiagnostic } from '../../../../utils/diagnostics.ts'; -import { readProcessStartTime } from '../../../../utils/process-identity.ts'; +import { readProcessStartTime } from '../../../../utils/host-process.ts'; import { acquireProcessLock, type ProcessLockOwner } from '../../../../utils/process-lock.ts'; import { isEnvTruthy } from '../../../../utils/retry.ts'; import { findProjectRoot } from '../../../../utils/version.ts'; diff --git a/src/platforms/apple/core/runner/runner-device-set.ts b/src/platforms/apple/core/runner/runner-device-set.ts index 7d5140002..39a3c27c9 100644 --- a/src/platforms/apple/core/runner/runner-device-set.ts +++ b/src/platforms/apple/core/runner/runner-device-set.ts @@ -5,7 +5,7 @@ import { AppError } from '../../../../kernel/errors.ts'; import { isIosFamily, type DeviceInfo } from '../../../../kernel/device.ts'; import { resolveIosSimulatorDeviceSetPath } from '../../../../utils/device-isolation.ts'; import { emitDiagnostic } from '../../../../utils/diagnostics.ts'; -import { readProcessStartTime } from '../../../../utils/process-identity.ts'; +import { readProcessStartTime } from '../../../../utils/host-process.ts'; import { acquireProcessLock, type ProcessLockOwner } from '../../../../utils/process-lock.ts'; const XCTEST_DEVICE_SET_BASE_NAME = 'XCTestDevices'; diff --git a/src/platforms/apple/core/runner/runner-disposal.ts b/src/platforms/apple/core/runner/runner-disposal.ts index 9cb4d8430..8637c080e 100644 --- a/src/platforms/apple/core/runner/runner-disposal.ts +++ b/src/platforms/apple/core/runner/runner-disposal.ts @@ -1,6 +1,10 @@ import { emitDiagnostic } from '../../../../utils/diagnostics.ts'; import type { DeviceInfo } from '../../../../kernel/device.ts'; -import { isProcessAlive, isProcessGroupAlive } from '../../../../utils/process-identity.ts'; +import { + isProcessAlive, + isProcessGroupAlive, + signalPidsBestEffort, +} from '../../../../utils/host-process.ts'; import type { ExecBackgroundResult } from '../../../../utils/exec.ts'; import { cleanupTempFile, waitForRunner } from './runner-transport.ts'; import { withRunnerCommandId, type RunnerCommand } from './runner-contract.ts'; @@ -187,9 +191,7 @@ async function killRunnerProcessTree( try { process.kill(-pid, signal); } catch {} - try { - process.kill(pid, signal); - } catch {} + signalPidsBestEffort([pid], signal); const pkillSignal = signal === 'SIGINT' ? 'INT' : signal === 'SIGTERM' ? 'TERM' : 'KILL'; try { await runAppleToolCommand('pkill', [`-${pkillSignal}`, '-P', String(pid)], { diff --git a/src/platforms/apple/core/runner/runner-lease.ts b/src/platforms/apple/core/runner/runner-lease.ts index cc1d07fdd..bacf6933e 100644 --- a/src/platforms/apple/core/runner/runner-lease.ts +++ b/src/platforms/apple/core/runner/runner-lease.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { emitDiagnostic } from '../../../../utils/diagnostics.ts'; import { AppError } from '../../../../kernel/errors.ts'; import { acquireProcessLock } from '../../../../utils/process-lock.ts'; -import { isProcessAlive, readProcessStartTime } from '../../../../utils/process-identity.ts'; +import { isProcessAlive, readProcessStartTime } from '../../../../utils/host-process.ts'; import type { RunnerLogicalLeaseContext } from '../../../../core/runner-lease-context.ts'; const RUNNER_LEASE_SCHEMA_VERSION = 1; diff --git a/src/platforms/web/agent-browser-lifecycle.test.ts b/src/platforms/web/agent-browser-lifecycle.test.ts index b252e54ef..d5374333e 100644 --- a/src/platforms/web/agent-browser-lifecycle.test.ts +++ b/src/platforms/web/agent-browser-lifecycle.test.ts @@ -22,9 +22,7 @@ import { agentBrowserChromeLaunchMarker, appendAgentDeviceChromeArgs, cleanupManagedAgentBrowserOrphans, - expandProcessTree, matchAgentBrowserChromeProcess, - parseHostProcessList, resolveAgentBrowserIdleTimeoutMs, summarizeAgentBrowserProcesses, } from './agent-browser-lifecycle.ts'; @@ -80,26 +78,6 @@ test('managed agent-browser idle timeout defaults to five minutes and respects o ); }); -test('host process parser preserves command text after pid and parent pid', () => { - assert.deepEqual( - parseHostProcessList( - [ - ' 101 1 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - ' 202 101 /tmp/chrome --type=renderer --flag value', - 'not a process', - ].join('\n'), - ), - [ - { - pid: 101, - ppid: 1, - command: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - }, - { pid: 202, ppid: 101, command: '/tmp/chrome --type=renderer --flag value' }, - ], - ); -}); - test('ownership detection accepts only Chrome-like processes with managed markers', () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-life-')); try { @@ -217,23 +195,6 @@ test('cleanup does not treat the shared socket directory mtime as browser activi } }); -test('process tree expansion includes descendants of matched browser roots', () => { - const expanded = expandProcessTree( - [{ process: { pid: 101, ppid: 1, command: 'chrome' }, reason: 'launch-marker' }], - [ - { pid: 101, ppid: 1, command: 'chrome' }, - { pid: 201, ppid: 101, command: 'Chrome Helper --type=renderer' }, - { pid: 301, ppid: 201, command: 'Chrome Helper --type=gpu' }, - { pid: 999, ppid: 1, command: 'Google Chrome' }, - ], - ); - - assert.deepEqual( - expanded.map((processInfo) => processInfo.pid), - [101, 201, 301], - ); -}); - test('cleanup signals matched browser process trees with TERM then KILL only for live pids', async () => { vi.useFakeTimers(); const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-life-')); diff --git a/src/platforms/web/agent-browser-lifecycle.ts b/src/platforms/web/agent-browser-lifecycle.ts index ab7259d78..a4a7c45d5 100644 --- a/src/platforms/web/agent-browser-lifecycle.ts +++ b/src/platforms/web/agent-browser-lifecycle.ts @@ -2,9 +2,13 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { runCmd, withoutCommandExecutorOverride } from '../../utils/exec.ts'; -import { isProcessAlive, waitForProcessExit } from '../../utils/process-identity.ts'; -import { sleep } from '../../utils/timeouts.ts'; +import { + expandProcessTree, + listHostProcesses, + stopPidsWithEscalation, + type HostProcessInfo, +} from '../../utils/host-process.ts'; +import { withoutCommandExecutorOverride } from '../../utils/exec.ts'; import type { AgentBrowserToolStatus } from './agent-browser-tool.ts'; const HOST_PROCESS_LIST_TIMEOUT_MS = 1_500; @@ -14,12 +18,6 @@ const PROVIDER_STARTUP_CLEANUP_DEBOUNCE_MS = 30_000; const AGENT_DEVICE_BROWSER_MARKER_PREFIX = '--agent-device-managed-web='; export const DEFAULT_AGENT_BROWSER_IDLE_TIMEOUT_MS = 5 * 60_000; -export type HostProcessInfo = { - pid: number; - ppid?: number; - command: string; -}; - export type AgentBrowserProcessMatch = { process: HostProcessInfo; reason: 'launch-marker' | 'managed-browser-home'; @@ -76,20 +74,6 @@ export function resolveAgentBrowserIdleTimeoutMs(env: NodeJS.ProcessEnv): number ); } -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); - 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 }); - } - return processes; -} - export function matchAgentBrowserChromeProcess( processInfo: HostProcessInfo, status: AgentBrowserToolStatus, @@ -123,7 +107,7 @@ export function summarizeAgentBrowserProcesses( export async function inspectManagedAgentBrowserProcesses( status: AgentBrowserToolStatus, ): Promise { - const processes = await listHostProcesses(); + const processes = await listRealHostProcesses(); return summarizeAgentBrowserProcesses(processes, status); } @@ -143,11 +127,12 @@ export async function cleanupManagedAgentBrowserOrphans( return skippedCleanupResult('recent-browser-activity', { idleTimeoutMs, latestActivityMs }); } - const processes = await listHostProcesses(); + const processes = await listRealHostProcesses(); const summary = summarizeAgentBrowserProcesses(processes, status); - const signalPids = expandProcessTree(summary.processes, processes).map( - (processInfo) => processInfo.pid, - ); + const signalPids = expandProcessTree( + summary.processes.map((match) => match.process.pid), + processes, + ).map((processInfo) => processInfo.pid); const result = { ...summary, signalPids }; if (summary.count === 0) return result; emitDiagnostic({ @@ -163,7 +148,11 @@ export async function cleanupManagedAgentBrowserOrphans( matchReasons: summary.processes.map((match) => match.reason), }, }); - await stopPids(signalPids); + await stopPidsWithEscalation({ + pids: signalPids, + termTimeoutMs: WEB_BROWSER_REAP_TERM_TIMEOUT_MS, + killTimeoutMs: WEB_BROWSER_REAP_KILL_TIMEOUT_MS, + }); return result; } @@ -181,59 +170,10 @@ export async function cleanupManagedAgentBrowserOrphansForProviderStartup( return await cleanupManagedAgentBrowserOrphans(status, 'provider-startup', options); } -export function expandProcessTree( - matches: AgentBrowserProcessMatch[], - processes: HostProcessInfo[], -): HostProcessInfo[] { - const selected = new Set(matches.map((match) => match.process.pid)); - let changed = true; - while (changed) { - changed = false; - for (const processInfo of processes) { - if (processInfo.ppid === undefined || !selected.has(processInfo.ppid)) continue; - if (selected.has(processInfo.pid)) continue; - selected.add(processInfo.pid); - changed = true; - } - } - return processes.filter((processInfo) => selected.has(processInfo.pid)); -} - -async function listHostProcesses(): Promise { - const result = await withoutCommandExecutorOverride( - async () => - await runCmd('ps', ['-ax', '-o', 'pid=,ppid=,command='], { - allowFailure: true, - timeoutMs: HOST_PROCESS_LIST_TIMEOUT_MS, - }), +async function listRealHostProcesses(): Promise { + return await withoutCommandExecutorOverride( + async () => await listHostProcesses({ timeoutMs: HOST_PROCESS_LIST_TIMEOUT_MS }), ); - if (result.exitCode !== 0) return []; - return parseHostProcessList(result.stdout); -} - -async function stopPids(pidsToStop: number[]): Promise { - const pids = uniquePids(pidsToStop); - for (const pid of pids) { - signalProcess(pid, 'SIGTERM'); - } - await sleep(WEB_BROWSER_REAP_TERM_TIMEOUT_MS); - for (const pid of pids) { - if (!isProcessAlive(pid)) continue; - signalProcess(pid, 'SIGKILL'); - } - await Promise.all( - pids.map(async (pid) => await waitForProcessExit(pid, WEB_BROWSER_REAP_KILL_TIMEOUT_MS)), - ); -} - -function signalProcess(pid: number, signal: NodeJS.Signals): void { - try { - process.kill(pid, signal); - } catch {} -} - -function uniquePids(pids: number[]): number[] { - return [...new Set(pids.filter((pid) => Number.isInteger(pid) && pid > 0))]; } function splitAgentBrowserArgs(args: string): string[] { diff --git a/src/platforms/web/agent-browser-tool.ts b/src/platforms/web/agent-browser-tool.ts index e8884cb83..acfee649d 100644 --- a/src/platforms/web/agent-browser-tool.ts +++ b/src/platforms/web/agent-browser-tool.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { runCmd } from '../../utils/exec.ts'; import { AppError, asAppError } from '../../kernel/errors.ts'; import { acquireProcessLock } from '../../utils/process-lock.ts'; -import { readProcessStartTime } from '../../utils/process-identity.ts'; +import { readProcessStartTime } from '../../utils/host-process.ts'; import { appendAgentDeviceChromeArgs, resolveAgentBrowserIdleTimeoutMs, diff --git a/src/utils/__tests__/daemon-client-lifecycle.test.ts b/src/utils/__tests__/daemon-client-lifecycle.test.ts index a622ef187..0fc5beff4 100644 --- a/src/utils/__tests__/daemon-client-lifecycle.test.ts +++ b/src/utils/__tests__/daemon-client-lifecycle.test.ts @@ -36,7 +36,7 @@ import { } from '../../__tests__/test-utils/index.ts'; import { AppError } from '../../kernel/errors.ts'; import { runCmdDetachedMonitored, runCmdSync } from '../exec.ts'; -import { readProcessStartTime } from '../process-identity.ts'; +import { readProcessStartTime } from '../host-process.ts'; import { sleep } from '../timeouts.ts'; import { findProjectRoot, readVersion } from '../version.ts'; diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index eadf01bc7..00142ad4e 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -28,9 +28,9 @@ import { isProcessAlive, readProcessCommand, readProcessStartTime, - stopProcessForTakeover, waitForProcessExit, -} from '../process-identity.ts'; +} from '../host-process.ts'; +import { stopProcessForTakeover } from '../../daemon/daemon-process.ts'; import { findProjectRoot, readVersion } from '../version.ts'; type MockHttpResponse = EventEmitter & { diff --git a/src/utils/__tests__/host-process.test.ts b/src/utils/__tests__/host-process.test.ts new file mode 100644 index 000000000..9f6dac3fd --- /dev/null +++ b/src/utils/__tests__/host-process.test.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import { + expandProcessTree, + isProcessAlive, + isProcessGroupAlive, + listHostProcesses, + parseHostProcessList, + readProcessCommand, + readProcessStartTime, + signalPidsBestEffort, + stopPidsWithEscalation, + uniquePositivePids, +} from '../host-process.ts'; + +test('isProcessAlive returns false for invalid pid', () => { + assert.equal(isProcessAlive(-1), false); +}); + +test('isProcessGroupAlive returns false for invalid pid', () => { + assert.equal(isProcessGroupAlive(-1), false); +}); + +test('readProcessStartTime returns value for current process', () => { + const startTime = readProcessStartTime(process.pid); + if (startTime === null) { + assert.equal(readProcessCommand(process.pid), null); + return; + } + assert.ok(startTime.length > 0); +}); + +test('host process parser preserves command text after pid and parent pid', () => { + assert.deepEqual( + parseHostProcessList( + [ + ' 101 1 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ' 202 101 /tmp/chrome --type=renderer --flag value', + ' 303 0 /tmp/orphaned', + 'not a process', + ].join('\n'), + ), + [ + { + pid: 101, + ppid: 1, + command: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + }, + { pid: 202, ppid: 101, command: '/tmp/chrome --type=renderer --flag value' }, + { pid: 303, ppid: undefined, command: '/tmp/orphaned' }, + ], + ); +}); + +test('host process listing uses injected command runner and returns parsed ps rows', async () => { + const calls: Array<{ cmd: string; args: string[]; timeoutMs: number | undefined }> = []; + const processes = await listHostProcesses({ + timeoutMs: 1234, + runCommand: async (cmd, args, options) => { + calls.push({ cmd, args, timeoutMs: options.timeoutMs }); + return { stdout: ' 111 1 /usr/bin/node server.js\n', stderr: '', exitCode: 0 }; + }, + }); + + assert.deepEqual(calls, [ + { cmd: 'ps', args: ['-ax', '-o', 'pid=,ppid=,command='], timeoutMs: 1234 }, + ]); + assert.deepEqual(processes, [{ pid: 111, ppid: 1, command: '/usr/bin/node server.js' }]); +}); + +test('host process listing returns no processes when ps fails', async () => { + const processes = await listHostProcesses({ + timeoutMs: 1234, + runCommand: async () => ({ stdout: ' 111 1 /usr/bin/node\n', stderr: '', exitCode: 1 }), + }); + + assert.deepEqual(processes, []); +}); + +test('process tree expansion includes descendants of matched roots', () => { + const expanded = expandProcessTree( + [101], + [ + { pid: 101, ppid: 1, command: 'chrome' }, + { pid: 201, ppid: 101, command: 'Chrome Helper --type=renderer' }, + { pid: 301, ppid: 201, command: 'Chrome Helper --type=gpu' }, + { pid: 999, ppid: 1, command: 'Google Chrome' }, + ], + ); + + assert.deepEqual( + expanded.map((processInfo) => processInfo.pid), + [101, 201, 301], + ); +}); + +test('positive pid filtering de-duplicates invalid values and optional excluded pid', () => { + assert.deepEqual(uniquePositivePids([3, -1, 0, 3, 4, 5], { excludePid: 4 }), [3, 5]); +}); + +test('best-effort signaling ignores invalid, current, and failed pids', () => { + const calls: Array<{ pid: number; signal: string | number | undefined }> = []; + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + const numericPid = Number(pid); + calls.push({ pid: numericPid, signal }); + if (numericPid === 202) { + const error = new Error('not found') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + }); + + try { + assert.equal(signalPidsBestEffort([101, -1, 101, process.pid, 202], 'SIGTERM'), 1); + assert.deepEqual(calls, [ + { pid: 101, signal: 'SIGTERM' }, + { pid: 202, signal: 'SIGTERM' }, + ]); + } finally { + killSpy.mockRestore(); + } +}); + +test('pid escalation sends TERM, then KILL only to live pids', async () => { + vi.useFakeTimers(); + const alivePids = new Set([101, 202, 303]); + const calls: Array<{ pid: number; signal: string | number | undefined }> = []; + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + const numericPid = Number(pid); + calls.push({ pid: numericPid, signal }); + if (signal === 'SIGTERM' && numericPid === 202) alivePids.delete(numericPid); + if (signal === 'SIGKILL') alivePids.delete(numericPid); + if (signal === 0 && !alivePids.has(numericPid)) { + const error = new Error('not found') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + }); + + try { + const stopped = stopPidsWithEscalation({ + pids: [101, 202, 101, -1, process.pid, 303], + termTimeoutMs: 1_500, + killTimeoutMs: 1_000, + }); + await vi.advanceTimersByTimeAsync(1_500); + await stopped; + + assert.deepEqual( + calls.filter((call) => call.signal === 'SIGTERM').map((call) => call.pid), + [101, 202, 303], + ); + assert.deepEqual( + calls.filter((call) => call.signal === 'SIGKILL').map((call) => call.pid), + [101, 303], + ); + } finally { + killSpy.mockRestore(); + vi.useRealTimers(); + } +}); + +test('pid escalation returns immediately for an empty pid set', async () => { + vi.useFakeTimers(); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + + try { + await stopPidsWithEscalation({ + pids: [-1, 0, process.pid], + termTimeoutMs: 1_500, + killTimeoutMs: 1_000, + }); + + assert.equal(killSpy.mock.calls.length, 0); + } finally { + killSpy.mockRestore(); + vi.useRealTimers(); + } +}); diff --git a/src/utils/__tests__/process-identity.test.ts b/src/utils/__tests__/process-identity.test.ts deleted file mode 100644 index 9b434991d..000000000 --- a/src/utils/__tests__/process-identity.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import { - isAgentDeviceDaemonCommand, - isProcessAlive, - isProcessGroupAlive, - readProcessStartTime, - readProcessCommand, -} from '../process-identity.ts'; - -test('isProcessAlive returns false for invalid pid', () => { - assert.equal(isProcessAlive(-1), false); -}); - -test('isProcessGroupAlive returns false for invalid pid', () => { - assert.equal(isProcessGroupAlive(-1), false); -}); - -test('readProcessStartTime returns value for current process', () => { - const startTime = readProcessStartTime(process.pid); - if (startTime === null) { - assert.equal(readProcessCommand(process.pid), null); - return; - } - assert.ok(startTime.length > 0); -}); - -test('isAgentDeviceDaemonCommand matches expected daemon command', () => { - assert.equal(isAgentDeviceDaemonCommand('node /tmp/agent-device/dist/src/daemon.js'), true); - assert.equal( - isAgentDeviceDaemonCommand('node /tmp/agent-device/dist/src/internal/daemon.js'), - true, - ); - assert.equal( - isAgentDeviceDaemonCommand( - 'node --experimental-strip-types /worktrees/agent-device/src/daemon.ts', - ), - true, - ); - assert.equal(isAgentDeviceDaemonCommand('node -e "setInterval(() => {}, 1000)"'), false); -}); diff --git a/src/utils/__tests__/process-lock.test.ts b/src/utils/__tests__/process-lock.test.ts index 27a826bb4..be79e0d32 100644 --- a/src/utils/__tests__/process-lock.test.ts +++ b/src/utils/__tests__/process-lock.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { afterEach, beforeEach, test } from 'vitest'; import { AppError } from '../../kernel/errors.ts'; import { acquireProcessLock, type ProcessLockOwner } from '../process-lock.ts'; -import { readProcessStartTime } from '../process-identity.ts'; +import { readProcessStartTime } from '../host-process.ts'; let tmpDir: string; diff --git a/src/utils/host-process.ts b/src/utils/host-process.ts new file mode 100644 index 000000000..97806bb05 --- /dev/null +++ b/src/utils/host-process.ts @@ -0,0 +1,162 @@ +import type { ExecOptions, ExecResult } from './exec.ts'; +import { runCmd, runCmdSync } from './exec.ts'; +import { sleep } from './timeouts.ts'; + +const PS_TIMEOUT_MS = 1_000; + +export type HostProcessInfo = { + pid: number; + ppid?: number; + command: string; +}; + +type HostProcessRunCommand = ( + cmd: string, + args: string[], + options: ExecOptions, +) => Promise; + +export type ListHostProcessesOptions = { + timeoutMs: number; + runCommand?: HostProcessRunCommand; +}; + +export type StopPidsWithEscalationOptions = { + pids: readonly number[]; + termTimeoutMs: number; + killTimeoutMs: number; +}; + +export function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +export function isProcessGroupAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(-pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +export function readProcessStartTime(pid: number): string | null { + return readProcessField(pid, 'lstart='); +} + +export function readProcessCommand(pid: number): string | null { + return readProcessField(pid, 'command='); +} + +function readProcessField(pid: number, field: 'lstart=' | 'command='): string | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + const result = runCmdSync('ps', ['-p', String(pid), '-o', field], { + allowFailure: true, + timeoutMs: PS_TIMEOUT_MS, + }); + if (result.exitCode !== 0) return null; + const value = result.stdout.trim(); + return value.length > 0 ? value : null; + } catch { + return 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); + 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 }); + } + return processes; +} + +export async function listHostProcesses( + options: ListHostProcessesOptions, +): Promise { + const result = await (options.runCommand ?? runCmd)('ps', ['-ax', '-o', 'pid=,ppid=,command='], { + allowFailure: true, + timeoutMs: options.timeoutMs, + }); + if (result.exitCode !== 0) return []; + return parseHostProcessList(result.stdout); +} + +export function expandProcessTree( + rootPids: readonly number[], + processes: readonly HostProcessInfo[], +): HostProcessInfo[] { + const selected = new Set(uniquePositivePids(rootPids)); + let changed = true; + while (changed) { + changed = false; + for (const processInfo of processes) { + if (processInfo.ppid === undefined || !selected.has(processInfo.ppid)) continue; + if (selected.has(processInfo.pid)) continue; + selected.add(processInfo.pid); + changed = true; + } + } + return processes.filter((processInfo) => selected.has(processInfo.pid)); +} + +export function uniquePositivePids( + values: Iterable, + options: { excludePid?: number } = {}, +): number[] { + return [...new Set(values)].filter( + (pid) => Number.isInteger(pid) && pid > 0 && pid !== options.excludePid, + ); +} + +export function signalPidsBestEffort( + pidsToSignal: readonly number[], + signal: NodeJS.Signals, +): number { + const pids = uniquePositivePids(pidsToSignal, { excludePid: process.pid }); + let signaled = 0; + for (const pid of pids) { + try { + process.kill(pid, signal); + signaled += 1; + } catch { + // Process already exited or cannot be signaled; cleanup remains best-effort. + } + } + return signaled; +} + +export async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + if (!isProcessAlive(pid)) return true; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + await sleep(50); + if (!isProcessAlive(pid)) return true; + } + return !isProcessAlive(pid); +} + +export async function stopPidsWithEscalation( + options: StopPidsWithEscalationOptions, +): Promise { + const pids = uniquePositivePids(options.pids, { excludePid: process.pid }); + if (pids.length === 0) return; + signalPidsBestEffort(pids, 'SIGTERM'); + await Promise.all(pids.map(async (pid) => await waitForProcessExit(pid, options.termTimeoutMs))); + const livePids = pids.filter((pid) => isProcessAlive(pid)); + signalPidsBestEffort(livePids, 'SIGKILL'); + await Promise.all(pids.map(async (pid) => await waitForProcessExit(pid, options.killTimeoutMs))); +} diff --git a/src/utils/process-identity.ts b/src/utils/process-identity.ts deleted file mode 100644 index 91cf9ba50..000000000 --- a/src/utils/process-identity.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { runCmdSync } from './exec.ts'; -import { sleep } from './timeouts.ts'; - -const PS_TIMEOUT_MS = 1_000; -const DAEMON_COMMAND_PATTERNS = [ - /(^|[/\s"'=])dist\/src\/daemon\.js($|[\s"'])/, - /(^|[/\s"'=])dist\/src\/internal\/daemon\.js($|[\s"'])/, - /(^|[/\s"'=])src\/daemon\.ts($|[\s"'])/, -]; - -export function isProcessAlive(pid: number): boolean { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (err) { - return (err as NodeJS.ErrnoException).code === 'EPERM'; - } -} - -export function isProcessGroupAlive(pid: number): boolean { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(-pid, 0); - return true; - } catch (err) { - return (err as NodeJS.ErrnoException).code === 'EPERM'; - } -} - -export function readProcessStartTime(pid: number): string | null { - if (!Number.isInteger(pid) || pid <= 0) return null; - try { - const result = runCmdSync('ps', ['-p', String(pid), '-o', 'lstart='], { - allowFailure: true, - timeoutMs: PS_TIMEOUT_MS, - }); - if (result.exitCode !== 0) return null; - const value = result.stdout.trim(); - return value.length > 0 ? value : null; - } catch { - return null; - } -} - -export function readProcessCommand(pid: number): string | null { - if (!Number.isInteger(pid) || pid <= 0) return null; - try { - const result = runCmdSync('ps', ['-p', String(pid), '-o', 'command='], { - allowFailure: true, - timeoutMs: PS_TIMEOUT_MS, - }); - if (result.exitCode !== 0) return null; - const value = result.stdout.trim(); - return value.length > 0 ? value : null; - } catch { - return null; - } -} - -export function isAgentDeviceDaemonCommand(command: string): boolean { - const normalized = command.toLowerCase().replaceAll('\\', '/'); - if (!normalized.includes('agent-device')) return false; - return DAEMON_COMMAND_PATTERNS.some((pattern) => pattern.test(normalized)); -} - -export function isAgentDeviceDaemonProcess(pid: number, expectedStartTime?: string): boolean { - if (!isProcessAlive(pid)) return false; - if (expectedStartTime) { - const actualStartTime = readProcessStartTime(pid); - if (!actualStartTime || actualStartTime !== expectedStartTime) return false; - } - const command = readProcessCommand(pid); - if (!command) return false; - return isAgentDeviceDaemonCommand(command); -} - -function trySignalProcess(pid: number, signal: NodeJS.Signals): boolean { - try { - process.kill(pid, signal); - return true; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ESRCH' || code === 'EPERM') return false; - throw err; - } -} - -export async function waitForProcessExit(pid: number, timeoutMs: number): Promise { - if (!isProcessAlive(pid)) return true; - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - await sleep(50); - if (!isProcessAlive(pid)) return true; - } - return !isProcessAlive(pid); -} - -export async function stopProcessForTakeover( - pid: number, - options: { - termTimeoutMs: number; - killTimeoutMs: number; - expectedStartTime?: string; - }, -): Promise { - if (!isAgentDeviceDaemonProcess(pid, options.expectedStartTime)) return; - if (!trySignalProcess(pid, 'SIGTERM')) return; - if (await waitForProcessExit(pid, options.termTimeoutMs)) return; - if (!trySignalProcess(pid, 'SIGKILL')) return; - await waitForProcessExit(pid, options.killTimeoutMs); -} diff --git a/src/utils/process-lock.ts b/src/utils/process-lock.ts index d3fc01e4d..1c6138741 100644 --- a/src/utils/process-lock.ts +++ b/src/utils/process-lock.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '../kernel/errors.ts'; -import { isProcessAlive, readProcessStartTime } from './process-identity.ts'; +import { isProcessAlive, readProcessStartTime } from './host-process.ts'; import { sleep } from './timeouts.ts'; const DEFAULT_LOCK_TIMEOUT_MS = 30_000; diff --git a/src/utils/swift-cache.ts b/src/utils/swift-cache.ts index b623e71c5..ef986d9ce 100644 --- a/src/utils/swift-cache.ts +++ b/src/utils/swift-cache.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { AppError } from '../kernel/errors.ts'; import { runCmd } from './exec.ts'; import { acquireProcessLock } from './process-lock.ts'; -import { readProcessStartTime } from './process-identity.ts'; +import { readProcessStartTime } from './host-process.ts'; const SWIFT_CACHE_VERSION = '2'; const LOCK_RETRY_DELAY_MS = 25; diff --git a/test/integration/smoke-daemon-clean.test.ts b/test/integration/smoke-daemon-clean.test.ts index cfafd235f..6da6a55a7 100644 --- a/test/integration/smoke-daemon-clean.test.ts +++ b/test/integration/smoke-daemon-clean.test.ts @@ -5,7 +5,8 @@ import os from 'node:os'; import path from 'node:path'; import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loopback.ts'; import { runCmdSync } from '../../src/utils/exec.ts'; -import { isProcessAlive, stopProcessForTakeover } from '../../src/utils/process-identity.ts'; +import { stopProcessForTakeover } from '../../src/daemon/daemon-process.ts'; +import { isProcessAlive } from '../../src/utils/host-process.ts'; import { runCliJson } from './test-helpers.ts'; type DaemonInfo = { diff --git a/test/integration/smoke-daemon-http.test.ts b/test/integration/smoke-daemon-http.test.ts index 3bce00bbe..0156705bc 100644 --- a/test/integration/smoke-daemon-http.test.ts +++ b/test/integration/smoke-daemon-http.test.ts @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { DAEMON_RPC_PROTOCOL_VERSION } from '../../src/daemon/http-health.ts'; import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loopback.ts'; -import { stopProcessForTakeover } from '../../src/utils/process-identity.ts'; +import { stopProcessForTakeover } from '../../src/daemon/daemon-process.ts'; import { runCliJson } from './test-helpers.ts'; type DaemonInfo = {