Repository navigation
Description
Note
Generated with Fable 5 as part of troubleshooting hanging builds
Version: Deno 2.9.1 (also inspected 2.9.4 — no relevant change), esbuild 0.25.5, esbuild_client 0.7.1 (src/lib.rs identical on its main as of a49e43d1)
Symptom
A deno bundle invocation and the esbuild service child it spawns both drop to 0% CPU and stay there indefinitely. Nothing is printed, neither process exits, and the state never resolves on its own (observed wedges of 33+ minutes locally and 60–74 minutes on CI before being killed).
Command that wedges (a browser bundle of ~2,500 modules):
deno bundle --platform=browser --minify --packages=bundle \
--node-modules-dir=manual --no-remote --frozen \
--output out.js packages/web/client/main.tsx
It only happens under concurrent load: our CI job that runs a full test battery concurrently on 4 vCPU hit it on 2 of 2 runs (ubuntu-latest); a 10-core macOS arm64 machine hits it in roughly 1 in 4 battery runs. The same suite run standalone has never wedged.
Analysis: a two-channel deadlock in esbuild_client's protocol loop
protocol_task (esbuild_client src/lib.rs) is a single tokio::select! loop that both writes the service's stdin and reads its stdout, and it forwards each decoded incoming packet with packet_tx.send(packet).await — a bounded mpsc (capacity 100) — inside the read arm (lib.rs:121 via handle_read). While that send is parked, the loop neither reads stdout nor drains response_rx.
The packet-handler task drains packet_rx. on-resolve/on-load hooks are spawned as separate tasks, but ping and on-start responses are sent inline with response_tx.send().await (lib.rs:712–720, 750–760) — the other bounded mpsc (capacity 100), which only protocol_task drains.
Every module load round-trips through the plugin hooks, and --ping keepalives arrive periodically. The client runs on a single-threaded runtime while the service is a multi-threaded Go process that keeps producing while the client is off-CPU. Under scheduler starvation the incoming flood backs both channels up, and the system can reach:
protocol_taskparked inpacket_tx.send().await→ needs the handler to drain;- the handler parked in
response_tx.send().awaitfor a ping/on-start response → needsprotocol_taskto drain.
Neither can proceed; the spawned hook tasks pile up parked on the same full response_tx. The service ends up blocked writing its stdout (pipe full) with its goroutines waiting for hook responses that will never be written. There is no timeout anywhere in the protocol, so the deadlock is silent and permanent.
Evidence from a live wedge (macOS arm64)
Parent deno bundle and its own esbuild-darwin-arm64 --service=0.25.5 --ping child, both 0.0% CPU for 15+ minutes:
sampleof the deno process: main thread inkevent(event loop idle), V8 workers inpsynch_cvwait;sampleof esbuild: every thread inpsynch_cvwait— all goroutines parked;- no read/write syscall in flight on either side;
lsofshows the pair's stdio pipes intact, and exactly one esbuild service existed on the machine (the stuck bundle's own child), ruling out interference from a stale service.
Happy to attach the full sample/lsof captures.
Reproduction notes
No minimal reproducer yet — the trigger is genuine scheduler starvation. Reliable recipe: repeatedly run a large deno bundle concurrently with a load that oversubscribes all cores (for us: a test battery of ~9 concurrent commands). Pure CPU-spinner load against an un-niced bundle did not reproduce it in ~200 attempts, and SIGSTOP/SIGCONT duty-cycling the whole deno process did not either (stopping all of deno also gates the service's flood through pipe backpressure) — the wedged instances we caught were running niced (SN) alongside a busy machine, i.e. asymmetric starvation of the client while its service keeps producing.
Possible fixes (any one breaks the cycle)
- Don't
awaita bounded-channel send inside the select arms — buffer decoded packets in an unbounded queue (memory is bounded in practice by esbuild's own request window), orpacket_tx.reserve().awaitoutside the critical section; - route
ping/on-startresponses through the same spawned-task path as the other hooks, so the handler task never blocks onresponse_tx; - add a watchdog timeout to the protocol so a wedged build at least fails loudly instead of hanging forever.
Activity
🤖 Name the upstream issue the bundle ceiling exists for
🤖 Verify the whole battery at once, and prove it changed nothing
🤖 Verify the whole battery at once, and prove it changed nothing
fix(bundle): avoid esbuild protocol deadlock (#36427)
fix(bundle): avoid esbuild protocol deadlock (#36427)
nathanwhit commentedon Aug 6, 2026
This should be fixed in deno 2.9.5. without a repro I couldn't confirm that your exact issue is solved, but I did find an bug with the same symptoms you report, so chances are it's your bug.
Feel free to reopen or open a new issue if you still encounter problems on 2.9.5
⬆️ Pin CI to Deno 2.9.5 for the esbuild bundle deadlock fix (#359)
🤖 Verify the whole battery at once, and prove it changed nothing
🤖 Verify the whole battery at once, and prove it changed nothing
🤖 Verify the whole battery at once, and prove it changed nothing
🤖 Verify the whole battery at once, and prove it changed nothing
🤖 Verify the whole battery at once, and prove it changed nothing (#313)
taras commentedon Aug 8, 2026
@nathanwhit thanks for a quick fix. It seems to have resolved in issue for me.