diff --git a/docs/concepts/runners.md b/docs/concepts/runners.md index 29f3fefe..3bfee33c 100644 --- a/docs/concepts/runners.md +++ b/docs/concepts/runners.md @@ -1012,6 +1012,76 @@ try { } ~~~~ +### Piped application output on Bun + +On affected Bun versions, importing *@optique/run* can cause large +`console.log()` output to be silently truncated when piped to another process. +Parsing can succeed and the application can exit naturally with code `0` while +still losing output. [Bun issue #36419] reports this behavior on macOS arm64 +with Bun 1.3.14 and 1.4.2. + +Large `console.error()` output to piped stderr is affected too; [Bun PR #43868] +includes tests for both `console.log()` and `console.error()`. + +Accessing `process.stdout` or `process.stderr` makes the corresponding pipe +nonblocking, and Bun's native console writer can drop the unwritten remainder +when the pipe is full. In tests with Bun 1.3.14, importing `node:process` alone +was enough to trigger the problem. +*@optique/run* imports that module and reads stdout's terminal capabilities. +Setting `colors` and `maxWidth` explicitly to skip TTY detection does not avoid +the import's effect. + +Write application data through `process.stdout.write()` instead of +`console.log()`, then let the process exit naturally: + +~~~~ typescript twoslash +import { object } from "@optique/core/constructs"; +import { run } from "@optique/run"; +import process from "node:process"; + +const parser = object({}); +const result = run(parser); +process.stdout.write(`${JSON.stringify(result)}\n`); +~~~~ + +For diagnostics, use `process.stderr.write()` instead of `console.error()` and +let the process exit naturally. + +If your application code must call `process.exit()`, wait for the completion +callbacks of all pending stdout/stderr writes first. For example, when this is +the only pending write: + +~~~~ typescript twoslash +import process from "node:process"; + +const result = { data: "x".repeat(200_000) }; +await new Promise((resolve, reject) => { + process.stdout.write(`${JSON.stringify(result)}\n`, (error) => { + if (error != null) reject(error); + else resolve(); + }); +}); +process.exit(0); +~~~~ + +An arbitrary delay is not a flush guarantee. Waiting after `console.log()` or +`console.error()` cannot recover bytes that Bun has already dropped. + +Custom `stdout`/`stderr` handlers must finish writing synchronously before +returning when using the default `onExit`: the runner invokes it immediately +after an output handler returns. The `onExit` hook is synchronous and cannot +await writes. To use asynchronous custom writers, record their completion +promises and throw an exception carrying the exit code from `onExit`. Catch +that exception outside the runner, await the recorded promises, then call +`process.exit()` with that code. Optique's default writers have the behavior +described in [Error handling behavior](#error-handling-behavior). + +The upstream fix was merged in [Bun PR #43868]. Check whether your Bun release +includes it before relying on piped console output. + +[Bun issue #36419]: https://github.com/oven-sh/bun/issues/36419 +[Bun PR #43868]: https://github.com/oven-sh/bun/pull/43868 + Async parser execution ---------------------- diff --git a/packages/run/src/run.ts b/packages/run/src/run.ts index 063cd731..97b1189c 100644 --- a/packages/run/src/run.ts +++ b/packages/run/src/run.ts @@ -52,6 +52,9 @@ export interface RunOptions { * after the whole text has been written, even if the reader is slow, so that * exiting the process right afterward does not truncate the output. * + * A custom writer using `console.log()` can lose large piped output on + * affected Bun versions. See the Bun output warning on {@link run}. + * * @default Writes to `process.stdout` with a trailing newline */ readonly stdout?: (text: string) => void; @@ -65,6 +68,9 @@ export interface RunOptions { * after the whole text has been written, even if the reader is slow, so that * exiting the process right afterward does not truncate the output. * + * A custom writer using `console.error()` can lose large piped output on + * affected Bun versions. See the Bun output warning on {@link run}. + * * @default Writes to `process.stderr` with a trailing newline */ readonly stderr?: (text: string) => void; @@ -72,6 +78,18 @@ export interface RunOptions { /** * Function used to exit the process on help/version display or parse error. * + * This hook is synchronous and runs immediately after an output handler + * returns. With the default `process.exit()` handler, custom `stdout` and + * `stderr` handlers must finish writing synchronously before returning. + * The default exit handler also does not wait for writes queued by + * application code. + * + * To wait for asynchronous writes, record their completion promises and + * throw an exception carrying the exit code from `onExit`. Catch that + * exception outside the runner, await the recorded promises, then call + * `process.exit()` with that code. + * An arbitrary delay does not guarantee that output has been flushed. + * * @default `process.exit` */ readonly onExit?: (exitCode: number) => never; @@ -435,6 +453,24 @@ function resolveProgramInput< * - Exit the process with appropriate codes on help or error * - Format output according to terminal capabilities * + * On affected Bun versions, importing `@optique/run` can cause large + * application `console.log()` output to be silently truncated when piped, + * even after successful parsing and natural process exit. This is + * [Bun issue #36419](https://github.com/oven-sh/bun/issues/36419), reported + * on macOS arm64 with Bun 1.3.14 and 1.4.2. Use `process.stdout.write()` + * instead of `console.log()` for application data, and `process.stderr.write()` + * instead of `console.error()` for diagnostics. Both console methods can lose + * large piped output. Let the process exit naturally. If explicit termination + * is necessary, wait for the completion callbacks of all pending stdout/stderr + * writes before calling `process.exit()`; a delay is not a flush guarantee. Waiting after `console.log()` or `console.error()` cannot + * recover dropped bytes. + * + * Setting `colors` and `maxWidth` explicitly does not avoid this Bun bug: + * importing `node:process`, which this module does, was enough to trigger it + * in Bun 1.3.14. The upstream fix was merged in + * [Bun PR #43868](https://github.com/oven-sh/bun/pull/43868); check whether + * your Bun release includes it. + * * @template T The parser type being executed. * @param parser The command-line parser to execute. * @param options Configuration options for customizing behavior. @@ -618,6 +654,8 @@ export function run>( * Use this when you know your parser is sync-only to get direct return values * without Promise wrappers. * + * See {@link run} for Bun's piped console output warning and workarounds. + * * @template T The sync parser type being executed. * @param parser The synchronous command-line parser to execute. * @param options Configuration options for customizing behavior. @@ -732,6 +770,8 @@ export function runSync>( * Promise. Use this when working with parsers that may contain async * value parsers. * + * See {@link run} for Bun's piped console output warning and workarounds. + * * @template T The parser type being executed. * @param parser The command-line parser to execute. * @param options Configuration options for customizing behavior.