Skip to content

Private-field array loses its contents under background load: Hono matches nothing when the first request is late #9717

Description

@proggeramlug

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 createServerreq.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:

  1. 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.
  2. 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.

Activity

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