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(); + } + }); });