Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 103 additions & 4 deletions test/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import {
mkdir,
mkdtemp,
Expand All @@ -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';

Expand Down Expand Up @@ -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<boolean> {
const deadline = Date.now() + 2_000;
while (isGroupAlive(group)) {
const deadline = Date.now() + 5_000;
while (groupHasLiveThread(group)) {
if (Date.now() > deadline) {
return false;
}
Expand Down
30 changes: 21 additions & 9 deletions test/integration/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1849,7 +1850,7 @@ describe('lifecycle integration', { timeout: 60_000 }, () => {
];

async function readEnvelopeOnceWritten(path: string): Promise<unknown> {
const deadline = Date.now() + 15_000;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
await sleep(100);
try {
Expand Down Expand Up @@ -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,
[
Expand All @@ -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,
],
Expand All @@ -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,
});
Expand Down
Loading