fix(daemon): take the linux audit start-time read off the main thread

The linux branch of the audit-only evidence probe still bottomed out in
getProcessStartedAtMs, i.e. two readFileSync calls plus an
execFileSync('getconf', ['CLK_TCK']) spawn on the Electron main thread —
the same hazard the darwin half of this change removed. The `async`
wrapper hid it from the type signature and from the main-thread
regression test, which only covered darwin.

That path is reached whenever the pid record carries no start ticks or
boot id: legacy bare-integer pid files (the upgrade population this audit
exists to observe) and any host where /proc boot-id or start-tick reads
fail.

readLinuxProcessStartedAtMs now reads procfs asynchronously and caches
CLK_TCK for the process lifetime (it is fixed for the kernel's lifetime),
retrying only after a failed probe. The main-thread test now covers linux
with a ticks-less pid record.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-30 22:15:34 -07:00
co-authored by Orca
parent e458cb86df
commit 8b3291a6cf
3 changed files with 100 additions and 16 deletions
@@ -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<typeof FsPromisesModule>()),
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)
)
})
})
+2 -10
View File
@@ -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<number | null> =>
getProcessStartedAtMs(pid)
export async function probeDaemonProcessIdentity(
exactIncarnation: ExactDaemonIncarnation | null,
endpoint: { socketPath: string; tokenPath: string },
@@ -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<number | null> | null = null
async function readClockTicksPerSecond(
runCommand: (file: string, args: string[], timeoutMs: number) => Promise<string>
): Promise<number | null> {
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<number | null> {
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(