Skip to content

Install progress: move counter to left of package name to reduce layo… - #1

Draft
kepae wants to merge 28 commits into
mainfrom
install-progress-counter
Draft

Install progress: move counter to left of package name to reduce layo…#1
kepae wants to merge 28 commits into
mainfrom
install-progress-counter

Conversation

@kepae

@kepae kepae commented Apr 9, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

This PR arranges the installed packages counter to the left of the package names rather than the right. This keeps the counter more readable -- its easier to tolerate 1-3 character shifts sequentially rather than more variable package names flying by.

This PR also attempts to make the buf writer conditionals more readable in passing. The ellipsis behavior remains the same, printed only for nodes without counters.

Before:

bun install
🚚 short-package-1 [2969/2972]
🚚 long-package-name-layout-shifter [2970/2972]
🚚 short-package-2 [2971/2972]

After:

bun install
[2969/2972] 🚚 short-package-1 
[2970/2972] 🚚 long-package-name-layout-shifter 
[2971/2972] 🚚 short-package-2

How did you verify your code works?

Created test project with package.json:

{"dependencies":{"next":"latest", "gatsby": "latest"}}

and ran:

BUN_DEBUG_QUIET_LOGS=1 bun/build/debug/bun-debug i

verified that the buffer displayed the counter and ellipses as expected.

dylan-conway and others added 18 commits April 8, 2026 14:51
…ven-sh#29021)

Two pre-existing `fs.Stats` constructor bugs in `NodeFSStatBinding.cpp`,
both found by the review bot on oven-sh#28989 (no overlap with that diff).

## 1. `Stats(...)` without `new` scrambles fields

`callJSStatsFunction` wrote `putDirectOffset` slots 0–9 in Node's
constructor-argument order (`dev, mode, nlink, uid, gid, rdev, blksize,
ino, size, blocks`), but the structure's slot layout
(`createJSStatsObjectStructure`) is `dev, ino, mode, nlink, uid, gid,
rdev, size, blksize, blocks` — so 8 of 10 integer fields landed under
the wrong property:

```js
const s = require("fs").Stats(0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
s.ino   // 1 (the "mode" argument)
s.size  // 7 (the "ino" argument)
```

`new Stats(...)` was already correct (uses `putDirect` by name), as was
`fs.statSync` (`Bun__createJSStatsObject` uses the right offsets). Fix:
reorder the ten `putDirectOffset` calls to match the structure.

## 2. `statSync(p) instanceof Stats` returns `false`

`initJSStatsClassStructure` created one `JSStatsPrototype` for
`Stats.prototype` / the constructor, and `createJSStatsObjectStructure`
created a **second** one baked into the instance structure. So every
instance's `[[Prototype]]` was a different object than
`Stats.prototype`:

```js
statSync(".") instanceof Stats                       // bun: false, node: true
Object.getPrototypeOf(statSync(".")) === Stats.prototype  // bun: false, node: true
```

Methods like `.isFile()` still worked because both prototypes were real
`JSStatsPrototype` instances — just not the same one. Same issue for
`BigIntStats`. Fix: `createJS{,BigInt}StatsObjectStructure` now take the
prototype as a parameter instead of creating their own.

## Tests

`test/js/node/fs/fs-stats-constructor.test.ts` covers both: field
ordering for `new Stats(...)` and `Stats(...)`, and `instanceof` /
prototype identity for `statSync`, `new Stats`, `Stats()`, and
`statSync({bigint:true})`. All fail on released bun, pass on this
branch.
Simpler alternative to oven-sh#28957.

## What

`fs.statSync(file).ino` returns `9223372036854775807` (`INT64_MAX`) for
any file whose inode is `>= 2^63`, because `Stat.zig`'s `clampedInt64`
saturates every `u64` stat field before handing it to C++. Every file on
an NFS mount with high 64-bit inodes collapses to the same number.

## Fix

Make `PosixStat` mirror libuv's `uv_stat_t` — every numeric field is
`u64` — and pass those `u64` values straight through to C++, which does
the same two casts Node does (`src/node_file-inl.h` `FillStatsArray`):

- **`Stats`**: `jsNumber(uint64_t)` → `static_cast<double>` (Node fills
a `Float64Array`)
- **`BigIntStats`**:
`JSBigInt::createFrom(static_cast<int64_t>(uint64_t))` (Node fills a
`BigInt64Array`)

`clampedInt64` is deleted; there is no per-field special-casing.

## vs oven-sh#28957

| | oven-sh#28957 | this PR |
|---|---|---|
| `PosixStat` field types | platform-dependent | all `u64` (=
`uv_stat_t`) |
| conversion helpers in `Stat.zig` | `clampedInt64` + `toF64` + `toI64`
| none |
| fields fixed | `dev`/`ino`/`rdev` only | all 10 (matches Node
uniformly) |
| test helper | ~30 LoC Zig + 18 LoC TS overloads | 5 LoC Zig + 1 LoC TS
|
| FUSE test (skipped in CI) | 267 LoC | dropped |
| net diff | +471 / −12 | +143 / −151 |

## Tests

`test/js/node/fs/fs-stats-truncate.test.ts` drives the real `statToJS`
path with `ino` values at `0`, `2^53-1`, `2^63-1`, `2^63`,
`9225185599684229422` (the NFS inode from the report), and `2^64-1`, and
asserts the Number path matches `Number(u64)` and the BigInt path
matches `BigInt.asIntN(64, u64)` — exactly what Node produces.

```
bun bd test test/js/node/fs/fs-stats-truncate.test.ts   # 3 pass
bun bd test test/js/node/fs/fs.test.ts -t stat          # 16 pass, 1 skip
```

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
…or' (oven-sh#29029)

Recent builds have been showing flaky `annotation error on <target>`
entries (e.g. builds #44422, #44397). These come from
`reportAnnotationToBuildKite` in `scripts/utils.mjs`, which spawns
`buildkite-agent annotate` with a 5-second `spawnSync` timeout. When the
agent doesn't finish in 5s, Node SIGTERMs it, `status` comes back
`null`, and the old fallback posted a second annotation titled
"annotation error" whose body was whatever stderr had been captured so
far — in every observed case just `INFO Reading annotation body from
STDIN`, which is useless.

The Buildkite status page being green doesn't protect against this:
`buildkite-agent`'s HTTP client has its own retry loop with exponential
backoff, so a single transient failure on one request (TCP reset, TLS
hiccup, a lone 5xx, or per-build rate limiting when many parallel test
jobs append to the shared `flaky` context at once) makes the agent sleep
several seconds before retrying internally — already past our 5s kill.
None of that registers as a service outage.

This change raises the timeout to 30s (enough for one internal agent
retry cycle), retries the original annotation once on failure instead of
posting a content-free "annotation error", and logs-and-continues rather
than `throw`ing if the retry also fails — the throw path could abort
`runner.node.mjs` mid-suite over what is ultimately a cosmetic reporting
step.
`getExecPathFromBuildKite` runs `buildkite-agent artifact download` via
`spawnSafe` with a 60 s timeout. On timeout `spawnSafe` kills the
process and returns `{ error: "timeout" }` — it doesn't throw — and the
caller doesn't check it. The loop then picks whichever `bun*.zip` made
it to disk and continues.

On a slow win-aarch64 VM (build #44517, 38 MiB in 52 s) the 149 MiB
`*-profile.zip` never finished, so the runner silently fell back to the
release `bun.exe` instead of `bun-profile.exe`. That in turn made
`which.test.ts` fail because `basename(process.execPath)` became
`"bun.exe"`, which `bootstrap.ps1` bakes into `C:\Windows\System32\` on
the v14 image.

Bump the timeout to 120 s and throw if it's hit so the job fails with a
clear message instead of running the wrong binary.
### What does this PR do?

On NixOS, `autoPatchelfHook` rewrites bun's `PT_INTERP` from
`/lib64/ld-linux-x86-64.so.2` to a `/nix/store/<hash>-glibc-.../lib/...`
path. `bun build --compile` copies the running binary verbatim, so the
output inherits that store path and only runs on the exact same Nix
generation.

This adds `ElfFile.normalizeInterpreter()`: when injecting the `.bun`
section, if `PT_INTERP` starts with `/nix/store/` or `/gnu/store/`,
rewrite it to the standard FHS path by mapping the linker basename:

| basename | rewritten to |
|---|---|
| `ld-linux-x86-64.so.2` | `/lib64/ld-linux-x86-64.so.2` |
| `ld-linux-aarch64.so.1` | `/lib/ld-linux-aarch64.so.1` |
| `ld-musl-x86_64.so.1` | `/lib/ld-musl-x86_64.so.1` |
| `ld-musl-aarch64.so.1` | `/lib/ld-musl-aarch64.so.1` |

Store paths are always longer than the FHS path (32-char hash + pname +
`/lib/` alone exceeds any of these), so this is an in-place shrink —
write the new string, zero-fill, update `p_filesz`/`p_memsz`. No segment
moves.

**No effect on non-Nix users** — gated on the store-path prefix; unknown
basenames are left untouched.

Fixes oven-sh#24742

### How did you verify your code works?

- `bun bd` builds, `bun run zig:check-all` passes on all targets
- New `test/regression/issue/24742.test.ts` (Linux-only, requires
`patchelf`): copies bun, sets a fake `/nix/store/...` interpreter, runs
`bun build --compile --compile-executable-path=<patched>`, asserts
output's `PT_INTERP` is the FHS path and the binary executes
- Test correctly skips on macOS locally; Linux CI will exercise the pass
case

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
## What & why

The WebSocket HTTP upgrade client received host, path, protocol,
headers, proxy host, proxy auth, proxy headers and target authorization
from C++ as `ZigString` wrappers over the underlying `WTF::StringImpl`.

When a `WTFStringImpl` was **not** 8-bit ASCII (either Latin1 with high
bytes, or UTF-16), calling `.slice()` on the `ZigString` in Zig returned
raw Latin1 / UTF-16 code units — not UTF-8. Those bytes were then
substituted into a printf-style format string inside `buildRequestBody`,
and the resulting garbage length could cause heap corruption in mimalloc
during `std.fmt.allocPrint`:

```
_mi_heap_realloc_zero (vendor/mimalloc/src/alloc.c)
Io.Writer.Allocating.drain (vendor/zig/lib/std/Io/Writer.zig)
Io.Writer.alignBuffer (vendor/zig/lib/std/Io/Writer.zig:525)
http.websocket_client.WebSocketUpgradeClient.buildRequestBody (Writer.zig:1007)
Bun__WebSocketHTTPClient__connect (WebSocketUpgradeClient.zig:140)
WebCore::WebSocket::connect (WebSocket.cpp:655)
WebCore::constructJSWebSocket3 (WebSocket.cpp:352)
```

## Fix

- Change the `Bun__WebSocketHTTPClient__connect` /
`…HTTPSClient__connect` FFI signature to take `BunString*` instead of
`ZigString*`.
- In `WebSocket::connect()` in C++, build `BunString` wrappers via
`Bun::toString(WTF::String&)` so the encoding tag is preserved
end-to-end (materialize URL `.host()` as a `WTF::String` first; keep
iterator key/value `WTF::String`s alive in side vectors for the duration
of the call).
- On the Zig side, decode every input up front with
`bun.String.toUTF8(allocator)`, which borrows the 8-bit ASCII backing
when possible and only allocates a UTF-8 copy for non-ASCII Latin1 /
UTF-16 inputs.
- Introduce `Headers8Bit` to hold the decoded header name/value slices
alongside the underlying `ZigString.Slice`s for deterministic cleanup.

## Tests

`test/js/web/websocket/websocket-utf16-headers.test.ts` covers:
- Latin1-with-high-bytes header values are sent as proper UTF-8
(verified by inspecting the upgrade request bytes received by a raw TCP
server).
- Non-ASCII path segments in the target URL.
- Proxy + custom TLS config + Latin1 headers (the exact combination from
the crash report).
- Many Latin1 headers through a proxy.
- Latin1 proxy header values.

Existing `websocket-custom-headers.test.ts` (10/10) and
`websocket-proxy.test.ts` (22/22 passing, 3 pre-existing failures
unrelated to these changes) still pass.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
…oven-sh#28785)

## Problem

When the current working directory is inside a directory that lacks read
permission (mode `0111`), `process.env` becomes completely empty — not
just missing `.env` file vars, but ALL environment variables including
those passed via `execve`.

### Root cause

In `runEnvLoader()`, `loadProcess()` (which reads `std.os.environ` into
the env map) was called **after** `readDirInfo()` (which walks parent
directories for project root discovery). When `readDirInfo()` failed due
to EACCES on the CWD, the function returned early and `loadProcess()`
was never called, leaving `process.env` completely empty.

### Reproduction

```dockerfile
FROM oven/bun:1.3.11 AS builder
WORKDIR /build
RUN echo 'for (const [k,v] of Object.entries(process.env)) { console.log(k + "=" + v); }' > test.ts \\
 && bun build --compile test.ts --outfile /build/test-bin

FROM redhat/ubi8-minimal
USER root
COPY --from=builder /build/test-bin /usr/local/bin/test-bin
RUN mkdir -p /noaccess/subdir && chmod 111 /noaccess
RUN microdnf install -y shadow-utils && microdnf clean all
RUN useradd -m testuser
USER testuser

CMD echo "=== From /tmp (works) ===" \\
 && cd /tmp && MY_VAR=visible test-bin \\
 && echo "=== From /noaccess/subdir (BROKEN) ===" \\
 && cd /noaccess/subdir && MY_VAR=visible test-bin
```

## Fix

1. **`src/transpiler.zig`**: Move `loadProcess()` before `readDirInfo()`
so OS-inherited environment variables are always loaded, regardless of
whether directory traversal succeeds. `.env` file loading still depends
on successful directory listing (which is correct — can't load files
from an unreadable directory).

2. **`src/resolver/resolver.zig`**: Replace `unreachable` at the end of
the directory queue loop with `return null`. When all directories in the
queue are skipped (e.g. every directory including the CWD returned
EACCES), the caller can now degrade gracefully instead of hitting
undefined behavior.

## Verification

- `USE_SYSTEM_BUN=1 bun test test/cli/run/env.test.ts -t 'process.env is
preserved'` → **FAIL** (process.env empty)
- `bun bd test test/cli/run/env.test.ts -t 'process.env is preserved'` →
**PASS** (process.env populated)

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Alistair Smith <hi@alistair.sh>
…#28977)

Split out from oven-sh#18111 by @martinamps.

`--elide-lines` currently exits with an error when stdout is not a
terminal, which breaks scripts that pass the flag and run in both
interactive and CI/hook contexts. The flag is already a no-op in this
case (the elision code only runs in the TTY redraw path), so the error
serves no purpose. This removes it.

Fixes oven-sh#16286

---------

Co-authored-by: Martin Amps <m@rtin.so>
Co-authored-by: Martin Amps <mamps@anthropic.com>
Co-authored-by: robobun <robobun@bun.sh>
…en-sh#28926)

`Bun.dns.setServers` called `toInt32()` on the family and port elements
of each server triple. The internal `toInt32()` helper falls through to
`JSC__JSValue__toInt32`, which calls `JSValue::asInt32()` and asserts
`isInt32()`. Passing a double (e.g. `-9007199254740991`) or leaving the
port out (so the index returns `undefined`) tripped the assertion:

```
ASSERTION FAILED: isInt32()
JavaScriptCore/JSCJSValue.h(994) : int32_t JSC::JSValue::asInt32() const
```

Repro:

```js
Bun.dns.setServers([[-9007199254740991, 15473]]);
```

Switch to `coerceToInt32`, which uses JSC's full numeric coercion. The
existing `family != 4 and family != 6` check still rejects invalid
families with a proper `TypeError`.

Found by Fuzzilli.
Fixes a segfault when reading `.fd` on the result of `Bun.listen({ tls:
{ ... } })`.

`Listener.getFD` was calling `uws_listener.socket(true).fd()` for TLS
listeners. For `is_ssl=true`, the uSockets wrapper
`us_internal_ssl_socket_get_native_handle` returns `s->ssl`, and `fd()`
then calls `SSL_get_fd()` on it. But a listen socket has no SSL object —
SSL is per-connection — so `s->ssl` is uninitialized memory (ASAN poison
`0xbebebe...`) and the call segfaults.

Listen sockets always have a plain poll fd regardless of TLS, so get it
via the non-SSL path.

```
oven-sh#3 SSL_get_rfd (ssl=0xbebebe0000000018)
oven-sh#4 SSL_get_fd  (ssl=0xbebebe0000000018)
oven-sh#5 deps.uws.socket.NewSocketHandler(true).fd () at src/deps/uws/socket.zig:283
   bun.js.api.bun.socket.Listener.getFD at src/bun.js/api/bun/socket/Listener.zig:532
```

Repro (also triggered when `console.log()` introspects the listener):

```js
const s = Bun.listen({
  hostname: "localhost", port: 0,
  socket: { data(){}, open(){}, close(){} },
  tls: { passphrase: "abc" },
});
console.log(s.fd);
```

Found by Fuzzilli.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Adds a short "Code Review Self-Check" section before "Important
Development Notes":

- Justify each non-obvious choice before writing it (research first,
don't write-then-justify)
- Don't take a bug report's suggested fix at face value — verify the
layer
- Understand *why* neighbors do what they do before deviating
When the entrypoint loader is `.md`, bun now reads the file, renders it
to ANSI, prints to stdout, and exits — no JavaScript VM spin-up.

### Supported

- Headings (h1-h6) with colored underlines
- **Bold**, *italic*, ~~strikethrough~~, underline, \`inline code\`
- Ordered / unordered / task lists (with nesting)
- Blockquotes (nested)
- Horizontal rules
- Fenced code blocks with syntax highlighting for JS/TS/JSX/TSX via
  \`QuickAndDirtyJavaScriptSyntaxHighlighter\`
- Tables with per-column alignment + box-drawing borders. Column widths
  are computed globally so **CJK, emoji, and combining characters align
correctly** (uses
\`bun.strings.visible.width.exclude_ansi_colors.utf8\`)
- OSC 8 hyperlinks (with \"text (url)\" fallback when stdout isn't a
TTY)
- Images as alt text with the src as an OSC 8 link
- Wikilinks
- Autolinks (url, www, email)
- Word-wrapping respecting COLUMNS
- Light / dark theme selection via \`COLORFGBG\`, \`NO_COLOR\`,
\`FORCE_COLOR\`

### Wiring

- \`.md\` added to \`default_loaders\` so \`bun ./file.md\` resolves it.
- \`_bootAndHandleError\` short-circuits when the loader resolves to
  \`.md\`, calls the renderer, flushes, and exits.
- No VM boot, no JSC init — much faster than spinning up a runtime.

### Tests

\`test/cli/run/markdown-entrypoint.test.ts\` — 20 snapshot tests
covering
every feature above plus NO_COLOR mode and the \`.markdown\` extension.
Tables specifically include CJK, emoji, and combining-character rows to
verify multi-width grapheme alignment.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <robobun@bun.sh>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
…_callback (oven-sh#29067)

## What

Take an explicit \`us_socket_context_ref\` in
\`us_socket_context_connect\` immediately after setting
\`c->pending_resolve_callback = 1\` (before \`Bun__addrinfo_set\`
exposes \`c\` to the DNS worker), and drop it at every exit of
\`us_internal_socket_after_resolve\`. Snapshot \`context\`/\`ssl\` into
locals at the top of \`after_resolve\` so the unref uses a stable
pointer even after \`us_connecting_socket_free\`/\`close\` has unlinked
\`c\`.

## Why

v1.3.11 crash signature, linux x86_64_baseline:

\`\`\`
Segmentation fault at address 0x00000000
- loop.c:238: us_loop_run_bun_tick
\`\`\`

\`loop\` is the first field of \`us_socket_context_t\`, so address 0x0
means \`c->context\` was NULL when the inlined \`after_resolve\` ran
\`c->context->loop->num_polls--\`. The existing invariant relies on the
link-ref taken by \`us_internal_socket_context_link_connecting_socket\`;
this PR makes the ref independent of link/unlink so the context is
guaranteed live for the entire pending-resolve window regardless of
which list \`c\` is on.

Features in the report: spawn, fetch, http_client_proxy, WebSocket,
abort_signal. \`Bun.spawnSync\` (whose tick changed in v1.3.11 from
\`tickWithoutJS\` to \`tickTasksOnly\`) lets \`dns_ready_head\`
accumulate while the JS thread is blocked, widening the window.

## Testing

No deterministic repro on macOS — the production crash is on linux with
the work-pool getaddrinfo path; an ASAN stress test (concurrent multi-IP
WebSocket + spawnSync + abort, 400 iters) runs clean on the debug build.
Existing suites pass on the debug build: socket.test.ts (29/29),
node-net.test.ts (31/31+1 skip), resolve-dns.test.ts (71/71).

## Related

- oven-sh#29064 snapshots \`loop\` before unref in
\`us_connecting_socket_free\` (independent ordering fix)
- oven-sh#29065 takes the ref inside \`after_resolve\` instead — this PR
supersedes it by acquiring the ref before the DNS thread can observe
\`c\`
… connecting socket is closed (oven-sh#29068)

Stacked on oven-sh#29067.

Two commits, both addressing the case where a \`us_connecting_socket_t\`
is closed before \`us_internal_socket_after_resolve\` runs.

## 642c35c — release addrinfo_req in the closed branch of after_resolve

When the socket is closed before the DNS callback fires,
\`us_connecting_socket_close()\` finds \`c->addrinfo_req == NULL\` (it
is only assigned by \`us_internal_dns_callback\`) and skips
\`Bun__addrinfo_freeRequest\`. The callback later assigns it and
enqueues \`c\` on \`dns_ready_head\`; \`after_resolve\` then takes the
\`c->closed\` branch and frees \`c\` without ever decrementing the
request refcount taken by \`Bun__addrinfo_get\`. Release it here.

## c311958 — cancel the pending DNS notify so close does not have to
wait for getaddrinfo

Previously, closing while DNS resolution was still in flight only marked
\`c->closed\`; the socket and its context remained pinned until the DNS
worker eventually called back. If \`getaddrinfo\` never returned, both
leaked.

\`c->addrinfo_req\` is now stored at connect time and a new
\`Bun__addrinfo_cancel(req, socket)\` removes the socket from the
request notify list under the global cache lock. \`afterResult\` sets
\`result\` and moves the notify list out under that same lock, so a
non-null \`result\` means the callback has fired or is about to fire and
cancellation is refused.

In \`us_connecting_socket_close\`, when \`pending_resolve_callback\` is
set, attempt cancellation. On success: undo what
\`us_socket_context_connect\` set up (\`num_polls\`/\`active_handles\`,
the pending-resolve context ref from oven-sh#29067, the request refcount) and
free the socket immediately. On failure: leave the socket alive so
\`after_resolve\` finishes teardown via its closed branch (first
commit).

## Testing

socket.test.ts (29/29), node-net.test.ts (31/31+1 skip),
resolve-dns.test.ts (71/71), fetch-abort (2/2), and a 100-iter abort
smoke test all pass on the debug build. The leak itself is a pinned
\`internal.Request\` refcount and a pinned context, neither observable
from JS without instrumentation, so no fail-before regression test.

---------

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
…ven-sh#29062)

Changes the Windows aarch64 runner sizes from `D16ps_v6`/`D4ps_v6` to
`D16pds_v6`/`D4pds_v6`. Same vCPU/RAM; the `d` infix adds local NVMe,
which lets robobun place the OS disk there via `diffDiskSettings`
(already wired up on the robobun side) — faster boot, faster disk I/O,
and no per-VM managed-disk cost.

**Do not merge until the `StandardDpdsv6Family` vCPU quota (requested to
100 across all regions) is approved** — current limit is 10
cores/region, which is less than one `D16pds_v6`.
### What does this PR do?

### How did you verify your code works?

---------

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
@kepae
kepae force-pushed the install-progress-counter branch from e7aceaa to aab6c8e Compare April 9, 2026 13:25
dylan-conway and others added 10 commits April 9, 2026 11:18
Fixes oven-sh#29072

## Repro

```js
import os from 'node:os';
console.log('total:', os.totalmem());
console.log('free :', os.freemem());
console.log('ratio:', (os.freemem() / os.totalmem()).toFixed(4));
```

On a Linux host with `MemTotal: 130G`, `MemFree: 36G`, `MemAvailable:
117G`:

```
$ bun repro.mjs        $ node repro.mjs
total: 133613760512    total: 133613760512
free : 36713017344     free : 121134342144
ratio: 0.2748          ratio: 0.9066
```

## Cause

`src/bun.js/bindings/OsBinding.cpp` on Linux used `sysinfo(2)`:

```cpp
struct sysinfo info;
if (sysinfo(&info) == 0) {
    return info.freeram * info.mem_unit;
}
```

`sysinfo.freeram` counts only pages that are completely unused — it
matches `MemFree` in `/proc/meminfo` and **excludes** the reclaimable
page cache. On a healthy Linux system the page cache is typically huge,
so this is always much smaller than what applications expect.

Node.js (via libuv, `vendor/libuv/src/unix/linux.c:2074`) reads
`MemAvailable:` from `/proc/meminfo` and only falls back to
`sysinfo.freeram` on failure. `MemAvailable` is the kernel's own
estimate of memory available for starting new applications without
swapping — it includes reclaimable cache/slab, which is what
`os.freemem()` is documented to return.

## Fix

Port libuv's `uv__read_proc_meminfo` + `uv_get_free_memory` logic to
`Bun__Os__getFreeMemory`:

1. Read `MemAvailable:` from `/proc/meminfo`
2. Fall back to `sysinfo.freeram * mem_unit` only if that fails

Parsing matches libuv's implementation (sscanf `"%PRIu64 kB"`, 4096-byte
buffer). Darwin and Windows paths are unchanged.

## Test

`test/regression/issue/29072.test.ts` — reads `/proc/meminfo` directly
and asserts that `os.freemem()` is close to `MemAvailable` (not
`MemFree`). On any system with a populated page cache the two differ
enough that the pre-fix behaviour fails the tolerance check, and when
`MemAvailable` is meaningfully larger than `MemFree` the test also
asserts that `freemem()` is closer to `MemAvailable` than to `MemFree`.

Skipped on non-Linux (Darwin/Windows don't use this code path).

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
)

Bumps `ZIG_COMMIT_PARALLEL` to `7d3c0c9b36` (oven-sh/zig
`upgrade-0.15.2` tip) and drops the darwin-only gate so Linux local
builds use the parallel compiler too.

The previous parallel commit hung on Linux during ELF flush — sharded
codegen emits per-function COMDAT sections and zig's self-hosted ELF
`-r` merge did one `pread`+alloc per section (hundreds of thousands of
tiny syscalls). `7d3c0c9b36` batches those into per-file contiguous
reads.

CI and Windows stay on the stable compiler.

> [!NOTE]
> Don't merge until autobuild for `7d3c0c9b36` finishes on oven-sh/zig
(compiler download will 404 otherwise).
Adds a short section to CLAUDE.md clarifying that edits to
`packages/bun-types/**/*.d.ts` are type-only and don't need a native
build — `bun-types.test.ts` just packs the declarations and runs `tsc`
against fixtures, so it can be run with system Bun directly.

Prevents agents from kicking off a 30-min cold build to validate a
`.d.ts` tweak.
Bump `LATEST` to 1.3.12 and `package.json` version to 1.3.13 following
the v1.3.12 release.
…ven-sh#29101)

## What does this PR do?

Calling `ws.close()` on a `wss://` WebSocket that is connecting through
an HTTP CONNECT proxy while the inner TLS handshake is in flight
double-freed `WebSocketProxy.#target_host`, corrupting the mimalloc
freelist. Subsequent allocations (X509 parse, `path.normalize`,
`Buffer.from`) crashed at garbage addresses.

The re-entrancy chain: `clearData()` → `WebSocketProxy.deinit()` frees
`target_host` then calls `tunnel.shutdown()` →
`SSLWrapper.shutdown(true)` synchronously fires `onHandshake`/`onClose`
→ `WebSocketProxyTunnel.onClose` → `upgrade_client.terminate()` →
`fail()` → `tcp.close()` → `handleClose()` → `clearData()` again, before
`this.proxy` had been set to `null`, so `deinit()` runs a second time.

Fix: null `this.proxy` and detach the tunnel's non-owning
`#upgrade_client` back-pointer before calling `deinit()`, so the
SSLWrapper shutdown callbacks cannot re-enter the upgrade client.
`#upgrade_client` is not an owning reference (the tunnel's own `deinit`
never frees it and `setConnectedWebSocket` already clears it the same
way), so detaching does not leak.

## Related issues

Most likely fixes oven-sh#28153 — crash report shows `WebSocket(25)` +
`http_client_proxy(966)` both active, segfault at `0x73746E657665227B` =
ASCII `{"events`, the exact freed-string-as-pointer freelist-corruption
signature this bug produces.

Most likely fixes oven-sh#27790 — same reporter/app/env as oven-sh#28153 with
`WebSocket(4)` + `http_client_proxy(84)`, segfault after ~3min uptime.

Not a duplicate of oven-sh#28965 — that PR fixes a memory leak (tunnel.shutdown
never closed the underlying socket); this one fixes a double-free. They
touch adjacent code but are independent bugs.

## How did you verify your code works?

New test
`test/js/web/websocket/websocket-proxy-close-reentrancy.test.ts` spawns
a fixture that opens 80 `wss://` connections through a local HTTP
CONNECT proxy and calls `close()` at staggered offsets across the
CONNECT → TLS handshake window. Before this change the fixture segfaults
in ~50ms on a release build (crash address `0x5448202F20544547` = ASCII
`"GET / HT"` — freed `target_host` bytes reinterpreted as a pointer).
With the fix it exits 0 in ~1.3s on a debug build. Skipped on Windows
where libuv defers the close callback so the re-entrancy cannot occur.
Pass `--locked` to `cargo build` in `emitCargo()` so transitive Rust
crate versions can't silently re-resolve against crates.io.

Bumps `LOLHTML_COMMIT` one commit forward (`e3aa547`→`77127cd`) because
the old pin's `c-api/Cargo.lock` was stale (upstream forgot to
regenerate it for 2.7.2). The new pin is the upstream lockfile fix; only
`c-api/Cargo.lock` differs between the two — same lol-html 2.7.2 source.
…cket_free (oven-sh#29064)

us_internal_socket_context_unlink_connecting_socket() calls
us_socket_context_unref(), which can drop the last reference and queue
the context for free. The two lines that follow then dereference
c->context->loop. Snapshot the loop pointer before unlink runs.

Matches the existing snapshot idiom in start_connections
(context.c:596-597).

Part of defensive hardening for the v1.3.11 crash signature (segfault at
0x0 inside us_loop_run_bun_tick at loop.c:238, the inlined
us_internal_socket_after_resolve / DNS drain path). No deterministic
repro; ASAN stress test (WebSocket + cancel + spawnSync, 50 iters) runs
clean on the debug build. Existing socket / node:net / DNS / proxy tests
pass (proxy redirect timeouts are pre-existing on main).

Companion PR: ref/unref the context across
us_internal_socket_after_resolve.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants