Post

Wrangler's test harness hangs forever under Bun, and why

Under Bun, wrangler's createTestHarness() listens fine and then every request hangs with no error. The cause is Bun's fetch() ignoring the undici dispatcher Miniflare routes requests through - and wrangler 4.151.3 now fails fast with a message that says so.

Wrangler's test harness hangs forever under Bun, and why

If you run a Cloudflare Workers test suite under Bun and every request against the dev harness just stops - listen() resolves, no error is printed, no timeout fires, the process sits there - you are not doing anything wrong. The requests never reach your Worker, and nothing in the chain is built to notice. This post reproduces the hang from a clean directory, traces it to the one line where Bun and Node disagree, and shows the fix that landed in wrangler 4.151.3. Versions tested: Node v24.21.0, Bun 1.4.3, wrangler 4.149.0 and 4.152.0, macOS arm64.

The symptom

The harness is wrangler's programmatic dev server, the createTestHarness() API. A minimal probe is three files:

1
2
3
4
5
6
// worker/index.js
export default {
  fetch() {
    return new Response("pong");
  },
};
1
2
3
4
5
6
// wrangler.jsonc
{
  "name": "probe",
  "main": "worker/index.js",
  "compatibility_date": "2026-10-01",
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// probe.mjs
import { createTestHarness } from "wrangler";

const t = createTestHarness({ workers: [{ configPath: "wrangler.jsonc" }] });
const { url } = await t.listen();
console.log("listen() resolved, url:", url.href);

const res = await t.fetch("http://example.com/ping");
console.log("harness.fetch status:", res.status);

const wres = await t.getWorker().fetch("http://example.com/ping");
console.log("worker.fetch status:", wres.status);

await t.close();

Under Node, both calls answer:

1
2
3
4
$ node probe.mjs
listen() resolved, url: http://127.0.0.1:49411/
harness.fetch status: 200
worker.fetch status: 200

Under Bun, listen() still resolves. Then t.fetch() never returns - no error, no timeout, nothing. With a 15 second AbortSignal bolted on, the direct-fetch variant makes the hang visible:

1
2
3
4
$ bun probe.mjs
listen() resolved, url: http://127.0.0.1:49997/
harness.fetch status: 404        <- not your Worker's response
direct fetch failed: TimeoutError The operation timed out.

Two things went wrong at once. The dispatch through the server (t.fetch()) returned a 404 that is not your Worker's Response - on this setup it is an error page, and the issue report records worse: a request that actually reached the real example.com instead of the test Worker. The plain global fetch() against the harness URL hung forever. The real Worker code never executed in either path.

Where the request dies

Miniflare, the local runtime underneath wrangler dev, does not serve dev requests over a plain socket. It routes them through a ProxyWorker, and it attaches control metadata to each request - a signal that tells the ProxyWorker "this request is for you, play it" - by passing an undici custom Dispatcher as an option to fetch().

sequenceDiagram
    participant T as your test
    participant F as fetch()
    participant P as ProxyWorker
    participant W as your Worker
    T->>F: harness.fetch(url)
    Note over F: Node: passes dispatcher<br/>Bun: ignores the option
    F->>P: request + control metadata
    Note over P: Node: metadata arrives,<br/>queue flushes
    Note over P: Bun: metadata never arrives,<br/>queue never flushes
    P->>W: (never happens under Bun)
    W-->>T: (no response ever)

Undici is Node's HTTP client, and the dispatcher option is an undici extension to the fetch() signature. Node's fetch honours it. Bun's fetch accepts the argument and ignores it - that is oven-sh/bun#39247, open with a fix pending. Without the metadata, the ProxyWorker's request queue never flushes: the request sits in a lane that will never be served, and because nothing ever reads it, nothing ever errors either. That is the whole hang: not a crash, a message that is never delivered.

The clue that makes it diagnosable without reading Miniflare source is in the issue thread: dispatch the same request under Node and under Bun side by side, and observe that everything up to the dispatcher behaves identically. The one stage whose contract is "an out-of-band object travels with the request" is the one stage that silently drops it.

The fix, and what it changed

Wrangler 4.151.3 stopped hiding this. The maintainers' reasoning is worth copying: rather than refuse to run under Bun entirely (Bun may implement the dispatcher option one day), the harness detects the combination and fails immediately with a message that names the cause. Running the same probe against wrangler 4.152.0:

1
2
3
4
5
6
7
$ bun probe.mjs
listen() resolved, url: http://127.0.0.1:53032/
harness.fetch failed: Error Wrangler can't forward requests to your Worker
under Bun, because Bun's `fetch()` ignores the undici `dispatcher` that
Miniflare routes them through (https://github.com/oven-sh/bun/issues/39247).
Run this using Node.js. `server.getWorker().getEnv()` and `getExport()` still
work under Bun.

So the cures, in order of preference:

  • Run the harness under Node. The Worker code is the thing under test; the test runner's runtime is incidental. Keeping the probe on Node while the rest of your tooling runs on Bun costs nothing and matches what the dev server itself will do in production.
  • Upgrade wrangler to at least 4.151.3 so the failure is loud instead of silent. A five second error beats a nine minute hang followed by 167 failed tests, which is what the issue reporter measured on a real suite.
  • If you need getEnv() or getExport() under Bun, those still work - the fail-fast covers only request forwarding.

How to tell it worked

Three assertions, all cheap:

  • The Worker's actual response comes back. pong, status 200 - not a 404 page you did not write, and not a response from the real site you used as a URL.
  • Under the broken combination, a request with a timeout aborts with TimeoutError instead of completing; under the fixed one it completes well inside the timeout.
  • The error text (wrangler 4.151.3+) names oven-sh/bun#39247 - if you see that string, you are on the fixed wrangler and hitting the still-open Bun gap.

The general lesson travels past wrangler: a fetch() extension option is only a contract if every runtime in your CI honours it. When a request hangs with no error, look for the piece of the call that travels out of band - a dispatcher, a signal, a context object - and check whether your runtime forwards it at all.

This post is licensed under CC BY 4.0 by the author.