## 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
120 lines
5.4 KiB
JavaScript
120 lines
5.4 KiB
JavaScript
// With the hello_web gallery served by Trunk:
|
|
// node test_queue.mjs http://127.0.0.1:8080 [worker-count]
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const origin = new URL(process.argv[2] ?? "http://127.0.0.1:8080").origin;
|
|
const workers = Number(process.argv[3] ?? 8);
|
|
assert.ok(Number.isInteger(workers) && workers > 0);
|
|
const chrome = process.env.CHROME ??
|
|
(process.platform === "darwin"
|
|
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
: "chromium");
|
|
const output = path.join(path.dirname(fileURLToPath(import.meta.url)), "target", "queue-test", String(Date.now()));
|
|
fs.mkdirSync(output, { recursive: true });
|
|
const log = fs.openSync(path.join(output, "chrome.log"), "w");
|
|
const browser = spawn(chrome, [
|
|
"--headless=new", "--no-first-run", "--no-default-browser-check",
|
|
"--remote-debugging-port=0", `--user-data-dir=${path.join(output, "profile")}`,
|
|
"about:blank",
|
|
], { stdio: ["ignore", log, log] });
|
|
const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
let launchError;
|
|
browser.on("error", error => { launchError = error; });
|
|
let socket;
|
|
try {
|
|
let port;
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if (launchError) throw launchError;
|
|
if (browser.exitCode !== null) throw new Error(`Chrome exited; see ${output}/chrome.log`);
|
|
const portFile = path.join(output, "profile", "DevToolsActivePort");
|
|
if (fs.existsSync(portFile)) {
|
|
port = fs.readFileSync(portFile, "utf8").split("\n")[0];
|
|
break;
|
|
}
|
|
await delay(100);
|
|
}
|
|
assert.ok(port, "Chrome did not start");
|
|
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
|
socket = new WebSocket(pages.find(page => page.type === "page").webSocketDebuggerUrl);
|
|
await new Promise((resolve, reject) => {
|
|
socket.addEventListener("open", resolve, { once: true });
|
|
socket.addEventListener("error", reject, { once: true });
|
|
});
|
|
let nextId = 0;
|
|
const pending = new Map();
|
|
const errors = [];
|
|
socket.addEventListener("message", ({ data }) => {
|
|
const message = JSON.parse(data);
|
|
if (message.id) {
|
|
const request = pending.get(message.id);
|
|
if (!request) return;
|
|
pending.delete(message.id);
|
|
clearTimeout(request.timer);
|
|
if (message.error) request.reject(new Error(JSON.stringify(message.error)));
|
|
else request.resolve(message.result);
|
|
} else if (message.method === "Runtime.exceptionThrown") {
|
|
errors.push(message.params);
|
|
}
|
|
});
|
|
const call = (method, params = {}) => new Promise((resolve, reject) => {
|
|
const id = ++nextId;
|
|
// This deadline runs outside the page, so it also catches a main-thread hang.
|
|
const timer = setTimeout(() => reject(new Error(`Timed out: ${method}`)), 120_000);
|
|
timer.unref();
|
|
pending.set(id, { resolve, reject, timer });
|
|
socket.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
await call("Runtime.enable");
|
|
await call("Page.enable");
|
|
const loaded = new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error("Page did not load")), 10_000);
|
|
socket.addEventListener("message", function onLoad({ data }) {
|
|
if (JSON.parse(data).method === "Page.loadEventFired") {
|
|
clearTimeout(timer);
|
|
socket.removeEventListener("message", onLoad);
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
await call("Page.navigate", { url: origin });
|
|
await loaded;
|
|
const result = await call("Runtime.evaluate", {
|
|
awaitPromise: true, returnByValue: true,
|
|
expression: `(async () => {
|
|
if (!crossOriginIsolated) throw new Error("Shared-memory isolation headers are required");
|
|
const fixture = await import("/queue_test.js");
|
|
await fixture.default();
|
|
let trapped = false;
|
|
try { fixture.forbidden_main_thread_wait(); }
|
|
catch (error) { trapped = error instanceof WebAssembly.RuntimeError; }
|
|
if (!trapped) throw new Error("Main-thread Wasm waits must trap in this browser");
|
|
const rounds = 128, batch = 4096;
|
|
const stress = new fixture.QueueStress(${workers}, rounds * batch);
|
|
const yieldToBrowser = () => new Promise(resolve => setTimeout(resolve, 2));
|
|
while (!stress.ready()) await yieldToBrowser();
|
|
for (let round = 0; round < rounds; round++) {
|
|
stress.send_batch(batch);
|
|
while (stress.drain() !== (round + 1) * batch) await yieldToBrowser();
|
|
// Let the request queue empty so workers repeatedly park and get notified.
|
|
await yieldToBrowser();
|
|
}
|
|
stress.stop();
|
|
while (!stress.stopped()) await yieldToBrowser();
|
|
stress.free();
|
|
return { workers: ${workers}, items: rounds * batch, rounds };
|
|
})()`,
|
|
});
|
|
assert.equal(result.exceptionDetails, undefined, JSON.stringify(result.exceptionDetails));
|
|
assert.deepEqual(errors, [], JSON.stringify(errors));
|
|
assert.equal(result.result.value.items, 128 * 4096);
|
|
console.log("PASS: main-thread/worker queue contention", result.result.value);
|
|
} finally {
|
|
socket?.close();
|
|
browser.kill();
|
|
fs.closeSync(log);
|
|
}
|