Visitar URL original
fix: reject hooks and routes added after ready() resolves or from onReady by Emma66886 · Pull Request #7205 · fastify/fastify · GitHub
Skip to content

fix: reject hooks and routes added after ready() resolves or from onReady - #7205

Open
Emma66886 wants to merge 1 commit into
fastify:mainfrom
Emma66886:fix/reject-registration-after-ready
Open

Emma66886 wants to merge 1 commit into
fastify:mainfrom
Emma66886:fix/reject-registration-after-ready

Conversation

@Emma66886

Copy link
Copy Markdown

Fixes #7166

Summary

After ready() resolves, or while its callback runs, Fastify still accepted new hooks, routes, decorators and content type parsers for a short window. A tick later the same calls threw.

That window had two concrete consequences:

  • An onClose hook added in the window ran before Fastify stopped the server. It released its resources (for example a database pool) while the server was still listening and requests were still in flight. This contradicts the documented shutdown lifecycle and the onClose docs: "By the time onClose hooks execute, the HTTP server has already stopped listening, all in-flight HTTP requests have been completed".
  • A route added in the window was rejected, but only after it had been inserted into the router. If the caller caught the error, the first request to that route crashed the process.

The same thing happens while onReady hooks are running, which the issue also reports. This PR closes both windows.

Reproduction

From the issue, shortened:

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

On main:

onClose, listening: true
500 {"statusCode":500,"error":"Internal Server Error","message":"pool is closed"}

With this PR, the late addHook('onClose') call throws FST_ERR_INSTANCE_ALREADY_STARTED. A hook registered at the correct time sees listening: false, and the request completes with 200 {"value":42}.

The same output on main is produced when the hook is added from a ready(cb) callback or from inside an onReady hook. For the route case, adding app.get('/late', ...) after await app.ready() (or from onReady), catching the error, and then requesting /late crashes the process with:

TypeError: Cannot read properties of undefined (reading 'length')
    at next (lib/hooks.js:341:32)
    at preParsingHookRunner (lib/hooks.js:371:3)

Root cause

Line numbers are at 0b076ca, avvio 9.3.1.

  1. kState.started is only set on avvio's 'start' event (fastify.js#L394). Avvio emits 'start' when its ready queue drains (avvio/index.js#L76-L77). That is after manageErr has resolved the ready() promise (fastify.js#L587), and after the code awaiting it, or the ready(cb) callback, has run.

  2. Every check that rejects late registrations reads kState.started:

    Inside the window, all of them pass.

  3. addHook('onClose') goes through avvio's onClose (fastify.js#L636), which calls _closeQ.unshift (avvio/index.js#L312). Close handlers therefore run in reverse registration order.

  4. Fastify's internal onClose handler is registered at preReady (fastify.js#L408). It sets closing, runs the preClose hooks and calls server.close(). It only works correctly if it is the last handler registered. Anything registered after it runs before it, while the server is still listening.

  5. route() runs the onRoute hooks (lib/route.js#L299) and inserts the route into the router (lib/route.js#L364) before calling this.after() (lib/route.js#L379). Once the root plugin has booted, this.after() throws AVV_ERR_ROOT_PLG_BOOTED. The route stays in the router with a context whose hooks were never set, and the first request to it fails at lib/hooks.js#L341 with an uncaught TypeError.

The same applies while the onReady hooks run. At that point the plugin tree has booted, so the internal close handler is already registered and this.after() throws. However, started is still false: only booting is true.

Changes

1. Mark the instance as started before ready() resolves (fastify.js)

manageErr now sets kState.started = true right before it resolves the ready promise.

Promise continuations run asynchronously, and ready(cb) is chained on the same promise. So every existing check sees the flag from the first line of user code that runs after ready(). With this change alone, addHook, routes, decorate and content type parsers are rejected right after await ready() and inside ready(cb), exactly as they already were a tick later.

The avvio 'start' listener is kept, because it still covers paths that don't go through the success branch of manageErr, such as close() called without ready().

2. Reject routes and onClose hooks while onReady hooks run (fastify.js, lib/route.js)

This adds a throwIfAlreadyBooted(msg) guard next to throwIfAlreadyStarted. It throws the same FST_ERR_INSTANCE_ALREADY_STARTED error when either started or booting is set.

It is used only where a registration made during onReady is actually broken:

  • addHook('onClose', ...): the hook would run before the server stops.
  • route(), and with it every shorthand method: the check is now at the top of route(). A rejected route never runs the onRoute hooks and never reaches the router. Before, it threw AVV_ERR_ROOT_PLG_BOOTED after both of those had already happened. Rejecting early also means plugins that collect routes in onRoute (for example for documentation) never see a route that doesn't exist.

Everything else keeps using throwIfAlreadyStarted, so registrations that work from onReady today keep working. See "What is intentionally not changed" below.

3. Docs (docs/Reference/Server.md)

Adds one sentence to the ready section: once ready() has completed, adding hooks, routes or decorators throws.

4. Tests (test/hooks.on-ready.test.js)

  • hooks, routes and decorators cannot be added once ready() has resolved: checks addHook('onClose'), addHook('preClose'), decorate, addContentTypeParser and get() right after await ready(). It then checks that the rejected route returns 404 instead of crashing.
  • hooks, routes and decorators cannot be added from the ready() callback: the same checks from a ready(cb) callback.
  • onReady cannot add onClose hooks: ready() rejects with FST_ERR_INSTANCE_ALREADY_STARTED, and the hook never runs.
  • onReady cannot add routes, and they are not left in the router: the route is rejected, and a request to it returns 404.

Behaviour before and after

Each API was called on a fresh instance, both right after await fastify.ready() and from inside an onReady hook:

Call After ready(), before this PR After ready(), with this PR From onReady, before this PR From onReady, with this PR
addHook('onClose') accepted, runs before server.close() FST_ERR_INSTANCE_ALREADY_STARTED accepted, runs before server.close() FST_ERR_INSTANCE_ALREADY_STARTED
addHook('preClose') accepted FST_ERR_INSTANCE_ALREADY_STARTED accepted accepted (unchanged)
addHook('onListen') accepted FST_ERR_INSTANCE_ALREADY_STARTED accepted accepted (unchanged)
addHook('onRequest') and other lifecycle hooks AVV_ERR_ROOT_PLG_BOOTED FST_ERR_INSTANCE_ALREADY_STARTED AVV_ERR_ROOT_PLG_BOOTED AVV_ERR_ROOT_PLG_BOOTED (unchanged)
decorate accepted FST_ERR_DEC_AFTER_START accepted accepted (unchanged)
addContentTypeParser accepted FST_ERR_CTP_INSTANCE_ALREADY_STARTED accepted accepted (unchanged)
get('/late') AVV_ERR_ROOT_PLG_BOOTED; the route is left in the router and the first request crashes the process FST_ERR_INSTANCE_ALREADY_STARTED; the request gets a 404 AVV_ERR_ROOT_PLG_BOOTED; the route is left in the router and the first request crashes the process FST_ERR_INSTANCE_ALREADY_STARTED; the request gets a 404

In the "after ready()" columns, the "before" behaviour only held inside the window. One macrotask later, every one of those calls already threw FST_ERR_INSTANCE_ALREADY_STARTED, FST_ERR_DEC_AFTER_START or FST_ERR_CTP_INSTANCE_ALREADY_STARTED. For that column, this PR makes the behaviour consistent rather than adding a new restriction.

What is intentionally not changed

  • decorate from onReady keeps working. The existing test "onReady can not add decorators or application hooks" expects it to be accepted. Loading something in onReady and decorating the instance with it is also a reasonable pattern.
  • preClose and onListen hooks added from onReady keep working. They are read from kHooks when they fire, so they already run at the correct time.
  • A nested onReady hook added from onReady is still accepted and never runs, as the existing test expects. Hooks.md already says onReady "cannot change the routes or add new hooks". Making every addHook call throw from onReady would also be reasonable. However, it would change the assertions of two existing tests, including the AVV_ERR_ROOT_PLG_BOOTED code checked in "onReady cannot add lifecycle hooks". I left it out to keep this PR focused on what is broken, and I'm happy to extend it if you prefer.
  • addSchema, the set* methods and addConstraintStrategy still use throwIfAlreadyStarted, so their behaviour inside onReady is unchanged.

Compatibility

Code that relied on the window now throws. That includes registering right after await ready() or in a ready(cb) callback, adding an onClose hook from onReady, and adding a route from onReady.

Before this change, the same calls threw a tick later, a late onClose hook ran in the wrong order, and a late route crashed the process. I would consider this a bug fix, but it could be treated as semver-major. main is currently 6.0.0-alpha.6.

Relation to #7169

#7169 makes the same manageErr change as point 1, which fixes the await ready() and ready(cb) cases.

I applied only that change and re-ran the issue's scenarios. Two onReady cases from the issue still reproduce:

  • An onClose hook added from onReady still runs while the server is listening, and the in-flight request gets the 500.
  • A route added from onReady is still left in the router, and the first request to it crashes the process.

This PR also covers those two cases and adds tests for both.

Testing

  • The 4 new tests fail on main and pass with this change.
  • borp --coverage --check-coverage --lines 100 (the CI coverage check): every test passes, with 100% statement, branch, function and line coverage.
  • npm run lint: passes.
  • npm run test:types: passes.
  • Node.js 22.22.3.
  • 4 existing tests hardcode port 3000: the 3 "fastify instance should contains listeningOrigin property" tests and "missing method from http client". Port 3000 was in use on my machine, so they failed there on main as well, unrelated to this change. With the port temporarily changed, they pass, and the coverage check above includes them.
  • I did not run the benchmarks: this change only affects registration at startup, not the request path.

Checklist

  • run npm run test && npm run benchmark --if-present (benchmarks not run, see above)
  • tests and/or benchmarks are included
  • documentation is changed or added
  • commit message and code follows the Developer's Certification of Origin
    and the Code of conduct

…eady

The instance was only flagged as started on avvio's 'start' event, which
is emitted once the ready queue drains. That happens after the code
awaiting ready(), or the ready(cb) callback, has already run. In that
window, addHook(), route(), decorate() and addContentTypeParser() were
still accepted.

An onClose hook added in that window runs before the internal onClose
handler that runs the preClose hooks and calls server.close(), because
onClose handlers run in reverse registration order and the internal one
is registered at preReady. The hook therefore released its resources
while the server was still listening and requests were in flight,
contrary to the documented shutdown lifecycle. A route added in that
window was inserted into the router before this.after() threw, leaving
a context that was never completed: the first request to it crashed
the process.

Flag the instance as started right before the ready() promise is
resolved, so every existing check rejects late registrations.

While the onReady hooks run, the plugin tree has already booted but the
instance is not started yet. Reject routes and onClose hooks there too,
with a new throwIfAlreadyBooted() guard that also checks the booting
state. Routes are rejected before the onRoute hooks run and before
anything reaches the router. Other registrations that work from onReady
today, such as decorators and preClose or onListen hooks, are left
unchanged.

Fixes: fastify#7166
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Nov 9, 2026
@SameRainbows

Copy link
Copy Markdown

Tested locally on Node 22 and verified across the test suite.

Key points verified:

  • Distinguishing between throwIfAlreadyStarted and throwIfAlreadyBooted accurately targets the vulnerable window while preserving valid existing patterns (such as registering decorators in onReady).
  • Calling throwIfAlreadyBooted at the top of route() before onRoute hooks run prevents half-registered route definitions from lingering in the router, properly returning a 404 rather than crashing on request execution.
  • Rejecting late onClose hooks while kState.booting === true prevents _closeQ.unshift from placing user cleanup hooks ahead of Fastify's internal server.close() handler, ensuring in-flight requests finish draining before resources are released.
  • Verified all 22 tests in test/hooks.on-ready.test.js pass with 100% coverage.

@SameRainbows

Copy link
Copy Markdown

Tested locally against main on Node 24.

Traced the lifecycle window this addresses. Previously fastify[kState].started was set on Avvio's 'start' event, which fires only after the ready queue drains. That left a turn where await app.ready() had resolved or the ready(cb) callback was executing while throwIfAlreadyStarted still evaluated to false.

Setting fastify[kState].started = true directly before readyResolver.resolve(fastify) closes that window immediately upon resolution. During onReady execution, fastify[kState].booting is true, so throwIfAlreadyBooted prevents late onClose hooks from being unshifted ahead of internal server teardown (which would run while sockets are still open) and prevents late routes from being inserted into the router without a complete plugin context.

Ran the test suite locally: all 22 tests in test/hooks.on-ready.test.js pass, ESLint is clean, and 1,286 type assertions pass across 16 files in tstyche. The behavior for existing onReady decorators and preClose/onListen hooks remains intact.

@SameRainbows

Copy link
Copy Markdown

Verified on Node 24. All 22 tests in test/hooks.on-ready.test.js, all 26 tests in test/close.test.js, and all 16 TypeScript test files (1,286 assertions) pass cleanly.

Setting fastify[kState].started = true directly before readyResolver.resolve(fastify) closes the gap between promise settlement and Avvio's start event, ensuring calls made immediately after await app.ready() or within ready(cb) throw FST_ERR_INSTANCE_ALREADY_STARTED.

Guarding route() and addHook('onClose') with throwIfAlreadyBooted also prevents the onClose queue inversion from onReady (since Avvio unshifts close handlers and the internal server shutdown handler registers earlier at preReady) and stops incomplete route contexts from lingering in the router. Route injection for rejected paths returns 404 cleanly instead of crashing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants