Visitar URL original
close() resolves without draining in-flight requests on the secondary dual-stack listener · Issue #7043 · fastify/fastify · GitHub
Skip to content

close() resolves without draining in-flight requests on the secondary dual-stack listener #7043

Description

@ooloth

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure it has not already been reported

Fastify version

5.12.5

Node.js version

v26.7.0

Operating system

macOS (Darwin 25.6.0, arm64)

Description

fastify.close() resolves, and onClose hooks run, while a request is still executing — when the
request arrived on the secondary listener created by the default dual-stack listen({ port }).

The documented lifecycle in docs/Reference/Server.md says that at step 5 "onClose hooks execute.
All in-flight requests have completed and the server is no longer listening", and the Hooks
reference calls onClose "the safe place for plugins to release resources such as database
connection pools". Neither holds here.

Relationship to #4098

This looks similar to #4098 and has a different cause. That one was traced to a Node bug, fixed in
Node 18.7.0 by nodejs/node#43890. This reproduces on Node v26.7.0 and is conditional on the bind:
with host: '0.0.0.0' the identical script on the identical Node drains correctly, which points at
Fastify's dual-stack path rather than at Node's idle-socket accounting.

Steps to Reproduce

import Fastify from 'fastify'

const app = Fastify()
let inFlight = 0

app.addHook('onClose', async () => console.log('onClose fired, inFlight =', inFlight))

app.get('/slow', async () => {
  inFlight++
  await new Promise((r) => setTimeout(r, 2000))
  inFlight--
  return { ok: true }
})

await app.listen({ port: 3000 })            // default dual-stack bind

fetch('http://127.0.0.1:3000/slow')         // arrives over IPv4

setTimeout(async () => {
  const t = Date.now()
  await app.close()
  console.log(`close() resolved after ${Date.now() - t}ms, inFlight =`, inFlight)
}, 500)

Actual:

onClose fired, inFlight = 1
close() resolved after 3ms, inFlight = 1

Expected: onClose fires with inFlight = 0 and close() resolves after roughly 1500ms.

Changing the bind to { port: 3000, host: '0.0.0.0' } — or pointing the client at ::1 — produces
the expected behaviour. Varying the two independently, the default bind with an IPv4 client is the
only combination that fails.

app.server.getConnections() reports 0 mid-request under the default bind and 1 with an
explicit host. Bare node:http, Express and Hono all report 1 and wait.

Expected Behavior

close() should not resolve, and onClose should not run, until in-flight requests on both
listeners have completed.

Possible cause

In lib/server.js (v5.12.5), the secondary server's close callback is discarded and its close is
only triggered once the main server has already closed:

const closeSecondary = () => {
  // ...
  // We swallow any error from the secondary server
  secondaryServer.close(() => {})
  // ...
}

mainServer.on('unref', closeSecondary)
mainServer.on('close', closeSecondary)
mainServer.on('error', closeSecondary)

Since app.server is mainServer, the instance.server.close(cb) in fastify.js that gates
done() only waits for the main listener. The secondary listener — which is where an IPv4 request
lands under a dual-stack bind — is closed afterwards with a callback nobody awaits.

Why this matters

The failure is silent and it corrupts. Following the documented pattern of decorate plus an
onClose that releases a resource — the shape fastify-postgres uses — the handle is released
while a handler is still running. In my case the handler resumed 1.5s later, threw, and the client
received 500 ERR_INVALID_STATE: database is not open over a half-written row. Nothing was logged
as an error by the shutdown path itself.

I am happy to open a PR if a maintainer can indicate the preferred shape — awaiting the secondary
server's close callback alongside the main one seems the minimal change, but the ordering comment
above closeSecondary suggests the current sequencing is deliberate and I would not want to break
the preClose guarantee it is protecting.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions