diff --git a/src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts b/src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts index 6df2a5ca33c..7cc9d5833b3 100644 --- a/src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts +++ b/src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts @@ -1,19 +1,30 @@ import type * as ChildProcessModule from 'node:child_process' +import type * as FsPromisesModule from 'node:fs/promises' import { describe, expect, it, vi } from 'vitest' import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence-types' -const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ +const LINUX_BOOT_TIME_SECONDS = 1_699_000_000 +const LINUX_START_TICKS = 1_234 +const LINUX_CLOCK_TICKS_PER_SECOND = 100 + +const { execFileMock, execFileSyncMock, readFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn( ( - _file: string, + file: string, _args: readonly string[], _options: unknown, callback: (error: Error | null, result: { stdout: string; stderr: string }) => void ) => { - callback(null, { stdout: `${new Date(1_700_000_000_000).toString()}\n`, stderr: '' }) + callback(null, { + stdout: file === 'getconf' ? '100\n' : `${new Date(1_700_000_000_000).toString()}\n`, + stderr: '' + }) } ), - execFileSyncMock: vi.fn(() => '') + execFileSyncMock: vi.fn(() => ''), + readFileMock: vi.fn(async (path: string) => + path === '/proc/stat' ? 'btime 1699000000\n' : `42 (orca-daemon) S${' 0'.repeat(18)} 1234 0 0\n` + ) })) vi.mock('node:child_process', async (importOriginal) => ({ @@ -22,12 +33,18 @@ vi.mock('node:child_process', async (importOriginal) => ({ execFileSync: execFileSyncMock })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: readFileMock +})) + const { probeDaemonProcessIdentity } = await import('./daemon-incarnation-evidence') const endpoint = { socketPath: '/runtime/daemon.sock', tokenPath: '/runtime/daemon.token' } const exactIncarnation: ExactDaemonIncarnation = { identity: { pid: 42, startedAtMs: 1_700_000_000_000, launchNonce: 'launch-a' } } +const daemonCommandLine = `node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}` describe('daemon audit evidence main-thread cost', () => { it('reads the macOS process start time without a synchronous main-thread spawn', async () => { @@ -35,8 +52,7 @@ describe('daemon audit evidence main-thread cost', () => { probeDaemonProcessIdentity(exactIncarnation, endpoint, { platform: 'darwin', signalProcess: () => 'occupied', - readCommandLine: async () => - `node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}` + readCommandLine: async () => daemonCommandLine }) ).resolves.toMatchObject({ state: 'present', reason: 'macos_identity_match' }) @@ -48,4 +64,31 @@ describe('daemon audit evidence main-thread cost', () => { expect.any(Function) ) }) + + // Legacy bare-integer pid files carry no start ticks, so linux falls back to the start time. + it('reads the linux process start time without a synchronous main-thread spawn', async () => { + const startedAtMs = + LINUX_BOOT_TIME_SECONDS * 1000 + (LINUX_START_TICKS / LINUX_CLOCK_TICKS_PER_SECOND) * 1000 + + await expect( + probeDaemonProcessIdentity( + { identity: { pid: 42, startedAtMs, launchNonce: 'launch-a' } }, + endpoint, + { + platform: 'linux', + signalProcess: () => 'occupied', + readLinuxStat: async () => ({ status: 'present', value: '42 (orca-daemon) S 1' }), + readCommandLine: async () => daemonCommandLine + } + ) + ).resolves.toMatchObject({ state: 'present', reason: 'linux_identity_match' }) + + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(execFileMock).toHaveBeenCalledWith( + 'getconf', + ['CLK_TCK'], + expect.anything(), + expect.any(Function) + ) + }) }) diff --git a/src/main/daemon/daemon-incarnation-evidence.ts b/src/main/daemon/daemon-incarnation-evidence.ts index 2a33ffc175f..fc4eba9c2b0 100644 --- a/src/main/daemon/daemon-incarnation-evidence.ts +++ b/src/main/daemon/daemon-incarnation-evidence.ts @@ -1,9 +1,5 @@ import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity' -import { - commandLineMatchesDaemon, - getProcessStartedAtMs, - startTimesWithinTolerance -} from './daemon-health' +import { commandLineMatchesDaemon, startTimesWithinTolerance } from './daemon-health' import { WINDOWS_CREATION_TIME_TOLERANCE_MS, type DaemonEvidenceSources, @@ -15,6 +11,7 @@ import { import { inspectProcessSignal, queryWindowsProcess, + readLinuxProcessStartedAtMs, readLinuxStat, readMacosProcessStartedAtMs, readProcessCommandLine @@ -35,11 +32,6 @@ export { const POSIX_START_TIME_TOLERANCE_MS = 1_500 -// Why: linux start time comes from procfs via the shared helper; only darwin's `ps` spawn -// had to move off the main thread. -const readLinuxProcessStartedAtMs = async (pid: number): Promise => - getProcessStartedAtMs(pid) - export async function probeDaemonProcessIdentity( exactIncarnation: ExactDaemonIncarnation | null, endpoint: { socketPath: string; tokenPath: string }, diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts index 282ba5ebbfb..d7f8b4b9eaf 100644 --- a/src/main/daemon/daemon-process-inspection.ts +++ b/src/main/daemon/daemon-process-inspection.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' import { promisify } from 'node:util' +import { parseLinuxBootTimeSeconds, parseLinuxProcStartTicks } from './daemon-health' import type { LinuxStatEvidence, ProcessSignalEvidence, @@ -114,6 +115,54 @@ export async function queryWindowsProcess( } } +// Why: the sync procfs helper in daemon-health spawns getconf per call; CLK_TCK is fixed for +// the kernel's lifetime, so cache one async spawn and only retry after a failure. +let clockTicksPerSecond: Promise | null = null + +async function readClockTicksPerSecond( + runCommand: (file: string, args: string[], timeoutMs: number) => Promise +): Promise { + clockTicksPerSecond ??= runCommand('getconf', ['CLK_TCK'], 1_000).then( + (stdout) => { + const ticks = Number(stdout.trim()) + return Number.isFinite(ticks) && ticks > 0 ? ticks : null + }, + () => null + ) + const ticksPerSecond = await clockTicksPerSecond + if (ticksPerSecond === null) { + clockTicksPerSecond = null + } + return ticksPerSecond +} + +// Why: same main-thread hazard as darwin — the sync linux helper does two readFileSync calls +// plus a getconf spawn, and this audit-only probe runs in the Electron main process. +export async function readLinuxProcessStartedAtMs( + pid: number, + dependencies: DaemonProcessInspectionDependencies = {} +): Promise { + const readTextFile = + dependencies.readTextFile ?? (async (path: string) => await readFile(path, 'utf8')) + try { + const startTicks = parseLinuxProcStartTicks(await readTextFile(`/proc/${pid}/stat`)) + const bootTimeSeconds = parseLinuxBootTimeSeconds(await readTextFile('/proc/stat')) + const ticksPerSecond = await readClockTicksPerSecond( + dependencies.runCommand ?? runInspectionCommand + ) + if ( + ticksPerSecond === null || + !Number.isFinite(startTicks) || + !Number.isFinite(bootTimeSeconds) + ) { + return null + } + return bootTimeSeconds * 1000 + (startTicks / ticksPerSecond) * 1000 + } catch { + return null + } +} + // Why: `ps` is darwin's only start-time source and this audit-only probe runs in the // Electron main process, where the sync spawn blocks every IPC/UI turn for its duration. export async function readMacosProcessStartedAtMs(