fix(desktop): transport binding args and results as DesktopValue so Uint8Array survives - #36573
Conversation
…int8Array survives Binding return values were funneled through serde_json::Value, which has no byte-array representation: a handler returning a Uint8Array made op_desktop_resolve_bind_call reject with "invalid type: byte array, expected any valid JSON value". Arguments took the same JSON round-trip, so a Uint8Array passed webview->Deno arrived as a plain numeric-keyed object (and objectifying large buffers was pathologically slow). laufey::Value and the backends already transport binary natively; only the runtime-side JSON round-trip dropped it. Give DesktopValue (which already has a Binary variant and ToV8 impl) manual Serialize/Deserialize impls where Binary maps to serde bytes - serde_v8 materializes those as Uint8Array in both directions - and switch BindCall args, PendingBindResponses, and op_desktop_resolve_bind_call from serde_json::Value to DesktopValue. Verified with the issue repro on macOS (webview backend): returns no longer reject and carry correct bytes; arguments arrive Deno-side as a real Uint8Array. Fixes #36498 Claude-Session: https://claude.ai/code/session_016w9C7umSW1KUYa7eXwSXRo
crowlbot
left a comment
There was a problem hiding this comment.
Traced both directions against the actual serde_v8 in libs/serde_v8, since the whole PR rests on the claim that serde bytes survive the boundary. The mechanism checks out:
- Rust → JS:
ser.rs:485—serialize_bytes→slice_to_uint8array. AndDesktopEventis#[serde(tag = "kind")], whose struct variants serialize their fields straight through the target serializer, with noContentbuffering — that's only the internally-tagged deserialize path, which isn't used here. SoBinaryreachesserialize_bytesintact. - JS → Rust:
de.rs:166—deserialize_anymapsArrayBufferView | ArrayBuffertovisit_byte_buf, which the new visitor handles. The visitor's missing integer methods are fine too: serde's defaults forwardvisit_i32→visit_i64andvisit_u32→visit_u64, which is exactly what serde_v8 calls atde.rs:152-155.
The JS glue holds as well — cli/rt/desktop.rs:749 does Array.isArray(ev.args), still true now that args is a Vec<DesktopValue> seq rather than a JSON array.
An unadvertised bug fix worth calling out
The old json_to_laufey_value did laufey::Value::Int(i as i32) on an as_i64() — a silently wrapping cast. 3_000_000_000 arrived at the renderer as -1294967296. The new visit_i64/visit_u64 degrade out-of-range integers to Double instead, and the test pins it. That's a real correctness fix beyond the stated scope, and the description undersells it.
Three things I'd raise
1. An avoidable deep copy per call — on the exact path this PR is optimizing.
let args: Vec<DesktopValue> = js_call
.args
.iter()
.cloned()
.map(laufey_value_to_desktop_value)
.collect();laufey_value_to_desktop_value takes ownership (cli/rt_desktop/lib.rs:1530), so .cloned() deep-copies every argument — binary payloads included — purely to satisfy the borrow, and then the conversion moves the same data again. Since part of the motivation is that the old path was pathologically slow for large buffers, std::mem::take(&mut js_call.args) on a mut js_call binding would drop one full copy per call. (A partial move of the field won't work if resolve takes self, hence mem::take.)
2. Typed-array type is silently lost.
Because serde_v8 routes all of ArrayBufferView | ArrayBuffer to visit_byte_buf, a handler returning a Float64Array, Int32Array, DataView, or a bare ArrayBuffer now arrives at the renderer as an opaque byte blob rather than its original view type. That's clearly better than the previous hard error, but it's lossy in a way neither the PR description nor the code comments mention — worth a sentence, especially alongside the existing note about the renderer materializing ArrayBuffer rather than Uint8Array.
3. No recursion depth limit in the new visitor.
A cyclic object returned from a handler (const o = {}; o.self = o; return o) recurses to stack overflow. This is pre-existing — serde_json::Value's visitor recursed unbounded too — so it isn't a regression. But this PR is rewriting precisely that visitor, which makes it the natural moment to add a depth guard.
Minor: PendingBindCall (runtime/ops/desktop.rs:404) had its types updated here, but as far as I can find it's only defined and re-exported through cli/rt/desktop.rs:1206 — never constructed.
…top-binding-uint8array # Conflicts: # runtime/ops/desktop.rs
…ttening Addresses review feedback on #36573: - The bind-call handler did `.iter().cloned()` on `js_call.args`, deep- copying every argument (binary payloads included) purely to satisfy the borrow, on the exact path this transport is meant to make cheap for large buffers. `js_call.resolve` consumes `js_call` so the field can't be moved out; `mem::take` on a `mut js_call` binding drops the copy. - The new visitor recursed unbounded, so a cyclic value returned from a binding handler ran the runtime thread out of stack rather than surfacing an error. Depth is now threaded through a DeserializeSeed and capped at 128, matching serde_json's own limit. Pre-existing with serde_json::Value, but this PR rewrites that visitor. - Document that the view type is not preserved: serde_v8 routes every ArrayBufferView and ArrayBuffer to visit_byte_buf, so Float64Array, Int32Array, DataView and bare ArrayBuffer all arrive as raw bytes.
|
Thanks — thorough trace, and all three land. Fixed in 4ddd7c1, plus a merge of 1. The deep copyFixed exactly as you described. let args: Vec<DesktopValue> = std::mem::take(&mut js_call.args)
.into_iter()
.map(laufey_value_to_desktop_value)
.collect();Good catch that this was on the path the PR is specifically meant to make cheap for large buffers — the clone undercut the stated motivation. 2. Typed-array type lossDocumented in both places. There's now a paragraph on the serde impls spelling out that serde_v8 routes every 3. Recursion depthAdded. Depth is threaded through a Two tests cover it — one nesting through arrays, one through objects, since the two guards are separate. Both build the value as a You're right it's pre-existing rather than a regression, but agreed it's the moment to fix it. The integer fixFair — the description undersold it. There's now a section calling out that the old
|
crowlbot
left a comment
There was a problem hiding this comment.
Re-reviewed at 4ddd7c1.
All three are addressed, and the depth work is done properly — threading the counter through a DeserializeSeed is the only way to carry state into a nested Deserialize, and both visit_seq and visit_map increment before recursing. mem::take on js_call.args is the right call, and the comment explaining why the field can't simply be moved out (resolve consumes js_call) will save the next reader the same detour. The "Limitations" section on typed-array view types is exactly the disclosure that was missing.
One follow-on, since the depth limit is now advertised as covering this: it only guards the JS → Rust direction. A DesktopValue also arrives from the other side, and that path is unbounded:
laufey_value_to_desktop_valuerecurses once per level over a renderer-suppliedlaufey::Value(binding arguments), before the deserializer is ever involved;DesktopValue::to_v8then recurses again over whatever came out.
Cycles presumably can't exist in a laufey::Value — something upstream would have to have already resolved them to build it — but depth can: a deeply nested array or object passed as a binding argument from the renderer still walks the runtime thread's stack, with no MAX_DEPTH anywhere on that path. Worth either bounding those two conversions too, or scoping the doc comment on MAX_DEPTH to the direction it actually covers, so a reader doesn't take it as a general guarantee for DesktopValue.
Also flagging that test integration (2/2) debug macos-x86_64 is red here. The same job is red on #36006 too, so it looks unrelated to this change, but worth confirming before merge.
crowlbot
left a comment
There was a problem hiding this comment.
Following up on the CI question from my last comment — the failure is unrelated to this change:
failed tests:
integration::repl::pty_regex_literal_with_quote
panicked at tests/integration/repl_tests.rs:67:13:
Timed out.
A PTY REPL timeout on macos-x86_64, failing identically on #36573 and #36006, neither of which touches the REPL. Flake — safe to ignore or re-run.
Addresses the second review pass on #36573: MAX_DEPTH only guarded the JS to Rust deserializer, but a DesktopValue also arrives the other way. laufey_value_to_desktop_value recurses over renderer-supplied binding arguments before the deserializer is ever involved, and to_v8 recurses over the result again, so a deeply nested value passed from the page walked the runtime thread off its stack with nothing to stop it. A laufey::Value can't be cyclic, but its depth is whatever the page sent. That conversion is now bounded by the same MAX_DEPTH, which is what makes to_v8 safe: it only ever walks a value that came through one of the two bounded entry points. Too-deep binding arguments reject that one call instead of taking down the app, and the MAX_DEPTH doc now names both directions rather than implying the deserializer covers everything.
|
Bounded the other direction in a3275c9 — you're right that scoping the doc comment would have been the weaker of the two options you offered, since the hazard is real either way.
Too-deep binding arguments reject that one call rather than taking the app down: Err(err) => {
js_call.reject(laufey::Value::String(err));
return;
}The Your point about cycles is right and worth keeping explicit, so it's in the Three tests: arrays past the limit, objects past the limit (separate arm, separate guard), and at-the-limit still converting. On CI: |
crowlbot
left a comment
There was a problem hiding this comment.
Re-reviewed at a3275c9. Fully green, and the remaining gap is closed properly.
Bounding laufey_value_to_desktop_value with the same MAX_DEPTH, exported so both directions share one constant, is the right shape — and surfacing an over-deep argument as js_call.reject(...) rather than an error means a hostile page gets a rejected binding call instead of taking the app down. Rejecting the execute_js result while falling back to the error string on the error branch is the correct asymmetry too: a too-deep error value shouldn't mask what the error was.
The doc comment on MAX_DEPTH is the part worth keeping as-is. Enumerating both entry points, noting that a laufey::Value can't be cyclic but can still be deep, and then closing with why bounding both is what makes to_v8 safe — that last line is the actual invariant, and it now lives somewhere a future edit will trip over it.
Nothing further from me on this one.
Signed-off-by: Leo Kettmeir <crowlkats@toaxl.com>
crowlbot
left a comment
There was a problem hiding this comment.
Re-reviewed at the current head — this round is a merge of main with no changes of your own, so nothing new on the code.
Since that merge pulls in #36575 and #36574, which touch the same three desktop files this branch does, I checked the resolution rather than assuming: no conflict markers anywhere, this branch's own changes are intact, and both merged PRs' changes survived in runtime/ops/desktop.rs (op_desktop_alert_async + ERROR_DIALOG_SHOWING from #36575, the .backup gate on the update sentinel from #36574). Clean.
CI is still running; nothing red so far.
…int8Array survives (#36573) Fixes #36498. ## Problem Binding return values were funneled through `serde_json::Value`, which has no byte-array representation: a handler returning a `Uint8Array` made `op_desktop_resolve_bind_call` reject with `invalid type: byte array, expected any valid JSON value`. Arguments took the same JSON round-trip in the other direction, so a `Uint8Array` passed webview→Deno arrived as a plain numeric-keyed object (`{"0":1,"1":2,...}`), which is also pathologically slow for large buffers. ## Fix `laufey::Value` and both backends (webview, CEF) already transport binary natively — only the runtime-side JSON round-trip dropped it. This PR: - gives `DesktopValue` (which already had a `Binary` variant and a `ToV8` impl) manual `Serialize`/`Deserialize` impls in which `Binary` maps to serde bytes — serde_v8 materializes bytes as `Uint8Array` in both directions; - switches `DesktopEvent::BindCall.args`, `PendingBindResponses`, and `op_desktop_resolve_bind_call` from `serde_json::Value` to `DesktopValue`; - converts `DesktopValue` ⇄ `laufey::Value` directly in `rt_desktop` (the now-unused `laufey_value_to_json` / `json_to_laufey_value` helpers are removed). Non-binary values keep exact JSON semantics (pinned by tests, including the int-vs-double distinction laufey's `Value::Int(i32)` requires). ### Also fixed: silently wrapping integers The old `json_to_laufey_value` did `laufey::Value::Int(i as i32)` on an `as_i64()`, a wrapping cast — `3_000_000_000` reached the renderer as `-1294967296`. The new `visit_i64`/`visit_u64` degrade out-of-range integers to `Double` instead. Pinned by test. ### Recursion is now bounded The visitor recurses once per nesting level, so a self-referential value returned from a handler (`const o = {}; o.self = o; return o`) ran the runtime thread off the end of its stack. Depth is threaded through a `DeserializeSeed` and capped at 128, matching serde_json's own recursion limit; past that the caller gets an error. This was equally true of the `serde_json::Value` visitor before, but this PR rewrites exactly that code. ## Limitations **The typed-array *view type* is not preserved.** serde_v8 routes every `ArrayBufferView` and `ArrayBuffer` to `visit_byte_buf`, so a handler returning a `Float64Array`, `Int32Array`, `DataView` or bare `ArrayBuffer` arrives on the other side as raw bytes rather than its original view type. Callers needing the original type must carry it themselves. This is lossy only relative to a transport that never worked — before this PR any of those was a hard `invalid type: byte array` error. **The renderer materializes incoming binary as `ArrayBuffer`, not `Uint8Array`** (both backends). That last-mile discrepancy is in laufey's renderer glue; fix upcoming there. With this PR the data itself is correct and complete in both directions on both backends. ## Verification Ran the issue's repro (extended to cover the argument direction) on macOS with the webview backend: - before: `ret=FAIL: TypeError: invalid type: byte array, expected any valid JSON value`, echo arrives as plain object - after: return carries correct bytes; `bindings.echo(new Uint8Array([9,8,7,128]))` arrives Deno-side as a real `Uint8Array` and round-trips intact. --------- Signed-off-by: Leo Kettmeir <crowlkats@toaxl.com>
Fixes #36498.
Problem
Binding return values were funneled through
serde_json::Value, which has no byte-array representation: a handler returning aUint8Arraymadeop_desktop_resolve_bind_callreject withinvalid type: byte array, expected any valid JSON value. Arguments took the same JSON round-trip in the other direction, so aUint8Arraypassed webview→Deno arrived as a plain numeric-keyed object ({"0":1,"1":2,...}), which is also pathologically slow for large buffers.Fix
laufey::Valueand both backends (webview, CEF) already transport binary natively — only the runtime-side JSON round-trip dropped it. This PR:DesktopValue(which already had aBinaryvariant and aToV8impl) manualSerialize/Deserializeimpls in whichBinarymaps to serde bytes — serde_v8 materializes bytes asUint8Arrayin both directions;DesktopEvent::BindCall.args,PendingBindResponses, andop_desktop_resolve_bind_callfromserde_json::ValuetoDesktopValue;DesktopValue⇄laufey::Valuedirectly inrt_desktop(the now-unusedlaufey_value_to_json/json_to_laufey_valuehelpers are removed).Non-binary values keep exact JSON semantics (pinned by tests, including the int-vs-double distinction laufey's
Value::Int(i32)requires).Also fixed: silently wrapping integers
The old
json_to_laufey_valuedidlaufey::Value::Int(i as i32)on anas_i64(), a wrapping cast —3_000_000_000reached the renderer as-1294967296. The newvisit_i64/visit_u64degrade out-of-range integers toDoubleinstead. Pinned by test.Recursion is now bounded
The visitor recurses once per nesting level, so a self-referential value returned from a handler (
const o = {}; o.self = o; return o) ran the runtime thread off the end of its stack. Depth is threaded through aDeserializeSeedand capped at 128, matching serde_json's own recursion limit; past that the caller gets an error. This was equally true of theserde_json::Valuevisitor before, but this PR rewrites exactly that code.Limitations
The typed-array view type is not preserved. serde_v8 routes every
ArrayBufferViewandArrayBuffertovisit_byte_buf, so a handler returning aFloat64Array,Int32Array,DataViewor bareArrayBufferarrives on the other side as raw bytes rather than its original view type. Callers needing the original type must carry it themselves. This is lossy only relative to a transport that never worked — before this PR any of those was a hardinvalid type: byte arrayerror.The renderer materializes incoming binary as
ArrayBuffer, notUint8Array(both backends). That last-mile discrepancy is in laufey's renderer glue; fix upcoming there. With this PR the data itself is correct and complete in both directions on both backends.Verification
Ran the issue's repro (extended to cover the argument direction) on macOS with the webview backend:
ret=FAIL: TypeError: invalid type: byte array, expected any valid JSON value, echo arrives as plain objectbindings.echo(new Uint8Array([9,8,7,128]))arrives Deno-side as a realUint8Arrayand round-trips intact.