atagen

macltok

public
Star0
Watch0

Commit 7b376d620a3d

tarball

atagen <boss@atagen.co>2026-09-27 01:42 UTC

6 files changed+46-6
engine: let the host hear a wasm panic

On wasm32 a panic traps the task mid poll and nothing unwinds, so the engine's catch_unwind never fires and the rest of it keeps running in an undefined state without ever sending Failed or Stopped. on_panic hands the host the message from inside the panic hook, chained after any hook already installed, so it can tear the worker down. It's a free function because a panic hook belongs to the whole instance, not to one engine.
Modified file: README.md+5 additions−2 deletions
@@ -112,8 +112,11 @@ rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]
112112
113113 what's different in a browser
114114
115-- a panic aborts the whole instance. natively a panicking command costs its
116- caller an `Interrupted`, here it takes the engine and everything else with it
115+- a panic doesn't abort anything. it traps the task mid poll and the rest of
116+ the engine carries on in an undefined state and goes quiet, no `Failed` and no
117+ `Stopped`. register `macltok::on_panic` and have it tear the worker down and
118+ start another. natively a panicking command just costs its caller an
119+ `Interrupted`
117120 - `MediaUploadProgress` only ever reports completion, the sdk's wasm http path
118121 has no upload progress
119122 - the sdk doesn't retry requests or time them out individually. sync still
Modified file: docs/design-decisions.md+5 additions−2 deletions
@@ -30,8 +30,11 @@ doing the work died before it could answer. `challenge` turns up with
3030 the run loop routes and does nothing slow. every task it spawns is registered
3131 with a context. a command task that panics costs its caller an `Interrupted`
3232 and gets logged. a watcher or a room actor that panics takes the engine down,
33-emitting `Failed` then `Stopped`. natively, anyway. wasm aborts the whole
34-instance on any panic and nothing is left to log it
33+emitting `Failed` then `Stopped`. natively, anyway. on wasm a panic traps the
34+task mid poll and nothing is caught. the rest keeps running in an undefined
35+state and goes silent, no `Failed` and no `Stopped`, so the host hears of it
36+through `on_panic`, a free function since a panic hook belongs to the whole
37+instance rather than to one engine
3538
3639 sync restarts itself with backoff after an error or an unexpected stop, and
3740 `ConnectionChanged` shows `Offline` meanwhile. a run that drops again inside
Modified file: src/engine/executor.rs+1 additions−1 deletions
@@ -14,7 +14,7 @@ pub(super) fn is_panic(error: &JoinError) -> bool {
1414 error.is_panic()
1515 }
1616
17-/// the sdk's wasm join error has no payload, though a panic there aborts the instance anyway
17+/// the sdk's wasm join error has no payload, and a real panic traps before it gets here
1818 #[cfg(target_family = "wasm")]
1919 pub(super) fn is_panic(error: &JoinError) -> bool {
2020 !error.is_cancelled()
Modified file: src/engine/mod.rs+2 additions−1 deletions
@@ -145,7 +145,8 @@ impl TaskContext {
145145 }
146146 }
147147
148-/// tasks catch their own panics, since the sdk's wasm join error drops the payload
148+/// tasks catch their own panics so each comes back with its context, since joining
149+/// through the sdk's executor gives no task id to look one up by. wasm has nothing to catch
149150 fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
150151 payload
151152 .downcast_ref::<&str>()
Modified file: src/lib.rs+4 additions
@@ -10,6 +10,8 @@ mod normalise;
1010 mod transport;
1111 mod uiaa;
1212 mod upload;
13+#[cfg(target_family = "wasm")]
14+mod wasm;
1315
1416 #[cfg(all(feature = "sqlite", target_family = "wasm"))]
1517 compile_error!("sqlite has no filesystem to live on in wasm, use indexeddb");
@@ -58,3 +60,5 @@ pub use uiaa::{
5860 InteractiveAuth, InteractiveAuthChallenge, InteractiveAuthFailure, InteractiveAuthFlow,
5961 InteractiveAuthOutcome,
6062 };
63+#[cfg(target_family = "wasm")]
64+pub use wasm::on_panic;
Added file: src/wasm.rs+29 additions
@@ -0,0 +1,29 @@
1+use std::cell::RefCell;
2+
3+type Handler = Box<dyn Fn(&str)>;
4+
5+thread_local! {
6+ static HANDLER: RefCell<Option<Handler>> = const { RefCell::new(None) };
7+}
8+
9+/// a panic on wasm32 traps the task that raised it and nothing unwinds, so the engine
10+/// catches nothing. the rest of it keeps running in an undefined state and goes silent,
11+/// no `Failed` and no `Stopped`. `handler` runs from the panic hook with the message,
12+/// after whatever hook was there before, and is the host's one chance to react, say by
13+/// terminating the worker and starting a fresh one. a second call replaces the handler
14+pub fn on_panic(handler: impl Fn(&str) + 'static) {
15+ let installed = HANDLER.with(|slot| slot.replace(Some(Box::new(handler))).is_some());
16+ if installed {
17+ return;
18+ }
19+ let previous = std::panic::take_hook();
20+ std::panic::set_hook(Box::new(move |info| {
21+ previous(info);
22+ let message = info.to_string();
23+ HANDLER.with(|slot| {
24+ if let Some(handler) = &*slot.borrow() {
25+ handler(&message);
26+ }
27+ });
28+ }));
29+}