Skip to content

[Bug]: Requests that arrive during server startup are never answered (socket accepts, handler not attached yet) #12215

Description

@jakob1379

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

Uses a throwaway data dir and a spare port, so no existing state is touched:

H=$(mktemp -d); export HOME=$H T3CODE_HOME=$H/.t3
t3 serve --host 127.0.0.1 --port 38775 > $H/log 2>&1 &

# 1. wait until the port accepts TCP connections
until (exec 3<>/dev/tcp/127.0.0.1/38775) 2>/dev/null; do sleep 0.05; done

# 2. send a request right away (before "T3 Code server is ready")
curl -s -o /dev/null -w "early: %{http_code} %{time_total}s\n" --max-time 45 http://127.0.0.1:38775/ &

# 3. wait for readiness, then send a second request
until grep -q "server is ready" $H/log; do sleep 0.1; done
curl -s -o /dev/null -w "late:  %{http_code} %{time_total}s\n" --max-time 5 http://127.0.0.1:38775/
wait

Expected behavior

A request that connects before the server is ready is either held and served once the app is ready, or rejected quickly (e.g. connection refused or 503 + Retry-After). The port should not accept connections it will never answer.

Actual behavior

The port accepts connections roughly 0.5–1s before the HTTP handler is attached. Requests sent in that window never get a response, not even after the server is ready. The socket stays open until the client gives up, so a client with no timeout hangs forever. In a NixOS VM test, a plain curl hung for the full 1h test timeout.

Requests sent after "T3 Code server is ready" are fine (200 in ~7ms).

Cause, as far as I can tell: in @effect/platform-node (NodeHttpServer.make, 4.0.0-beta.103), server.listen() runs while the layer is being built, but server.on("request", handler) / server.on("upgrade", ...) are only registered later in serve(httpApp). The other server startup layers (SQLite migrations, provider setup, …) run in between. Node's http.Server emits request with no listener, so the request is silently dropped.

Possible fixes: register the request/upgrade listeners before listen(), or answer 503 Retry-After until serve() runs, or bring up the HTTP server layer after the heavy startup layers. The root cause is probably worth a matching issue in Effect-TS/effect-smol.

Impact

Minor bug or occasional failure

Version or commit

t3 v0.0.40 (@effect/platform-node 4.0.0-beta.103)

Environment

NixOS 26.11, Linux 7.2.4, Node 24.20.0, t3 serve (web mode, headless)

Logs or stack traces

late:  200 0.007279s
early: 000 45.001325s

# server log: nothing is logged for the early request
[14:32:13.680] INFO (#409): Listening on http://127.0.0.1:38775
T3 Code server is ready.

Screenshots, recordings, or supporting files

No response

Workaround

No response

Activity

  1. added
    bugSomething is broken or behaving incorrectly.
    needs-triageIssue needs maintainer review and initial categorization.
    on Sep 17, 2026
  2. juliusmarminge commented on Sep 17, 2026

    @juliusmarminge
    Member

    Triage

    Thanks for the repro — this is real, and the cause analysis is right.

    What happens

    NodeHttpServer.make calls server.listen() while the HTTP server layer is being built. server.on("request", …) / server.on("upgrade", …) are registered later, in serve(httpApp). T3 builds HttpServerLive first (Layer.provideMerge(HttpServerLive)), then the heavy startup graph (SQLite, providers, routes), then HttpRouter.serve. In that window the OS accepts TCP, Node emits request with no app handler, and the event is not replayed when serve() finally attaches. The socket stays open until the client gives up. Nothing is logged.

    That matches the captured output (early: 000 45s, late: 200 ~7ms, no server line for the early request).

    Still present on current main (@effect/platform-node 4.0.0-rc.112 / vendored effect-smol). Same listen()-then-serve() split as 4.0.0-beta.103.

    Likely code

    • apps/server/src/server.ts — HttpServerLive (~L224) listens as soon as the layer is acquired; HttpRouter.serve(makeRoutesLayer) (~L785) attaches handlers later; Layer.provideMerge(HttpServerLive) (~L801) forces listen-first.
    • apps/server/src/httpResponseErrorGuard.ts — already listens for request / upgrade only to attach write-error handlers. That does not answer the request, so an early GET still hangs.
    • apps/server/src/server.ts commandReadinessLayer (~L559) — global middleware that holds requests until awaitCommandReady. It only runs after serve() attaches. The intended “hold until ready” behavior already exists; it never sees the dropped request.
    • .repos/effect-smol/packages/platform/node/src/NodeHttpServer.ts make — server.listen() (~L122) in the constructor; server.on("request" | "upgrade") (~L173) only inside serve().
    • apps/server/src/serverRuntimeStartup.ts — headless “T3 Code server is ready” is parked until activation, and activation waits on routesReady. Log-ready is a valid probe; TCP-accept is not. Listening on comes from HttpServer.withLogAddress on HttpRouter.serve, so it also means the handler is attached.

    No duplicate issue. No existing Effect-TS/effect or effect-smol ticket for this split that I could find. Adjacent but different: #8442 / #9652 (second server / live data dir), #7703 (port-in-use message).

    Fix direction

    Do not wait on Effect for user-visible impact.

    1. Local (T3): on the raw Node server created in HttpServerLive / guardHttpResponseWriteErrors, attach a temporary request handler that answers 503 + Retry-After (and close early upgrades) before Effect calls listen(). Flip it off when HttpRouter.serve attaches the real handler (a flag is enough; after that the existing commandReadinessLayer holds until command-ready). Do not leave a second listener that also writes 503 after serve().
    2. Upstream: file/track an Effect-TS/effect-smol issue: register request/upgrade before listen(), or move listen() into serve(). T3 cannot reorder this away — HttpServer is only provided after make() has already listened.
    3. Test: HTTP GET as soon as the port accepts, assert a finite response (503 or a held 200), not a client timeout. Cover upgrade as well if cheap.

    Moving HttpServerLive later in the layer graph does not help: constructing the service is listen().

    Workaround

    Wait for Listening on or T3 Code server is ready in the server log (or retry curl with a short --max-time). Do not treat “TCP connect succeeds” as ready. That is enough for NixOS VM tests until the local 503 ships.

    Accepting as a server startup bug. Root cause is upstream Effect; T3 can and should close the window locally.

  3. added
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    and removed
    needs-triageIssue needs maintainer review and initial categorization.
    on Sep 17, 2026
  4. added a commit that references this issue on Oct 1, 2026
    1b87683
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

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.upstreamvia-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions