From 2b604d8061a6dbf702ba7cec79bf19712c347d6e Mon Sep 17 00:00:00 2001 From: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:11:28 +0000 Subject: [PATCH] fix: flush CLI stdio before forced exit --- src/cli.ts | 26 ++++++------ ...li-force-exit-behavior.integration.test.ts | 41 +++++++++++-------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2294924..50d23e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -388,7 +388,7 @@ function flushWriteStream(stream: NodeJS.WriteStream, timeoutMs: number): Promis let settled = false; let timeout: ReturnType | undefined; - const resolveFlush = () => { + const cleanup = () => { if (settled) { return; } @@ -396,22 +396,22 @@ function flushWriteStream(stream: NodeJS.WriteStream, timeoutMs: number): Promis if (timeout) { clearTimeout(timeout); } + stream.off('drain', finishAfterDrain); + stream.off('error', cleanup); resolve(); }; - const finish = () => { - stream.off('error', finish); - resolveFlush(); + const finishAfterDrain = () => { + setImmediate(cleanup); }; - timeout = setTimeout(resolveFlush, timeoutMs); - stream.once('error', finish); - try { - stream.write('', () => { - setImmediate(finish); - }); - } catch { - finish(); - } + timeout = setTimeout(cleanup, timeoutMs); + stream.once('drain', finishAfterDrain); + stream.once('error', cleanup); + setImmediate(() => { + if (stream.destroyed || stream.writableEnded || !stream.writableNeedDrain) { + cleanup(); + } + }); }); } diff --git a/tests/cli-force-exit-behavior.integration.test.ts b/tests/cli-force-exit-behavior.integration.test.ts index 4269cf3..7d8573e 100644 --- a/tests/cli-force-exit-behavior.integration.test.ts +++ b/tests/cli-force-exit-behavior.integration.test.ts @@ -49,31 +49,33 @@ import { pathToFileURL } from 'node:url'; const [cliEntry, configPath] = process.argv.slice(2); process.env.MCPORTER_DISABLE_AUTORUN = '1'; -let cleanupWriteSeen = false; +let cleanupErrorListenerSeen = false; +let cliRunning = false; Object.defineProperty(process.stdout, 'writableNeedDrain', { configurable: true, - get: () => true, + get: () => false, }); -const originalWrite = process.stdout.write.bind(process.stdout); -process.stdout.write = (chunk, encoding, callback) => { - const done = typeof encoding === 'function' ? encoding : callback; - if (chunk === '') { - cleanupWriteSeen = true; - process.nextTick(() => { - const error = new Error('write EPIPE'); - error.code = 'EPIPE'; - process.stdout.emit('error', error); - }); - process.nextTick(() => done?.()); - return false; +Object.defineProperty(process.stdout, 'writableLength', { + configurable: true, + get: () => 1, +}); +const originalOnce = process.stdout.once.bind(process.stdout); +process.stdout.once = (event, listener) => { + const result = originalOnce(event, listener); + if (cliRunning && event === 'error' && !cleanupErrorListenerSeen) { + cleanupErrorListenerSeen = true; + const error = new Error('write EPIPE'); + error.code = 'EPIPE'; + process.stdout.emit('error', error); } - return originalWrite(chunk, encoding, callback); + return result; }; const { runCli } = await import(pathToFileURL(cliEntry).href); +cliRunning = true; await runCli(['--config', configPath, 'list', 'force-exit', '--schema', '--output', 'json']); -if (!cleanupWriteSeen) { - console.error('expected force-exit cleanup to flush stdout'); +if (!cleanupErrorListenerSeen) { + console.error('expected force-exit cleanup to observe stdout errors'); process.exitCode = 1; } `; @@ -203,6 +205,11 @@ await server.connect(transport); it('does not fail when stdout reports EPIPE during force exit cleanup', async () => { const result = await runCliWithCleanupStdoutError(configPath, tempDir); + if (result.code !== 0 || result.stderr !== '') { + throw new Error( + `cleanup stdout EPIPE command failed with code ${result.code}; stdout bytes=${Buffer.byteLength(result.stdout)}; stderr=${JSON.stringify(result.stderr)}` + ); + } expect(result.code).toBe(0); expect(result.stderr).toBe(''); }, 20000);