Skip to content

/browse daemons bind OS-assigned ephemeral ports on 127.0.0.1 and can silently hijack local test-suite traffic (phantom 404s) #2314

Closed

Description

@devop-hub

Summary

The /browse terminal agent (browse/src/terminal-agent.ts) starts with
Bun.serve({ hostname: '127.0.0.1', port: 0 }) and keeps that OS-assigned port for its whole
lifetime (days/weeks). port: 0 hands out a port from the OS ephemeral range
(49152–65535 on macOS) — the same pool every short-lived test server draws from. Because the
daemon answers 404 "not found" to everything it doesn't recognize, it can silently absorb
traffic meant for ephemeral test servers, producing "impossible" phantom 404s in any Node test
suite on the machine.

On this machine two gstack daemons were squatting the ephemeral range:

$ lsof -iTCP -sTCP:LISTEN -n -P | grep 127.0.0.1
bun ... 127.0.0.1:49197 (LISTEN)   ← browse terminal-agent, alive since 2026-07-10
bun ... 127.0.0.1:54221 (LISTEN)

The hijack mechanism (verified end-to-end)

  1. supertest (and many test helpers) start one ephemeral server per request with
    app.listen(0) — no host, so Node binds the wildcard :: (dual-stack). The bind is
    handed a port by the kernel.

  2. On macOS, a specific tcp4 bind (127.0.0.1:49197, the daemon) and a v6 wildcard
    bind
    (:::49197, the test server) coexist — the kernel happily assigns the test
    server the daemon's port number. Verified:

    // with the gstack daemon alive on 127.0.0.1:49197
    http.createServer(...).listen(49197)          // → SUCCESS, address = { address: '::', port: 49197 }
    http.get('http://127.0.0.1:49197/ping')       // → 404 "not found"  ← answered by the DAEMON
  3. supertest connects to http://127.0.0.1:<port>. For v4 traffic, the daemon's more
    specific
    bind wins. Every request of that unlucky test goes to the daemon → its 404.

With ~2 squatted ports out of ~16,384 and thousands of listen(0) calls per full-suite run,
the per-run collision probability lands at the ~10-30% we measured. The result is a flake with
a rotating victim test, never reproducible in isolation, insensitive to jest workers/CPU — it
cost us multiple engineering days across two incident investigations before instrumentation
traced the 404s to a constant foreign port.

Suggested fixes (any one of these kills the class)

  1. Bind a port outside the ephemeral range. E.g. scan a small fixed window in the
    registered range (say 8531–8599) instead of port: 0, keeping the existing port-file
    discovery. Long-lived daemons and port: 0 are a bad pairing: the OS assumes ephemeral
    ports are returned quickly.
  2. Alternatively, make the port configurable (env var) and document the interference so
    affected users can move it.
  3. At minimum: respond to unknown routes with something distinctive (e.g. a
    Server: gstack-browse header or a JSON body naming the daemon). A greppable fingerprint
    would have turned days of debugging into minutes.

Workaround we shipped on our side

Client-side: patch supertest's Test.prototype.serverAddress in jest.setup.js so tests
connect via http://[::1]:<port> (pure IPv6 loopback) — a tcp4-bound daemon can never receive
that traffic. (Binding the test servers to 127.0.0.1 instead is NOT viable: supertest reads
server.address() synchronously after listen(0), and passing a host makes the bind async via
dns.lookup.)

Environment

  • gstack 1.60.1.0, browse terminal-agent started via bun run .../browse/src/terminal-agent.ts
  • macOS (darwin 25.2.0), Bun ≥ 1.x, Node 22.22.3 test suite (jest 30 + supertest 7)
  • Diagnosis one-liner for affected users:
    lsof -iTCP -sTCP:LISTEN -n -P | grep 127.0.0.1 → any port ≥ 49152 held by a daemon is a
    potential phantom-404 source for local test suites.

Activity

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions