Skip to content

NodeHttpServer: requests on keep-alive connections hang during graceful shutdown #8728

Description

@eugene1g

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 disposed

What 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:

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

  1. eugene1g commented on Oct 4, 2026

    @eugene1g
    Author

    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:

    1. The bug returns when gracefulShutdownTimeout runs out. The timeout only stops waiting for server.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.
    2. Busy keep-alive connections never finish draining. After server.close(), Node keeps sending Connection: keep-alive and reusing the socket (verified with plain node: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() reject

    shutdown is Effect.cached:

    const shutdown = yield* Effect.callback<void>((resume) => {
    if (!server.listening) {
    return resume(Effect.void)
    }
    server.close((error) => {
    if (error) {
    resume(Effect.die(error))
    } else {
    resume(Effect.void)
    }
    })
    }).pipe(Effect.cached)
    const preemptiveShutdown = options.disablePreemptiveShutdown ?
    Effect.void :
    Effect.timeoutOrElse(shutdown, {
    duration: options.gracefulShutdownTimeout ?? Duration.seconds(20),
    orElse: () => Effect.void
    })
    yield* Scope.addFinalizer(scope, shutdown)

    When the timeout interrupts the first run, Effect.cached remembers the interruption. The outer Scope.addFinalizer(scope, shutdown) gets that interruption back, so ManagedRuntime.dispose() rejects with All fibers interrupted without error. This happens on every shutdown that reaches the timeout, independent of the keep-alive bug.

    Suggested fix

    Four changes:

    • keep the listeners attached until draining ends;
    • send Connection: close while draining;
    • close all connections when the timeout runs out;
    • replace Effect.cached with a Deferred.
    // make()
    const closed = Deferred.makeUnsafe<void>()
    let closeStarted = false
    const shutdown = Effect.suspend(() => {
      if (!closeStarted) {
        closeStarted = true
        if (!server.listening) {
          Deferred.doneUnsafe(closed, Exit.void)
        } else {
          server.close((error) => Deferred.doneUnsafe(closed, error ? Exit.die(error) : Exit.void))
        }
      }
      return Deferred.await(closed)
    })
    
    const preemptiveShutdown = options.disablePreemptiveShutdown ?
      Effect.void :
      Effect.timeoutOrElse(shutdown, {
        duration: options.gracefulShutdownTimeout ?? Duration.seconds(20),
        orElse: () => Effect.sync(() => server.closeAllConnections())
      })
    // serve()
    let draining = false
    const pending = new Set<Http.ServerResponse>()
    const onRequest = (nodeRequest: Http.IncomingMessage, nodeResponse: Http.ServerResponse) => {
      if (draining) {
        nodeResponse.setHeader("Connection", "close")
      } else {
        pending.add(nodeResponse)
        nodeResponse.once("close", () => pending.delete(nodeResponse))
      }
      handler(nodeRequest, nodeResponse)
    }
    yield* Scope.addFinalizerExit(serveScope, () =>
      Effect.suspend(() => {
        draining = true
        for (const nodeResponse of pending) {
          if (!nodeResponse.headersSent) nodeResponse.setHeader("Connection", "close")
        }
        return preemptiveShutdown
      }).pipe(
        Effect.ensuring(Effect.sync(() => {
          server.off("request", onRequest)
          server.off("upgrade", upgradeHandler)
        }))
      )
    )
    server.on("request", onRequest)
    server.on("upgrade", upgradeHandler)

    disablePreemptiveShutdown behaves as before.

    Results (gracefulShutdownTimeout: 1.5s)

    Scenario 4.0.0 Two-line fix Suggested fix
    Repro from this issue 2nd request hangs; dispose() rejects at 1.5 s answered; dispose() rejects at 1.5 s connection closes after 1st response; disposed in 2 ms
    Client keeps reusing its connection hangs; dispose() rejects at 1.5 s served until timeout; dispose() rejects Connection: close after one response; disposed in 2 ms
    Handler never finishes (wrapped in Effect.interruptible, see below) dispose() rejects at 1.5 s dispose() rejects at 1.5 s sockets closed at timeout; disposed at 1.5 s

    Rejected 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 until keepAliveTimeout or the drain timeout.

    Separate bug, probably worth its own issue: handlers run uninterruptibly

    HttpEffect.toHandled switches the fiber to uninterruptible and, on the fast path (no middleware, native tracer), runs the handler without switching back:

    return Effect.withFiber((fiber) => {
    fiberEnterUninterruptibleUnsafe(fiber)
    const scope = Scope.makeUnsafe()
    const frame = new RequestFrame(scope, fiber.context, Context.getUnsafe(fiber.context, HttpServerRequest))
    fiber.setContext(Context.add(frame.prev, Scope.Scope, scope))
    // The native tracer has no backend, so its spans are unobservable.
    const currentTracer = fiber.cache.tracer
    if (
    middleware === undefined &&
    (currentTracer === undefined || currentTracer === nativeTracer || isTracerDisabledUnsafe(fiber, frame.request))
    ) {
    ;(fiber as any)._stack.push(frame)
    return self
    }
    return finishAfter(frame, fiber, middleware === undefined ? traced(frame) : withMiddleware(frame))

    Effects:

    • A client disconnect calls fiber.interruptUnsafe(...) in makeHandler, but the handler is never interrupted. Reproduced: an Effect.never handler's onInterrupt never runs.
    • A stuck handler blocks dispose() forever, even with the fix above. The "never finishes" row only passes because the test handler is wrapped in Effect.interruptible.

    Not tested

    • WebSocket upgrades during the drain
    • HTTP pipelining
    • this repo's test suite
  2. mvanhorn commented on Oct 6, 2026

    @mvanhorn

    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.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions