From 04aa28ac02ca9fb5c20a3ffff2cb3dbf78d2cc49 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 6 Oct 2026 00:41:13 +0900 Subject: [PATCH 1/3] Document Bun's piped console output workaround Explain how Bun can drop large piped console output after importing @optique/run, even when parsing succeeds and the process exits naturally. Document stream writes and completion callbacks in the runner JSDoc and VitePress guide, with links to the upstream bug and merged fix. Fixes https://github.com/dahlia/optique/issues/1007 Changelog: none Assisted-by: Codex:gpt-6.1-sol --- docs/concepts/runners.md | 56 ++++++++++++++++++++++++++++++++++++++++ packages/run/src/run.ts | 29 +++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/docs/concepts/runners.md b/docs/concepts/runners.md index 29f3fefe..eaba563c 100644 --- a/docs/concepts/runners.md +++ b/docs/concepts/runners.md @@ -1012,6 +1012,62 @@ 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. + +Accessing `process.stdout` makes the 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`); +~~~~ + +If you 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()` +cannot recover bytes that Bun has already dropped. These workarounds apply to +application output and custom output handlers; 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..b6b480b1 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; @@ -72,6 +75,12 @@ export interface RunOptions { /** * Function used to exit the process on help/version display or parse error. * + * The default `process.exit()` handler does not wait for writes queued by + * application code or custom output handlers. Before calling + * `process.exit()`, wait for the completion callbacks of pending + * `process.stdout.write()` and `process.stderr.write()` calls. An arbitrary + * delay does not guarantee that output has been flushed. + * * @default `process.exit` */ readonly onExit?: (exitCode: number) => never; @@ -435,6 +444,22 @@ 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. Write application data with + * `process.stdout.write()` and 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()` 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 +643,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 +759,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. From a30cb2101a4261bf4c26cf6798269040ee2b3832 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 6 Oct 2026 01:32:59 +0900 Subject: [PATCH 2/3] Clarify when custom output must finish writing The synchronous onExit hook cannot await queued stream writes. Explain that custom output handlers must finish synchronously with the default exit handler, and describe throwing an exit signal so the caller can await write completion outside the runner before exiting. https://github.com/dahlia/optique/pull/1011#discussion_r4186283484 Changelog: none Assisted-by: Codex:gpt-6.1-sol --- docs/concepts/runners.md | 19 +++++++++++++------ packages/run/src/run.ts | 16 +++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/concepts/runners.md b/docs/concepts/runners.md index eaba563c..502e11be 100644 --- a/docs/concepts/runners.md +++ b/docs/concepts/runners.md @@ -1040,9 +1040,9 @@ const result = run(parser); process.stdout.write(`${JSON.stringify(result)}\n`); ~~~~ -If you 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: +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"; @@ -1058,9 +1058,16 @@ process.exit(0); ~~~~ An arbitrary delay is not a flush guarantee. Waiting after `console.log()` -cannot recover bytes that Bun has already dropped. These workarounds apply to -application output and custom output handlers; Optique's default writers have -the behavior described in [Error handling behavior](#error-handling-behavior). +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. diff --git a/packages/run/src/run.ts b/packages/run/src/run.ts index b6b480b1..5d00531d 100644 --- a/packages/run/src/run.ts +++ b/packages/run/src/run.ts @@ -75,11 +75,17 @@ export interface RunOptions { /** * Function used to exit the process on help/version display or parse error. * - * The default `process.exit()` handler does not wait for writes queued by - * application code or custom output handlers. Before calling - * `process.exit()`, wait for the completion callbacks of pending - * `process.stdout.write()` and `process.stderr.write()` calls. An arbitrary - * delay does not guarantee that output has been flushed. + * 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` */ From eeca3ec97068177ae272fd89b85e6c8ed082c466 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 6 Oct 2026 01:44:24 +0900 Subject: [PATCH 3/3] Cover Bun's piped stderr truncation too Large console.error output can be dropped just like console.log output. Document process.stderr.write for diagnostics alongside the stdout workaround, and warn users of custom stderr handlers in the JSDoc. https://github.com/dahlia/optique/pull/1011#discussion_r4186424977 Changelog: none Assisted-by: Codex:gpt-6.1-sol --- docs/concepts/runners.md | 17 ++++++++++++----- packages/run/src/run.ts | 15 ++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/concepts/runners.md b/docs/concepts/runners.md index 502e11be..3bfee33c 100644 --- a/docs/concepts/runners.md +++ b/docs/concepts/runners.md @@ -1020,9 +1020,13 @@ 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. -Accessing `process.stdout` makes the 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. +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. @@ -1040,6 +1044,9 @@ 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: @@ -1057,8 +1064,8 @@ await new Promise((resolve, reject) => { process.exit(0); ~~~~ -An arbitrary delay is not a flush guarantee. Waiting after `console.log()` -cannot recover bytes that Bun has already dropped. +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 diff --git a/packages/run/src/run.ts b/packages/run/src/run.ts index 5d00531d..97b1189c 100644 --- a/packages/run/src/run.ts +++ b/packages/run/src/run.ts @@ -68,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; @@ -454,11 +457,13 @@ function resolveProgramInput< * 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. Write application data with - * `process.stdout.write()` and 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()` cannot recover dropped bytes. + * 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