From f9f11d118015d4d213ed78df88c44ada995795b6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 00:47:51 +0000 Subject: [PATCH 1/3] test: count a group of zombies as ended, and kill create once it wrote the manifest - groupEnds waited for init to reap the program's killed orphans: kill(-pgid, 0) finds a zombie. On a loaded host init reaped them after the 2 s deadline, so "destroy ends a child of the program whose main thread exited" and the tests of a background process failed although destroy had ended every process. On Linux groupEnds now reads the threads in /proc and counts a group whose threads are all zombies as ended. - The early-destroy test killed create once the program ran, so it still needed the host to start the program before create gave up. It now kills create once the manifest exists: the host reads an existing manifest also after its creator died. Its envelope wait is 30 s. --- test/helpers.ts | 60 ++++++++++++++++++++++++++++-- test/integration/lifecycle.test.ts | 23 +++++++----- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/test/helpers.ts b/test/helpers.ts index 49c3a685..acc357c4 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,61 @@ export function killGroupIfAlive(group: number): void { } } -// init reaps the killed processes a moment after they die. +function readStateIfExists(path: string): string | null { + try { + return parseProcStat(readFileSync(path, 'utf8')).state; + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ESRCH')) { + return null; + } + throw error; + } +} + +// 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. +function groupHasLiveThread(group: number): boolean { + if (!isGroupAlive(group)) { + return false; + } + if (process.platform !== 'linux') { + return true; + } + 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; + } + tasks = readdirSync(`/proc/${entry}/task`); + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ESRCH')) { + continue; + } + throw error; + } + if ( + tasks.some((task) => { + const state = readStateIfExists(`/proc/${entry}/task/${task}/stat`); + return state !== null && state !== 'Z' && state !== 'X'; + }) + ) { + return true; + } + } + return 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..7dc07c4f 100644 --- a/test/integration/lifecycle.test.ts +++ b/test/integration/lifecycle.test.ts @@ -1849,7 +1849,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 +1889,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 +1906,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 +1924,26 @@ 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); + [sessionId] = await readdir(sessions).catch((): string[] => []); } } 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, }); From c836c62308d4a1661fc19c6f6f63576052cad693 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 01:06:40 +0000 Subject: [PATCH 2/3] test: scan /proc twice in groupEnds, and let the probe decide where /proc hides processes Codex on #314: a process can fork and end between the /proc listing and its own read, and a /proc that is missing, refuses a read or hides other users cannot show the whole group. --- test/helpers.ts | 112 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 78 insertions(+), 34 deletions(-) diff --git a/test/helpers.ts b/test/helpers.ts index acc357c4..a3a095ac 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -360,56 +360,100 @@ export function killGroupIfAlive(group: number): void { } } -function readStateIfExists(path: string): string | null { +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, so the scan proves nothing. +function scanFindsLiveThread(group: number): boolean | null { try { - return parseProcStat(readFileSync(path, 'utf8')).state; + 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; + } + 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 (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ESRCH')) { + if ( + ['EACCES', 'EPERM', 'ENOENT'].some((code) => hasErrorCode(error, code)) + ) { return null; } throw error; } + return false; } // 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. +// 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') { + if (process.platform !== 'linux' || !procShowsEveryUser()) { return true; } - 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; - } - tasks = readdirSync(`/proc/${entry}/task`); - } catch (error) { - if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ESRCH')) { - continue; - } - throw error; - } - if ( - tasks.some((task) => { - const state = readStateIfExists(`/proc/${entry}/task/${task}/stat`); - return state !== null && state !== 'Z' && state !== 'X'; - }) - ) { - return true; - } - } - return false; + return ( + scanFindsLiveThread(group) !== false || scanFindsLiveThread(group) !== false + ); } export async function groupEnds(group: number): Promise { From 524b2ba87073542b8db2c27483b3ef7c24e26065 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 01:30:39 +0000 Subject: [PATCH 3/3] test: read a scan that shows no group member as inconclusive, and let only ENOENT pass the sessions read #314 advisor: a /proc of another pid namespace shows no member of the group, so the probe decides there. Only a missing sessions folder means that create has not written yet. --- test/helpers.ts | 7 +++++-- test/integration/lifecycle.test.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/test/helpers.ts b/test/helpers.ts index a3a095ac..90d6898b 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -389,8 +389,10 @@ function procShowsEveryUser(): boolean { } // Whether a scan of /proc shows a thread of the group that is not a zombie, -// or null when a read is refused, so the scan proves nothing. +// 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)) { @@ -404,6 +406,7 @@ function scanFindsLiveThread(group: number): boolean | null { ) { continue; } + seen = true; tasks = readdirSync(`/proc/${entry}/task`); } catch (error) { if (isGone(error)) { @@ -436,7 +439,7 @@ function scanFindsLiveThread(group: number): boolean | null { } throw error; } - return false; + return seen ? false : null; } // A killed process stays a zombie until its parent reaps it. An orphan's diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts index 7dc07c4f..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, @@ -1933,7 +1934,13 @@ describe('lifecycle integration', { timeout: 60_000 }, () => { ) { expect(Date.now()).toBeLessThan(deadline); await sleep(20); - [sessionId] = await readdir(sessions).catch((): string[] => []); + // 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');