From 2ef8e43d1e3d12df1d02c6b208a967252695a012 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Tue, 25 Aug 2026 07:31:18 +0500 Subject: [PATCH 1/2] Fix: close fetch sockets before context helper exit (#573) On Windows/Node 24, a live undici keep-alive from the update-check fetch aborted libuv during teardown after valid stdout. Destroy the dispatcher first, matching concept-seed. AI assistance: implemented with Cursor Grok 4.6. Co-authored-by: Cursor --- skill/scripts/context.mjs | 13 +++++++++++++ tests/context.test.mjs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index 203bb378e..e94cd562d 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -1013,6 +1013,16 @@ 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 */ } + } +} + // 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 @@ -1160,6 +1170,7 @@ async function cli() { appendStalenessDirective(parts, ctx, cliOptions); if (updateDirective) parts.push(updateDirective); process.stdout.write(parts.join('\n\n---\n\n') + '\n'); + await destroyFetchDispatcher(); process.exit(0); } const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`]; @@ -1207,6 +1218,8 @@ async function cli() { } if (updateDirective) parts.push(updateDirective); process.stdout.write(parts.join('\n\n---\n\n') + '\n'); + await destroyFetchDispatcher(); + process.exit(0); } function parseCliOptions(args) { diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 4c04276b8..bd7d671b1 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -1588,4 +1588,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(); + } + }); }); From 6bea544a0a33d1372171f0fae6472628606324a3 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Tue, 25 Aug 2026 07:51:47 +0500 Subject: [PATCH 2/2] Fix: drain context stdout before process.exit (#573) process.exit after a queued write truncated boot output on a backpressured pipe. Await the write callback, then close the fetch dispatcher. AI assistance: implemented with Cursor Grok 4.6. Co-authored-by: Cursor --- skill/scripts/context.mjs | 19 +++++++++++++------ tests/context.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index e94cd562d..ea5cad4c0 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -1023,6 +1023,17 @@ async function destroyFetchDispatcher() { } } +// 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 @@ -1169,9 +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'); - await destroyFetchDispatcher(); - process.exit(0); + await finishCli(parts.join('\n\n---\n\n') + '\n'); } const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`]; if (ctx.hasDesign) { @@ -1217,9 +1226,7 @@ async function cli() { } } if (updateDirective) parts.push(updateDirective); - process.stdout.write(parts.join('\n\n---\n\n') + '\n'); - await destroyFetchDispatcher(); - process.exit(0); + 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 bd7d671b1..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