Repository navigation
Description
Prerequisites
- I have written a descriptive issue title
- I have searched existing issues to ensure the bug has not already been reported
Fastify version
5.12.7 (also on main, 6.0.0-alpha.5 at 5df959f)
Plugin version
Node.js version
24.21.0
Operating system
Windows
Operating system version (i.e. 20.04, 11.3, 10)
11
Description
addHook() is supposed to throw once the instance has started (FST_ERR_INSTANCE_ALREADY_LISTENING on 5.x, FST_ERR_INSTANCE_ALREADY_STARTED on main), but there is a window right after ready() resolves where it is still accepted. An onClose hook added in that window runs before Fastify's own close routine, so it releases its resource while the server is still listening and requests are still in flight.
Hooks.md says the opposite: "By the time onClose hooks execute, the HTTP server has already stopped listening, all in-flight HTTP requests have been completed, and connections have been drained."
// repro.mjs
import Fastify from 'fastify'
const pool = { open: true, query: async () => { if (!pool.open) throw new Error('pool is closed'); return 42 } }
const app = Fastify()
app.get('/slow', async () => {
await new Promise(resolve => setTimeout(resolve, 200))
return { value: await pool.query() }
})
await app.ready()
app.addHook('onClose', async () => {
console.log('onClose, listening:', app.server.listening)
pool.open = false
})
await app.listen({ port: 0, host: '127.0.0.1' })
const res = fetch(`http://127.0.0.1:${app.server.address().port}/slow`, { headers: { connection: 'close' } }).then(async r => [r.status, await r.text()])
await new Promise(resolve => setTimeout(resolve, 50))
const closing = app.close()
console.log(...await res)
await closingOutput:
onClose, listening: true
500 {"statusCode":500,"error":"Internal Server Error","message":"pool is closed"}
Registering the same hook before ready() prints listening: false and 200 {"value":42}. The same hook is also accepted from a ready(cb) callback, and from inside an onReady hook, where it also runs before the server stops (see the note at the end about onReady).
The same window also leaves a half-registered route behind. After await app.ready(), app.get('/late', ...) throws AVV_ERR_ROOT_PLG_BOOTED, but the route was already inserted into the router (lib/route.js:363, before the this.after at lib/route.js:378 that throws). If the caller catches the error and keeps going, the first request to /late crashes the process with TypeError: Cannot read properties of undefined (reading 'length') (on main it is thrown from lib/hooks.js:341, while running the preParsing hooks of a context that was never finished).
What I think is happening (line numbers at 5df959f):
ready()resolves the user promise inmanageErr(fastify.js:587), inside avvio's ready queue.kState.startedis only set on avvio's'start'event (fastify.js:394, emitted atavvio/index.js:77, avvio 9.3.1), which happens after that.- Every check that rejects late registration reads
kState.started:throwIfAlreadyStarted(fastify.js:491, used byaddHookandroute),decorate(lib/decorate.js:146) and the content type parser methods (lib/content-type-parser.js:378). SoaddHook,routeanddecoratecalled from aready(cb)callback or right afterawait app.ready()pass them. One macrotask later (setImmediate) the same calls throw as expected. addHook('onClose')goes to avvio'sonClose(fastify.js:636), which does_closeQ.unshift(avvio/index.js:312), so close hooks run last-registered first.- The internal hook that runs
preCloseand callsserver.close()is registered at preReady (fastify.js:408) and relies on being the last one registered. Anything added after it runs before it.
Link to code that reproduces the bug
Inline above.
Expected Behavior
Once ready() has resolved, adding a hook, a route or a decorator throws, as it does a tick later. A route that fails to register is not left in the router.
One way to get there is to set kState.started = true in manageErr right before the ready promise is resolved (fastify.js:587), so every check above sees it. I tried that locally on main: the await app.ready() and ready(cb) cases throw for addHook and decorate, the late route is rejected before it reaches the router (the request gets a 404 instead of crashing the process), and npm run unit passes (2386 passed, 0 failed).
This does not change onReady. test/hooks.on-ready.test.js:270 expects decorate to be accepted inside onReady (and a nested addHook('onReady'), which is accepted but never runs), so an onClose added there still runs before the server stops. Hooks.md already says onReady "cannot change the routes or add new hooks"; whether that case should throw or only be documented is your call.
Code that relies on the window after ready() would start throwing, so this may count as a breaking change. I'd be happy to send a PR with tests if this direction makes sense.
Activity
SameRainbows commentedon Nov 1, 2026
Reproduced this with the inline script: when
onCloseis registered right afterawait app.ready(), it gets unshifted onto_closeQafter the internalpreClose/server.close()hook, so it runs first whileserver.listeningis still true and in-flight requests are waiting on resources.Setting
fastify[kState].started = trueinsidemanageErrright beforereadyResolver.resolve(fastify)cleanly closes this window. Onceready()resolves, subsequent calls toaddHook,decorate, and route definitions throwFST_ERR_INSTANCE_ALREADY_STARTEDas expected.Verified across
test/hooks.on-ready.test.js,test/hooks.test.js, andtest/close.test.js(all 209 tests pass).A PR with regression tests for the post-ready hook rejection and late-route cleanup would be a great addition.
visualkhh commentedon Nov 7, 2026
Reproduced the contract violation and can confirm the deeper cause — plus the fix that made my own shutdown behave, which is the same shape.
I run @dooboostore/simple-boot-http-server and hit this from the other end: my library had no graceful close at all, so callers used a bare
server.close(). Measured behavior matches the report's premise — a keep-alive socket left by an earlier request makesclose()hang until the keep-alive timeout, so "the server has stopped listening" is not true when a naive shutdown hook thinks it is. In my test harness the fix was a 178ms shutdown for a 150ms in-flight request, versus ~3.1s when only a single "drop idle" nudge was issued.The three properties that made it correct, in order:
close()), so no new requests enter while draining.closeIdleConnections()until the close callback fires collapses that wait to ~0.listeningflips before in-flight responses complete, which is exactly the window the report describes.On the Fastify-specific half: the
addHookwindow right afterready()is a separate bug (registration should be sealed at the same momentready()resolves, not atlisten()), but it compounds with the ordering above — a hook that runs before drain will always be able to observe a live pool. Both halves want the same regression test shape: start a slow request, then shut down, and assert (a) the client still gets its 200 + body, (b) the resource the hook releases is still usable during that request, and (c)close()resolves only after both.