diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3c37650ea2..09888d34a4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -682,10 +682,10 @@ jobs: # too slow and flaky. Perhaps better in v2? # - host: ubuntu-latest # browser: firefox + - host: windows-latest + browser: chromium - host: macos-latest browser: webkit - # - host: windows-latest - # browser: chromium runs-on: ${{ matrix.settings.host }} diff --git a/e2e/adapters-e2e/playwright.config.ts b/e2e/adapters-e2e/playwright.config.ts index cf7e0e59a69..81e9fbb2a19 100644 --- a/e2e/adapters-e2e/playwright.config.ts +++ b/e2e/adapters-e2e/playwright.config.ts @@ -44,5 +44,6 @@ export default defineConfig({ port: 3000, stdout: 'pipe', reuseExistingServer: !process.env.CI, + timeout: 120000, }, }); diff --git a/packages/qwik-city/src/adapters/shared/vite/index.ts b/packages/qwik-city/src/adapters/shared/vite/index.ts index f5eafd70c57..a2ff2374417 100644 --- a/packages/qwik-city/src/adapters/shared/vite/index.ts +++ b/packages/qwik-city/src/adapters/shared/vite/index.ts @@ -202,34 +202,6 @@ export function viteAdapter(opts: ViteAdapterPluginOptions) { `\n==============================================` ); } - if (opts.ssg !== null) { - /** - * HACK: for some reason the build hangs after SSG. `why-is-node-running` shows 4 - * culprits: - * - * ``` - * There are 4 handle(s) keeping the process running. - * - * # CustomGC - * ./node_modules/.pnpm/lightningcss@1.30.1/node_modules/lightningcss/node/index.js:20 - module.exports = require(`lightningcss-${parts.join('-')}`); - * - * # CustomGC - * ./node_modules/.pnpm/@tailwindcss+oxide@4.1.12/node_modules/@tailwindcss/oxide/index.js:229 - return require('@tailwindcss/oxide-linux-x64-gnu') - * - * # Timeout - * node_modules/.vite-temp/vite.config.timestamp-1755270314169-a2a97ad5233f9.mjs:357 - * ./node_modules/.pnpm/vite@7.1.2_@types+node@24.3.0_jiti@2.5.1_lightningcss@1.30.1_terser@5.43.1_tsx@4.20.4_yaml@2.8.1/node_modules/vite/dist/node/chunks/dep-CMEinpL-.js:36657 - return (await import(pathToFileURL(tempFileName).href)).default; - * - * # CustomGC - * ./packages/qwik/dist/optimizer.mjs:1328 - const mod2 = module.default.createRequire(import.meta.url)(`../bindings/${triple.platformArchABI}`); - * ``` - * - * For now, we'll force exit the process after SSG with some delay. - */ - setTimeout(() => { - process.exit(0); - }, 5000).unref(); - } } }, }, diff --git a/packages/qwik-city/src/static/main-thread.ts b/packages/qwik-city/src/static/main-thread.ts index be77f87b928..1fefa974391 100644 --- a/packages/qwik-city/src/static/main-thread.ts +++ b/packages/qwik-city/src/static/main-thread.ts @@ -80,13 +80,17 @@ export async function mainThread(sys: System) { while (!isCompleted && main.hasAvailableWorker() && queue.length > 0) { const staticRoute = queue.shift(); if (staticRoute) { - render(staticRoute); + render(staticRoute).catch((e) => { + console.error(`render failed for ${staticRoute.pathname}`, e); + }); } } if (!isCompleted && isRoutesLoaded && queue.length === 0 && active.size === 0) { isCompleted = true; - completed(); + completed().catch((e) => { + console.error('SSG completion failed', e); + }); } }; @@ -134,6 +138,7 @@ export async function mainThread(sys: System) { flushQueue(); } catch (e) { + console.error(`render failed for ${staticRoute.pathname}`, e); isCompleted = true; reject(e); } @@ -216,8 +221,12 @@ export async function mainThread(sys: System) { flushQueue(); }; - loadStaticRoutes(); + loadStaticRoutes().catch((e) => { + console.error('SSG route loading failed', e); + reject(e); + }); } catch (e) { + console.error('SSG main thread failed', e); reject(e); } }); @@ -244,6 +253,6 @@ function validateOptions(opts: StaticGenerateOptions) { try { new URL(siteOrigin); } catch (e) { - throw new Error(`Invalid "origin": ${e}`); + throw new Error(`Invalid "origin"`, { cause: e as Error }); } } diff --git a/packages/qwik-city/src/static/node/index.ts b/packages/qwik-city/src/static/node/index.ts index 4f31e3ab603..279b442499c 100644 --- a/packages/qwik-city/src/static/node/index.ts +++ b/packages/qwik-city/src/static/node/index.ts @@ -1,6 +1,6 @@ import type { StaticGenerateOptions } from '../types'; import { createSystem } from './node-system'; -import { isMainThread, workerData } from 'node:worker_threads'; +import { isMainThread, workerData, threadId } from 'node:worker_threads'; import { mainThread } from '../main-thread'; import { workerThread } from '../worker-thread'; @@ -15,9 +15,20 @@ export async function generate(opts: StaticGenerateOptions) { } if (!isMainThread && workerData) { + const opts = workerData as StaticGenerateOptions; (async () => { - // self initializing worker thread with workerData - const sys = await createSystem(workerData); - await workerThread(sys); - })(); + try { + if (opts.log === 'debug') { + // eslint-disable-next-line no-console + console.debug(`Worker thread starting (ID: ${threadId})`); + } + // self initializing worker thread with workerData + const sys = await createSystem(opts, threadId); + await workerThread(sys); + } catch (error) { + console.error(`Error occurred in worker thread (ID: ${threadId}): ${error}`); + } + })().catch((e) => { + console.error(e); + }); } diff --git a/packages/qwik-city/src/static/node/node-main.ts b/packages/qwik-city/src/static/node/node-main.ts index 4bf079ba0b6..6941f49b08d 100644 --- a/packages/qwik-city/src/static/node/node-main.ts +++ b/packages/qwik-city/src/static/node/node-main.ts @@ -10,10 +10,9 @@ import type { import fs from 'node:fs'; import { cpus as nodeCpus } from 'node:os'; import { Worker } from 'node:worker_threads'; -import { isAbsolute, resolve } from 'node:path'; +import { dirname, extname, isAbsolute, join, resolve } from 'node:path'; import { ensureDir } from './node-system'; import { normalizePath } from '../../utils/fs'; -import { createSingleThreadWorker } from '../worker-thread'; export async function createNodeMainProcess(sys: System, opts: StaticGenerateOptions) { const ssgWorkers: StaticGeneratorWorker[] = []; @@ -51,45 +50,34 @@ export async function createNodeMainProcess(sys: System, opts: StaticGenerateOpt } } - const singleThreadWorker = await createSingleThreadWorker(sys); - - const createWorker = (workerIndex: number) => { - if (workerIndex === 0) { - // same thread worker, don't start a new process - const ssgSameThreadWorker: StaticGeneratorWorker = { - activeTasks: 0, - totalTasks: 0, - - render: async (staticRoute) => { - ssgSameThreadWorker.activeTasks++; - ssgSameThreadWorker.totalTasks++; - const result = await singleThreadWorker(staticRoute); - ssgSameThreadWorker.activeTasks--; - return result; - }, - - terminate: async () => {}, - }; - return ssgSameThreadWorker; - } - + const createWorker = () => { let terminateResolve: (() => void) | null = null; const mainTasks = new Map(); let workerFilePath: string | URL; + let terminateTimeout: number | null = null; + // Launch the worker using the package's index module, which bootstraps the worker thread. if (typeof __filename === 'string') { - workerFilePath = __filename; + // CommonJS path + const ext = extname(__filename) || '.js'; + workerFilePath = join(dirname(__filename), `index${ext}`); } else { - workerFilePath = import.meta.url; - } + // ESM path (import.meta.url) + const thisUrl = new URL(import.meta.url); + const pathname = thisUrl.pathname || ''; + let ext = '.js'; + if (pathname.endsWith('.ts')) { + ext = '.ts'; + } else if (pathname.endsWith('.mjs')) { + ext = '.mjs'; + } - if (typeof workerFilePath === 'string' && workerFilePath.startsWith('file://')) { - workerFilePath = new URL(workerFilePath); + workerFilePath = new URL(`./index${ext}`, thisUrl); } const nodeWorker = new Worker(workerFilePath, { workerData: opts }); - + nodeWorker.unref(); const ssgWorker: StaticGeneratorWorker = { activeTasks: 0, totalTasks: 0, @@ -116,7 +104,9 @@ export async function createNodeMainProcess(sys: System, opts: StaticGenerateOpt terminateResolve = resolve; nodeWorker.postMessage(msg); }); - await nodeWorker.terminate(); + terminateTimeout = setTimeout(async () => { + await nodeWorker.terminate(); + }, 1000) as unknown as number; }, }; @@ -146,7 +136,11 @@ export async function createNodeMainProcess(sys: System, opts: StaticGenerateOpt }); nodeWorker.on('exit', (code) => { - if (code !== 1) { + if (terminateTimeout) { + clearTimeout(terminateTimeout); + terminateTimeout = null; + } + if (code !== 0) { console.error(`worker exit ${code}`); } }); @@ -200,9 +194,15 @@ export async function createNodeMainProcess(sys: System, opts: StaticGenerateOpt console.error(e); } } - ssgWorkers.length = 0; await Promise.all(promises); + ssgWorkers.length = 0; + + // On Windows, give extra time for all workers to fully exit + // This prevents resource conflicts in back-to-back builds + if (process.platform === 'win32') { + await new Promise((resolve) => setTimeout(resolve, 300)); + } }; if (sitemapOutFile) { @@ -214,7 +214,11 @@ export async function createNodeMainProcess(sys: System, opts: StaticGenerateOpt } for (let i = 0; i < maxWorkers; i++) { - ssgWorkers.push(createWorker(i)); + ssgWorkers.push(createWorker()); + // On Windows, add delay between worker creation to avoid resource contention + if (process.platform === 'win32' && i < maxWorkers - 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } } const mainCtx: MainContext = { diff --git a/packages/qwik-city/src/static/node/node-system.ts b/packages/qwik-city/src/static/node/node-system.ts index efb61bd7321..b9edfc667b5 100644 --- a/packages/qwik-city/src/static/node/node-system.ts +++ b/packages/qwik-city/src/static/node/node-system.ts @@ -2,15 +2,15 @@ import type { StaticGenerateOptions, System } from '../types'; import fs from 'node:fs'; import { dirname, join } from 'node:path'; -import { patchGlobalThis } from '../../middleware/node/node-fetch'; import { createNodeMainProcess } from './node-main'; import { createNodeWorkerProcess } from './node-worker'; import { normalizePath } from '../../utils/fs'; /** @public */ -export async function createSystem(opts: StaticGenerateOptions) { - patchGlobalThis(); - +export async function createSystem( + opts: StaticGenerateOptions, + threadId?: number +): Promise { const createWriteStream = (filePath: string) => { return fs.createWriteStream(filePath, { flags: 'w', @@ -29,6 +29,13 @@ export async function createSystem(opts: StaticGenerateOptions) { }; const createLogger = async () => { + if (threadId !== undefined) { + return { + debug: opts.log === 'debug' ? console.debug.bind(console, `[${threadId}]`) : () => {}, + error: console.error.bind(console, `[${threadId}]`), + info: console.info.bind(console, `[${threadId}]`), + }; + } return { debug: opts.log === 'debug' ? console.debug.bind(console) : () => {}, error: console.error.bind(console), diff --git a/packages/qwik-city/src/static/node/node-worker.ts b/packages/qwik-city/src/static/node/node-worker.ts index 904486e684d..a2701143a77 100644 --- a/packages/qwik-city/src/static/node/node-worker.ts +++ b/packages/qwik-city/src/static/node/node-worker.ts @@ -6,5 +6,8 @@ export async function createNodeWorkerProcess( ) { parentPort?.on('message', async (msg: WorkerInputMessage) => { parentPort?.postMessage(await onMessage(msg)); + if (msg.type === 'close') { + parentPort?.close(); + } }); } diff --git a/packages/qwik-city/src/static/types.ts b/packages/qwik-city/src/static/types.ts index e0090901672..04a331828e0 100644 --- a/packages/qwik-city/src/static/types.ts +++ b/packages/qwik-city/src/static/types.ts @@ -6,7 +6,7 @@ export interface System { createMainProcess: (() => Promise) | null; createWorkerProcess: ( onMessage: (msg: WorkerInputMessage) => Promise - ) => void; + ) => void | Promise; createLogger: () => Promise; getOptions: () => StaticGenerateOptions; ensureDir: (filePath: string) => Promise; diff --git a/packages/qwik-city/src/static/worker-thread.ts b/packages/qwik-city/src/static/worker-thread.ts index 962631cb683..f4b843c4021 100644 --- a/packages/qwik-city/src/static/worker-thread.ts +++ b/packages/qwik-city/src/static/worker-thread.ts @@ -15,28 +15,42 @@ import { _deserializeData, _serializeData, _verifySerializable } from '@builder. export async function workerThread(sys: System) { const ssgOpts = sys.getOptions(); const pendingPromises = new Set>(); + const log = await sys.createLogger(); const opts: StaticGenerateHandlerOptions = { ...ssgOpts, + // TODO export this from server render: (await import(pathToFileURL(ssgOpts.renderModulePath).href)).default, + // TODO this should be built-in qwikCityPlan: (await import(pathToFileURL(ssgOpts.qwikCityPlanModulePath).href)).default, }; - sys.createWorkerProcess(async (msg) => { - switch (msg.type) { - case 'render': { - return new Promise((resolve) => { - workerRender(sys, opts, msg, pendingPromises, resolve); - }); - } - case 'close': { - const promises = Array.from(pendingPromises); - pendingPromises.clear(); - await Promise.all(promises); - return { type: 'close' }; + sys + .createWorkerProcess(async (msg) => { + switch (msg.type) { + case 'render': { + log.debug(`Worker thread rendering: ${msg.pathname}`); + return new Promise((resolve) => { + workerRender(sys, opts, msg, pendingPromises, resolve).catch((e) => { + console.error('Error during render', msg.pathname, e); + }); + }); + } + case 'close': { + if (pendingPromises.size) { + log.debug(`Worker thread closing, waiting for ${pendingPromises.size} pending renders`); + const promises = Array.from(pendingPromises); + pendingPromises.clear(); + await Promise.all(promises); + } + log.debug(`Worker thread closed`); + return { type: 'close' }; + } } - } - }); + }) + ?.catch((e) => { + console.error('Worker process creation failed', e); + }); } export async function createSingleThreadWorker(sys: System) { @@ -51,7 +65,9 @@ export async function createSingleThreadWorker(sys: System) { return (staticRoute: StaticRoute) => { return new Promise((resolve) => { - workerRender(sys, opts, staticRoute, pendingPromises, resolve); + workerRender(sys, opts, staticRoute, pendingPromises, resolve).catch((e) => { + console.error('Error during render', staticRoute.pathname, e); + }); }); }; } @@ -254,6 +270,13 @@ async function workerRender( } } }) + .catch((e) => { + console.error('Unhandled error during request handling', staticRoute.pathname, e); + result.error = { + message: String(e), + stack: e.stack || '', + }; + }) .finally(() => { pendingPromises.delete(promise); callback(result);