Skip to content

Commit fd5abed

Browse files
VickyXAI1bcMax
andauthored
test: stop CLI-spawn timeouts from flaking the release gate (#114)
Four tests failed intermittently under parallel load while passing in ~0.6s idle. All the same cause: a spawn bound sized for an unloaded machine. cli: franklin task tail reconciles stale queued tasks 5045ms (bound 5000) cli: franklin task cancel <runId> kills running task 5841ms (bound 5000) cli: franklin task wait reconciles stale queued tasks 5048ms (bound 5000) franklin skills --json emits a parseable structure 8037ms (bound 8000) The durations were the bound firing, not the work taking that long — the same tests run in ~640ms when the machine is idle. `node dist/index.js` boots the whole CLI (config load, MCP discovery, wallet init) before the subcommand runs, and that startup balloons under concurrency. Three things were wrong beyond the number: 1. The failures were undiagnosable. On timeout spawnSync returns status:null with empty stderr, so `assert.equal(result.status, 0, result.stderr)` printed "null !== 0" and named nothing. First time I hit this I could not tell what had failed. assertCliExit() now says it exceeded the bound and that a loaded machine is the likely cause. 2. skills.local.mjs had two bounds fighting: runCli defaulted to 8000ms while the tests around it declared { timeout: 15_000 }, so the inner one always won and the declared one never applied. Two call sites had already been patched to timeoutMs: 15_000 individually — the usual sign that the default, not the call site, is what's wrong. 3. One test told the CLI `--timeout 10000` under a 5000ms spawn bound, so the harness would have killed the process before the behaviour under test could finish. It only passed because the task goes 'lost' almost immediately. Both files now use CLI_SPAWN_TIMEOUT_MS = 20_000, matching what test/local.mjs already used for its other CLI-spawning tests ({ timeout: 20_000 }); 5s and 8s were the outliers. A flaky test in a release gate is worse than a slow one: it teaches you to wave the next real failure past as "just that one again". Verified with two suites running concurrently — the condition that produced the original failures — 636 pass on both. Co-authored-by: 1bcMax <195689928+1bcMax@users.noreply.github.com>
1 parent ee00e2a commit fd5abed

2 files changed

Lines changed: 57 additions & 13 deletions

File tree

test/local.mjs

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7625,6 +7625,31 @@ test('startDetachedTask: returns runId immediately, child completes async', asyn
76257625
}
76267626
});
76277627

7628+
// Spawning `node dist/index.js` boots the whole CLI — config load, MCP server
7629+
// discovery, wallet init — before the subcommand runs at all. These spawns were
7630+
// capped at 5000ms, and three of them flaked under parallel load on 2026-07-21
7631+
// (task tail / task cancel / task wait). The failure was also undiagnosable:
7632+
// on timeout spawnSync returns status:null with empty stderr, so
7633+
// `assert.equal(result.status, 0, result.stderr.toString())` reported
7634+
// "null !== 0" and said nothing about a timeout. A flaky test in a release gate
7635+
// is worse than a slow one — it trains you to wave the next real failure past.
7636+
//
7637+
// 20s matches what the rest of this file already uses for CLI-spawning tests
7638+
// ({ timeout: 20_000 } at lines 117, 127, 597, ...); 5s was the outlier.
7639+
const CLI_SPAWN_TIMEOUT_MS = 20_000;
7640+
7641+
/** Assert a spawned CLI exited as expected, naming a timeout as a timeout. */
7642+
function assertCliExit(result, expectedStatus, what) {
7643+
if (result.error) {
7644+
const hint = result.error.code === 'ETIMEDOUT'
7645+
? ` — exceeded CLI_SPAWN_TIMEOUT_MS (${CLI_SPAWN_TIMEOUT_MS}ms); the machine was likely loaded, not the code broken`
7646+
: '';
7647+
assert.fail(`${what}: spawn failed with ${result.error.code || result.error.message}${hint}`);
7648+
}
7649+
assert.equal(result.status, expectedStatus,
7650+
`${what}: expected exit ${expectedStatus}, got ${result.status}\nstderr: ${result.stderr?.toString() ?? ''}`);
7651+
}
7652+
76287653
test('cli: franklin task list prints recent tasks', async () => {
76297654
const os = await import('node:os');
76307655
const path = await import('node:path');
@@ -7645,9 +7670,9 @@ test('cli: franklin task list prints recent tasks', async () => {
76457670

76467671
const cli = path.join(process.cwd(), 'dist', 'index.js');
76477672
const result = spawnSync(process.execPath, [cli, 'task', 'list'], {
7648-
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: 5000,
7673+
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: CLI_SPAWN_TIMEOUT_MS,
76497674
});
7650-
assert.equal(result.status, 0, result.stderr.toString());
7675+
assertCliExit(result, 0, 'task list');
76517676
const out = result.stdout.toString();
76527677
assert.match(out, /t2/);
76537678
assert.match(out, /t1/);
@@ -7683,9 +7708,9 @@ test('cli: franklin task tail <runId> prints log + status', async () => {
76837708

76847709
const cli = path.join(process.cwd(), 'dist', 'index.js');
76857710
const result = spawnSync(process.execPath, [cli, 'task', 'tail', runId], {
7686-
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: 5000,
7711+
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: CLI_SPAWN_TIMEOUT_MS,
76877712
});
7688-
assert.equal(result.status, 0, result.stderr.toString());
7713+
assertCliExit(result, 0, 'task tail');
76897714
const out = result.stdout.toString();
76907715
assert.match(out, /line1/);
76917716
assert.match(out, /line2/);
@@ -7722,9 +7747,9 @@ test('cli: franklin task tail reconciles stale queued tasks before printing stat
77227747

77237748
const cli = path.join(process.cwd(), 'dist', 'index.js');
77247749
const result = spawnSync(process.execPath, [cli, 'task', 'tail', runId], {
7725-
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: 5000,
7750+
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: CLI_SPAWN_TIMEOUT_MS,
77267751
});
7727-
assert.equal(result.status, 0, result.stderr.toString());
7752+
assertCliExit(result, 0, 'task tail (stale queued)');
77287753
assert.match(result.stdout.toString(), /lost/);
77297754
assert.equal(readTaskMeta(runId).status, 'lost');
77307755
} finally {
@@ -7756,9 +7781,9 @@ test('cli: franklin task cancel <runId> kills running task', async () => {
77567781

77577782
const cli = path.join(process.cwd(), 'dist', 'index.js');
77587783
const result = spawnSync(process.execPath, [cli, 'task', 'cancel', runId], {
7759-
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: 5000,
7784+
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: CLI_SPAWN_TIMEOUT_MS,
77607785
});
7761-
assert.equal(result.status, 0, result.stderr.toString());
7786+
assertCliExit(result, 0, 'task cancel');
77627787

77637788
// Give runner a moment to finalize
77647789
await new Promise(r => setTimeout(r, 1500));
@@ -7828,11 +7853,15 @@ test('cli: franklin task wait reconciles stale queued tasks before blocking', as
78287853
});
78297854

78307855
const cli = path.join(process.cwd(), 'dist', 'index.js');
7856+
// The CLI is told to wait up to 10s, so the spawn bound must exceed it —
7857+
// it was 5000ms, i.e. the harness would have killed the process before the
7858+
// behaviour under test could finish. That only passed because the task goes
7859+
// 'lost' almost immediately; a slower reconcile would have failed here for
7860+
// reasons having nothing to do with `task wait`.
78317861
const result = spawnSync(process.execPath, [cli, 'task', 'wait', runId, '--timeout', '10000'], {
7832-
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: 5000,
7862+
env: { ...process.env, FRANKLIN_HOME: fakeHome }, timeout: CLI_SPAWN_TIMEOUT_MS,
78337863
});
7834-
assert.equal(result.error, undefined, result.error?.message);
7835-
assert.equal(result.status, 1, result.stderr.toString());
7864+
assertCliExit(result, 1, 'task wait (stale queued)');
78367865
assert.match(result.stdout.toString(), /lost/);
78377866
assert.equal(readTaskMeta(runId).status, 'lost');
78387867
} finally {

test/skills.local.mjs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,19 @@ import { formatSkillHints } from '../dist/skills/triggers.js';
2121

2222
const DIST = fileURLToPath(new URL('../dist/index.js', import.meta.url));
2323

24-
function runCli(args, { timeoutMs = 8000, env, cwd } = {}) {
24+
// Booting `node dist/index.js` runs the whole CLI startup path (config, MCP
25+
// discovery, wallet init) before the subcommand executes. The default here was
26+
// 8000ms while the tests around it declare { timeout: 15_000 } — so the inner
27+
// bound fired first and the declared one never applied. Two call sites had
28+
// already been patched individually to timeoutMs: 15_000, which is the usual
29+
// sign that the default, not the call site, is what's wrong.
30+
//
31+
// Observed 2026-07-21: `franklin skills --json` failed at 8037ms under parallel
32+
// load while passing in ~1s idle. Raised to 20s to match what test/local.mjs
33+
// uses for CLI spawns, so a loaded machine doesn't read as a broken build.
34+
const CLI_SPAWN_TIMEOUT_MS = 20_000;
35+
36+
function runCli(args, { timeoutMs = CLI_SPAWN_TIMEOUT_MS, env, cwd } = {}) {
2537
return new Promise((resolve, reject) => {
2638
const proc = spawn('node', [DIST, ...args], {
2739
stdio: ['ignore', 'pipe', 'pipe'],
@@ -34,7 +46,10 @@ function runCli(args, { timeoutMs = 8000, env, cwd } = {}) {
3446
proc.stderr.on('data', (d) => { stderr += d.toString(); });
3547
const timer = setTimeout(() => {
3648
proc.kill('SIGTERM');
37-
reject(new Error(`Timeout after ${timeoutMs}ms`));
49+
reject(new Error(
50+
`franklin ${args.join(' ')} exceeded ${timeoutMs}ms. CLI startup is slow ` +
51+
`under load; this usually means a busy machine, not broken code.`
52+
));
3853
}, timeoutMs);
3954
proc.on('close', (code) => {
4055
clearTimeout(timer);

0 commit comments

Comments
 (0)