Skip to content

Read typed array elements in place at any address (console.log, Bun.unsafe.arrayBufferToString, node:zlib) - #46580

Closed
robobun wants to merge 4 commits into
mainfrom
robobun/9d804ea5/unaligned-typed-array-read
Closed

robobun wants to merge 4 commits into
mainfrom
robobun/9d804ea5/unaligned-typed-array-read

Conversation

@robobun

@robobun robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun aborts when it prints a typed array whose ArrayBuffer is at an unaligned address: panic: cast_slice>TargetAlignmentGreaterAndInputNotAligned, exit 134. Repro: console.log(new Int32Array(toArrayBuffer(ptr(u8), 1, 16))) with bun:ffi. v1.3.14 prints it.
  • print_typed_array (src/jsc/ConsoleObject.rs:5557) and Bun.unsafe.arrayBufferToString (src/runtime/api/UnsafeObject.rs:35) cast the bytes with bytemuck::cast_slice, which panics. ArrayBuffer::as_u32 (node:zlib) checks only in debug builds.

Fix

  • ArrayBuffer::elements::<T>() returns &[Unaligned<T>]. The console formatter, the bun:test formatter and node:zlib read through it. arrayBufferToString copies the units when the address is odd. as_u32 is deleted.
  • Correct because no &[T] is formed. x64 and arm64 load unaligned with the same instruction. Node prints these arrays.
  • Verified: 12 new tests in test/js/bun/util/inspect.test.js, unsafe.test.js and test/js/node/zlib/zlib-handle-bounds-check.test.ts. Also console-log.test.ts, zlib.test.js.
  • Self-reviewed: 8 concerns raised, 7 addressed. Not done: a lint against cast_slice on buffer bytes.

Background

  • An ArrayBuffer can adopt memory that JSC did not allocate (bun:ffi, N-API, Bun.mmap, a slice of a large Blob). It starts at any address.
  • Unaligned<T> is #[repr(C, packed)] around one T, valid at any address. A &[T] at an unaligned address is undefined behaviour.
  • Considered a copy fallback at each call site: as_u32 keeps its debug-only check. Considered aligning in the producers: bun:ffi and N-API addresses are the caller's choice.

Downsides

  • A print of a 1-byte typed array runs 3 more instructions (103 to 106), a wide one 1 fewer. Per-element code and .text size do not change.
  • cast_slice(buffer.byte_slice()) still compiles. A clippy disallowed-methods entry would reject it (68 call sites, not here).
Notes

Origin. Found during the review of #45947. That PR rewrites the memfd mapper and the Blob Transfer arm and leaves the readers to a separate change, which is this one. There is no GitHub issue for this abort. The bun.report and Sentry counts were not checked.

History of this panic. #31417 handled it for Blob.text() and Blob.json() (it took over #31006). #31188 is a user report of the same cast for an odd length. #45450 added an is_empty() guard to arrayBufferToString for the empty slice of a detached view. This PR removes that guard: try_cast_slice fails for that slice and the copy arm returns the empty string. test/js/bun/util/unsafe.test.js pins it.

Regression window. The Zig code used an unchecked @alignCast. The Rust port (#30412) made the cast checked. v1.2.20, v1.3.5 and v1.3.14 print a view of a bun:ffi buffer and of a slice of a 12 MiB Blob. v1.4.0 to v1.4.4 abort. Bun.mmap returns the view at the requested offset since #34120 (v1.4.0), so v1.3.14 had no unaligned view there.

Who reaches it. Every entry into the console formatter: console.*, %o, a table cell, Bun.inspect, the REPL, the printer of an uncaught error, the message of a failed bun:test matcher (the test runner aborts), a Node error that prints the value it received. Without bun:ffi: a slice at an odd offset of a Blob of 8 MiB or more (Linux), a Response body that is a slice of a Blob, Bun.mmap at an odd offset.

Not changed.

Credit. The Pod impl for Unaligned<T> and the pretty_format.rs hunk are from #40235. #44268 deletes pretty_format.rs and Unaligned::get and keeps the cast_slice arms of ConsoleObject.rs. The PR that lands second rebases.

Measurements. Release builds on linux x64 of 5c6d4e2 (main) and of 67693a9 (this PR).

What main this PR
the 11 per-element closures (llvm-objdump) identical
print_typed_array per call, Int8Array(4) (gdb nexti) 103 instructions, 10 branches 106, 10
same, Uint8Array(4) 153, 23 156, 23
same, Int32Array(4) 107, 12 106, 10
alignment tests, panic edges in print_typed_array 18, 1 0, 0
Bun.inspect(Int32Array(513)), instructions per print 339,337 339,334
Bun.inspect(Uint8Array(513)), instructions per print 336,287 336,303
flush_write_result of zlib, brotli, zstd, instructions 36, 49, 51 36, 49, 51
deflateSync(1 KiB), instructions per call 47,813 47,754
arrayBufferToString, even address, allocations per 1000 calls 1182 1180
arrayBufferToString, odd address, allocations per 1000 calls abort 1224
bun file size, .text size 107,808,480 B, 82,231,705 B the same
unsafe blocks 3 fewer, 2 more unsafe impl
  • The instruction counts are instructions:u from perf_event_open, with main and the PR interleaved 10 times. The table has the lowest count of each side. Main against main differs by 6 to 39 instructions per print and by 71 per deflateSync call.
  • print_typed_array runs 3 more instructions for a 1-byte array. They are register moves. Uint8Array(513) as a whole is 15 above main in the lowest counts and 2 below main in the medians. An earlier pair of builds, on fec4a88, gave 28 above main. I did not find a cause other than those 3 instructions.
  • arrayBufferToString at an odd address allocates the string first and copies the bytes into it (encoding::to_bun_string): one allocation and one copy, as at an even address. A view of 2^31 units gives the empty string at both addresses, with no copy.
  • 6 of 111,046 functions changed size: print_typed_array 1693 to 1556 B and 1735 to 1571 B, the three node:zlib init functions by -13, -29 and -36 B, arrayBufferToString 288 to 486 B.

Tests.

Build inspect.test.js unsafe.test.js zlib-handle-bounds-check.test.ts
main, debug and ASAN 8 fail 2 fail 2 fail
main, release 8 fail 2 fail pass
this PR, debug and ASAN pass pass pass
this PR, release pass pass pass

The two node:zlib tests fail only on a debug or ASAN build of main. A release build of main stores the right values through a misaligned &mut [u32].

Each child that reads a buffer that Bun makes (Bun.mmap, a slice of a large Blob, a Response body) prints the address of the buffer modulo 4 first, so the test fails if the buffer is aligned. The Blob test runs only where a memfd is available (Linux). The two Bun.mmap tests do not run on Windows, which has no Bun.mmap. The other tests run on every platform.

aarch64 Atomics: a separate problem, not changed here. JSC runs Atomics.* with atomic instructions, and arm64 faults on an unaligned atomic access. Measured with Bun v1.4.4 linux-aarch64 under qemu-aarch64 10.0.13 (user mode), not on arm64 hardware:

  • -cpu cortex-a72 and -cpu neoverse-n1 (no LSE2): Atomics.load on element 0 of an Int32Array over an unaligned buffer ends in Bus error, exit 135.
  • -cpu neoverse-v1 and -cpu max (LSE2): an element that crosses a 16-byte boundary ends in Bus error.
  • Atomics.add(new Int32Array(await new Blob([new Uint8Array(12 * 1024 * 1024)]).slice(1, 17).arrayBuffer()), 0, 1) ends in Bus error with -cpu neoverse-n1. Node gives an aligned buffer for a slice of a Blob.
  • Node v26.3.0 linux-arm64 faults the same way for a node:ffi buffer at an unaligned address.

Only an aligned buffer from the producer avoids it. That is a change to the memfd mapper and the Transfer arm (#45947).


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/unsafe.test.js, test/js/bun/util/inspect.test.js

…nsafe.arrayBufferToString, node:zlib)

An ArrayBuffer over memory that JSC did not allocate (bun:ffi
toArrayBuffer, an N-API external buffer, Bun.mmap with an offset, a
slice of a large Blob) can start at any address. The console formatter
and Bun.unsafe.arrayBufferToString cast the bytes of a typed array to
`&[T]` with `bytemuck::cast_slice`, which panics when the address is
not aligned for `T`:

    panic: cast_slice>TargetAlignmentGreaterAndInputNotAligned

`ArrayBuffer::as_u32` (node:zlib) made the same cast behind a debug
assertion.

`ArrayBuffer::elements::<T>()` and `elements_mut::<T>()` now return
`&[Unaligned<T>]`, built by `Unaligned::slice_from_bytes`. The console
formatter, the bun:test formatter and node:zlib read and write elements
through it. `arrayBufferToString` keeps the cast when the address is
even, and copies the units when it is not. `ArrayBuffer::as_u32`,
`as_u32_unaligned` and `Unaligned::slice_align_cast_mut` are deleted.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. #46580

How I reproduced it (linux x64):

import { ptr, toArrayBuffer } from "bun:ffi";
const backing = new Uint8Array(64).fill(1);
console.log(new Int32Array(toArrayBuffer(ptr(backing), 1, 16)));
  • Bun v1.2.20, v1.3.5 and v1.3.14 print Int32Array(4) [ 16843009, 16843009, 16843009, 16843009 ].
  • Bun v1.4.0 to v1.4.4 and a release build of main abort: panic: cast_slice>TargetAlignmentGreaterAndInputNotAligned, exit 134.
  • The same abort without bun:ffi: console.log(new Uint16Array(await new Blob([new Uint8Array(12 * 1024 * 1024)]).slice(1, 17).arrayBuffer())) on Linux, and a view of Bun.mmap(path, { offset: 1, size: 16 }).buffer.
  • Bun.unsafe.arrayBufferToString(new Uint16Array(buffer)) aborts on the same buffers.

@robobun

robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:23 AM PT - Oct 23rd, 2026

✅ @robobun, your commit 0edfa004ab0719fb4cf3c84107c4f046f1e21c32 passed in Build #131294! 🎉


🧪   To try this PR locally:

bunx bun-pr 46580

That installs a local version of the PR into your bun-46580 executable, so you can run:

bun-46580 --bun

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 671da42 and 0929eba.

📒 Files selected for processing: 2
  • src/runtime/api/UnsafeObject.rs
  • test/js/bun/util/unsafe.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

Walkthrough

The PR adds unaligned element views for Pod values and uses them in typed-array formatting, ArrayBuffer access, and native compression bindings. It updates 16-bit arrayBufferToString handling and adds tests for unaligned views across formatting, string conversion, and compression handles.

Changes

Unaligned Typed-Array Access

Layer / File(s) Summary
Unaligned element views and buffer access
src/bun_core/util.rs, src/jsc/array_buffer.rs, test/harness.ts
Unaligned gains bytemuck traits, a constructor, and byte-slice conversions. ArrayBuffer gains generic element views. The test harness adds a fixture for creating unaligned typed arrays.
Typed-array formatting and string conversion
src/jsc/ConsoleObject.rs, src/runtime/api/UnsafeObject.rs, src/runtime/test_runner/pretty_format.rs, test/js/bun/util/inspect.test.js, test/js/bun/util/unsafe.test.js
Typed-array formatting reads elements through unaligned wrappers. The 16-bit arrayBufferToString branch uses a checked cast with a UTF-16 conversion fallback. Tests cover unaligned formatting and string conversion.
Compression handle access and bounds
src/runtime/node/node_zlib_binding.rs, src/runtime/node/zlib/NativeBrotli.rs, src/runtime/node/zlib/NativeZlib.rs, src/runtime/node/zlib/NativeZstd.rs, test/js/node/zlib/zlib-handle-bounds-check.test.ts
The compression bindings use the new element accessors and retain length checks. Tests cover aligned and unaligned inputs, short buffers, and a shrinking resizable buffer.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 0929e

The selected string-conversion changes have no identified merge-blocking issue; they are ready for normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title names three areas (console.log, Bun.unsafe.arrayBufferToString, node:zlib) but the main change is that typed array elements are now read in place through Unaligned so reads work at any ad…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The pull request description clearly explains the problem, the fix, scope, limitations, performance impact, and verification results. It does not use the exact template headings, but it provides compl…
Full details: Title check

Explanation

The title names three areas (console.log, Bun.unsafe.arrayBufferToString, node:zlib) but the main change is that typed array elements are now read in place through Unaligned<T> so reads work at any address. The title states that main idea and the affected call sites. It is specific and not misleading. The summary matches the changeset: ArrayBuffer::elements/elements_mut, Unaligned<T> views in the console formatter, arrayBufferToString's odd-address copy path, and zlib/zstd/brotli parameter reads, with as_u32 removed.

Full details: Description check

Explanation

The pull request description clearly explains the problem, the fix, scope, limitations, performance impact, and verification results. It does not use the exact template headings, but it provides complete information for both required sections: what the PR does and how the code was verified.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the refactored paths for behavior drift and ruled these out: flush_write_result keeps the old write order (index 0 gets avail_out, index 1 gets avail_in, same as the previous r0/r1 split); the Pod impl for #[repr(C, packed)] Unaligned<T> is sound and slice_from_bytes cannot hit a cast_slice panic (target align is 1, length is truncated to a multiple of size_of::<T>(), ZST is a const assert); and the new .len < 2 checks in the zlib/brotli/zstd init paths run after the Uint32Array type check, so len is the element count the old as_u32().len() returned.

Extended reasoning...

The change replaces aligned &[T] casts over JS ArrayBuffer storage with &[Unaligned<T>] reads in the console formatter, the bun:test formatter, Bun.unsafe.arrayBufferToString, and the three node:zlib bindings, and deletes ArrayBuffer::as_u32 and Unaligned::slice_align_cast_mut. It touches memory-safety-sensitive helpers (bun_core::Unaligned, ArrayBuffer::elements) but no auth, crypto, or injection surface. The one confirmed inline finding (copying a large odd-address Uint16Array before the string length limit is checked) plus the core-helper scope mean a human should still look, so this is a defer rather than an approve.

Comment thread src/runtime/api/UnsafeObject.rs Outdated
…ess view

The odd-address arm collected the units into a `Vec<u16>` and only then
found that a string cannot hold them. A view of 2^31 units cost a 4 GiB
copy to return the empty string, and a failed allocation aborted.

The arm now calls `encoding::to_bun_string`, which allocates the string
first and copies the bytes into it: one allocation, and no copy when the
string cannot be made. The result is the empty string then, as at an
even address.
Comment thread src/bun_core/util.rs Outdated
Comment thread src/bun_core/util.rs Outdated
Comment thread src/bun_core/util.rs Outdated
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/runtime/api/UnsafeObject.rs Outdated
Comment thread src/runtime/api/UnsafeObject.rs Outdated

@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 re-reviewed after 0929eba and found no bugs; the second commit addresses the earlier inline concern (the odd-address arrayBufferToString path now goes through to_bun_string, which fails fast in tryCreateUninitialized before any copy, and the sparse-mmap test pins that). Because this changes the generic ArrayBuffer element accessor and adds unsafe impl Pod/Zeroable for Unaligned<T>, a human look from someone who owns the JSC bindings would still be worthwhile.

What was reviewed:

  • Unaligned<T> Pod/Zeroable impls and slice_from_bytes{,_mut}: repr(C, packed) over a Pod T has no padding and align 1, so cast_slice cannot panic once the length is truncated to a multiple of size_of::<T>().
  • flush_write_result ordering: old code passed &mut r1[0], &mut r0[0] as (avail_in, avail_out), so index 0 = avail_out and index 1 = avail_in — the new locals preserve that; detached/shrunk buffers still return early via byte_slice_mut returning empty.
  • Too-long and odd-address arrayBufferToString both end in an empty string, matching the aligned EncodedSlice::to_js path (toStringCopy returns {} on tryCreateUninitialized failure); is_dead() covers both the Dead and OutOfMemory tags.
  • Searched for remaining callers of as_u32/as_u32_unaligned/slice_align_cast_mut: none left. One test nit only: the Response-body case in inspect.test.js polls collected < 2 without a bound, so a never-firing finalizer hangs the child until the test timeout rather than failing with a message.
Extended reasoning...

The change replaces panicking bytemuck::cast_slice reads of JS typed-array storage with &[Unaligned<T>] reads in the console formatter, bun:test formatter, Bun.unsafe.arrayBufferToString, and the three node:zlib handles, deleting the debug-only-checked ArrayBuffer::as_u32 accessor and three unsafe blocks while adding two unsafe impl lines in bun_core. It touches no auth, crypto, or injection surface; the sensitive part is memory safety of a generic reinterpretation helper used across the runtime. I verified the Pod soundness argument, the preserved index ordering in flush_write_result, the detached-buffer path, and that the fallback string path checks length before allocating; the new tests spawn children for every crashing input and cover ffi, mmap, memfd Blob, Response body, zlib/brotli/zstd. Deferring rather than approving because a core jsc accessor and new unsafe impls merit an owner's look, and the bug hunt only ran dry rather than proving the generic helper sound at every future call site.

Also give the Response body test an error when the Blob and its slice
are not collected, in place of the test timeout.
@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Changes after the first review round:

  • Bun.unsafe.arrayBufferToString at an odd address allocates the string before it copies the units (0929eba). It calls encoding::to_bun_string, so there is one allocation and no Vec. Before that commit, a view of 2^31 units at an odd address took 7.3 s and 4 GiB of memory to return the empty string. It returns at once now. test/js/bun/util/unsafe.test.js has a test for it with a sparse file.
  • A view that is too long for a string still gives the empty string at an odd address, and does not throw. The even-address arm gives the empty string for the same view, and v1.3.14 did too. The new test checks both addresses.
  • Each new comment in src/ is one line (67693a9).
  • The Response body test throws an error when the Blob and its slice are not collected after 100 collections. Before, it ran into the test timeout (67693a9).

@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 re-reviewed the two new commits and found no bugs; the earlier concern about arrayBufferToString copying before the length check is addressed (the fallback now goes through to_bun_string, whose tryCreateUninitialized rejects an over-long length before allocating, and the sparse-mmap test pins copied: false). Because this adds unsafe impl Pod/Zeroable for Unaligned<T> and reworks slice reinterpretation used by the console formatter and node:zlib, a human look at the soundness argument would still be worthwhile.

What was reviewed:

  • Unaligned<T> Pod/Zeroable impls and slice_from_bytes[_mut]: repr(C, packed) over a Pod T has no padding and align 1, the truncation arithmetic cannot underflow, and the size_of != 0 const assert rules out a zero-sized T; bun_core::f16 already has a Pod impl, so the TypedArrayElement: Pod bound compiles for Float16Array.
  • flush_write_result: the new write_result[0] = avail_out / [1] = avail_in matches the old r1[0]/r0[0] mapping against update_write_result(avail_in, avail_out); the len < 2 checks in the three init functions run after the Uint32Array type check, so .len is the element count as before.
  • No remaining callers of as_u32/as_u32_unaligned/slice_align_cast_mut in src/.
Extended reasoning...

The change replaces aligned &[T] casts over JSC ArrayBuffer bytes with &[Unaligned<T>] views in the console formatter, the bun:test pretty formatter, Bun.unsafe.arrayBufferToString and the three node:zlib bindings, adds Pod/Zeroable impls and two safe constructors on Unaligned<T>, and deletes ArrayBuffer::as_u32. It touches no auth, injection or data-exposure surface, but it does add new unsafe impl blocks and changes slice reinterpretation on memory adopted from bun:ffi, N-API and Bun.mmap, which is the memory-safety category Bun reviewers block most often. The two commits since the last review address the one inline finding previously posted, the bug hunt ran dry with no findings, and the behavior-preserving claims (zlib out-parameter ordering, length checks, dead-code removal) check out, which is why this is a defer rather than a request for changes; the unsafe surface and the ~500-line size are why it is not an approve. No CODEOWNERS entry covers the changed files.

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Closing this PR. #47037 landed the same change. On main, console.log, Bun.inspect, the expect() diff printer, Bun.unsafe.arrayBufferToString and the node:zlib write state read typed array memory through Unaligned<T>. bytemuck is gone from main, so this branch no longer applies.

I added the tests of this PR to main at 9b8347a, without its source changes, and ran them on a debug build. test/js/bun/util/inspect.test.js: 113 pass. test/js/node/zlib/zlib-handle-bounds-check.test.ts: 43 pass. test/js/bun/util/unsafe.test.js: 13 pass, 1 fail. The one failure is a difference in behaviour, not the panic: for a Uint16Array of 2^31 units, main throws ERR_STRING_TOO_LONG after it reads all the units, and this PR returned an empty string with no read. The cases here that main has no test for (a view of Bun.mmap at an odd offset, of a slice of a large Blob, of a Response body, an uncaught typed array, the message of a failed matcher) pass on main. A later test-only change can add them.

@robobun robobun closed this Oct 27, 2026
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.

1 participant