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