Skip to content

Don't let a wake slip in between a wait's checks - #2

Merged
syrusakbary merged 2 commits into
mainfrom
fix/lost-wake
Oct 28, 2026
Merged

syrusakbary merged 2 commits into
mainfrom
fix/lost-wake

Conversation

@syrusakbary

@syrusakbary syrusakbary commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

A wake could be lost between a wait's two checks, so the guest overslept by up to a wait slice (50 ms).

The race

wguiInstanceWaitEvents loops: it takes the sticky wake flag (take_wake), checks for events and timeouts, then parks for a slice. A wguiInstanceWake (or any other wake) that arrived after take_wake but before the thread was in the wait set's list found nobody to unpark. park then looked only for events, not the flag, and slept for the rest of the slice.

Change

WaitSet::park (src/host/wait.rs) now also looks at the wake flag after the thread is in the list, without consuming it: either it sees the wake, or the waker finds the thread and unparks it.

A SeqCst fence in park (between joining the list and looking) and one in notify (before it looks at the list) make that "either" hold under the memory model. Acquire and release alone don't order a write before a read of another location; until now this leaned on crossbeam's internals. The fences cover every reason notify is called for, not just wakes.

Tests

On the waiting thread (the wake comes from the thread that is about to park):

Test Without the fix With it
a_wake_between_outer_check_and_registration_does_not_park fails (sleeps through the wake) passes
a_wake_just_before_the_park_ends_it_at_once (guards the existing unpark-token path) passes passes

Across threads. Three tests hold the waiting thread at one point while another thread calls wake(), then let it go on. It parks with a 30 s timeout; the test waits at most 5 s for it, so a wake that's lost fails the test rather than slowing it. A one-line #[cfg(test)] hook in park holds the thread once it is in the list.

Held What can end the wait Fails when
before it is in the list only the look after registering that look is removed, or goes back to ready() only (the bug this fixes)
in the list, before it looks the look, or the unpark both the look and the unpark are missing
in the list, after its look only the unpark unpark is removed from notify

These order the two threads, so they can't show reordering; removing both fences passes them all.

Reordering, under loom. A model of the handshake (#[cfg(all(test, loom))] mod model in src/host/wait.rs, run by make test-loom) explores every interleaving, and every value each load may read. The waiter registers, fences and looks; the notifier publishes, fences and looks for waiters. It checks that either the waiter's look sees what was published or the notifier finds the waiter. Registration is a Relaxed flag, so the fences alone have to be enough, whatever crossbeam's queue does.

Change to the model Result
as in this PR passes (26 executions)
the waiter's fence removed fails
the notifier's fence removed fails
the look after registering removed fails
both fences AcqRel fails
  • loom's limits. loom 0.7 implements SeqCst fences, but treats SeqCst loads and stores as AcqRel; the handshake relies on the fences only.
  • A false alarm it raises. With take_wake's swap, loom reports a lost wake that C11 rules out, because it doesn't order the swap before the notifier's store. So the model takes the wake with a compare_exchange(true, false), which returns the same and leaves the flag the same. A comment in the model says so.
  • What it is. A model of the handshake, not of this code: crossbeam's queue, a waiter's queued mark and parking itself are left out.

loom is a dev-dependency only under cfg(loom), declared to the unexpected_cfgs lint; ordinary builds don't compile it. CI doesn't run make test-loom yet: my token can't change workflows.

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

Check Result
make check clean
make test 90 unit, 2 ABI sweep, 4 + 1 program suites: all passed
make test-loom passed
the three cross-thread tests, 20 runs all passed

🤖 Generated with Claude Code

wguiInstanceWaitEvents takes the sticky wake flag, then parks. A wake
that arrived after that check but before the thread was in the set's
list found nobody to unpark, and park looked only for events, so the
guest slept through the wake for the rest of the slice: up to 50 ms.

park now looks at the wake flag too, after the thread is in the list,
without consuming it: either it sees the wake, or the waker finds the
thread and unparks it. A SeqCst fence in park, between joining the list
and looking, and one in notify, before it looks at the list, make that
"either" hold under the memory model, for wakes and for every other
reason to stop waiting that notify is called for; Acquire and Release
alone don't order a write before a read of another location.

Tests: a wake between the outer check and the thread joining the list
no longer parks (fails without the fix), and a wake between that and
the park itself is still caught by the thread's unpark token.

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 full wait protocol, its instance_wait_events caller, waiter reuse across sets, notification sites, and the interaction with #3. I found no blocking correctness issue in this diff.

The additional check preserves the sticky wake for the outer loop to consume. Once registration has happened, Rust's unpark token semantics cover notification just before parking. Keeping the explicit ordering protocol is preferable to relying on Crossbeam's current internal atomics. This also fits Emscripten's pending-notification approach to precise wakeups, adapted to Rust thread parking.

Validation: all 83 native default-feature unit tests passed on this head; all 86 passed with #2 applied together with #3. One non-blocking test-hardening suggestion is inline. I did not run a weak-memory model checker or browser/Wasm runtime test for this change.

Comment thread src/host/wait.rs
Comment on lines +208 to +217
// Mirrors instance_wait_events' outer-loop check.
assert!(!set.take_wake());
if before_registration {
assert!(set.waiters.is_empty());
set.wake();
}
set.park(Duration::from_secs(30), || {
if !before_registration {
// The waiter exists, but park_timeout has not run.
set.wake();

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] Add coverage for the cross-thread ordering claim

Both new boundary cases call set.wake() on the waiting thread itself. They usefully catch the missing sticky-flag check and exercise the unpark token, but cannot expose a publication/observation reordering between threads, which is the other reason for this change. As a follow-up, please add a small Loom-style model of the register → fence → condition-check / publish-condition → fence → inspect-registration protocol, plus a coordinated two-thread boundary test. That would make the intended ordering guarantee reviewable without depending on an ARM stress run or Crossbeam's present implementation. This is test hardening, not a demonstrated defect in the current fences.

The tests of the last change call wake on the waiting thread itself. They
catch a missing look at the sticky flag, but can't show how one thread's
accesses look to another, which is what the fences are for.

- A loom model of the handshake in src/host/wait.rs, built only with
  `--cfg loom` (`make test-loom`): the waiter takes a pending wake,
  registers, fences and looks; the notifier publishes, fences and looks
  for waiters. It fails if either fence or the look after registering is
  taken out, or if the fences are AcqRel. loom 0.7 implements SeqCst
  fences, though not SeqCst loads and stores. The model takes the wake
  with a compare-exchange rather than take_wake's swap: with the swap,
  loom reports a lost wake that C11 rules out.
- The waiting thread held while another thread wakes the set: before it
  is in the list, where only the look after registering ends the wait;
  in the list before it looks, where the look or the unpark does; and
  after its look, where only the unpark does. A test-only hook in park
  holds it in the list.

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