Summary
In lib/static/node.mjs, ssgWorker.render stores the promise resolver in mainTasks and posts the task to the worker. The only thing that ever settles that promise is the nodeWorker.on("message") handler. There is no timer and no expiry.
If a worker accepts a task and never posts a render message back — a route handler or loader that never settles, for any reason — the consequences cascade:
ssgWorker.activeTasks never decrements, so hasAvailableWorker() degrades permanently;
- the main thread's
active Set never empties, so completed() never runs;
- no file is written for that page;
- nothing is logged, because the main thread only logs a page when
result.filePath != null.
The build hangs with no diagnostic. If an external watchdog kills it and the surrounding tooling treats a killed build as success, the site ships with pages missing and no signal anywhere.
A second instance of the same gap
terminate() has it too. The worker only answers { type: "close" } after await Promise.all(pendingPromises), so a single never-settling render also hangs the close handshake forever — and that one hangs after the SSG results summary has printed, so the build looks finished and then never exits.
The existing terminateTimeout is armed only after that await resolves, so it cannot fire in the situation it appears to have been written for.
Impact we measured
On our site (~750 static pages, CI, maxWorkers: 2):
- 4 of 8 consecutive deploys hung. Across 13 build logs there were zero
worker error and zero worker exit events — the workers stay alive, which is why it hangs rather than crashing.
- One hung build shipped with three pages missing; they returned HTTP 404 in production until we noticed. One of them had been indexed by Google and was dropped from the index.
- Throughput accelerates into the stall (55–109 pages per 5s, then an instant cliff), so this does not look like memory pressure.
Why it can't be worked around from configuration
StaticGenerateRenderOptions is outDir, origin, maxWorkers, maxTasksPerWorker, sitemapOutFile, log, emitHtml, emitData, emit404Pages, include, exclude. There is no per-task timeout and no deadline, and staticAdapter() adds nothing. Bounding the whole generate() call instead would abort mid-write and lose every other page's verdict.
Suggested fix
Race each task against a configurable timeout. On expiry: delete the mainTasks entry, decrement activeTasks, and re-dispatch once (preferring a different worker). On a second expiry, resolve with a normal result.error so the page lands in generatorResult.errors and is named, rather than being silently absent. The same treatment applies to the close handshake.
We are running exactly this as a local pnpm patch against 1.20.0 and it has been stable — a normal build is unchanged (same page count, +0.2s), a single stall recovers on retry, and a persistent stall fails the build naming the pathname. Happy to open a PR if the approach is acceptable.
Version
@builder.io/qwik-city 1.20.0
Summary
In
lib/static/node.mjs,ssgWorker.renderstores the promise resolver inmainTasksand posts the task to the worker. The only thing that ever settles that promise is thenodeWorker.on("message")handler. There is no timer and no expiry.If a worker accepts a task and never posts a
rendermessage back — a route handler or loader that never settles, for any reason — the consequences cascade:ssgWorker.activeTasksnever decrements, sohasAvailableWorker()degrades permanently;activeSet never empties, socompleted()never runs;result.filePath != null.The build hangs with no diagnostic. If an external watchdog kills it and the surrounding tooling treats a killed build as success, the site ships with pages missing and no signal anywhere.
A second instance of the same gap
terminate()has it too. The worker only answers{ type: "close" }afterawait Promise.all(pendingPromises), so a single never-settling render also hangs the close handshake forever — and that one hangs after theSSG resultssummary has printed, so the build looks finished and then never exits.The existing
terminateTimeoutis armed only after that await resolves, so it cannot fire in the situation it appears to have been written for.Impact we measured
On our site (~750 static pages, CI,
maxWorkers: 2):worker errorand zeroworker exitevents — the workers stay alive, which is why it hangs rather than crashing.Why it can't be worked around from configuration
StaticGenerateRenderOptionsisoutDir, origin, maxWorkers, maxTasksPerWorker, sitemapOutFile, log, emitHtml, emitData, emit404Pages, include, exclude. There is no per-task timeout and no deadline, andstaticAdapter()adds nothing. Bounding the wholegenerate()call instead would abort mid-write and lose every other page's verdict.Suggested fix
Race each task against a configurable timeout. On expiry: delete the
mainTasksentry, decrementactiveTasks, and re-dispatch once (preferring a different worker). On a second expiry, resolve with a normalresult.errorso the page lands ingeneratorResult.errorsand is named, rather than being silently absent. The same treatment applies to the close handshake.We are running exactly this as a local
pnpm patchagainst 1.20.0 and it has been stable — a normal build is unchanged (same page count, +0.2s), a single stall recovers on retry, and a persistent stall fails the build naming the pathname. Happy to open a PR if the approach is acceptable.Version
@builder.io/qwik-city1.20.0