SIGN IN SIGN UP
oven-sh / bun UNCLAIMED

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one

0 0 149 Rust

Fix use-after-free in `bun run --parallel` workspace script spawning (#31637)

Fixes #31636

### Repro

```sh
mkdir -p repro/packages/pdf-service && cd repro
echo '{ "name": "monorepo-root", "private": true, "workspaces": ["packages/*"] }' > package.json
echo '{ "name": "pdf-service", "scripts": { "dev": "bun --watch index.ts" } }' > packages/pdf-service/package.json
echo 'console.log("started"); setInterval(() => {}, 1000);' > packages/pdf-service/index.ts
bun install
bun run --parallel --filter=pdf-service dev
```

On `1.4.0-canary` this exits 127 with a corrupted shell command — the
bytes
vary between runs (`bash: line 1: $'\375\001': command not found`, or
truncated `node_modules/.bin` PATH segments). Works on 1.3.14;
non-parallel
`bun run dev` is unaffected.

### Cause

The garbage command is a **heap-use-after-free** (deterministic under
ASAN,
intermittent garbage on release — hence the varying bytes and why a tiny
repro
"may succeed").

`multi_run::run` (the `--parallel`/`--filter`/`--workspaces` path)
collects
each matching workspace package's `scripts` map into a `MatchedPackage`.
The
map is `StringArrayHashMap<&'static [u8]>`, and those `&'static [u8]`
values
borrow the `PackageJSON.source_contents` the map was parsed from. The
standalone `PackageJSON` is a local that drops at the end of each
collection-loop iteration, freeing `source_contents` while the stored
map
still points into it. A later phase reads those dangling bytes while
building
the shell command via `replacePackageManagerRun`:

```
heap-use-after-free
  READ   src/install/lifecycle_script_runner.rs:153  (replace_package_manager_run)
  FREED  src/runtime/cli/multi_run.rs (PackageJSON drops at end of loop iteration)
  ALLOC  src/resolver/package_json.rs:1103            (source_contents)
```

In the original Zig, `PackageJSON` was kept alive for the process
lifetime by
the DirInfo cache, so these borrows stayed valid; the Rust port made
`PackageJSON` a standalone owner, so the borrows dangle once it drops.
The
single-package path is unaffected — its package.json comes from the
(still
process-lifetime) DirInfo cache.

### Fix

Deep-copy the scripts map into an owned `StringArrayHashMap<Box<[u8]>>`
while
the `PackageJSON` is still alive, so `MatchedPackage` no longer borrows
freed
memory. `add_script_configs` is made generic over the map value type so
the
single-package path keeps borrowing its process-lifetime map unchanged
(same
codegen as before).

### Verification

- New regression test `test/regression/issue/31636.test.ts`: a workspace
whose
package scripts run `bun <file>` (so the `bun` rewrite scans the script
bytes
  and the child actually starts), run via `--parallel --filter` and
`--parallel --workspaces`, asserting the children print their marker and
exit
  0 with no corrupted-command errors in stderr.
- Fails under `bun bd` (debug + ASAN) without the fix (UAF → child never
starts), passes with it. Gated on debug builds; release CI lanes skip
it, the
  ASAN gate lane catches it.
- `test/cli/run/multi-run.test.ts` workspace integration suite passes.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
R
robobun committed
5ac120ca374b4b957bd361caba870fd51d86056f
Parent: 2629789
Committed by GitHub <noreply@github.com> on 6/1/2026, 2:35:08 AM