vite dev: `createServiceEnvironment` registers `ssr` in `_viteEnvs` before building it, so the dev worker's first `getBuiltins` invoke is dropped and SSR wedges permanently
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 11.2k
- Forks
- 899
- Avg merge
- 2d 24m
- Merged PRs (30d)
- 40
Description
Environment
- nitro
3.0.260610-beta(also present in3.0.260903-betaand onmain) - vite
8.3.0, env-runner0.1.16,NITRO_DEV_RUNNERunset (node-worker) - Node v24.1.0, pnpm 10.28.2, macOS (darwin arm64)
- TanStack Start app (the
ssrservice environment is TanStack Start's)
Describe the bug
On most vite dev starts the SSR environment never comes up and every request
returns 500 for the life of the process:
NitroViteError: Vite environment "ssr" is unavailable
at ViteEnvRunner.fetch (nitro/dist/runtime/internal/vite/dev-worker.mjs)
and, 60s into the run:
transport invoke timed out after 60000ms
(data: {"type":"custom","event":"vite:invoke",
"data":{"name":"getBuiltins","id":"send:IAvQac_CZj4IB62ayHqXK","data":[]}})
at reviveInvokeError (vite/dist/node/module-runner.js:540:14)
at ModuleRunner.import (vite/dist/node/module-runner.js:1131:23)
at ViteEnvRunner.reload (nitro/dist/runtime/internal/vite/dev-worker.mjs:58:20)
It is not the cold-start 503 fixed for #4295 / #4405 — that one recovers.
Here the very first module-runner invoke is dropped, so the SSR entry never
finishes importing, entryError is set, and the process never recovers. It
still happens on 3.0.260903-beta (whose longer reload wait just turns the
first 30s into did not finish reloading within 30000ms before the same
permanent failure).
Cause
createServiceEnvironment registers the environment in ctx._viteEnvs
before awaiting its construction, while createNitroEnvironment registers it
after. On main, src/build/vite/env.ts:
// createNitroEnvironment — correct order
createEnvironment: async (envName, envConfig) => {
const entry = resolve(runtimeDir, "internal/vite/dev-entry.mjs");
const { createFetchableDevEnvironment } = await import("./dev.ts");
const env = await createFetchableDevEnvironment(envName, envConfig, await initEnvRunner(ctx), entry, {...});
ctx._transformRequest = (id) => env.transformRequest(id);
(ctx._viteEnvs ??= new Map()).set(envName, entry); // <-- last
return env;
},
// createServiceEnvironment — L96-106
createEnvironment: async (envName, envConfig) => {
const entry = tryResolve(serviceConfig.entry);
(ctx._viteEnvs ??= new Map()).set(envName, entry); // <-- first
const { createFetchableDevEnvironment } = await import("./dev.ts");
return createFetchableDevEnvironment(envName, envConfig, await initEnvRunner(ctx), entry, {...});
},
initEnvRunner installs manager.onReady(...), which replays every entry of
ctx._viteEnvs to the dev worker as a nitro:vite-env message. So if the
runner becomes ready during the two awaits above, the worker is told about
ssr and builds its ViteEnvRunner — whose constructor immediately calls
reload() → runner.import() → transport.invoke("getBuiltins") — while the
main thread has not yet constructed the ssr FetchableDevEnvironment and so
has no vite:invoke listener for viteEnv: "ssr" on the hot channel. The
worker's TRANSPORT.send lands in RunnerManager._messageListeners with
nothing matching, is silently dropped, and getBuiltins times out 60s later.
Logging every send, on, match and non-match in env-runner/dist/vite.mjs
shows it directly. A failing run, verbatim from lines 5-15 of the dev server's
output — MAIN/WORK is worker_threads.isMainThread, HOTCH is
createViteHotChannel, TRANSPORT is createViteTransport, and HOTCH.MISS
is a message reaching a hot channel listener whose viteEnv does not match:
[ER:WORK] createViteTransport ssr
[ER:WORK] TRANSPORT.connect ssr
[ER:WORK] TRANSPORT.send ssr custom/vite:invoke/getBuiltins#send:7EygdUvzCeLFqpT7hHiuy viteEnv=undefined
[ER:MAIN] createViteHotChannel ssr <-- the ssr env is constructed here, after the send
[ER:MAIN] HOTCH.on ssr vite:invoke <-- its listener registers two lines late
[ER:MAIN] HOTCH.on ssr vite:invalidate
[ER:MAIN] createViteHotChannel nitro
[ER:MAIN] HOTCH.on nitro vite:invoke
[ER:MAIN] HOTCH.on nitro vite:invalidate
[ER:WORK] createViteTransport nitro
[ER:MAIN] HOTCH.MISS ssr wanted vite:invoke got custom/vite:invoke/getBuiltins#send:K13t7sdjbYPmQqoaZfTw7 viteEnv=nitro
send:7EygdUvzCeLFqpT7hHiuy appears on no [ER:MAIN] line anywhere in the
run — not as a match, not even as a MISS. That absence is the evidence: at the
moment the worker sent it there was no hot channel listener for any
environment, so nothing on the main thread could observe it at all. The only
other place that id reappears is the timeout 60s later:
transport invoke timed out after 60000ms (data: {"type":"custom","event":"vite:invoke","data":{"name":"getBuiltins","id":"send:7EygdUvzCeLFqpT7hHiuy","data":[]}}).
(viteEnv=undefined on the send line is an artefact of the logger printing the
payload before createViteTransport.send spreads viteEnv onto it; the
message itself carries viteEnv: "ssr".)
The nitro environment in the same run is fine, because its _viteEnvs.set
comes last, so the worker only ever hears about it after the listener exists —
its getBuiltins is matched immediately:
[ER:WORK] TRANSPORT.send nitro custom/vite:invoke/getBuiltins#send:K13t7sdjbYPmQqoaZfTw7 viteEnv=undefined
[ER:MAIN] HOTCH.match nitro custom/vite:invoke/getBuiltins#send:K13t7sdjbYPmQqoaZfTw7 viteEnv=nitro
[ER:WORK] TRANSPORT.recv nitro custom/vite:invoke/getBuiltins#response:K13t7sdjbYPmQqoaZfTw7 viteEnv=nitro
And with the reorder below applied, the same logging shows the ordering flip
and the ssr invoke matching:
[ER:MAIN] createViteHotChannel ssr
[ER:MAIN] HOTCH.on ssr vite:invoke
[ER:WORK] createViteTransport ssr
[ER:MAIN] HOTCH.match ssr custom/vite:invoke/getBuiltins#send:rk2tXJQKe6QZ1DHTRcznh viteEnv=ssr
Reproduction
A pristine @tanstack/cli scaffold with zero application code reproduces it:
run pnpm dev and curl localhost:3000/. The first start after a cold
node_modules tends to succeed and subsequent starts fail, which fits the
window being timing-dependent — but the ordering above is the whole bug, and
whether it is hit is machine- and cache-dependent, not app-dependent.
Removing .tanstack, .nitro and node_modules/.vite changes nothing.
Fix
Moving the set after the environment is constructed, matching
createNitroEnvironment, closes the window — 4/4 consecutive pnpm dev starts
serve SSR with no dropped invoke, against 0/2 before:
createEnvironment: async (envName, envConfig) => {
const entry = tryResolve(serviceConfig.entry);
const { createFetchableDevEnvironment } = await import("./dev.ts");
const env = await createFetchableDevEnvironment(envName, envConfig, await initEnvRunner(ctx), entry, {...});
(ctx._viteEnvs ??= new Map()).set(envName, entry);
return env;
},
FetchableDevEnvironment.init() already announces the environment itself after
super.init(), so nothing is lost by keeping it out of _viteEnvs until it
exists; the onReady replay then only ever names environments whose hot
channel is listening.
Worth considering alongside it: the worker's dropped-invoke path is silent for
a full 60s and unrecoverable afterwards. Having ViteEnvRunner retry a reload
that failed with a transport timeout, or having _viteEnvs hold the
environment rather than just its entry path, would make this class of race
self-healing rather than fatal.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/build/vite/env.ts, comparing createServiceEnvironment with createNitroEnvironment and tracing initEnvRunner's onReady replay through createFetchableDevEnvironment. Run pnpm dev and curl localhost:3000/ with the TanStack CLI scaffold; done means repeated starts serve SSR without the dropped getBuiltins invoke, timeout, or permanent ViteEnvRunner failure.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- devtools
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100