Skip to content

deno bundle silently deadlocks with its esbuild service under CPU starvation (both at 0% CPU forever) #36417

Closed
denoland/esbuild_client
#18

Description

@taras

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_task parked in packet_tx.send().await → needs the handler to drain;
  • the handler parked in response_tx.send().await for a ping/on-start response → needs protocol_task to 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:

  • sample of the deno process: main thread in kevent (event loop idle), V8 workers in psynch_cvwait;
  • sample of esbuild: every thread in psynch_cvwait — all goroutines parked;
  • no read/write syscall in flight on either side;
  • lsof shows 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)

  1. Don't await a 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), or packet_tx.reserve().await outside the critical section;
  2. route ping/on-start responses through the same spawned-task path as the other hooks, so the handler task never blocks on response_tx;
  3. add a watchdog timeout to the protocol so a wedged build at least fails loudly instead of hanging forever.

Activity

  1. added a commit that references this issue on Aug 6, 2026
    8af9351
  2. added a commit that references this issue on Aug 6, 2026
    7679272
  3. nathanwhit commented on Aug 6, 2026

    @nathanwhit
    Member

    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

  4. taras commented on Aug 8, 2026

    @taras
    Author

    @nathanwhit thanks for a quick fix. It seems to have resolved in issue for me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions