From c40d857d0419c334058b79bd459d8239f025d6d8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 7 Oct 2026 11:52:35 +0000 Subject: [PATCH] qc: flush stdout before exiting, so piped --json is never truncated (qc 0.6.4) bin/qc.js called process.exit() as soon as a command resolved. Into a pipe stdout is asynchronous, so anything past the 64 KB pipe buffer was dropped: `qc read --json` on a long chat handed its reader cut-off JSON. That broke riotcoder's inbox sweep (every tick since 09:14 UTC failed with "Unterminated string ... char 65139"), so Anthony's messages went unanswered. exitWhenFlushed() ends stdout and stderr and exits from the callback, with a 30 s guard for a reader that never reads. end(), not write('', cb): under Bun the empty write calls back before earlier data drains. Verified live: qc 0.6.3 delivers 65,390 bytes of the riotcoder DM; this branch delivers all 67,285 and they parse. Co-Authored-By: Claude Opus 5.5 --- bin/qc.js | 9 +++++---- packages/qryptchat/bin/qc.js | 6 +++--- packages/qryptchat/package.json | 2 +- src/cli/commands.js | 1 + src/cli/exit.js | 10 ++++++++++ tests/cli/exit.test.js | 35 +++++++++++++++++++++++++++++++++ 6 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 src/cli/exit.js create mode 100644 tests/cli/exit.test.js diff --git a/bin/qc.js b/bin/qc.js index 3f9e1dd5..8bb0dac0 100755 --- a/bin/qc.js +++ b/bin/qc.js @@ -1,16 +1,17 @@ #!/usr/bin/env node // qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest. import { readFileSync } from 'node:fs'; -import { errorLine, main } from '../src/cli/commands.js'; +import { errorLine, exitWhenFlushed, main } from '../src/cli/commands.js'; const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); // main resolves when the command is done (the TUI quit, stdin closed under -// mcp); exit then, or the SSE socket and timers would hold the process open. +// mcp); exit then, or the SSE socket and timers would hold the process open, +// but only once stdout has drained (see exit.js). main(process.argv.slice(2), { version: pkg.version }).then( - () => process.exit(0), + () => exitWhenFlushed(0), (err) => { process.stderr.write(`${errorLine(err)}\n`); - process.exit(1); + exitWhenFlushed(1); }, ); diff --git a/packages/qryptchat/bin/qc.js b/packages/qryptchat/bin/qc.js index f095126a..a290c2d7 100644 --- a/packages/qryptchat/bin/qc.js +++ b/packages/qryptchat/bin/qc.js @@ -1,14 +1,14 @@ #!/usr/bin/env node // qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest. import { readFileSync } from 'node:fs'; -import { errorLine, main } from '../dist/commands.js'; +import { errorLine, exitWhenFlushed, main } from '../dist/commands.js'; const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); main(process.argv.slice(2), { version: pkg.version }).then( - () => process.exit(0), + () => exitWhenFlushed(0), (err) => { process.stderr.write(`${errorLine(err)}\n`); - process.exit(1); + exitWhenFlushed(1); }, ); diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index 583a501e..bb1453ac 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.6.3", + "version": "0.6.4", "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.", "type": "module", "bin": { diff --git a/src/cli/commands.js b/src/cli/commands.js index 178b9af8..55adb5e8 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -8,6 +8,7 @@ import { login } from './login.js'; import { joinAsAgent } from './agent.js'; import { em, fail, info, mark, ok, warn, who } from './style.js'; import { notify } from './notify.js'; +export { exitWhenFlushed } from './exit.js'; /** An error as qc prints it: the error icon and the message. */ export const errorLine = (err) => `qc: ${fail(err?.message ?? String(err))}`; diff --git a/src/cli/exit.js b/src/cli/exit.js new file mode 100644 index 00000000..6d4ac522 --- /dev/null +++ b/src/cli/exit.js @@ -0,0 +1,10 @@ +// process.exit() does not wait for stdout. Into a pipe (qc read --json | jq, a +// script's subprocess) the write is asynchronous, so anything past the 64 KB +// pipe buffer was dropped and the reader got truncated JSON. Exit once both +// streams have finished; the timer only guards a reader that never reads. +// end(), not write('', cb): under Bun an empty write calls back before the +// earlier data has drained. +export function exitWhenFlushed(code, { timeoutMs = 30_000 } = {}) { + setTimeout(() => process.exit(code), timeoutMs); + process.stdout.end(() => process.stderr.end(() => process.exit(code))); +} diff --git a/tests/cli/exit.test.js b/tests/cli/exit.test.js new file mode 100644 index 00000000..454726b8 --- /dev/null +++ b/tests/cli/exit.test.js @@ -0,0 +1,35 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// vitest runs from the repo root; import.meta.url is http:// under jsdom. +const exitJs = pathToFileURL(resolve('src/cli/exit.js')).href; +const SIZE = 1024 * 1024; + +// Run a child that writes SIZE bytes to a piped stdout, then exits the given way. +// `node` is Bun under `bun --bun vitest`, so this covers both runtimes between +// a local `node` run and CI. +function run(exit) { + const script = `import { exitWhenFlushed } from ${JSON.stringify(exitJs)}; +process.stdout.write('x'.repeat(${SIZE})); +${exit}`; + const r = spawnSync('node', ['--input-type=module', '-e', script], { maxBuffer: SIZE * 2 }); + return r; +} + +describe('exitWhenFlushed', () => { + it('delivers all of a large piped stdout before exiting', () => { + const r = run('exitWhenFlushed(0);'); + expect(r.status).toBe(0); + expect(r.stdout.length).toBe(SIZE); + }); + + it('keeps the exit code', () => { + expect(run('exitWhenFlushed(3);').status).toBe(3); + }); + + it('is needed: a bare process.exit truncates at the pipe buffer', () => { + expect(run('process.exit(0);').stdout.length).toBeLessThan(SIZE); + }); +});