Skip to content

response.arrayBuffer() returns metadata-only object — body bytes never reach TS #227

@proggeramlug

Description

@proggeramlug

Summary

`await response.arrayBuffer()` returns an object `{ byteLength: N }` with no actual byte storage — the response body is dropped on the floor. As a result, `Buffer.from(buf)` / `new Uint8Array(buf)` against the returned value end up empty (or length 1 for the synthesized object).

This breaks every binary-download flow: file downloads, image fetches, the auto-updater binary download, anything that needs the response body as bytes rather than `.text()` / `.json()`.

Surfaced where

PR #224 (perry/updater) needed to download a signed binary, verify SHA-256, then write to disk. The high-level `@perry/updater::Update.download()` carries an inline caveat noting this; the smoke test (`scripts/smoke_updater.sh`) sidesteps it by pre-staging via shell `cp` rather than exercising the real download path.

Root cause

`crates/perry-stdlib/src/fetch.rs:1016-1037` — `js_response_array_buffer` allocates a 1-field object with just `byteLength` set and resolves the Promise with that:

```rust
#[no_mangle]
pub unsafe extern "C" fn js_response_array_buffer(handle: f64) -> *mut perry_runtime::Promise {
let promise = perry_runtime::js_promise_new();
let id = handle as usize;
let body_len = {
let guard = FETCH_RESPONSES.lock().unwrap();
match guard.get(&id) {
Some(resp) => resp.body.len(),
None => 0,
}
};
// Allocate an object { byteLength: body_len }
let packed = b"byteLength\0".as_ptr();
let obj = js_object_alloc_with_shape(0x7FFE_FE01, 1, packed, 11);
perry_runtime::js_object_set_field(obj, 0, JSValue::number(body_len as f64));
let val = JSValue::object_ptr(obj as *mut u8);
perry_runtime::js_promise_resolve(promise, f64::from_bits(val.bits()));
promise
}
```

The `resp.body: Vec` is read for its `.len()` only — never copied into the resolved value. The TS side gets an opaque object that quacks like an ArrayBuffer (`byteLength` is queryable) but holds no bytes.

Fix shape

Allocate a real `BufferHeader` (or an ArrayBuffer-shaped object backed by one) with `resp.body.len()` capacity, copy the body bytes into it, and resolve the Promise with the buffer pointer NaN-boxed appropriately. The receiving TS code then treats it as an ArrayBuffer-like value where `new Uint8Array(buf)` / `Buffer.from(buf)` actually see the bytes.

The lookup of `resp` in `FETCH_RESPONSES` already has the body in scope — the body is consumed (`body.clone()` if needed for retention rules) and copied into a freshly-allocated buffer.

`response.blob()` immediately below has the same shape and presumably the same bug; worth fixing together.

Repro

```ts
const res = await fetch("https://example.com/some.bin\");
const buf = await res.arrayBuffer();
console.log("byteLength:", buf.byteLength); // correct
const bytes = new Uint8Array(buf);
console.log("actual length:", bytes.length); // 0 or 1, not byteLength
console.log("first byte:", bytes[0]); // undefined / 0
```

Severity

Blocks any user code that downloads binary payloads via fetch. Currently the workarounds are: route through Node-native `fs` for local files, or use `response.text()` if the payload is text-shaped. There's no in-band way to get a downloaded binary into Perry today.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions