Files
zed/crates/gpui_web/examples/hello_web/queue_test.rs
T
Antonio Scandurra 2e31de1c44 gpui: Avoid main-thread waits when unlocking web task queues (#64298)
## Problem

GPUI's web dispatcher already uses `spin_send` and `spin_try_pop` on the
browser main thread. Those operations spin on `try_lock()` instead of
blocking while acquiring the queue mutex. However, that does **not**
make releasing a `parking_lot` mutex safe on that thread.

When a worker is waiting for the queue mutex, dropping the main thread's
guard follows this path:

```text
RawMutex::unlock_slow
  → parking_lot_core::unpark_one
    → lock_bucket
      → WordLock::lock_slow
        → ThreadParker::park
          → memory.atomic.wait32
```

`unpark_one` first locks the internal table used to locate sleeping
threads. If another thread holds that bucket lock, the caller can itself
park before reaching the actual wake instruction. See
[`unpark_one`](https://github.com/Amanieu/parking_lot/blob/d7828fff7b5d6327ae608e82db45f888b344449a/core/src/parking_lot.rs#L732-L737),
[`lock_bucket`](https://github.com/Amanieu/parking_lot/blob/d7828fff7b5d6327ae608e82db45f888b344449a/core/src/parking_lot.rs#L358-L366),
and [`WordLock`'s park
call](https://github.com/Amanieu/parking_lot/blob/d7828fff7b5d6327ae608e82db45f888b344449a/core/src/word_lock.rs#L163-L168).

Browsers forbid synchronous atomic waits on the main thread. Chromium
reports `Atomics.wait cannot be called in this context`; Safari can
misleadingly report an out-of-bounds memory access. Ordinary UI activity
can encounter this contention while scheduling background work; no
explicit blocking operation by the application is required.

## Why `std` fixes it

For **atomics-enabled Wasm**, Rust's standard-library mutex uses a futex
directly. Unlocking changes the mutex state with an atomic swap and, if
needed, calls `memory.atomic.notify`. Its condition-variable
notification similarly increments an atomic notification counter and
calls `memory.atomic.notify`. Neither operation acquires a secondary
waiter-table lock or performs an atomic wait.

A contended `std::Mutex::lock()` would still be unsafe on the browser
main thread, so this change **retains the existing `try_lock()` spin
loops**. Workers retain ordinary blocking acquisition and
condition-variable waiting. This relies on the Wasm backend's
implementation, not a portable guarantee that standard mutex operations
never block on every platform.

The production change only replaces the queue synchronization primitives
and adapts their APIs. The high/medium/low queues, weighted selection,
queue methods, and disconnect behavior are unchanged. Poisoned guards
are recovered to preserve the previous non-poisoning behavior. The
main-thread-only mailbox receiver also uses a standard mutex with
`try_lock()` so an unexpected acquisition cannot park it.

## Regression test and verification

One browser/Wasm stress test exchanges work and replies between the
actual browser main thread and configurable `N` Web Workers. It checks
exact-once delivery, cycles between idle and busy queues, and waits for
all workers to stop. An explicit Wasm wait is a negative control
confirming that the main thread rejects waits; a deadline outside the
page detects hangs.

- **Fix stashed:** rebuilt and ran the test against the original queue.
It failed with `Atomics.wait cannot be called in this context`, with
`WordLock::lock_slow → RawMutex::unlock_slow → spin_send` in the stack.
- **Fix restored:** rebuilt and ran the unchanged test with **8 and 16
workers**. Both passed, each exchanging **524,288 items over 128
rounds**.
- Both existing native queue tests passed. Shared-memory Wasm
compilation, Rust formatting, JavaScript syntax, and diff checks passed.
- Browser execution was verified in Chromium, not directly on an iOS
Safari device. This does not update any downstream application's pinned
GPUI dependency.

The test is available through the existing `hello_web` gallery:

```sh
node crates/gpui_web/examples/hello_web/test_queue.mjs http://127.0.0.1:8080 8
```

For the recorded runs, the fixture was built directly with the gallery's
shared-memory flags, `nightly-2026-07-01`, and matching wasm-bindgen
0.2.120, then served with COOP/COEP headers.

## Suggested .rules additions

For code running on the browser main thread, audit mutex unlock and
notification paths as well as acquisition: using `try_lock()` does not
guarantee that releasing the guard cannot park.

Release Notes:

- [GPUI] N/A
2026-09-16 13:17:03 +00:00

123 lines
3.8 KiB
Rust

#![feature(stdarch_wasm_atomic_wait)]
use gpui::{Priority, PriorityQueueReceiver, PriorityQueueSender};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use wasm_bindgen::prelude::*;
fn main() {}
/// Exercises both directions of a queue shared by the browser and real workers.
#[wasm_bindgen]
pub struct QueueStress {
sender: PriorityQueueSender<Option<usize>>,
receiver: PriorityQueueReceiver<usize>,
ready: Arc<AtomicUsize>,
stopped: Arc<AtomicUsize>,
workers: Vec<wasm_thread::JoinHandle<()>>,
seen: Vec<bool>,
sent: usize,
received: usize,
}
#[wasm_bindgen]
impl QueueStress {
#[wasm_bindgen(constructor)]
pub fn new(worker_count: usize, item_count: usize) -> Self {
assert!(worker_count > 0 && item_count > 0);
let (sender, receiver) = PriorityQueueReceiver::<Option<usize>>::new();
let (reply_sender, reply_receiver) = PriorityQueueReceiver::new();
let reply_sender = Arc::new(reply_sender);
let ready = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicUsize::new(0));
let workers = (0..worker_count)
.map(|_| {
let mut receiver = receiver.clone();
let reply_sender = reply_sender.clone();
let ready = ready.clone();
let stopped = stopped.clone();
wasm_thread::spawn(move || {
ready.fetch_add(1, Ordering::Release);
while let Some(item) = receiver.pop().expect("receive work") {
reply_sender.send(priority(item), item).expect("send reply");
}
stopped.fetch_add(1, Ordering::Release);
})
})
.collect();
Self {
sender,
receiver: reply_receiver,
ready,
stopped,
workers,
seen: vec![false; item_count],
sent: 0,
received: 0,
}
}
pub fn ready(&self) -> bool {
self.ready.load(Ordering::Acquire) == self.workers.len()
}
pub fn send_batch(&mut self, count: usize) {
let end = (self.sent + count).min(self.seen.len());
for item in self.sent..end {
self.sender
.spin_send(priority(item), Some(item))
.expect("send work from browser main thread");
}
self.sent = end;
}
pub fn drain(&mut self) -> usize {
while let Some(item) = self
.receiver
.spin_try_pop()
.expect("receive reply on browser main thread")
{
let seen = self.seen.get_mut(item).expect("unexpected reply");
assert!(!*seen, "duplicate reply");
*seen = true;
self.received += 1;
}
self.received
}
pub fn stop(&mut self) {
assert_eq!(self.sent, self.seen.len());
assert_eq!(self.received, self.sent);
assert!(self.seen.iter().all(|seen| *seen));
// The queue's existing disconnect behavior does not wake empty receivers.
for _ in &self.workers {
self.sender
.spin_send(Priority::Low, None)
.expect("stop worker");
}
}
pub fn stopped(&self) -> bool {
self.stopped.load(Ordering::Acquire) == self.workers.len()
}
}
fn priority(item: usize) -> Priority {
match item % 3 {
0 => Priority::High,
1 => Priority::Medium,
_ => Priority::Low,
}
}
/// Negative control: a browser-main Wasm atomic wait must trap, even with zero timeout.
#[wasm_bindgen]
pub fn forbidden_main_thread_wait() {
let value = std::sync::atomic::AtomicI32::new(0);
unsafe {
std::arch::wasm32::memory_atomic_wait32(value.as_ptr(), 0, 0);
}
}