Skip to content

fix(port-reservation): serve the port bound during provide instead of re-listening - #55

Merged
Upd4ting merged 2 commits into
mainfrom
fix/port-reservation-race
Sep 26, 2026
Merged

Upd4ting merged 2 commits into
mainfrom
fix/port-reservation-race

Conversation

@Upd4ting

@Upd4ting Upd4ting commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Problem. In 1.3.0 the server port is reserved from provide until start by a placeholder net.createServer() with no connection handler, then start closes the placeholder and calls listen() again on the real server. If a client connects during that window (for example a startup probe on /health), the accepted socket is never read nor closed, so the placeholder's close(cb) never calls back, listenServers() never reaches the real listen(), and the HTTP server never starts ("Server started" is never logged). In production this made a pod crash-loop until a boot happened to receive no connection during the window.

Fix: bind once, serve later. The reserve → close → re-listen handoff is replaced by a single listening socket, like Go's net.Listen followed by http.Serve:

  • provide binds the definitive listening socket for each configured server (net.createServer({ pauseOnConnect: true })), with the existing rules — dev fallback up to 20 ports above then an OS-assigned port, strictPort, port: 0, PortReservationError with the strict hint — and publishes the port read back from that socket.
  • start creates the http/https server with the existing factory and never calls listen() on it. Connections accepted before start are queued paused and unread; serve hands them, then every new connection, to the server with server.emit("connection", socket) (TLS wraps the socket for https). The server is also sent listening, which arms Node's connection tracking (request/headers timeouts, closing idle keep-alive connections on close()).
  • Nothing is closed and re-bound between provide and start, so there is no race: no 503, no timeouts, no deadline log.
  • stop closes the servers and the listening sockets. listenServers() binds lazily through the same code path when no matching socket is held (after a stop, when provide never ran, or when the configuration no longer names the bound address), so restart and autoListen: false + listenServers() keep working. A second start hands the bound socket to the new servers.
  • Port fallback now lives in one place (src/port-listener.ts, renamed from port-reservation.ts); port-binding.ts keeps only the shared helpers. src/startup-deadline.ts, the 503 answer, the release timeout and the listen deadline log are removed.
  • applyReservedPorts is kept: construct may receive a rebuilt config carrying the requested port again, and the config must keep carrying the bound port. getListeningEndpoints reports the bound socket's address.
  • README: the port reservation section describes the bound-once socket; connections arriving before start are served once the server starts.

Public exports (provide, construct, start, stop, listenServers, getListeningEndpoints, applyReservedPorts) and config semantics are unchanged. No version bump or changelog edit: both are produced by the release workflow (release-it + changelogen).

Related issue

No issue: production incident on 2026-09-25 (startup probe hitting the reservation window prevented the server from ever listening).

Verification

  • New src/test/early-connections.test.ts:
    • a client sends a request after provide and before start: it gets no answer until start, then a real HTTP response from the server (not a 503);
    • a silent client holding a connection does not block start;
    • port: 0 publishes and serves the same OS-assigned port;
    • stop then start binds the port again and serves it;
    • calling start twice hands the bound socket to the new servers;
    • stop does not wait on idle keep-alive connections.
  • src/test/config-vars.test.ts: the reservation suite now exercises bindServerPorts (requested port, fallback, PortReservationError under strictPort, port: 0, cleanup on partial failure); the publication suite checks the published port equals the served one, including fallback.
  • The first two tests fail on main ("The server never started serving").
  • https hand-off (emit("connection") on an https server with queued and later connections) checked manually with a self-signed certificate; the repo has no TLS fixtures.
  • pnpm prepack, pnpm lint, pnpm format:check, pnpm knip, pnpm test (206 passing).

Checklist

  • The pull request title follows Conventional Commits.
  • I added or updated tests when behavior changed.
  • I updated documentation when users or contributors are affected.
  • I documented breaking changes and migration steps. (N/A: no breaking change)
  • I did not include credentials or unrelated changes.

The port held between `provide` and `start` was a bare `net.createServer()`
without a connection handler. A client connecting during that window, such
as a Kubernetes startup probe, got a socket that was never read nor closed,
so `holder.close()` never called back, the real `listen()` was never reached
and the HTTP server never started.

The placeholder now answers every accepted connection with a minimal
`503 Service Unavailable` carrying `Connection: close` and `Retry-After`,
ends it, and tracks it; releasing the reservation destroys any connection
still open so the close always completes. Releasing the reservations is
additionally bounded by a timeout, and an error is logged when the servers
are still not listening five seconds after `start`.
…it from start

Replace the reserve, close and re-listen handoff with a single listening
socket bound during provide and served from start, like Go's net.Listen
followed by http.Serve. The socket is created with pauseOnConnect, so
connections accepted before start wait unread and are handed to the
http or https server, with every later connection, through its
connection event. Nothing is closed and bound again between provide and
start, so an early client can no longer keep the server from listening.

The port fallback rules now live in one place, the listener binding,
used by provide and lazily by listenServers after a stop or when the
configuration no longer matches the bound address.

This removes the 503 placeholder answer, the release timeout and the
listen deadline log, which only papered over the handoff.
@Upd4ting Upd4ting changed the title fix(port-reservation): stop the port placeholder from hanging startup fix(port-reservation): serve the port bound during provide instead of re-listening Sep 26, 2026
@Upd4ting
Upd4ting merged commit bde88c6 into main Sep 26, 2026
2 checks passed
@Upd4ting
Upd4ting deleted the fix/port-reservation-race branch September 26, 2026 11:39
Upd4ting pushed a commit to AntelopeJS/dms-frontend that referenced this pull request Sep 27, 2026
…#48)

`ajs dms dev` holds the frontend port with a bare net server during the
workspace setup, then closes it right before starting `server.mjs`.
`server.close()` only calls back once every accepted connection has
ended, and the holder accepted connections without ever reading or
closing them. So a client that connected to the port during the setup (a
browser tab reloading, a startup probe) kept `release()` pending, and
the frontend server never started.

The holder now destroys every connection it accepts: it only reserves
the port. Same root cause as AntelopeJS/api#56, which api 1.3.1 fixed
for its own reservation (AntelopeJS/api#55).
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.

1 participant