Skip to content

staticGenerate: a render task that never posts back deadlocks the worker pool, silently #8928

Description

@v0lt-systems

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

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