## 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
123 lines
3.8 KiB
Rust
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);
|
|
}
|
|
}
|