Summary
In a compiled server, a lazily-built data structure held in a private class
field loses its contents if it is first read ~10–20 s after startup while
other async work is running. The array is still an array — it is empty.
The visible effect in MB24 (apps/api): the HTTP server starts, accepts
connections and runs middleware, but every route returns 404 forever. The
process never recovers. If the same structure is read once early, everything
works for the life of the process.
This is not a Hono bug as far as I can tell — the same source runs correctly
under Node, and correctly under Perry when the first read happens early.
Environment
|
|
| Perry |
75b886a381918e345f22b7f84dde7f4bb42e8a9a (2026-09-04, reports 0.5.1520) |
| Platform |
Linux x86_64 |
| hono |
4.13.4 |
| @hono/node-server |
1.19.17 |
| Build |
perry compile src/server.ts --embed "src/assets/**" -o mb24-api |
What breaks
hono/router/smart-router keeps not-yet-installed routes in a private field
and replays them into the concrete router on the first match():
add(method, path, handler) {
if (!this.#routes) throw new Error(MESSAGE_MATCHER_IS_ALREADY_BUILT);
this.#routes.push([method, path, handler]);
}
match(method, path) {
const routes = this.#routes;
for (...) {
for (let i2 = 0, len2 = routes.length; i2 < len2; i2++) router.add(...routes[i2]);
res = router.match(method, path);
this.match = router.match.bind(router);
this.#routes = void 0; // discarded — one shot only
break;
}
}
#routes is empty at that first match(), so nothing is installed into the
concrete router, and this.match is then permanently rebound to that empty
router. It is empty rather than undefined: undefined takes the
throw new Error("Fatal error") path and we would see a 500, and we see a
clean 404 from the application's own notFound handler.
The registration itself is intact — app.routes still lists all 103 routes at
the moment matching fails. Only the private-field copy is gone.
Reproduction
Repo Skelpo/mb24 at d7d39c9, apps/api. The scheduler is a
setInterval(…, 1000) started right after serve(); each tick runs a list of
independent database jobs.
perry compile src/server.ts --embed "src/assets/**" -o mb24-api
PORT=3095 ./mb24-api # then, from another shell:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3095/v1/health
Timing is the whole bug. Curl at t≈3 s and the process is healthy forever.
Curl first at t≈16 s and that request and every later one returns 404.
I gated the pieces behind environment variables to bisect (one binary, flags
chosen at runtime), first request at t=16 s in every row:
| Configuration |
/v1/health |
| everything on |
404 |
| scheduler not started |
200 |
| WebSocket upgrade handler not attached |
404 |
| scheduler started with an empty job list |
200 |
| first 7 scheduler jobs |
200 |
| first 8 scheduler jobs |
404 |
| any single job alone (each of 14 tried) |
200 |
one in-process app.request() before serve() |
200 |
No individual job is responsible — the 8-job threshold is cumulative async
work, not a code path. The WebSocket handler is irrelevant.
Once it fails it stays failed, and it is not confined to the socket path:
external curl at t=16s -> 404
PROBE t=20s app.request -> 404
PROBE t=20s app.fetch -> 404
PROBE t=20s app.routes.length -> 103
external curl at t=26s -> 404
What does NOT reproduce it
All of these compile and run correctly on the same commit, so the trigger is
narrower than any one of them:
- bare Hono, in-process
app.request() — 103 routes, wildcard middleware,
onError/notFound, params, external registration functions
- Hono behind
@hono/node-server, requests over a real socket
- raw
node:http createServer — req.url and routing both correct
- 100+ routes, with or without a warm-up request
- background
setInterval, with and without unref(), with and without async work
- background
mysql2 pool queries every 200 ms for 3 s
- ~10.5 M object allocations over 15 s before the first request
--embed, and the real createApp with all 103 routes built and served
The last one is the sharp edge: the same createApp, served the same
way, is fine in a small entrypoint. It only fails inside the full server, where
a database pool, a scheduler and 14 jobs are also live.
Hypothesis
Something is dropping the contents of a private-field array that nothing else
references between writes and the single late read. Two candidates:
- GC / liveness. The
[method, path, handler] tuples in #routes are
reachable only through the private field. If the private-field backing store
is not scanned as a root, allocation pressure from the scheduler collects the
contents while the array object itself survives.
- Spread-call miscompilation.
router.add(...routes[i2]) silently adding
nothing would look identical from outside. I think this is less likely,
because it would fail deterministically rather than only when the first
match is late.
Observation 1 fits the timing evidence better: the failure appears only after
enough background allocation, and never when the structure is read early.
Workaround in use
One in-process request before serve(), which forces the router to be built
while the routes are still there:
// PERRY-WORKAROUND(#9717)
await app.request("http://localhost/v1/health");
Also worth knowing
The current public release 0.5.1220 and the installed 0.5.1520 cannot
run this binary at all — it dies at startup in @hono/node-server:
TypeError: Cannot read properties of undefined (reading 'listen')
at serve (<anonymous>)
That one is fixed on 75b886a38. It does not reproduce in a small program
either.
Summary
In a compiled server, a lazily-built data structure held in a private class
field loses its contents if it is first read ~10–20 s after startup while
other async work is running. The array is still an array — it is empty.
The visible effect in MB24 (
apps/api): the HTTP server starts, acceptsconnections and runs middleware, but every route returns 404 forever. The
process never recovers. If the same structure is read once early, everything
works for the life of the process.
This is not a Hono bug as far as I can tell — the same source runs correctly
under Node, and correctly under Perry when the first read happens early.
Environment
75b886a381918e345f22b7f84dde7f4bb42e8a9a(2026-09-04, reports0.5.1520)perry compile src/server.ts --embed "src/assets/**" -o mb24-apiWhat breaks
hono/router/smart-routerkeeps not-yet-installed routes in a private fieldand replays them into the concrete router on the first
match():#routesis empty at that firstmatch(), so nothing is installed into theconcrete router, and
this.matchis then permanently rebound to that emptyrouter. It is empty rather than
undefined:undefinedtakes thethrow new Error("Fatal error")path and we would see a 500, and we see aclean 404 from the application's own
notFoundhandler.The registration itself is intact —
app.routesstill lists all 103 routes atthe moment matching fails. Only the private-field copy is gone.
Reproduction
Repo
Skelpo/mb24atd7d39c9,apps/api. The scheduler is asetInterval(…, 1000)started right afterserve(); each tick runs a list ofindependent database jobs.
Timing is the whole bug. Curl at t≈3 s and the process is healthy forever.
Curl first at t≈16 s and that request and every later one returns 404.
I gated the pieces behind environment variables to bisect (one binary, flags
chosen at runtime), first request at t=16 s in every row:
/v1/healthapp.request()beforeserve()No individual job is responsible — the 8-job threshold is cumulative async
work, not a code path. The WebSocket handler is irrelevant.
Once it fails it stays failed, and it is not confined to the socket path:
What does NOT reproduce it
All of these compile and run correctly on the same commit, so the trigger is
narrower than any one of them:
app.request()— 103 routes, wildcard middleware,onError/notFound, params, external registration functions@hono/node-server, requests over a real socketnode:httpcreateServer—req.urland routing both correctsetInterval, with and withoutunref(), with and without async workmysql2pool queries every 200 ms for 3 s--embed, and the realcreateAppwith all 103 routes built and servedThe last one is the sharp edge: the same
createApp, served the sameway, is fine in a small entrypoint. It only fails inside the full server, where
a database pool, a scheduler and 14 jobs are also live.
Hypothesis
Something is dropping the contents of a private-field array that nothing else
references between writes and the single late read. Two candidates:
[method, path, handler]tuples in#routesarereachable only through the private field. If the private-field backing store
is not scanned as a root, allocation pressure from the scheduler collects the
contents while the array object itself survives.
router.add(...routes[i2])silently addingnothing would look identical from outside. I think this is less likely,
because it would fail deterministically rather than only when the first
match is late.
Observation 1 fits the timing evidence better: the failure appears only after
enough background allocation, and never when the structure is read early.
Workaround in use
One in-process request before
serve(), which forces the router to be builtwhile the routes are still there:
Also worth knowing
The current public release 0.5.1220 and the installed 0.5.1520 cannot
run this binary at all — it dies at startup in
@hono/node-server:That one is fixed on
75b886a38. It does not reproduce in a small programeither.