close() resolves without draining in-flight requests on the secondary dual-stack listener
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 = 1Expected: 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.
Source: fastify/fastify