Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/concepts/runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
dahlia marked this conversation as resolved.

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<void>((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
----------------------
Expand Down
40 changes: 40 additions & 0 deletions packages/run/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -65,13 +68,28 @@ 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;

/**
* 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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -618,6 +654,8 @@ export function run<T extends Parser<Mode, unknown, unknown>>(
* 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.
Expand Down Expand Up @@ -732,6 +770,8 @@ export function runSync<T extends Parser<"sync", unknown, unknown>>(
* 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.
Expand Down
Loading