From caa55037f7d87fd757ec8929e2877eb8f215adfc Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Sun, 24 May 2026 17:09:46 +0200 Subject: [PATCH] fix: harden core build system against silent failures - Wrap rebuild() in try/finally to guarantee mutex release (prevents deadlock) - Surface rebuild errors via .catch() instead of discarding with void - Add null checks for vfile.data.relativePath (prevents crash on virtual files) - Add try-finally for worker pool cleanup in parse.ts (prevents thread leaks) - Remove process.exit(1) from parse error handler (let errors propagate) - Add per-emitter error handling in emit phase with degraded-build warning - Replace unsafe Map.get()! assertions with null-checked access in config-loader --- quartz/build.ts | 255 +++++++++++++------------ quartz/plugins/loader/config-loader.ts | 16 +- quartz/processors/emit.ts | 13 +- quartz/processors/parse.ts | 68 ++++--- 4 files changed, 192 insertions(+), 160 deletions(-) diff --git a/quartz/build.ts b/quartz/build.ts index bc36832..4b67760 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -124,7 +124,12 @@ async function startWatching( for (const content of initialContent) { const [_tree, vfile] = content - contentMap.set(vfile.data.relativePath!, { + const relPath = vfile.data.relativePath + if (!relPath) { + console.warn(`Skipping file with no relativePath: ${vfile.path}`) + continue + } + contentMap.set(relPath, { type: "markdown", content, }) @@ -165,19 +170,25 @@ async function startWatching( fp = toPosixPath(fp) if (buildData.ignored(fp)) return changes.push({ path: fp as FilePath, type: "add" }) - void rebuild(changes, clientRefresh, buildData) + rebuild(changes, clientRefresh, buildData).catch((err) => { + console.error(styleText("red", "Rebuild failed:"), err.message ?? err) + }) }) .on("change", (fp) => { fp = toPosixPath(fp) if (buildData.ignored(fp)) return changes.push({ path: fp as FilePath, type: "change" }) - void rebuild(changes, clientRefresh, buildData) + rebuild(changes, clientRefresh, buildData).catch((err) => { + console.error(styleText("red", "Rebuild failed:"), err.message ?? err) + }) }) .on("unlink", (fp) => { fp = toPosixPath(fp) if (buildData.ignored(fp)) return changes.push({ path: fp as FilePath, type: "delete" }) - void rebuild(changes, clientRefresh, buildData) + rebuild(changes, clientRefresh, buildData).catch((err) => { + console.error(styleText("red", "Rebuild failed:"), err.message ?? err) + }) }) return async () => { @@ -194,147 +205,155 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD buildData.lastBuildMs = new Date().getTime() const numChangesInBuild = changes.length const release = await mut.acquire() - - // if there's another build after us, release and let them do it - if (ctx.buildId !== buildId) { - release() - return - } - - const perf = new PerfTimer() - perf.addEvent("rebuild") - console.log(styleText("yellow", "Detected change, rebuilding...")) - - // update changesSinceLastBuild - for (const change of changes) { - changesSinceLastBuild[change.path] = change.type - } - - const staticResources = getStaticResourcesFromPlugins(ctx) - const pathsToParse: FilePath[] = [] - for (const [fp, type] of Object.entries(changesSinceLastBuild)) { - if (type === "delete" || path.extname(fp) !== ".md") continue - const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath - pathsToParse.push(fullPath) - } - - const parsed = await parseMarkdown(ctx, pathsToParse) - for (const content of parsed) { - contentMap.set(content[1].data.relativePath!, { - type: "markdown", - content, - }) - } - - // update state using changesSinceLastBuild - // we do this weird play of add => compute change events => remove - // so that partialEmitters can do appropriate cleanup based on the content of deleted files - for (const [file, change] of Object.entries(changesSinceLastBuild)) { - if (change === "delete") { - // universal delete case - contentMap.delete(file as FilePath) + try { + // if there's another build after us, release and let them do it + if (ctx.buildId !== buildId) { + return } - // manually track non-markdown files as processed files only - // contains markdown files - if (change === "add" && path.extname(file) !== ".md") { - contentMap.set(file as FilePath, { - type: "other", + const perf = new PerfTimer() + perf.addEvent("rebuild") + console.log(styleText("yellow", "Detected change, rebuilding...")) + + // update changesSinceLastBuild + for (const change of changes) { + changesSinceLastBuild[change.path] = change.type + } + + const staticResources = getStaticResourcesFromPlugins(ctx) + const pathsToParse: FilePath[] = [] + for (const [fp, type] of Object.entries(changesSinceLastBuild)) { + if (type === "delete" || path.extname(fp) !== ".md") continue + const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath + pathsToParse.push(fullPath) + } + + const parsed = await parseMarkdown(ctx, pathsToParse) + for (const content of parsed) { + const relPath = content[1].data.relativePath + if (!relPath) { + console.warn(`Skipping file with no relativePath: ${content[1].path}`) + continue + } + contentMap.set(relPath, { + type: "markdown", + content, }) } - } - const changeEvents: ChangeEvent[] = Object.entries(changesSinceLastBuild).map(([fp, type]) => { - const path = fp as FilePath - const processedContent = contentMap.get(path) - if (processedContent?.type === "markdown") { - const [_tree, file] = processedContent.content + // update state using changesSinceLastBuild + // we do this weird play of add => compute change events => remove + // so that partialEmitters can do appropriate cleanup based on the content of deleted files + for (const [file, change] of Object.entries(changesSinceLastBuild)) { + if (change === "delete") { + // universal delete case + contentMap.delete(file as FilePath) + } + + // manually track non-markdown files as processed files only + // contains markdown files + if (change === "add" && path.extname(file) !== ".md") { + contentMap.set(file as FilePath, { + type: "other", + }) + } + } + + const changeEvents: ChangeEvent[] = Object.entries(changesSinceLastBuild).map(([fp, type]) => { + const path = fp as FilePath + const processedContent = contentMap.get(path) + if (processedContent?.type === "markdown") { + const [_tree, file] = processedContent.content + return { + type, + path, + file, + } + } + return { type, path, - file, + } + }) + + // update allFiles and then allSlugs with the consistent view of content map + ctx.allFiles = Array.from(contentMap.keys()) + ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath)) + + const markdownContent = Array.from(contentMap.values()) + .filter((file) => file.type === "markdown") + .map((file) => file.content) + reportSlugCollisions(markdownContent) + let processedFiles = filterContent(ctx, markdownContent) + + let emittedFiles = 0 + + // Phase 1: Run PageTypeDispatcher first so it populates ctx.virtualPages + const dispatcher = cfg.plugins.emitters.find((e) => e.name === "PageTypeDispatcher") + if (dispatcher) { + ctx.virtualPages = [] + const emitFn = dispatcher.partialEmit ?? dispatcher.emit + const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents) + if (emitted !== null) { + if (Symbol.asyncIterator in emitted) { + for await (const file of emitted) { + emittedFiles++ + if (ctx.argv.verbose) { + console.log(`[emit:${dispatcher.name}] ${file}`) + } + } + } else { + emittedFiles += emitted.length + if (ctx.argv.verbose) { + for (const file of emitted) { + console.log(`[emit:${dispatcher.name}] ${file}`) + } + } + } } } - return { - type, - path, - } - }) + // Phase 2: Run all other emitters with content extended by virtual pages + const contentWithVirtual = + ctx.virtualPages.length > 0 ? [...processedFiles, ...ctx.virtualPages] : processedFiles + for (const emitter of cfg.plugins.emitters) { + if (emitter.name === "PageTypeDispatcher") continue + // Try to use partialEmit if available, otherwise assume the output is static + const emitFn = emitter.partialEmit ?? emitter.emit + const emitted = await emitFn(ctx, contentWithVirtual, staticResources, changeEvents) + if (emitted === null) { + continue + } - // update allFiles and then allSlugs with the consistent view of content map - ctx.allFiles = Array.from(contentMap.keys()) - ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath)) - - const markdownContent = Array.from(contentMap.values()) - .filter((file) => file.type === "markdown") - .map((file) => file.content) - reportSlugCollisions(markdownContent) - let processedFiles = filterContent(ctx, markdownContent) - - let emittedFiles = 0 - - // Phase 1: Run PageTypeDispatcher first so it populates ctx.virtualPages - const dispatcher = cfg.plugins.emitters.find((e) => e.name === "PageTypeDispatcher") - if (dispatcher) { - ctx.virtualPages = [] - const emitFn = dispatcher.partialEmit ?? dispatcher.emit - const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents) - if (emitted !== null) { if (Symbol.asyncIterator in emitted) { + // Async generator case for await (const file of emitted) { emittedFiles++ if (ctx.argv.verbose) { - console.log(`[emit:${dispatcher.name}] ${file}`) + console.log(`[emit:${emitter.name}] ${file}`) } } } else { + // Array case emittedFiles += emitted.length if (ctx.argv.verbose) { for (const file of emitted) { - console.log(`[emit:${dispatcher.name}] ${file}`) + console.log(`[emit:${emitter.name}] ${file}`) } } } } + + console.log( + `Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`, + ) + console.log(styleText("green", `Done rebuilding in ${perf.timeSince()}`)) + changes.splice(0, numChangesInBuild) + clientRefresh() + } finally { + release() } - - // Phase 2: Run all other emitters with content extended by virtual pages - const contentWithVirtual = - ctx.virtualPages.length > 0 ? [...processedFiles, ...ctx.virtualPages] : processedFiles - for (const emitter of cfg.plugins.emitters) { - if (emitter.name === "PageTypeDispatcher") continue - // Try to use partialEmit if available, otherwise assume the output is static - const emitFn = emitter.partialEmit ?? emitter.emit - const emitted = await emitFn(ctx, contentWithVirtual, staticResources, changeEvents) - if (emitted === null) { - continue - } - - if (Symbol.asyncIterator in emitted) { - // Async generator case - for await (const file of emitted) { - emittedFiles++ - if (ctx.argv.verbose) { - console.log(`[emit:${emitter.name}] ${file}`) - } - } - } else { - // Array case - emittedFiles += emitted.length - if (ctx.argv.verbose) { - for (const file of emitted) { - console.log(`[emit:${emitter.name}] ${file}`) - } - } - } - } - - console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`) - console.log(styleText("green", `Done rebuilding in ${perf.timeSince()}`)) - changes.splice(0, numChangesInBuild) - clientRefresh() - release() } export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => { diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts index 5e3daa6..4480a0a 100644 --- a/quartz/plugins/loader/config-loader.ts +++ b/quartz/plugins/loader/config-loader.ts @@ -854,10 +854,13 @@ function resolveGroups( const groupConfig = groups[item.group] groupPriority.set(item.group, groupConfig?.priority ?? item.priority) } - groupedComponents.get(item.group)!.push({ - component: item.component, - groupOptions: item.groupOptions, - }) + const groupMembers = groupedComponents.get(item.group) + if (groupMembers) { + groupMembers.push({ + component: item.component, + groupOptions: item.groupOptions, + }) + } } } @@ -873,7 +876,8 @@ function resolveGroups( if (processedGroups.has(item.group)) continue processedGroups.add(item.group) - const members = groupedComponents.get(item.group)! + const members = groupedComponents.get(item.group) + if (!members) continue const groupConfig = groups[item.group] ?? {} const flexComponents = members.map((m) => ({ @@ -896,7 +900,7 @@ function resolveGroups( gap: groupConfig.gap ?? "1rem", }) as QuartzComponent - entries.push({ priority: groupPriority.get(item.group)!, component: flexComponent }) + entries.push({ priority: groupPriority.get(item.group) ?? 50, component: flexComponent }) } else { entries.push({ priority: item.priority, component: item.component }) } diff --git a/quartz/processors/emit.ts b/quartz/processors/emit.ts index 2ad3883..ccfce6a 100644 --- a/quartz/processors/emit.ts +++ b/quartz/processors/emit.ts @@ -78,12 +78,23 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) { const otherEmitters = cfg.plugins.emitters.filter( (e) => e.name !== "PageTypeDispatcher" && e.name !== "ComponentResources", ) + let emitErrors = 0 const counts = await Promise.all( otherEmitters.map((emitter) => - runEmitter(emitter, ctx, contentWithVirtual, staticResources, log), + runEmitter(emitter, ctx, contentWithVirtual, staticResources, log).catch((err) => { + emitErrors++ + console.error(`Emitter "${emitter.name}" failed:`, err.message ?? err) + return 0 + }), ), ) emittedFiles += counts.reduce((sum, c) => sum + c, 0) + if (emitErrors > 0) { + console.warn( + `\nBuild completed with ${emitErrors} emitter failure(s). Output may be incomplete.`, + ) + } + log.end(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince()}`) } diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index 582ff21..b5e9df1 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -171,11 +171,6 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise { - console.error(err) - process.exit(1) - } - const serializableCtx: WorkerSerializableBuildCtx = { buildId: ctx.buildId, argv: ctx.argv, @@ -185,37 +180,40 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise[] = [] - let processedFiles = 0 - for (const chunk of chunks(fps, CHUNK_SIZE)) { - textToMarkdownPromises.push(pool.exec("parseMarkdown", [serializableCtx, chunk])) + try { + const textToMarkdownPromises: WorkerPromise[] = [] + let processedFiles = 0 + for (const chunk of chunks(fps, CHUNK_SIZE)) { + textToMarkdownPromises.push(pool.exec("parseMarkdown", [serializableCtx, chunk])) + } + + const mdResults: Array = await Promise.all( + textToMarkdownPromises.map(async (promise) => { + const result = await promise + processedFiles += result.length + log.updateText(`text->markdown ${styleText("gray", `${processedFiles}/${fps.length}`)}`) + return result + }), + ) + + const markdownToHtmlPromises: WorkerPromise[] = [] + processedFiles = 0 + for (const mdChunk of mdResults) { + markdownToHtmlPromises.push(pool.exec("processHtml", [serializableCtx, mdChunk])) + } + const results: ProcessedContent[][] = await Promise.all( + markdownToHtmlPromises.map(async (promise) => { + const result = await promise + processedFiles += result.length + log.updateText(`markdown->html ${styleText("gray", `${processedFiles}/${fps.length}`)}`) + return result + }), + ) + + res = results.flat() + } finally { + await pool.terminate() } - - const mdResults: Array = await Promise.all( - textToMarkdownPromises.map(async (promise) => { - const result = await promise - processedFiles += result.length - log.updateText(`text->markdown ${styleText("gray", `${processedFiles}/${fps.length}`)}`) - return result - }), - ).catch(errorHandler) - - const markdownToHtmlPromises: WorkerPromise[] = [] - processedFiles = 0 - for (const mdChunk of mdResults) { - markdownToHtmlPromises.push(pool.exec("processHtml", [serializableCtx, mdChunk])) - } - const results: ProcessedContent[][] = await Promise.all( - markdownToHtmlPromises.map(async (promise) => { - const result = await promise - processedFiles += result.length - log.updateText(`markdown->html ${styleText("gray", `${processedFiles}/${fps.length}`)}`) - return result - }), - ).catch(errorHandler) - - res = results.flat() - await pool.terminate() } log.end(`Parsed ${res.length} Markdown files in ${perf.timeSince()}`)