Compare commits

...
Author SHA1 Message Date
Abdul WahabandCursor 6bea544a0a 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 <cursoragent@cursor.com>
2026-08-25 07:51:47 +05:00
Abdul WahabandCursor 2ef8e43d1e 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 <cursoragent@cursor.com>
2026-08-25 07:31:18 +05:00
2 changed files with 79 additions and 3 deletions
+23 -3
View File
@@ -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) {
+56
View File
@@ -1060,6 +1060,27 @@ describe('context.mjs CLI', () => {
assert.match(res.stdout, /detect\.mjs --json <changed targets>/);
});
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();
}
});
});