diff --git a/test/helpers.ts b/test/helpers.ts index 49c3a685..90d6898b 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { mkdir, mkdtemp, @@ -13,6 +13,7 @@ import { join } from 'node:path'; import process from 'node:process'; import type { SessionRecord as ProtocolSessionRecord } from '../src/protocol/schemas.js'; +import { parseProcStat } from '../src/host/programGroup.js'; import type { SemanticSnapshot } from '../src/renderer/types.js'; import { hasErrorCode } from '../src/util/hasErrorCode.js'; @@ -359,10 +360,108 @@ export function killGroupIfAlive(group: number): void { } } -// init reaps the killed processes a moment after they die. +const GONE_CODES = ['ENOENT', 'ESRCH']; + +function isGone(error: unknown): boolean { + return GONE_CODES.some((code) => hasErrorCode(error, code)); +} + +// Whether /proc shows the processes of every user. hidepid leaves other +// users out, and an unreadable mountinfo counts as hiding them. +function procShowsEveryUser(): boolean { + let mountinfo: string; + try { + mountinfo = readFileSync('/proc/self/mountinfo', 'utf8'); + } catch { + return false; + } + let shows = false; + for (const line of mountinfo.split('\n')) { + const fields = line.split(' '); + const separator = fields.indexOf('-'); + if (fields[4] === '/proc' && fields[separator + 1] === 'proc') { + shows = !/(^|,)hidepid=(2|4|invisible|ptraceable)(,|$)/.test( + fields[separator + 3] ?? '', + ); + } + } + return shows; +} + +// Whether a scan of /proc shows a thread of the group that is not a zombie, +// or null when a read is refused or the scan shows no member at all, as a +// /proc of another pid namespace does, so the scan proves nothing. +function scanFindsLiveThread(group: number): boolean | null { + let seen = false; + try { + for (const entry of readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) { + continue; + } + let tasks: string[]; + try { + if ( + parseProcStat(readFileSync(`/proc/${entry}/stat`, 'utf8')).pgid !== + group + ) { + continue; + } + seen = true; + tasks = readdirSync(`/proc/${entry}/task`); + } catch (error) { + if (isGone(error)) { + continue; + } + throw error; + } + for (const task of tasks) { + let state: string; + try { + state = parseProcStat( + readFileSync(`/proc/${entry}/task/${task}/stat`, 'utf8'), + ).state; + } catch (error) { + if (isGone(error)) { + continue; + } + throw error; + } + if (state !== 'Z' && state !== 'X') { + return true; + } + } + } + } catch (error) { + if ( + ['EACCES', 'EPERM', 'ENOENT'].some((code) => hasErrorCode(error, code)) + ) { + return null; + } + throw error; + } + return seen ? false : null; +} + +// A killed process stays a zombie until its parent reaps it. An orphan's +// parent is init, which can take seconds to reap on a loaded machine, so on +// Linux a group whose threads are all zombies counts as ended. A process can +// fork and end between the /proc listing and its own read, so two scans must +// agree. Where /proc cannot show the whole group, the probe decides alone. +function groupHasLiveThread(group: number): boolean { + if (!isGroupAlive(group)) { + return false; + } + if (process.platform !== 'linux' || !procShowsEveryUser()) { + return true; + } + return ( + scanFindsLiveThread(group) !== false || scanFindsLiveThread(group) !== false + ); +} + export async function groupEnds(group: number): Promise { - const deadline = Date.now() + 2_000; - while (isGroupAlive(group)) { + const deadline = Date.now() + 5_000; + while (groupHasLiveThread(group)) { if (Date.now() > deadline) { return false; } diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts index d7ad5e77..33789fb7 100644 --- a/test/integration/lifecycle.test.ts +++ b/test/integration/lifecycle.test.ts @@ -23,6 +23,7 @@ import { parseProcStat } from '../../src/host/programGroup.js'; import { probeLibghosttyVt } from '../../src/renderer/readiness.js'; import { SessionRecordSchema } from '../../src/protocol/schemas.js'; import { writeManifest } from '../../src/storage/manifests.js'; +import { hasErrorCode } from '../../src/util/hasErrorCode.js'; import { manifestPath, programRecordPath, @@ -1849,7 +1850,7 @@ describe('lifecycle integration', { timeout: 60_000 }, () => { ]; async function readEnvelopeOnceWritten(path: string): Promise { - const deadline = Date.now() + 15_000; + const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await sleep(100); try { @@ -1889,11 +1890,12 @@ describe('lifecycle integration', { timeout: 60_000 }, () => { // the whole destroy. The hold matches the host's argv only, so the inner // CLI, which inherits it, runs at full speed. A held host never answers // create, which gives up after about 5 s and sends it SIGTERM. On a slow - // runner that came before the inner destroy, so create is killed once the - // program runs, and the destroy kills the held host. + // runner that came before the inner destroy, so create is killed once it + // has written the manifest, which the host still reads after its creator + // died, and the destroy kills the held host. it('destroy --force before the host names the program prints its result', async () => { const out = join(testHome, 'early-destroy.json'); - const started = `${out}.started`; + const sessions = join(testHome, 'sessions'); const create = spawn( process.execPath, [ @@ -1905,7 +1907,7 @@ describe('lifecycle integration', { timeout: 60_000 }, () => { '--', '/bin/sh', '-c', - ': > "$0.started"; "$@" destroy "$AGENT_TTY_SESSION_ID" --force --json > "$0"', + '"$@" destroy "$AGENT_TTY_SESSION_ID" --force --json > "$0"', out, ...cli, ], @@ -1923,22 +1925,32 @@ describe('lifecycle integration', { timeout: 60_000 }, () => { stdio: 'ignore', }, ); + let sessionId: string | undefined; try { const deadline = Date.now() + 20_000; - while (!existsSync(started)) { + while ( + sessionId === undefined || + !existsSync(manifestPath(sessionDir(testHome, sessionId))) + ) { expect(Date.now()).toBeLessThan(deadline); - await sleep(50); + await sleep(20); + // create makes the sessions folder only after it starts. + [sessionId] = await readdir(sessions).catch((error: unknown) => { + if (hasErrorCode(error, 'ENOENT')) { + return []; + } + throw error; + }); } } finally { create.kill('SIGKILL'); } - const [sessionId] = await readdir(join(testHome, 'sessions')); expect(await readEnvelopeOnceWritten(out)).toMatchObject({ ok: true, result: { sessionId, destroyed: true }, }); - expect(inspectSession(testHome, sessionId as string)).toMatchObject({ + expect(inspectSession(testHome, sessionId)).toMatchObject({ status: 'destroyed', childPid: null, });