Files
zed/crates/gpui_web/examples/hello_web/test_queue.mjs
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

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);
}