Skip to content

fix(#707): route the remaining test servers through a loopback listen helper - #708

Open
Weegy wants to merge 3 commits into
mainfrom
fix/listen-await-ipv4-sweep
Open

fix(#707): route the remaining test servers through a loopback listen helper#708
Weegy wants to merge 3 commits into
mainfrom
fix/listen-await-ipv4-sweep

Conversation

@Weegy

@Weegy Weegy commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #707. Follow-up to #703.

What this finishes

#703 fixed the mechanism behind the intermittent 401/404/HTTPParserError in the middleware suite, but could only convert the call sites that already waited for their listening callback. The other 57 read server.address().port synchronously on the next line, which stops working the moment a host is passed — listen then goes through the dns.lookup path even for an IP literal and no longer binds synchronously.

All of them now go through test/_helpers/listenLoopback.ts, which binds 127.0.0.1 and resolves on listening. No test server under middleware/test/ is left holding a port it never dials.

A correction to #703

#703 said the wildcard socket is IPV6_V6ONLY. Measured on macOS, it is not. [::] is dual-stack and http://127.0.0.1:<port> normally reaches it — which is precisely why the bug presented as intermittent rather than as a hard failure. Had the socket really been v6-only, every one of these tests would have failed every time.

The real mechanism:

wildcard bound: {"address":"::","family":"IPv6","port":57754}
  IPv4 dial -> 200 wildcard                       => DUAL-STACK, not v6only
  specific 127.0.0.1:57754 bind -> bound          => coexists with the wildcard
  IPv4 dial now served by: specific               <== the shadowing

The wildcard bind's port is chosen only against other wildcard binds. A process that binds 127.0.0.1:<port> specifically may already hold that exact port, and on BSD/macOS the more specific bind coexists and wins for connections addressed to 127.0.0.1. Local dev servers bind 127.0.0.1 by default — which is exactly why the shadowers observed while diagnosing #703 were an MCP server (401 … provide valid authorization token) and a Flask app (404 <!doctype html>).

The fix and its rationale are unchanged; only the description of why the port was unprotected was wrong. Corrected in the changelog, in the helper's doc comment, and in both dev-platform harnesses, which now delegate to the helper rather than carrying their own copy of the explanation.

A bug this surfaced

12 files hung to the 120s test timeout after the first sweep. They contained:

const server = await listenLoopback(app);
await new Promise<void>((resolve) => server.once('listening', resolve));  // never fires

The helper already resolves after listening, so the second wait could never fire. 23 such waits removed. Worth noting: those files had been awaiting correctly all along — they only ever needed the host, not the await.

Also

canvas-core's WebSocket stub server had the same shape (new WebSocketServer({ port: 0 }) with callers dialling ws://127.0.0.1:<port>) and now binds the loopback. Its suite is 172/172.

middleware/test/guardedOutboundFetch.test.ts is left alone deliberately: it binds 127.0.0.1 and dials localhost on purpose, exercising the resolver path for the DNS-rebinding guard, and only asserts rejection.

Verification

Gate Result
npm run test green ×2, 6459 tests, 0 fail, 0 cancelled — rebased on current main (incl. #702, #704)
npm run test:updater 95/95
npm run test -w @omadia/canvas-core 172/172
npm run typecheck:test baseline 406, no regressions
npm run lint clean
core decoupling ratchet (#470) held at 3294 — no baseline change
Original flake file, 6 concurrent 0 red / 36 on this head (0 red / 48 earlier)
CI on the head commit 8/8 green, mergeable_state: clean

Mutation check: pointing the helper at '::' instead of '127.0.0.1' turns all 3 binding guards red.

No retries, sleeps, timeout bumps, or skips. Lockfile untouched.

Weegy added 3 commits August 14, 2026 15:23
… helper

#703 fixed the mechanism but could only convert the call sites that already
waited for their `listening` callback. The remaining 57 read
`server.address().port` synchronously on the next line, which stops working the
moment a host is passed: `listen` then goes through the `dns.lookup` path even
for an IP literal and no longer binds synchronously.

They now go through `test/_helpers/listenLoopback.ts`, which binds 127.0.0.1 and
resolves on `listening`. Enclosing hooks and three helper functions became async
to match. canvas-core's WebSocket stub server had the same shape and now binds
the loopback too.

Also removed 23 now-dead `await once('listening')` waits that followed a
converted site: the helper already resolves after `listening`, so the second
wait could never fire and hung 12 files to the 120s test timeout. Those files
had been awaiting correctly all along -- they only ever needed the host.

Corrects #703's explanation. That entry claimed the wildcard socket is
IPV6_V6ONLY. Measured on macOS it is not: `[::]` is dual-stack and
http://127.0.0.1:<port> normally reaches it, which is why the bug presented as
intermittent rather than as a hard failure. The real mechanism is that the
wildcard bind's port is chosen only against other wildcard binds, while a
process binding 127.0.0.1:<port> specifically may already hold it -- and on
BSD/macOS the more specific bind coexists with the wildcard and wins for
connections to 127.0.0.1. Local dev servers bind 127.0.0.1 by default, which is
why the observed shadowers were an MCP server and a Flask app.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Test servers still bind a port they never dial (55 sites left after #703)

1 participant