From 6bea544a0a33d1372171f0fae6472628606324a3 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Tue, 25 Aug 2026 07:51:47 +0500 Subject: [PATCH] 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