Repository navigation
Conversation
…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
|
Tested locally on Node 22 and verified across the test suite. Key points verified:
|
|
Tested locally against Traced the lifecycle window this addresses. Previously Setting Ran the test suite locally: all 22 tests in |
|
Verified on Node 24. All 22 tests in Setting Guarding |
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:
onClosehook 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 theonClosedocs: "By the timeonClosehooks execute, the HTTP server has already stopped listening, all in-flight HTTP requests have been completed".The same thing happens while
onReadyhooks are running, which the issue also reports. This PR closes both windows.Reproduction
From the issue, shortened:
On
main:With this PR, the late
addHook('onClose')call throwsFST_ERR_INSTANCE_ALREADY_STARTED. A hook registered at the correct time seeslistening: false, and the request completes with200 {"value":42}.The same output on
mainis produced when the hook is added from aready(cb)callback or from inside anonReadyhook. For the route case, addingapp.get('/late', ...)afterawait app.ready()(or fromonReady), catching the error, and then requesting/latecrashes the process with:Root cause
Line numbers are at 0b076ca, avvio 9.3.1.
kState.startedis 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 aftermanageErrhas resolved theready()promise (fastify.js#L587), and after the code awaiting it, or theready(cb)callback, has run.Every check that rejects late registrations reads
kState.started:throwIfAlreadyStarted, which is used byaddHook,route,addSchemaand theset*methodsdecorate(lib/decorate.js)lib/content-type-parser.js)Inside the window, all of them pass.
addHook('onClose')goes through avvio'sonClose(fastify.js#L636), which calls_closeQ.unshift(avvio/index.js#L312). Close handlers therefore run in reverse registration order.Fastify's internal
onClosehandler is registered atpreReady(fastify.js#L408). It setsclosing, runs thepreClosehooks and callsserver.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.route()runs theonRoutehooks (lib/route.js#L299) and inserts the route into the router (lib/route.js#L364) before callingthis.after()(lib/route.js#L379). Once the root plugin has booted,this.after()throwsAVV_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 atlib/hooks.js#L341with an uncaughtTypeError.The same applies while the
onReadyhooks run. At that point the plugin tree has booted, so the internal close handler is already registered andthis.after()throws. However,startedis stillfalse: onlybootingistrue.Changes
1. Mark the instance as started before
ready()resolves (fastify.js)manageErrnow setskState.started = trueright 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 afterready(). With this change alone,addHook, routes,decorateand content type parsers are rejected right afterawait ready()and insideready(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 ofmanageErr, such asclose()called withoutready().2. Reject routes and
onClosehooks whileonReadyhooks run (fastify.js,lib/route.js)This adds a
throwIfAlreadyBooted(msg)guard next tothrowIfAlreadyStarted. It throws the sameFST_ERR_INSTANCE_ALREADY_STARTEDerror when eitherstartedorbootingis set.It is used only where a registration made during
onReadyis 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 ofroute(). A rejected route never runs theonRoutehooks and never reaches the router. Before, it threwAVV_ERR_ROOT_PLG_BOOTEDafter both of those had already happened. Rejecting early also means plugins that collect routes inonRoute(for example for documentation) never see a route that doesn't exist.Everything else keeps using
throwIfAlreadyStarted, so registrations that work fromonReadytoday keep working. See "What is intentionally not changed" below.3. Docs (
docs/Reference/Server.md)Adds one sentence to the
readysection: onceready()has completed, adding hooks, routes or decorators throws.4. Tests (
test/hooks.on-ready.test.js)addHook('onClose'),addHook('preClose'),decorate,addContentTypeParserandget()right afterawait ready(). It then checks that the rejected route returns 404 instead of crashing.ready(cb)callback.ready()rejects withFST_ERR_INSTANCE_ALREADY_STARTED, and the hook never runs.Behaviour before and after
Each API was called on a fresh instance, both right after
await fastify.ready()and from inside anonReadyhook:ready(), before this PRready(), with this PRonReady, before this PRonReady, with this PRaddHook('onClose')server.close()FST_ERR_INSTANCE_ALREADY_STARTEDserver.close()FST_ERR_INSTANCE_ALREADY_STARTEDaddHook('preClose')FST_ERR_INSTANCE_ALREADY_STARTEDaddHook('onListen')FST_ERR_INSTANCE_ALREADY_STARTEDaddHook('onRequest')and other lifecycle hooksAVV_ERR_ROOT_PLG_BOOTEDFST_ERR_INSTANCE_ALREADY_STARTEDAVV_ERR_ROOT_PLG_BOOTEDAVV_ERR_ROOT_PLG_BOOTED(unchanged)decorateFST_ERR_DEC_AFTER_STARTaddContentTypeParserFST_ERR_CTP_INSTANCE_ALREADY_STARTEDget('/late')AVV_ERR_ROOT_PLG_BOOTED; the route is left in the router and the first request crashes the processFST_ERR_INSTANCE_ALREADY_STARTED; the request gets a 404AVV_ERR_ROOT_PLG_BOOTED; the route is left in the router and the first request crashes the processFST_ERR_INSTANCE_ALREADY_STARTED; the request gets a 404In the "after
ready()" columns, the "before" behaviour only held inside the window. One macrotask later, every one of those calls already threwFST_ERR_INSTANCE_ALREADY_STARTED,FST_ERR_DEC_AFTER_STARTorFST_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
decoratefromonReadykeeps working. The existing test "onReady can not add decorators or application hooks" expects it to be accepted. Loading something inonReadyand decorating the instance with it is also a reasonable pattern.preCloseandonListenhooks added fromonReadykeep working. They are read fromkHookswhen they fire, so they already run at the correct time.onReadyhook added fromonReadyis still accepted and never runs, as the existing test expects. Hooks.md already saysonReady"cannot change the routes or add new hooks". Making everyaddHookcall throw fromonReadywould also be reasonable. However, it would change the assertions of two existing tests, including theAVV_ERR_ROOT_PLG_BOOTEDcode 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, theset*methods andaddConstraintStrategystill usethrowIfAlreadyStarted, so their behaviour insideonReadyis unchanged.Compatibility
Code that relied on the window now throws. That includes registering right after
await ready()or in aready(cb)callback, adding anonClosehook fromonReady, and adding a route fromonReady.Before this change, the same calls threw a tick later, a late
onClosehook ran in the wrong order, and a late route crashed the process. I would consider this a bug fix, but it could be treated assemver-major.mainis currently6.0.0-alpha.6.Relation to #7169
#7169 makes the same
manageErrchange as point 1, which fixes theawait ready()andready(cb)cases.I applied only that change and re-ran the issue's scenarios. Two
onReadycases from the issue still reproduce:onClosehook added fromonReadystill runs while the server is listening, and the in-flight request gets the500.onReadyis 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
mainand 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.mainas well, unrelated to this change. With the port temporarily changed, they pass, and the coverage check above includes them.Checklist
npm run test && npm run benchmark --if-present(benchmarks not run, see above)and the Code of conduct