Skip to content

🐛 BUG: Miniflare hangs indefinitely when workerd exits before sending all listen events on control FD #14077

Description

@fuyu28

What versions & operating system are you using?

System:
OS: Linux 7.0 EndeavourOS
CPU: (16) x64 13th Gen Intel(R) Core(TM) i7-1360P
Memory: 11.24 GB / 15.30 GB
Container: Yes
Shell: 5.9 - /usr/bin/zsh
Binaries:
Node: 26.1.0 - /home/fuyu/.local/share/mise/installs/node/26.1.0/bin/node

Please provide a link to a minimal reproduction

N/A

Describe the Bug

Summary

When workerd exits during startup before writing all expected
{"event":"listen"} / {"event":"listen-inspector"} messages to the control
file descriptor (FD3), Miniflare's internal waitForPorts() blocks forever.

From the user's perspective, wrangler dev stalls at:

⎔ Starting local server...

with no error, no timeout, and no way to diagnose the failure without external
tools like strace.

Environment

  • OS: EndeavourOS Linux (kernel 7.0.10-arch1-1)
  • Node.js: v26
  • Wrangler: 4.95.0
  • Miniflare: 4.20260526.0

Steps to Reproduce

One concrete trigger, though there may be others:

  1. Disable IPv6 at the kernel level:

    net.ipv6.conf.all.disable_ipv6 = 1
    
  2. Leave the default /etc/hosts entry intact:

    ::1 localhost ip6-localhost ip6-loopback
    
  3. Run:

    wrangler dev

wrangler dev prints the following and never proceeds:

⎔ Starting local server...

Root Cause Analysis

What Miniflare does

Miniflare spawns workerd with:

--inspector-addr=localhost:0

localhost is hardcoded in:

packages/miniflare/src/index.ts:2432

Miniflare then reads the control FD, FD3, line-by-line, waiting for listen
events from all required sockets:

{"event":"listen","socket":"entry","port":8787}
{"event":"listen-inspector","port":12345}

The inspector socket uses a separate event type, listen-inspector.

waitForPorts() resolves only when all expected events arrive.

What goes wrong

  1. --inspector-addr=localhost:0 is passed to workerd.
  2. workerd calls getaddrinfo("localhost").
  3. RFC 6724 address selection prefers ::1 over 127.0.0.1 when both appear
    in /etc/hosts.
  4. workerd attempts bind(::1, 0) for the inspector socket.
  5. With IPv6 disabled at the kernel level, ::1 is not available, so the bind
    fails.
  6. workerd exits without writing the listen-inspector event to FD3.
  7. Miniflare's waitForPorts() is still waiting for the inspector event, but
    the process is already dead.

The missing piece

waitForPorts() does not race against the workerd process exit event.

If workerd exits for any reason before all listen events are written,
Miniflare has no way to detect this and hangs indefinitely.

There is also no error event defined in the FD3 control protocol, so workerd
has no mechanism to signal startup failure to Miniflare.

Secondary gap: handleStartupFailure() only covers one case

packages/miniflare/src/runtime/index.ts:198-212 currently inspects
workerd's stderr only for the string:

Address already in use

An IPv6 bind failure produces a different message.

Therefore, even if waitForPorts() returned undefined rather than hanging,
the caller would receive only the generic message:

The Workers runtime failed to start. There is likely additional logging output above.

This is still insufficient for diagnosis.

Expected Behavior

When workerd exits during startup, Miniflare should immediately reject with
an error like:

Error: workerd exited (code=1) before all sockets were ready.
Required: entry, inspector. Received: entry.

Proposed Fix

The fix belongs in updateConfig() in:

packages/miniflare/src/runtime/index.ts

This is where the process reference is available.

Race waitForPorts() against the child process exit:

// In Runtime#updateConfig(), after writing the config to stdin:

const ports = await Promise.race([
  waitForPorts(controlPipe, options),
  new Promise<never>((_, reject) => {
    runtimeProcess.once("exit", (code, signal) => {
      reject(
        new MiniflareCoreError(
          "ERR_RUNTIME_FAILURE",
          `workerd exited (code=${code ?? signal}) before all sockets were ready.`
        )
      );
    });
  }),
]);

Note: kInspectorSocket is a Symbol, so the error message should map it to a
human-readable name, such as inspector, rather than emitting
Symbol(kInspectorSocket).

This fix is robust because it handles any workerd startup failure regardless
of cause, including:

  • port conflict
  • missing binary
  • bind error
  • permission denied
  • other startup-time failures

Notes

  • The immediate workaround for the IPv6 case is to comment out ::1 localhost
    in /etc/hosts, so that localhost resolves only to 127.0.0.1.
  • A separate improvement would be for workerd to write an error event to FD3
    on startup failure, so that the control protocol has explicit failure
    semantics.
  • handleStartupFailure() should also be extended to surface other startup
    failure patterns from workerd's stderr, not just "Address already in use".
  • Related: #4866 (workerd not cleaned up on wrangler error)
  • Related: #6510 orphaned workerd after port bind failure

Please provide any relevant error logs

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    No fields configured for Bug.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions