Skip to content

Commit 81ef72b

Browse files
committed
test(bin): count killed renderers apart; check the exit path statically
The og-image stress test failed macOS Node 24 once on #2: two of 24 renderers were killed by a signal under --stress-concurrent-allocation. None hung. A killed child never reached its exit, so it is now reported as unmeasured, and the test skips as unmeasured if more than a third are killed. All 24 still run at once: with process.exit() reinstated, 24 at once hung 15 of 96 children and one at a time hung none. A new static test reads the renderer and fails if it calls process.exit() or calls run() outside a try/catch. It failed on both hazards reinstated.
1 parent c651bd3 commit 81ef72b

2 files changed

Lines changed: 39 additions & 5 deletions

File tree

‎bin/test/docsite-og-image-entry.test.mjs‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
1010
import { join, dirname, resolve } from 'node:path';
1111
import { fileURLToPath } from 'node:url';
1212
import { inflateSync } from 'node:zlib';
13+
import { stripComments } from '../lib/tracked-imports.mjs';
1314

1415
const CW = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
1516
const OG = join(CW, 'bin', 'docsite-og-image.mjs');
@@ -103,17 +104,41 @@ test('--check fails on a drifted file and reports both sizes', (t) => {
103104
// --check runs hung under V8's concurrent-allocation stress, so 24 clean exits make a regression show.
104105
// The control is a script that only allocates and never exits explicitly. Node 22 hangs it 9 of 24
105106
// times under this stress, so on such a runtime a hung renderer says nothing about the renderer.
106-
const stressExits = (args, env = process.env) => Promise.all(Array.from({ length: 24 }, () => new Promise((done) => {
107+
// The deadlock lives inside process.exit() and an uncaught throw, so the renderer must reach neither.
108+
// Reading the source holds on every run, including the rounds the stress run below misses.
109+
const EXIT_CALL = 'calls process.exit()';
110+
const UNCAUGHT_RUN = 'run() is not called inside a try/catch that sets process.exitCode';
111+
const exitHazards = (src) => {
112+
const code = stripComments(src);
113+
return [
114+
...(/\bprocess\.exit\s*\(/.test(code) ? [EXIT_CALL] : []),
115+
...(/\btry\s*\{[^}]*\bprocess\.exitCode\s*=\s*run\(\)[^}]*\}\s*catch\b/.test(code) ? [] : [UNCAUGHT_RUN]),
116+
];
117+
};
118+
119+
test('the renderer never calls process.exit() and catches every throw from run()', () => {
120+
assert.deepEqual(exitHazards(readFileSync(OG, 'utf8')), []);
121+
assert.deepEqual(exitHazards('process.exit(run());'), [EXIT_CALL, UNCAUGHT_RUN], 'the check must fire on a renderer that has both');
122+
assert.deepEqual(exitHazards('// never process.exit()\ntry { process.exitCode = run(); } catch { process.exitCode = 1; }'), [],
123+
'a comment naming process.exit() is not a call');
124+
});
125+
126+
// All 24 run at once because contention is what exposes the deadlock: with it reinstated, 24 at once
127+
// hung 15 of 96 children and one at a time hung none. A child killed by a signal never reached its
128+
// exit, so it is unmeasured: neither a pass nor the deadlock.
129+
const STRESS_RUNS = 24;
130+
const STRESS_MIN_MEASURED = 16;
131+
const stressExits = (args, env = process.env) => Promise.all(Array.from({ length: STRESS_RUNS }, () => new Promise((done) => {
107132
const c = spawn(process.execPath, ['--stress-concurrent-allocation', ...args], { env, stdio: 'ignore' });
108133
const timer = setTimeout(() => { c.kill('SIGKILL'); done('hung'); }, 30_000);
109-
c.on('exit', (code) => { clearTimeout(timer); done(code); });
134+
c.on('exit', (code, signal) => { clearTimeout(timer); done(code ?? signal); });
110135
})));
111136

112137
test('the renderer exits under concurrent-allocation stress', async (t) => {
113138
const control = await stressExits(['-e', 'const a = []; for (let i = 0; i < 2e5; i++) a.push({ i });']);
114139
const controlHung = control.filter((c) => c === 'hung').length;
115140
if (controlHung) {
116-
t.skip(`Node ${process.version} hung ${controlHung}/24 allocation-only controls under the same stress`);
141+
t.skip(`Node ${process.version} hung ${controlHung}/${STRESS_RUNS} allocation-only controls under the same stress`);
117142
return;
118143
}
119144
const { root, png } = site(t);
@@ -122,5 +147,14 @@ test('the renderer exits under concurrent-allocation stress', async (t) => {
122147
const env = { ...process.env, CW_DOCSITE_ROOT: root };
123148
delete env.CW_DOCSITE_PRIVATE;
124149
const exits = await stressExits([OG, '--check'], env);
125-
assert.deepEqual(exits, Array(24).fill(1), '1 is --check reporting the drift; "hung" is the deadlock');
150+
assert.ok(!exits.includes('hung'), `"hung" is the deadlock: ${exits.join(', ')}`);
151+
const killed = exits.filter((e) => typeof e === 'string');
152+
const killedBy = [...new Set(killed)].join(', ');
153+
if (STRESS_RUNS - killed.length < STRESS_MIN_MEASURED) {
154+
t.skip(`${killed.length}/${STRESS_RUNS} renderers were killed by ${killedBy} before exiting -- the exit path was NOT measured`);
155+
return;
156+
}
157+
if (killed.length) t.diagnostic(`${killed.length}/${STRESS_RUNS} renderers were killed by ${killedBy}, unmeasured`);
158+
assert.deepEqual(exits.filter((e) => typeof e === 'number'), Array(STRESS_RUNS - killed.length).fill(1),
159+
'1 is --check reporting the drift');
126160
});

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "commitwork",
3-
"version": "0.5.4",
3+
"version": "0.5.5",
44
"description": "Repository security scanner, local CI runner and scheduled monitor, with remediation and compliance-evidence tooling. Manifest-driven; runs against any git repository.",
55
"type": "module",
66
"bin": {

0 commit comments

Comments
 (0)