Skip to content

Let work done outside the window system wake a waiting guest - #3

Merged
syrusakbary merged 3 commits into
mainfrom
feat/external-wake
Oct 28, 2026
Merged

syrusakbary merged 3 commits into
mainfrom
feat/external-wake

Conversation

@syrusakbary

@syrusakbary syrusakbary commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

A guest waiting for input in wguiInstanceWaitEvents only looks at other work, such as a WebGPU event, when its wait ends. If that work completes on another thread meanwhile, the guest sees it up to 250 ms later, when the host interrupts the wait, or at its timeout. This lets the embedder end the wait as soon as such work is ready.

Best merged after #2: external wakes come often and from other threads, so they hit that race more than wguiInstanceWake does.

Change

  • GuiCtx::event_waker() returns an Arc<dyn Fn() + Send + Sync> that ends the wait of every guest of the context, as wguiInstanceWake does. It can be called from any thread. It holds only a weak reference to the context, so work that outlives the context can't keep it alive.
  • GuiRuntimeControl::wake_all() does the same from a runtime control.
  • Guest contract (spec/gui.json, regenerated gui.h and sys): a wait can now end with WGUIWaitReasons_Woken when the host has other work for the program, not only after wguiInstanceWake.

How it behaves:

  • Sticky. A guest that isn't waiting gets the wake at its next wait.
  • No side effects. No input is made up and no scope is closed.
  • Context-wide. Every guest of the context is woken, whoever the work is for; one with nothing else to look at waits again. wasmer-webgpu's waker carries no identity to narrow this.
  • Locks. The object table is held only to list a scope's instances, never while waking them. gui never calls into WebGPU while holding its locks, so a waker called from inside WebGPU can't deadlock.

It's meant for WebGpuCtxBuilder::event_waker, from wasmerio/webgpu#2. Wiring the two together in gui_wasmer waits for that PR: CI builds against webgpu's webgpu-gui branch, which doesn't have it yet.

Tests

  • an_external_wake_ends_a_wait_at_once: a guest thread waits with WAIT_FOREVER, its wait slice and interrupt stretched to a minute (a #[cfg(test)] override). Once a #[cfg(test)] hook says it has parked, the test calls the real event_waker(), and the wait must return Woken within 5 s. So the wake has to unpark the guest: it can't be caught at the next 50 ms look. With notify() removed from WaitSet::wake, it fails (5 of 5 runs); the earlier version of the test still passed.
  • external_wake_is_sticky_for_live_scopes_without_synthetic_input: a wake before the wait is kept and consumed once, and makes up no input. Another context's scopes are left alone, and a closed scope has nothing left to wake.
  • external_waker_does_not_retain_a_retired_context: the waker doesn't keep a dropped context alive, and calling it afterwards is harmless.

Locally (macOS arm64, Rust 1.95; also clippy on nightly 1.100):

Check Result
make check (incl. gen.py --check, check-web) clean
make test 88 unit, ABI sweep, program suites: all passed
an_external_wake_ends_a_wait_at_once, 20 runs alone and 20 beside the whole suite all passed

🤖 Generated with Claude Code

A guest that waits for input with wguiInstanceWaitEvents only looks at
other work, such as a WebGPU event, when its wait ends. If that work
completes on another thread while the guest waits, the guest sees it up
to 250 ms later, when the host interrupts the wait, or at its timeout.

- GuiCtx::event_waker returns a closure that ends the wait of every
  guest of the context, as wguiInstanceWake does. It can be called from
  any thread, and holds only a weak reference to the context. It is
  meant for hooks like wasmer_webgpu's WebGpuCtxBuilder::event_waker.
- GuiRuntimeControl::wake_all does the same from a runtime control.

A wake is sticky: a guest that isn't waiting gets it at its next wait.
No input is made up and no scope is closed. Every guest of the context
is woken, whoever the work is for; one with nothing else to look at
waits again. The object table is only held to list a scope's instances,
never while waking them.

The header now says that the host may end a wait with
WGUIWaitReasons_Woken when it has other work for the program, not only
when the program called wguiInstanceWake.

Tests: a wake from another thread ends a blocked wait at once; it
reaches each live scope and makes up no input, another context's
scopes are left alone; and the waker doesn't keep a dropped context
alive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@syrusakbary syrusakbary left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed the callback's ownership, context/scope lifetime, instance enumeration and lock release, sticky wake behavior, guest wait loop, generated contract changes, and the WebGPU event-waker producer. I found no blocking implementation defect in this diff. Merge after #2 as described, so an external wake also closes the registration gap.

Waking the guest to re-check its work, while leaving callback execution on the guest's thread, is an appropriate Wasmer adaptation of Emscripten's side-channel notification model. The weak context reference and releasing the object-table lock before calling wait.wake() keep this hook appropriately narrow.

Validation: all 84 native default-feature unit tests passed; generated files match the spec; all 86 unit tests passed with #2 applied too. I also mutation-tested the latency test: deleting the actual notification from WaitSet::wake still passes it (~58 ms). The inline comment explains how to guard the behavior this feature is intended to provide. The end-to-end WebGPU→GUI wiring remains deferred, as stated in the PR; it was not exercised by these unit tests.

Comment thread src/host/tests.rs Outdated
Comment on lines +1938 to +1942
assert_eq!(rig.read_u32(SIZE), wait_reasons::WOKEN, "took {took:?}");
// Long before the host would have ended the wait itself (Interrupted).
assert!(
took < std::time::Duration::from_millis(240),
"took {took:?}"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P3] Make the latency regression fail when unparking is removed

This assertion checks against the 250 ms interrupt, but the wait loop already rechecks the sticky flag every 50 ms (WAIT_SLICE). I removed self.notify() from WaitSet::wake, leaving only woken.store(true, ...), and this test still passed in 58.4 ms: the guest observed the flag after polling, without being unparked. Therefore it does not protect the prompt external wake that this PR adds. Please add a coordinated test using the real event_waker against a registered waiter with a long park timeout (or a test-only wait-slice override), and a bounded completion channel, so removing notification reliably fails. Retain the ABI assertion for WOKEN; avoid simply tightening the wall-clock threshold, which would make CI scheduling affect the result.

syrusakbary and others added 2 commits October 28, 2026 09:13
The test of GuiCtx::event_waker passed with the unpark taken out of
WaitSet::wake: a waiting guest looks at the sticky flag every 50 ms
(WAIT_SLICE), well within the 250 ms the test allowed.

The guest now waits on a thread of its own, with WAIT_SLICE and
INTERRUPT_AFTER stretched to a minute for that thread, and the test wakes
it through event_waker once park says the guest is done looking and parks
(both test-only). The wait then has to end with WGUIWaitReasons_Woken
within 5 seconds; without the unpark it doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps both sides where they met: the wait counter from #1 next to the
stretched test wait, and the frame pacing measurement from #5 after the
external wake tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@syrusakbary
syrusakbary merged commit 00f9ab3 into main Oct 28, 2026
6 checks passed
syrusakbary added a commit that referenced this pull request Oct 28, 2026
#3 now has main, with #1's wait count and #2's handshake between waiting
and waking:

- `notify` bumps the counter a suspended wait sleeps on, tells it, then
  takes #2's fence before it looks at the list of parked threads.
- `park` checks a pending wake after the fence, as #2 has it; `wait_step`
  still takes it.
- The parking wait counts the guest's waits once per call, as #1 has it,
  and the suspending wait now does too, on its first look: otherwise
  a window redrawn while the guest waits by suspending would be "asked for
  while drawing" from then on, and `eagerRedraw` would never answer at once.
- Cargo.toml and the Makefile keep both sides' test setups (loom and the
  web wait test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
syrusakbary added a commit that referenced this pull request Oct 28, 2026
main is #3 and #6, both already merged here: the tree is unchanged.
GitHub saw a conflict in Cargo.toml from the two merge bases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant