Visitar URL original
onClose hook added right after `await app.ready()` runs before the server stops, while requests are still in flight · Issue #7166 · fastify/fastify · GitHub
Skip to content

onClose hook added right after await app.ready() runs before the server stops, while requests are still in flight #7166

Description

@DenisCDev

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 closing

Output:

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

  1. ready() resolves the user promise in manageErr (fastify.js:587), inside avvio's ready queue.
  2. kState.started is only set on avvio's 'start' event (fastify.js:394, emitted at avvio/index.js:77, avvio 9.3.1), which happens after that.
  3. Every check that rejects late registration reads kState.started: throwIfAlreadyStarted (fastify.js:491, used by addHook and route), decorate (lib/decorate.js:146) and the content type parser methods (lib/content-type-parser.js:378). So addHook, route and decorate called from a ready(cb) callback or right after await app.ready() pass them. One macrotask later (setImmediate) the same calls throw as expected.
  4. addHook('onClose') goes to avvio's onClose (fastify.js:636), which does _closeQ.unshift (avvio/index.js:312), so close hooks run last-registered first.
  5. The internal hook that runs preClose and calls server.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

  1. SameRainbows commented on Nov 1, 2026

    @SameRainbows

    Reproduced this with the inline script: when onClose is registered right after await app.ready(), it gets unshifted onto _closeQ after the internal preClose / server.close() hook, so it runs first while server.listening is still true and in-flight requests are waiting on resources.

    Setting fastify[kState].started = true inside manageErr right before readyResolver.resolve(fastify) cleanly closes this window. Once ready() resolves, subsequent calls to addHook, decorate, and route definitions throw FST_ERR_INSTANCE_ALREADY_STARTED as expected.

    Verified across test/hooks.on-ready.test.js, test/hooks.test.js, and test/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.

  2. visualkhh commented on Nov 7, 2026

    @visualkhh

    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 makes close() 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:

    1. Stop accepting first (close()), so no new requests enter while draining.
    2. Drop idle sockets repeatedly, not once. A connection that is busy when shutdown starts becomes idle when its request finishes; without a second drop it sits there for its keep-alive timeout. A short poll of closeIdleConnections() until the close callback fires collapses that wait to ~0.
    3. Resolve only after the close callback, never on the "listening === false" signal — listening flips before in-flight responses complete, which is exactly the window the report describes.

    On the Fastify-specific half: the addHook window right after ready() is a separate bug (registration should be sealed at the same moment ready() resolves, not at listen()), 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.

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