Skip to content

bun-types: type Subprocess stdio properties by what the runtime exposes for each stdio option - #39283

Open
robobun wants to merge 1 commit into
mainfrom
farm/1f3acdd6/spawn-stdio-result-types
Open

bun-types: type Subprocess stdio properties by what the runtime exposes for each stdio option#39283
robobun wants to merge 1 commit into
mainfrom
farm/1f3acdd6/spawn-stdio-result-types

Conversation

@robobun

@robobun robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.spawn(cmd, { stdin: Bun.file(path) }), { stdin: new Blob(..) }, { stdin: new Uint8Array(..) }, { stdin: new Response("x") } and { stdout: Bun.file(path) } all type proc.stdin / proc.stdout as number; at runtime (bun 1.4.0) every one of them is undefined.
  • { stdin: someReadableStream } (or a Request/Response with a streaming body) types proc.stdin as undefined; at runtime it is the stream.
  • { stdin: 0 } / { stdout: 1 } / { stderr: 2 } type the property as number; at runtime Bun turns the parent's own descriptor into "inherit" and the property is undefined.
  • Bun.spawnSync(cmd, { stdout: fd }) types result.stdout as undefined; at runtime it is the fd number.
  • Cause: the three mapping types in packages/bun-types/bun.d.ts (Spawn.ReadableToIO, ReadableToSyncIO, WritableToIO, around line 7439) list BunFile | ArrayBufferView | Blob | Request | Response | number as inputs that yield number. The runtime only echoes a caller-supplied fd: Writable::to_js (src/runtime/api/bun/subprocess/Writable.rs:393) returns a number for Writable::Fd and undefined for Buffer / Memfd / Ignore / Inherit; Readable::to_js and to_buffered_value (subprocess/Readable.rs:240, :270) do the same; Stdio::extract_blob (src/runtime/api/bun/spawn/stdio.rs:589) turns Bun.file(fd) into Stdio::Fd and Bun.file(path) into Stdio::Path; a ReadableStream stdin is cached as the stdin property itself (js_bun_spawn_bindings.rs:1728); fds 0/1/2 in their own slot become Stdio::Inherit (stdio.rs:480).
  • test/integration/bun-types/fixture/spawn.ts:162-180 asserted the wrong number claim for Request, Response and Uint8Array stdin.

Fix

  • ReadableToIO / ReadableToSyncIO: number | BunFile maps to number | undefined; everything other than "pipe" maps to undefined. WritableToIO: same, plus ReadableStream | Request | Response maps to ReadableStream | undefined. WritableIO (the union of everything stdin can hold) gains ReadableStream to stay equal to WritableToIO<Writable>.
  • Why number | undefined rather than number for fd inputs: Bun.file(path) and Bun.file(fd) are the same static type but give undefined and a number, and a raw 0/1/2 in its own slot gives undefined; number alone is wrong for the most common Bun.file() usage. Why | undefined on the stream arm: a Request/Response with an in-memory body, and streams Bun can read as a blob (blob.stream(), Bun.file(p).stream()), give undefined. The holder-type unions stay the same shape except for the additions above: Subprocess["stdout"] is still ReadableStream | number | undefined, Subprocess["stdin"] becomes FileSink | ReadableStream | number | undefined, SyncSubprocess["stdout"] becomes Buffer | number | undefined; the fixture pins all three.
  • Blast radius check: cd test && bun run typecheck (bun's own test suite, which spawns constantly) before and after the change adds no diagnostic to any line that type-checked before; the only new ones are the three fixture lines that encoded the old claim and a second diagnostic on test/js/node/process/process-stdout-write-after-end.test.ts:65, which already failed to type-check (.text() on number | ReadableStream). scripts/build type-checks identically before and after.
  • JSDoc on the mapping types and on Subprocess.stdin/stdout/stderr and SyncSubprocess.stdout/stderr states the mapping (these properties had no JSDoc, and the alias JSDoc is not what the IDE shows when hovering proc.stdin). The Reference block in docs/runtime/child-process.mdx gets the two changed unions.
  • Tests: test/integration/bun-types/fixture/spawn.ts asserts the exact property type for fd, 0/1/2, Bun.file(), Blob, ArrayBufferView, ReadableStream, Request, Response and fetch() stdin, fd / Bun.file() / ArrayBufferView stdout and stderr (spawn and spawnSync), readable, and the holder-type unions. Against the unfixed bun.d.ts the fixture produces 25 diagnostics (lines 172-250); with the fix bun test test/integration/bun-types/bun-types.test.ts passes all 16 cases (no-lib.dom, lib.dom, tsgo).
  • The in-process type-checking cases in bun-types.test.ts are skipped on debug builds, so a small case that runs tsc over fixture/spawn.ts is added so bun bd test exercises the fixture too. It is the same case bun-types: declare Subprocess.writable and lint interface Subprocess against the classes.ts table #39279 adds (byte-identical, so the two merge cleanly), and both become redundant if test(bun-types): replace the tsgo and Bun.mmap spawns with one whole-fixture tsc run, enforced by a lint #39270 (which type-checks the whole fixture on debug builds) lands first.
  • Related open PRs, not duplicates: bun-types: declare Subprocess.writable and lint interface Subprocess against the classes.ts table #39279 declares Subprocess.writable and asserts stdin: 0 / stdout: 1 give number, which this PR changes to number | undefined; whichever lands second needs that one assertion updated. Bun.spawnSync: write stdout/stderr into a caller-provided Uint8Array #34864 adds an ArrayBufferView arm to ReadableToSyncIO for a new spawnSync feature and will need a trivial rebase over this.
  • Out of scope, left as is: ReadableToIO<undefined> says ReadableStream, which is right for stdout (default "pipe") but not for Bun.spawn's stderr (default "inherit"); fixing that needs the mapping to know which slot it is for, and is tracked separately.

Background

  • Bun.spawn infers three type parameters (In, Out, Err) from the stdin / stdout / stderr (or stdio) options, and Subprocess<In, Out, Err> computes the type of each stdio property from them with a conditional type: WritableToIO<In> for stdin, ReadableToIO<Out> / <Err> for stdout and stderr; SyncSubprocess uses ReadableToSyncIO. With a literal option (stdin: "pipe") the property gets the exact type; with the unconstrained default (Bun.Subprocess used as a holder type) the conditional distributes over the whole option union, so the property becomes the union of every arm. That is why the PR pins the holder-type unions: they are what code that stores a Subprocess without knowing its configuration sees.
  • On the runtime side each stdio slot is parsed into a Stdio variant (spawn/stdio.rs) and then into a Readable / Writable variant owned by the Subprocess; the JS getters (subprocess.rs get_stdin etc., cached on first read) convert that variant to a value. Fd is the only variant that produces a number. Inputs Bun copies into the child itself (Blob, ArrayBufferView, memfd, a path it opens) produce undefined because there is nothing for the caller to hold; a "pipe" produces a FileSink (stdin) or ReadableStream (stdout/stderr); a ReadableStream stdin is piped by Bun and the stream object itself is stored in the stdin property slot.
Runtime probe (bun 1.4.0, linux x64): what each option produces
stdin: "pipe"                             -> FileSink
stdin: fd number                          -> number(7)
stdin: 0 (own stdin fd)                   -> undefined
stdin: Bun.file(path)                     -> undefined
stdin: Bun.file(fd)                       -> number(8)
stdin: Blob                               -> undefined
stdin: empty Blob                         -> undefined
stdin: Uint8Array                         -> undefined
stdin: empty Uint8Array                   -> undefined
stdin: Response(string)                   -> undefined
stdin: Response(null body)                -> undefined
stdin: Response(Bun.file(path))           -> undefined
stdin: Response(ReadableStream)           -> ReadableStream
stdin: Request(string body)               -> undefined
stdin: Request(no body)                   -> undefined
stdin: ReadableStream                     -> ReadableStream
stdin: Bun.file(path).stream()            -> undefined
stdin: Blob.stream()                      -> undefined
stdin: "inherit" / "ignore" / null / undefined -> undefined
stdout: "pipe" / undefined                -> ReadableStream
stdout: fd number                         -> number(9)
stdout: 1 (own stdout fd)                 -> undefined
stdout: 2 (own stderr fd, as stdout)      -> number(2)
stdout: Bun.file(path)                    -> undefined
stdout: Bun.file(fd)                      -> number(10)
stdout: Uint8Array(64)                    -> throws ERR_INVALID_ARG_TYPE "ArrayBufferView cannot be used for stdout/stderr yet"
stdout: empty Uint8Array                  -> undefined
stdout: "inherit" / "ignore" / null       -> undefined
stderr: undefined (spawn default)         -> undefined
terminal: {...}                           -> stdin, stdout, stderr all null
spawnSync stdout: "pipe" / undefined      -> Buffer
spawnSync stdout: fd number               -> number(7)
spawnSync stdout: 1 (own stdout)          -> undefined
spawnSync stdout: Bun.file(fd)            -> number(8)
spawnSync stdout: Bun.file(path) / "inherit" / "ignore" / null -> undefined

The fd numbers are unchanged after the child exits and on a first read after exit.

Diagnostics the new fixture produces against the unfixed bun.d.ts
spawn.ts(172,33): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(173,34): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(174,34): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(175,36): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(179,33): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(180,34): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(181,34): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(189,33): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(190,34): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(191,34): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(192,36): error TS2344: Type 'number | undefined' does not satisfy the constraint 'number'.
spawn.ts(196,33): error TS2344: Type 'undefined' does not satisfy the constraint 'number'.
spawn.ts(200,33): error TS2344: Type 'undefined' does not satisfy the constraint 'number'.
spawn.ts(204,33): error TS2344: Type 'undefined' does not satisfy the constraint 'number'.
spawn.ts(205,34): error TS2344: Type 'undefined' does not satisfy the constraint 'number'.
spawn.ts(206,34): error TS2344: Type 'undefined' does not satisfy the constraint 'number'.
spawn.ts(210,33): error TS2344: Type 'ReadableStream<any> | undefined' does not satisfy the constraint 'undefined'.
spawn.ts(214,33): error TS2344: Type 'ReadableStream<any> | undefined' does not satisfy the constraint 'number'.
spawn.ts(218,33): error TS2344: Type 'ReadableStream<any> | undefined' does not satisfy the constraint 'number'.
spawn.ts(225,33): error TS2344: Type 'ReadableStream<any> | undefined' does not satisfy the constraint 'number'.
spawn.ts(230,34): error TS2344: Type 'number | undefined' does not satisfy the constraint 'undefined'.
spawn.ts(231,34): error TS2344: Type 'number | undefined' does not satisfy the constraint 'undefined'.
spawn.ts(245,46): error TS2344: Type 'number | FileSink | ReadableStream<any> | undefined' does not satisfy the constraint 'number | FileSink | undefined'.
spawn.ts(249,47): error TS2344: Type 'number | Buffer<ArrayBufferLike> | undefined' does not satisfy the constraint 'Buffer<ArrayBufferLike> | undefined'.
spawn.ts(250,50): error TS2344: Type 'number | FileSink | ReadableStream<any> | undefined' does not satisfy the constraint 'number | FileSink | undefined'.

… stdio option

Spawn.ReadableToIO, ReadableToSyncIO and WritableToIO claimed that a
BunFile, ArrayBufferView, Blob, Request or Response passed as stdio shows
up on the Subprocess as a number. Only a caller-supplied file descriptor
(a number or Bun.file(fd)) does, and even that is undefined when it is the
parent's own standard stream; a ReadableStream passed as stdin (directly
or as a Request/Response body) comes back as the stream; everything else
is undefined. spawnSync also returns the fd number for fd stdout/stderr,
which ReadableToSyncIO typed as undefined.

Map fd inputs to number | undefined, stream inputs to ReadableStream |
undefined and the rest to undefined, document the mapping on the types
and the Subprocess/SyncSubprocess properties, fix the fixture assertions
that encoded the old claim, and type-check fixture/spawn.ts on debug
builds too so the assertions run there.
@robobun
robobun requested a review from alii as a code owner August 16, 2026 03:38
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3c199ec9-f8be-4d0b-bdcd-aef1b453fe7f

📥 Commits

Reviewing files that changed from the base of the PR and between 8c5296a and ef689f6.

📒 Files selected for processing (4)
  • docs/runtime/child-process.mdx
  • packages/bun-types/bun.d.ts
  • test/integration/bun-types/bun-types.test.ts
  • test/integration/bun-types/fixture/spawn.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:38 PM PT - Aug 15th, 2026

@robobun, your commit ef689f6 is building: #99085

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on the released bun 1.4.0 (linux x64) by spawning cat with each stdio input kind and printing proc.stdin / proc.stdout (table in the PR description): only a caller-supplied fd comes back as a number, a ReadableStream stdin comes back as the stream, everything else is undefined. test/integration/bun-types/fixture/spawn.ts fails against the unfixed bun.d.ts with 25 diagnostics and passes with it; bun test test/integration/bun-types/bun-types.test.ts passes all 16 cases locally.

Overlaps to be aware of when merging: #39279 adds the same debug-build tsc case (byte-identical) and asserts stdin: 0 / stdout: 1 give number, which this PR changes to number | undefined; #39270 supersedes the debug-build case; #34864 touches ReadableToSyncIO and needs a trivial rebase after this.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this and didn't find any bugs. Since it changes the public bun-types surface for Subprocess stdio (widening several property types and adding ReadableStream to WritableIO), and packages/bun-types/ has a CODEOWNER, a human sign-off is still worth having — the description also flags coordination with #39279 and #34864.

What was reviewed:

  • The three conditional mapping types (ReadableToIO, ReadableToSyncIO, WritableToIO) against the runtime behavior described for Writable::to_js / Readable::to_js / Stdio::extract_blob.
  • Conditional-type ordering: BunFile extends Blob, so the number | BunFile arm correctly matches before the fall-through undefined arm; ArrayBufferView/Blob now fall through to undefined as intended.
  • The holder-type unions pinned in the fixture and mirrored in docs/runtime/child-process.mdx.
  • The new bun-types.test.ts case follows the existing Bun.mmap tsc-spawn pattern and drains stdout/stderr concurrently.
Extended reasoning...

Overview

This PR corrects three conditional mapping types in packages/bun-types/bun.d.ts (SpawnOptions.ReadableToIO, ReadableToSyncIO, WritableToIO) so that Subprocess.stdin/stdout/stderr and SyncSubprocess.stdout/stderr are typed by what the runtime actually returns for each stdio option. It adds ReadableStream to WritableIO, widens fd/BunFile inputs to number | undefined, and moves Blob/ArrayBufferView inputs to the undefined arm. It adds JSDoc to the mapping types and to the Subprocess/SyncSubprocess stdio properties, updates the Reference block in docs/runtime/child-process.mdx, replaces three incorrect fixture assertions with ~90 lines of exact-type assertions in test/integration/bun-types/fixture/spawn.ts, and adds a spawned-tsc test case in bun-types.test.ts so the fixture is exercised on debug builds.

Security risks

None. This is a .d.ts change plus docs and type-fixture assertions; no executable runtime code changes.

Level of scrutiny

Medium. The change is a correctness fix that aligns declared types with observed runtime behavior (the PR includes a runtime probe table and points at the exact Rust functions that produce each value). However, it changes the public @types/bun surface: Bun.Subprocess["stdin"] gains ReadableStream, SyncSubprocess["stdout"] gains number, and callers who previously got number for stdin: Bun.file(...) / stdin: fd will now get number | undefined. The author already checked blast radius (cd test && bun run typecheck before/after), but user-facing type widenings on a heavily-used API are the kind of thing the packages/bun-types/ CODEOWNER should confirm.

Other factors

  • packages/bun-types/ is owned by a CODEOWNER in .github/CODEOWNERS, which per the approval guidelines means I should not auto-approve.
  • The PR description explicitly notes it interacts with two open PRs (#39279 asserts stdin: 0number, which this changes to number | undefined; #34864 adds an ArrayBufferView arm to ReadableToSyncIO). Whichever lands second needs a small follow-up, so a human should be aware of the merge order.
  • I checked conditional-type resolution order: BunFile extends Blob, but the new WritableToIO tests number | BunFile before falling through, so BunFile correctly maps to number | undefined and plain Blob/ArrayBufferView fall through to undefined. The ReadableStream | Request | Response arm is placed after the fd arm and before the catch-all, which is correct.
  • Tests: the fixture uses tsd.expectType(...).is<...>() for exact equality (not just assignability), pins the distributed holder-type unions, and the PR shows 25 diagnostics against the unfixed .d.ts, so the assertions are demonstrably load-bearing. The new bun-types.test.ts case mirrors the existing Bun.mmap spawned-tsc pattern byte-for-byte.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants