Skip to content

fix(timers): propagate unwind safety to user callbacks#562

Open
guybedford wants to merge 2 commits into
ranile:masterfrom
guybedford:fix-gloo-timers-unwind-safety
Open

fix(timers): propagate unwind safety to user callbacks#562
guybedford wants to merge 2 commits into
ranile:masterfrom
guybedford:fix-gloo-timers-unwind-safety

Conversation

@guybedford
Copy link
Copy Markdown

@guybedford guybedford commented Apr 28, 2026

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 and tests under -Cpanic=unwind — see the failure on MattiasBuelens/wasm-streams#35.

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

// before
pub fn new<F>(millis: u32, callback: F) -> Timeout
where F: 'static + FnOnce(),
{
    let closure = Closure::once(callback);
    ...
}
// after
pub fn new<F>(millis: u32, callback: F) -> Timeout
where F: 'static + FnOnce() + CallbackUnwindSafe,
{
    #[cfg(all(target_arch = "wasm32", panic = "unwind"))]
    let closure = Closure::once_assert_unwind_safe(callback);
    #[cfg(not(all(target_arch = "wasm32", panic = "unwind")))]
    let closure = Closure::once(callback);
    ...
}

This is not a breaking change for existing users:

  • Under panic = "abort" (the default), CallbackUnwindSafe is a blanket implemented for every T, so the bound is invisible. Every existing caller continues to compile unchanged.
  • Under 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) and RUSTFLAGS="-Cpanic=unwind" cargo +nightly build -p gloo-timers --target wasm32-unknown-unknown -Zbuild-std=std,panic_unwind — both pass cleanly. A new panic_unwind_build CI job exercises the latter case so this regression cannot recur silently.

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.
Copy link
Copy Markdown
Collaborator

@Madoshakalaka Madoshakalaka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@guybedford
Copy link
Copy Markdown
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants