craft-web-host: queue messages that arrive while the handler runs

A page host dispatches its events synchronously, so it can answer (a
saved for the save the handler just posted) while the handler is still
running. The nested borrow_mut then panicked. Both transports now hand
messages to an Inbox that queues each one and drains the queue one call
at a time, in order; nothing is dropped. The new test fails with the old
borrow ("already borrowed").

FileData::size() reads a Blob's length from the Blob; a host's size
field is no longer trusted for an app's size check. lib.rs gets the
crate-level deny of unwrap, expect, panic and the like.
This commit is contained in:
Helen Kwok
2026-11-05 13:30:14 +08:00
parent da0b274a28
commit ae1fa48449
3 changed files with 74 additions and 14 deletions
+2
View File
@@ -27,6 +27,8 @@ Compatibility with the bridges shipped before this crate:
- Fields marked `?` are optional. A host answers `saved` only for a `save` that carries an `id`; an app that sends none treats its save as done once it is posted.
- A host that never sends `host` gets the behaviour of the earlier bridges.
- `size` beside `file` is for hosts and older bridges; this crate takes a `Blob`'s length from the `Blob` itself.
- A page host may dispatch an event while the app's handler runs (a `saved` answering the `save` it just posted). The bridge holds it and calls the handler again once the running call returns, so messages arrive one at a time and in order.
- `version` is `"1"`. The earlier bridges put the app's own release in it (`"0.14.0"`), so a host treats a `version` it doesn't know as `"1"`. The crate sends the release as `appVersion`.
## Use in an app's web shell
+59
View File
@@ -26,6 +26,11 @@
//! messages are `CustomEvent`s on the canvas, the fields in `detail`). This module is the contract
//! itself and builds everywhere; [`web`] (wasm32 only) is the bridge an app's web shell calls.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::cell::RefCell;
use std::collections::VecDeque;
#[cfg(target_arch = "wasm32")]
pub mod web;
@@ -130,6 +135,34 @@ pub fn file_name(raw: Option<&str>, fallback: &str) -> String {
trimmed.chars().take(MAX_NAME_CHARS).collect()
}
/// Hands the host's messages to the app's handler one at a time. A page host dispatches its events
/// synchronously, so it can answer while the handler runs (a `saved` for the `save` the handler
/// just posted): that message waits here and is handled as soon as the running call returns, never
/// dropped and never given to a second, overlapping call.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) struct Inbox<M, F> {
handler: RefCell<F>,
queue: RefCell<VecDeque<M>>,
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
impl<M, F: FnMut(M)> Inbox<M, F> {
pub(crate) fn new(handler: F) -> Self {
Self { handler: RefCell::new(handler), queue: RefCell::new(VecDeque::new()) }
}
pub(crate) fn deliver(&self, message: M) {
self.queue.borrow_mut().push_back(message);
// Already running: the outer call drains the queue.
let Ok(mut handler) = self.handler.try_borrow_mut() else { return };
loop {
let next = self.queue.borrow_mut().pop_front();
let Some(message) = next else { break };
handler(message);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -173,4 +206,30 @@ mod tests {
assert_eq!(file_name(None, "Document.pdf"), "Document.pdf");
assert_eq!(file_name(Some(&"é".repeat(300)), "x").chars().count(), MAX_NAME_CHARS);
}
#[test]
fn messages_that_arrive_while_the_handler_runs_wait_their_turn() {
use std::rc::Rc;
type Shared = Rc<Inbox<&'static str, Box<dyn FnMut(&'static str)>>>;
let seen = Rc::new(RefCell::new(Vec::new()));
let inbox: Rc<RefCell<Option<Shared>>> = Rc::new(RefCell::new(None));
let (log, me) = (seen.clone(), inbox.clone());
let handler: Box<dyn FnMut(&'static str)> = Box::new(move |m: &'static str| {
log.borrow_mut().push(format!("start {m}"));
// The page answers inside the handler, as a synchronous dispatchEvent does.
if m == "open"
&& let Some(inbox) = me.borrow().clone()
{
inbox.deliver("saved");
inbox.deliver("handoff-done");
}
log.borrow_mut().push(format!("end {m}"));
});
let shared = Rc::new(Inbox::new(handler));
*inbox.borrow_mut() = Some(shared.clone());
shared.deliver("open");
assert_eq!(*seen.borrow(), ["start open", "end open", "start saved", "end saved", "start handoff-done", "end handoff-done"]);
shared.deliver("host");
assert_eq!(seen.borrow().len(), 8, "the inbox is usable again after draining");
}
}
+13 -14
View File
@@ -7,29 +7,29 @@
//! }
//! ```
use std::cell::{Cell, RefCell};
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast as _, JsValue};
use web_sys::js_sys;
use crate::{Incoming, Mode, Outgoing, VERSION, file_name, message_type};
use crate::{Inbox, Incoming, Mode, Outgoing, VERSION, file_name, message_type};
/// A file the host passes in, not yet copied into the app's memory: check its size first.
pub enum FileData {
/// `bytes`: an `ArrayBuffer` or `Uint8Array`, viewed in place.
Bytes(js_sys::Uint8Array),
/// `file` + `size`: a `File` or `Blob`, for the app to read in slices.
Blob { blob: web_sys::Blob, size: f64 },
/// `file`: a `File` or `Blob`, for the app to read in slices.
Blob(web_sys::Blob),
}
impl FileData {
/// The file's length in bytes.
/// The file's length in bytes, from the data itself (a host's `size` field is not trusted).
pub fn size(&self) -> f64 {
match self {
Self::Bytes(array) => f64::from(array.length()),
Self::Blob { size, .. } => *size,
Self::Blob(blob) => blob.size(),
}
}
}
@@ -95,10 +95,10 @@ impl Host {
/// Hear the host's messages for this app. The listener lives as long as the page.
pub fn listen(&self, handler: impl FnMut(Message) + 'static) {
let app = self.app;
let handler = Rc::new(RefCell::new(handler));
let inbox = Rc::new(Inbox::new(handler));
match &self.transport {
Transport::Parent { window, parent, origin } => {
let (parent, origin, handler) = (parent.clone(), origin.clone(), handler.clone());
let (parent, origin, inbox) = (parent.clone(), origin.clone(), inbox.clone());
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
@@ -106,7 +106,7 @@ impl Host {
let data = event.data();
let Some(kind) = string(&data, "type").and_then(|t| Incoming::parse(app, &t)) else { return };
if let Some(message) = read(kind, &data) {
(handler.borrow_mut())(message);
inbox.deliver(message);
}
});
if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
@@ -115,10 +115,10 @@ impl Host {
}
Transport::Page { canvas } => {
for kind in Incoming::ALL {
let handler = handler.clone();
let inbox = inbox.clone();
let on_event = Closure::<dyn FnMut(web_sys::CustomEvent)>::new(move |event: web_sys::CustomEvent| {
if let Some(message) = read(kind, &event.detail()) {
(handler.borrow_mut())(message);
inbox.deliver(message);
}
});
let name = message_type(app, kind.kind());
@@ -213,12 +213,11 @@ fn read(kind: Incoming, data: &JsValue) -> Option<Message> {
})
}
/// `file` (a `Blob`, with `size`) or `bytes` (an `ArrayBuffer` or `Uint8Array`), not copied.
/// `file` (a `Blob`; its `size` field is ignored for the Blob's own) or `bytes` (an `ArrayBuffer` or `Uint8Array`), not copied.
fn file_data(data: &JsValue) -> Option<FileData> {
let file = get(data, "file");
if let Some(blob) = file.dyn_ref::<web_sys::Blob>() {
let size = get(data, "size").as_f64().unwrap_or_else(|| blob.size());
return Some(FileData::Blob { blob: blob.clone(), size });
return Some(FileData::Blob(blob.clone()));
}
let bytes = get(data, "bytes");
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {