fix(port-reservation): serve the port bound during provide instead of re-listening - #55
Merged
Merged
Conversation
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.
This was referenced Sep 26, 2026
fix: the port reservation holder can stall startup when a client connects while the port is held
#56
Closed
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Problem. In 1.3.0 the server port is reserved from
provideuntilstartby a placeholdernet.createServer()with no connection handler, thenstartcloses the placeholder and callslisten()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'sclose(cb)never calls back,listenServers()never reaches the reallisten(), 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.Listenfollowed byhttp.Serve:providebinds 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,PortReservationErrorwith the strict hint — and publishes the port read back from that socket.startcreates the http/https server with the existing factory and never callslisten()on it. Connections accepted beforestartare queued paused and unread;servehands them, then every new connection, to the server withserver.emit("connection", socket)(TLS wraps the socket for https). The server is also sentlistening, which arms Node's connection tracking (request/headers timeouts, closing idle keep-alive connections onclose()).provideandstart, so there is no race: no 503, no timeouts, no deadline log.stopcloses the servers and the listening sockets.listenServers()binds lazily through the same code path when no matching socket is held (after astop, whenprovidenever ran, or when the configuration no longer names the bound address), so restart andautoListen: false+listenServers()keep working. A secondstarthands the bound socket to the new servers.src/port-listener.ts, renamed fromport-reservation.ts);port-binding.tskeeps only the shared helpers.src/startup-deadline.ts, the 503 answer, the release timeout and the listen deadline log are removed.applyReservedPortsis kept:constructmay receive a rebuilt config carrying the requested port again, and the config must keep carrying the bound port.getListeningEndpointsreports the bound socket's address.startare 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
src/test/early-connections.test.ts:provideand beforestart: it gets no answer untilstart, then a real HTTP response from the server (not a 503);start;port: 0publishes and serves the same OS-assigned port;stopthenstartbinds the port again and serves it;starttwice hands the bound socket to the new servers;stopdoes not wait on idle keep-alive connections.src/test/config-vars.test.ts: the reservation suite now exercisesbindServerPorts(requested port, fallback,PortReservationErrorunderstrictPort,port: 0, cleanup on partial failure); the publication suite checks the published port equals the served one, including fallback.main("The server never started serving").emit("connection")on anhttpsserver 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