diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index 203bb378e..ea5cad4c0 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() { } } +// Destroy fetch's global undici dispatcher before process.exit(): a live +// keep-alive socket trips a libuv assertion on Windows/Node 24 after a +// successful boot (nodejs/node#56645, issue #573). +async function destroyFetchDispatcher() { + const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')]; + if (dispatcher && typeof dispatcher.destroy === 'function') { + try { await dispatcher.destroy(); } catch { /* exit regardless */ } + } +} + +// Drain the boot payload before process.exit(): a live pipe that has not +// flushed yet is truncated when Node tears down (issue #573 review). Then +// close fetch so Windows teardown does not abort on the keep-alive socket. +async function finishCli(output) { + await new Promise((resolve) => { + process.stdout.write(output, () => resolve()); + }); + await destroyFetchDispatcher(); + process.exit(0); +} + // Two instructions used to sit in one directive: ask, and "if they agree, run // it". Nothing gated the second on an answer, and the same sentence said to // continue without waiting, so a run that could never establish agreement was @@ -1159,8 +1180,7 @@ async function cli() { appendImageToolsDirective(parts); appendStalenessDirective(parts, ctx, cliOptions); if (updateDirective) parts.push(updateDirective); - process.stdout.write(parts.join('\n\n---\n\n') + '\n'); - process.exit(0); + await finishCli(parts.join('\n\n---\n\n') + '\n'); } const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`]; if (ctx.hasDesign) { @@ -1206,7 +1226,7 @@ async function cli() { } } if (updateDirective) parts.push(updateDirective); - process.stdout.write(parts.join('\n\n---\n\n') + '\n'); + await finishCli(parts.join('\n\n---\n\n') + '\n'); } function parseCliOptions(args) { diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 4c04276b8..76db0648d 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -1060,6 +1060,27 @@ describe('context.mjs CLI', () => { assert.match(res.stdout, /detect\.mjs --json /); }); + it('drains stdout before exit when the parent pipe is paused', async () => { + const MARKER = 'END_MARKER_573'; + write('PRODUCT.md', `# Acme\n\n${'x'.repeat(256 * 1024)}\n\n${MARKER}\n`); + const child = spawn(process.execPath, [SCRIPT_PATH], { + cwd: scratch, + env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, + }); + let stdout = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stdout.pause(); + const resume = setTimeout(() => child.stdout.resume(), 100); + const status = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + clearTimeout(resume); + assert.equal(status, 0); + assert.match(stdout, /END_MARKER_573/); + assert.match(stdout, /RESOLVED_CONTEXT:/); + }); + // The build-path preference rides the unified config beside hook and // detector settings. The local file wins because whether a machine can // generate images is a property of that machine, not of the committed @@ -1588,4 +1609,39 @@ describe('context.mjs update check', () => { assert.equal(typeof cache.lastCheck, 'number'); // stamped so we don't re-poll every boot assert.equal(cache.latestVersion, undefined); // nothing learned }); + + // Targeted live-fetch boot: the Windows abort in issue #573 fired after + // stdout was already complete, so the contract is exit 0 with the full + // context still on stdout. + it('exits 0 after a targeted live-fetch boot writes full context', async () => { + const { srv, host } = await startStub({ skills: '2.0.0' }); + try { + const { skillScript, project, env } = setup({}, { host }); + fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ private: true, workspaces: ['packages/*'] }), + ); + const jervPi = path.join(project, 'packages', 'jerv-pi'); + fs.mkdirSync(jervPi, { recursive: true }); + fs.writeFileSync(path.join(jervPi, 'PRODUCT.md'), '# Jerv Pi product\n'); + + const result = await new Promise((resolveRun, rejectRun) => { + const child = spawn(process.execPath, [skillScript, '--target', 'packages/jerv-pi'], { + cwd: project, + env, + }); + let stdout = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.on('error', rejectRun); + child.on('close', (status) => resolveRun({ status, stdout })); + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /RESOLVED_CONTEXT:/); + assert.match(result.stdout, /# Jerv Pi product/); + assert.match(result.stdout, /UPDATE_AVAILABLE/); + } finally { + srv.close(); + } + }); });