mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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:
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user