Repository navigation
NodeHttpServer: requests on keep-alive connections hang during graceful shutdown #8728
Description
What version of Effect is running?
4.0
What steps can reproduce the bug?
Node's server.close() drains connections instead of cutting them off. It stops accepting new TCP connections and closes idle ones. An existing connection with an in-flight HTTP request stays open, and if that connection has keep-alive enabled (eg from a reverse proxy before Node), that connection will stay open even after the response gets flushed. Node keeps emitting request event for every HTTP request on that connection.
NodeHttpServer.serve handles requests by listening to Node's request event. At shutdown, its finalizer removes the listener first and only then calls server.close(). So while the server drains, Node is still accepting requests on existing connections, but Effect is no longer listening. The next request on such a connection gets no response. It hangs until the client times out or gracefulShutdownTimeout forces the socket closed.
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Latch, Layer, ManagedRuntime } from "effect"
import { HttpServer, HttpServerResponse } from "effect/http"
import http from "node:http"
import net from "node:net"
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// Hold the first request in the handler until shutdown starts.
const requestStarted = Latch.makeUnsafe()
const releaseResponse = Latch.makeUnsafe()
const app = Effect.gen(function* () {
yield* requestStarted.open
yield* releaseResponse.await
return HttpServerResponse.text("ok")
})
const server = http.createServer()
server.keepAliveTimeout = 5000
const runtime = ManagedRuntime.make(
HttpServer.serve(app).pipe(
Layer.provide(NodeHttpServer.layer(() => server, {
host: "127.0.0.1",
port: 0,
gracefulShutdownTimeout: "3 seconds"
}))
)
)
await runtime.context()
// One TCP socket for both requests.
const { port } = server.address()
const socket = net.connect(port, "127.0.0.1")
let responseData = ""
let socketClosed = false
const firstResponse = new Promise((resolve) => {
socket.on("data", (chunk) => {
responseData += chunk
resolve()
})
})
socket.on("close", () => {
socketClosed = true
})
await new Promise((resolve) => socket.once("connect", resolve))
// Start request 1, then dispose the runtime while it is in flight.
socket.write("GET /a HTTP/1.1\r\nHost: localhost\r\n\r\n")
await Effect.runPromise(requestStarted.await)
const disposed = runtime.dispose()
// Wait for Effect's finalizer to call server.close().
while (server.listening) {
await sleep(1)
}
// Let request 1 finish while the server drains.
releaseResponse.openUnsafe()
await firstResponse
console.log(
"first answered:", responseData.includes("HTTP/1.1 200"),
"| closed:", socketClosed
)
// Send request 2 on the same socket, before the drain timeout.
socket.write("GET /b HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
await sleep(800)
const responseCount = (responseData.match(/HTTP\/1.1 200/g) ?? []).length
console.log("second answered:", responseCount === 2)
socket.destroy()
await disposedWhat is the expected behavior?
During shutdown draining, the HTTP server should either (1) handle them or (2) reject new requests (on already accepted TCP connections). Right now, those connections just hang.
What do you see instead?
first answered: true | closed: false
second answered: false
Request 2 is never answered. A reverse proxy that reuses the connection waits until its own response timeout. Node's requestTimeout doesn't cap this wait because it only limits how long the server takes to receive a request.
Additional information
Cause: the listeners are removed before draining starts:
effect/packages/platform/node/src/NodeHttpServer.ts
Lines 169 to 173 in bc44526
| yield* Scope.addFinalizerExit(serveScope, () => { | |
| server.off("request", handler) | |
| server.off("upgrade", upgradeHandler) | |
| return preemptiveShutdown | |
| }) |
Suggested fix: detach the listeners after draining finishes:
yield* Scope.addFinalizerExit(serveScope, () =>
Effect.ensuring(
preemptiveShutdown,
Effect.sync(() => {
server.off("request", handler)
server.off("upgrade", upgradeHandler)
})
)
)server.close() still refuses new TCP connections. The shutdown timeout and forced socket closing work as before.
Activity
eugene1g commentedon Oct 4, 2026
Note
AI-generated follow-up. from the issue author Claude 5.5 wrote this analysis while helping me prepare this issue
Why the two-line fix in this issue is incomplete
Keeping the listeners attached until draining ends fixes the repro, but two problems remain:
gracefulShutdownTimeoutruns out. The timeout only stops waiting forserver.close(). Nothing closes the sockets. The listeners are then removed while connections are still open, and the next request on any of them hangs as before.server.close(), Node keeps sendingConnection: keep-aliveand reusing the socket (verified with plainnode:http). A pooled client or proxy keeps the connection busy, so every shutdown runs the full timeout and then hits problem 1. In testing, a client that reused its connection got 15 responses during a 1.5 s drain, and the drain never finished on its own.Related bug: a timed-out shutdown makes
dispose()rejectshutdownisEffect.cached:effect/packages/platform/node/src/NodeHttpServer.ts
Lines 104 to 124 in bc44526
When the timeout interrupts the first run,
Effect.cachedremembers the interruption. The outerScope.addFinalizer(scope, shutdown)gets that interruption back, soManagedRuntime.dispose()rejects withAll fibers interrupted without error. This happens on every shutdown that reaches the timeout, independent of the keep-alive bug.Suggested fix
Four changes:
Connection: closewhile draining;Effect.cachedwith aDeferred.disablePreemptiveShutdownbehaves as before.Results (
gracefulShutdownTimeout: 1.5s)dispose()rejects at 1.5 sdispose()rejects at 1.5 sdispose()rejects at 1.5 sdispose()rejectsConnection: closeafter one response; disposed in 2 msEffect.interruptible, see below)dispose()rejects at 1.5 sdispose()rejects at 1.5 sRejected alternative
Calling
server.closeIdleConnections()when a response finishes during the drain looks attractive, but it destroys the socket before the last body chunk is sent, which truncates streamed responses. Without it, a response whose keep-alive headers went out before shutdown leaves its connection open untilkeepAliveTimeoutor the drain timeout.Separate bug, probably worth its own issue: handlers run uninterruptibly
HttpEffect.toHandledswitches the fiber to uninterruptible and, on the fast path (no middleware, native tracer), runs the handler without switching back:effect/packages/effect/src/http/HttpEffect.ts
Lines 172 to 186 in bc44526
Effects:
fiber.interruptUnsafe(...)inmakeHandler, but the handler is never interrupted. Reproduced: anEffect.neverhandler'sonInterruptnever runs.dispose()forever, even with the fix above. The "never finishes" row only passes because the test handler is wrapped inEffect.interruptible.Not tested
mvanhorn commentedon Oct 6, 2026
The serve finalizer removed the request and upgrade listeners before server.close(), so Node still emitted request events for connections it was draining and nothing handled them. shutdown was Effect.cached, so the interruption from timeoutOrElse was stored as the close result, and the timeout branch was Effect.void, which left those sockets open.
server.close now starts once and completes a Deferred, so a timed-out waiter does not record the close as abandoned.