fix(timers): propagate unwind safety to user callbacks#562
Open
guybedford wants to merge 2 commits into
Open
Conversation
wasm-bindgen 0.2.117+ added `MaybeUnwindSafe` bounds on `Closure::wrap` and `Closure::once`, because closures handed to JS are invoked across a `catch_unwind` boundary under `panic = "unwind"`. `gloo-timers` accepts arbitrary user `FnOnce` / `FnMut` callbacks, but its `Closure::wrap(...)` / `Closure::once(...)` calls fail to compile under panic=unwind because the `Box::new(callback) as Box<dyn FnMut()>` coercion (and the `FnOnce` trait selection) erase the static `UnwindSafe` bound that wasm-bindgen requires. This breaks every downstream crate that pulls in `gloo-timers` as a `dev-dependency` and tests under `-Cpanic=unwind` \u2014 see the failure on MattiasBuelens/wasm-streams#35. Fix: - Surface the requirement at the public API: `Timeout::new` / `Interval::new` now require `F: CallbackUnwindSafe`, a marker that resolves to `std::panic::UnwindSafe` under `panic = "unwind"` on wasm and to a no-op blanket otherwise. Callers with non-`UnwindSafe` captures must wrap them in `std::panic::AssertUnwindSafe` at the call site, which is where the invariants can actually be reasoned about. - Internally use `Closure::wrap_assert_unwind_safe` / `Closure::once_assert_unwind_safe` under panic=unwind to acknowledge the dyn-erasure explicitly. The public bound has already enforced the requirement at the call site, so the internal assertion is sound. - Bump the minimum `wasm-bindgen` requirement to `0.2.117` (where the `_assert_unwind_safe` helpers were added). - Add a `panic_unwind_build` CI job that builds `gloo-timers` with `-Cpanic=unwind` so this regression cannot recur silently.
cd8cde8 to
82b17da
Compare
Madoshakalaka
requested changes
May 1, 2026
Collaborator
Madoshakalaka
left a comment
There was a problem hiding this comment.
The futures feature is broken now:
RUSTFLAGS='-Cpanic=unwind' cargo +nightly check -p gloo-timers --features futures --target wasm32-unknown-unknown -Zbuild-std=std,panic_unwind
fails with
error[E0277]: the type `UnsafeCell<Option<Waker>>` may contain interior mutability and a reference
may not be safely transferrable across a catch_unwind boundary
--> crates/timers/src/future.rs:143:43
...
note: required for `{closure@crates/timers/src/future.rs:143:43: 143:50}`
to implement `CallbackUnwindSafe`
The CI job runs cargo build -p gloo-timers --target wasm32-unknown-unknown -Zbuild-std=std,panic_unwind with default features only so this went undetected.
The previous commit propagated `CallbackUnwindSafe` through `Timeout::new` / `Interval::new`, but the `futures` module's own closures capture `oneshot::Sender` and `mpsc::UnboundedSender`, both of which hold an `Arc<Inner<T>>` with `UnsafeCell` interior and so are not `UnwindSafe`. Building `gloo-timers --features futures` under `-Cpanic=unwind` failed against the new bound; the CI job only exercised default features so the regression went undetected. Wrap each sender in `AssertUnwindSafe` and ensure the closure captures the wrapper rather than the inner sender. RFC 2229 disjoint capture projects through any explicit field access (`tx.0`) or irrefutable destructure (`let AssertUnwindSafe(x) = tx`), defeating the wrapper. The `TimeoutFuture` case (FnOnce, `send` consumes `self`) routes the unwrap through a tiny helper so the captured path stays at the wrapper. The `IntervalStream` case (FnMut, `unbounded_send` takes `&self`) already autoderefs through `AssertUnwindSafe<T>: Deref`, so just wrapping at the bind site is sufficient. Soundness: for `TimeoutFuture` the sender is consumed and never observed again, so any panic inside `send` cannot expose torn state. For `IntervalStream` `unbounded_send` is a lock-free push whose only realistic panic site is allocation, which aborts under default config; the worst-case observable consequence of an interrupted push is a hung stream, not a memory-safety violation. Extend the panic=unwind CI job to also build with `--features futures` so this path is exercised.
Author
|
I added the fix for the futures case as well and a CI test to check that in future. We have since landed the wasm-bindgen change that does these stricter assertions which will be going out in wasm-bindgen@0.2.122 - would be great to have this landed by then! |
guybedford
added a commit
to guybedford/wasm-streams
that referenced
this pull request
May 14, 2026
The unwind-safety bound added in wasm-bindgen makes the published gloo-timers 0.3.0 fail to compile against newer wasm-bindgen. Patch to the gloo fork branch which propagates UnwindSafe through the timer callbacks until ranile/gloo#562 lands and a new gloo-timers is published.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
wasm-bindgen 0.2.117+ added
MaybeUnwindSafebounds onClosure::wrapandClosure::once, because closures handed to JS are invoked across acatch_unwindboundary underpanic = "unwind".gloo-timersaccepts arbitrary userFnOnce/FnMutcallbacks, but itsClosure::wrap(...)/Closure::once(...)calls fail to compile under panic=unwind because theBox::new(callback) as Box<dyn FnMut()>coercion (and theFnOncetrait selection) erase the staticUnwindSafebound that wasm-bindgen requires.This breaks every downstream crate that pulls in
gloo-timersand tests under-Cpanic=unwind— see the failure on MattiasBuelens/wasm-streams#35.The fix:
Surface the requirement at the public API.
Timeout::new/Interval::newnow requireF: CallbackUnwindSafe, a marker that resolves tostd::panic::UnwindSafeunderpanic = "unwind"on wasm and to a no-op blanket otherwise. Callers with non-UnwindSafecaptures must wrap them instd::panic::AssertUnwindSafeat the call site, which is where the invariants can actually be reasoned about.Internally use
Closure::wrap_assert_unwind_safe/Closure::once_assert_unwind_safeunder panic=unwind to acknowledge the dyn-erasure explicitly. The public bound has already enforced the requirement at the call site, so the internal assertion is sound.Bump the minimum
wasm-bindgenrequirement to0.2.117(where the_assert_unwind_safehelpers were added).This is not a breaking change for existing users:
panic = "abort"(the default),CallbackUnwindSafeis a blanket implemented for everyT, so the bound is invisible. Every existing caller continues to compile unchanged.panic = "unwind", gloo-timers previously did not compile at all against wasm-bindgen 0.2.117+, so there are no existing panic=unwind callers to break. The change unblocks the configuration; it does not regress it.Tested locally with
cargo build -p gloo-timers --target wasm32-unknown-unknown(default panic strategy) andRUSTFLAGS="-Cpanic=unwind" cargo +nightly build -p gloo-timers --target wasm32-unknown-unknown -Zbuild-std=std,panic_unwind— both pass cleanly. A newpanic_unwind_buildCI job exercises the latter case so this regression cannot recur silently.