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