Skip to content

fix(hir+codegen): chained Buffer.concat(x).slice(...) → real Buffer.slice (#1177) - #1180

Merged
proggeramlug merged 1 commit into
mainfrom
fix/1177-buffer-slice-dangling-view
May 20, 2026
Merged

fix(hir+codegen): chained Buffer.concat(x).slice(...) → real Buffer.slice (#1177)#1180
proggeramlug merged 1 commit into
mainfrom
fix/1177-buffer-slice-dangling-view

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

Fixes #1177. Buffer.concat(chunks).slice(0, 8) written inline (no intermediate const) returned a "string" of length 8 whose bytes were all empty/undefined. Storing the concat result in a local first made .slice work — which is why the bug went unnoticed: every example in real code happened to use the local-bound form, until someone wrote the natural one-liner during the #1132 verification detour.

Root cause

The buffer-method dispatch block at crates/perry-hir/src/lower/expr_call/mod.rs:3809 is guarded by if let ast::Expr::Ident(arr_ident) = member.obj.as_ref() — it only fires when the .slice receiver is a bare identifier. For chained Buffer.concat(chunks).slice(0,8) the receiver is itself a CallExpr, so the buffer-aware path is skipped.

The .slice arm at :5396 folded receivers like ArrayMap, ArraySort, Array(_), etc. to Expr::ArraySlice, but BufferConcat / BufferFrom / BufferSlice weren't in the list. Generic Call fallthrough routed .slice through js_native_call_method which picked String.slice semantics on the NaN-boxed Buffer pointer — producing typeof === "string", isBuffer === false, length=8, all bytes empty.

Fix

  1. HIR fold (expr_call/mod.rs:5396): before the existing ArraySlice fold, recognize BufferConcat / BufferFrom / BufferSlice receivers and emit Expr::BufferSlice { buffer, start, end }. Must come before the Array shapes so a Buffer receiver isn't misrouted to ArraySlice (which would call js_array_slice on a Buffer pointer).
  2. Codegen (expr/mod.rs): Expr::BufferSlice had a HIR variant declared and walked but no codegen lowering. Added an arm that lowers start/end to i32 (default 0 and i32::MAX for the "no end" case — js_buffer_slice clamps end to length) and calls js_buffer_slice directly. js_buffer_slice already ptr::copy_nonoverlapping's bytes into a newly allocated Buffer registered in BUFFER_REGISTRY, so the result has its own backing storage independent of the parent's lifetime.

Test plan

  • Issue-body repro Array.from(Buffer.concat(chunks).slice(0, 8)).join(",")137,80,78,71,13,10,26,10 (was , , , , , , , )
  • typeof Buffer.concat(c).slice(0,8) === "object" (was "string")
  • Buffer.isBuffer(Buffer.concat(c).slice(0,8)) === true (was false)
  • Chain of slices Buffer.concat(c).slice(0,8).slice(2,4)78,71
  • Buffer.from([1,2,3,4,5]).slice(1,4)2,3,4
  • Default-end Buffer.concat(c).slice(2)78,71,13,10,26,10
  • Pre-existing stored-then-slice form const b = Buffer.concat(c); b.slice(0,8) still works (regression check)
  • Existing HIR scope leak: outer arrow's (req, res) native-instance tag bleeds into inner same-named (res) parameter #1132 verification (http.createServerhttp.getchunksslice) unchanged
  • cargo test --release -p perry-hir -p perry-codegen clean

…ffer.slice

`Buffer.concat(chunks).slice(0, 8)` written inline (no intermediate
`const`) returned a "string" of length 8 whose bytes were all
empty/undefined — the user-visible bug from the #1132 verification
detour. Storing the concat result in a local first made `.slice` work
correctly, which is why the bug went unnoticed (every example in
real code happened to use the local-bound form, until someone wrote
the natural one-liner).

## Root cause

The buffer-method dispatch block at
`crates/perry-hir/src/lower/expr_call/mod.rs:3809` is guarded by
`if let ast::Expr::Ident(arr_ident) = member.obj.as_ref()` — it only
fires when the `.slice` receiver is a bare identifier. For a chained
`Buffer.concat(chunks).slice(0,8)` the receiver is itself a `CallExpr`,
so the buffer-aware path is skipped.

The `.slice` arm at `:5396` then folded receivers like `ArrayMap`,
`ArraySort`, `Array(_)`, etc. to `Expr::ArraySlice`, but `BufferConcat`
/ `BufferFrom` / `BufferSlice` weren't in the list. Generic Call
fallthrough routed `.slice` through `js_native_call_method` which
picked String.slice semantics on the NaN-boxed Buffer pointer
— producing `typeof === "string"`, `isBuffer === false`, `length=8`,
all bytes empty.

## Fix

Two small pieces:

1. **HIR fold** (`expr_call/mod.rs:5396`): before the existing
   ArraySlice fold, recognize `BufferConcat` / `BufferFrom` /
   `BufferSlice` receivers and emit `Expr::BufferSlice { buffer,
   start, end }`. Must come BEFORE the Array shapes so a Buffer
   receiver isn't misrouted to `ArraySlice` (which would call
   `js_array_slice` on a Buffer pointer).

2. **Codegen** (`expr/mod.rs`): `Expr::BufferSlice` had a HIR variant
   declared and walked but no codegen lowering. Added an arm that
   lowers start/end to i32 (default `0` and `i32::MAX` for the
   "no end" case — `js_buffer_slice` clamps end to length) and calls
   `js_buffer_slice` directly, NaN-boxing the resulting fresh
   `BufferHeader` with POINTER_TAG. `js_buffer_slice` already
   `ptr::copy_nonoverlapping`'s bytes into a newly allocated Buffer
   registered in BUFFER_REGISTRY, so the result has its own backing
   storage independent of the parent's lifetime.

## Verified

- Issue-body repro `Array.from(Buffer.concat(chunks).slice(0, 8)).join(",")`
  → `137,80,78,71,13,10,26,10` ✓ (was `, , , , , , , `)
- `typeof Buffer.concat(c).slice(0,8) === "object"` ✓ (was `"string"`)
- `Buffer.isBuffer(Buffer.concat(c).slice(0,8)) === true` ✓ (was `false`)
- Chain of slices `Buffer.concat(c).slice(0,8).slice(2,4)` → `78,71` ✓
- `Buffer.from([1,2,3,4,5]).slice(1,4)` → `2,3,4` ✓
- Default-end `Buffer.concat(c).slice(2)` → `78,71,13,10,26,10` ✓
- Pre-existing stored-then-slice form `const b = Buffer.concat(c); b.slice(0,8)`
  still works (regression check) ✓
- Existing #1132 verification repro
  (`http.createServer` → `http.get` → `chunks` → `slice`) unchanged
- `cargo test --release -p perry-hir -p perry-codegen` clean
@proggeramlug
proggeramlug merged commit 01ecdb1 into main May 20, 2026
16 of 17 checks passed
@proggeramlug
proggeramlug deleted the fix/1177-buffer-slice-dangling-view branch May 20, 2026 13:48
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.

Buffer.concat(x).slice(...) returns dangling view — data is zeroed/freed when the concat result isn't held in a local

1 participant