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
This commit is contained in:
saberzero1
2026-05-24 17:09:46 +02:00
parent 17aa8ab233
commit caa55037f7
4 changed files with 192 additions and 160 deletions

View File

@@ -124,7 +124,12 @@ async function startWatching(
for (const content of initialContent) { for (const content of initialContent) {
const [_tree, vfile] = content 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", type: "markdown",
content, content,
}) })
@@ -165,19 +170,25 @@ async function startWatching(
fp = toPosixPath(fp) fp = toPosixPath(fp)
if (buildData.ignored(fp)) return if (buildData.ignored(fp)) return
changes.push({ path: fp as FilePath, type: "add" }) 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) => { .on("change", (fp) => {
fp = toPosixPath(fp) fp = toPosixPath(fp)
if (buildData.ignored(fp)) return if (buildData.ignored(fp)) return
changes.push({ path: fp as FilePath, type: "change" }) 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) => { .on("unlink", (fp) => {
fp = toPosixPath(fp) fp = toPosixPath(fp)
if (buildData.ignored(fp)) return if (buildData.ignored(fp)) return
changes.push({ path: fp as FilePath, type: "delete" }) 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 () => { return async () => {
@@ -194,147 +205,155 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD
buildData.lastBuildMs = new Date().getTime() buildData.lastBuildMs = new Date().getTime()
const numChangesInBuild = changes.length const numChangesInBuild = changes.length
const release = await mut.acquire() const release = await mut.acquire()
try {
// if there's another build after us, release and let them do it // if there's another build after us, release and let them do it
if (ctx.buildId !== buildId) { if (ctx.buildId !== buildId) {
release() return
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)
} }
// manually track non-markdown files as processed files only const perf = new PerfTimer()
// contains markdown files perf.addEvent("rebuild")
if (change === "add" && path.extname(file) !== ".md") { console.log(styleText("yellow", "Detected change, rebuilding..."))
contentMap.set(file as FilePath, {
type: "other", // 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]) => { // update state using changesSinceLastBuild
const path = fp as FilePath // we do this weird play of add => compute change events => remove
const processedContent = contentMap.get(path) // so that partialEmitters can do appropriate cleanup based on the content of deleted files
if (processedContent?.type === "markdown") { for (const [file, change] of Object.entries(changesSinceLastBuild)) {
const [_tree, file] = processedContent.content 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 { return {
type, type,
path, 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 { // Phase 2: Run all other emitters with content extended by virtual pages
type, const contentWithVirtual =
path, 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) { if (Symbol.asyncIterator in emitted) {
// Async generator case
for await (const file of emitted) { for await (const file of emitted) {
emittedFiles++ emittedFiles++
if (ctx.argv.verbose) { if (ctx.argv.verbose) {
console.log(`[emit:${dispatcher.name}] ${file}`) console.log(`[emit:${emitter.name}] ${file}`)
} }
} }
} else { } else {
// Array case
emittedFiles += emitted.length emittedFiles += emitted.length
if (ctx.argv.verbose) { if (ctx.argv.verbose) {
for (const file of emitted) { 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) => { export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {

View File

@@ -854,10 +854,13 @@ function resolveGroups(
const groupConfig = groups[item.group] const groupConfig = groups[item.group]
groupPriority.set(item.group, groupConfig?.priority ?? item.priority) groupPriority.set(item.group, groupConfig?.priority ?? item.priority)
} }
groupedComponents.get(item.group)!.push({ const groupMembers = groupedComponents.get(item.group)
component: item.component, if (groupMembers) {
groupOptions: item.groupOptions, groupMembers.push({
}) component: item.component,
groupOptions: item.groupOptions,
})
}
} }
} }
@@ -873,7 +876,8 @@ function resolveGroups(
if (processedGroups.has(item.group)) continue if (processedGroups.has(item.group)) continue
processedGroups.add(item.group) 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 groupConfig = groups[item.group] ?? {}
const flexComponents = members.map((m) => ({ const flexComponents = members.map((m) => ({
@@ -896,7 +900,7 @@ function resolveGroups(
gap: groupConfig.gap ?? "1rem", gap: groupConfig.gap ?? "1rem",
}) as QuartzComponent }) as QuartzComponent
entries.push({ priority: groupPriority.get(item.group)!, component: flexComponent }) entries.push({ priority: groupPriority.get(item.group) ?? 50, component: flexComponent })
} else { } else {
entries.push({ priority: item.priority, component: item.component }) entries.push({ priority: item.priority, component: item.component })
} }

View File

@@ -78,12 +78,23 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
const otherEmitters = cfg.plugins.emitters.filter( const otherEmitters = cfg.plugins.emitters.filter(
(e) => e.name !== "PageTypeDispatcher" && e.name !== "ComponentResources", (e) => e.name !== "PageTypeDispatcher" && e.name !== "ComponentResources",
) )
let emitErrors = 0
const counts = await Promise.all( const counts = await Promise.all(
otherEmitters.map((emitter) => 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) 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()}`) log.end(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince()}`)
} }

View File

@@ -171,11 +171,6 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
maxWorkers: concurrency, maxWorkers: concurrency,
workerType: "thread", workerType: "thread",
}) })
const errorHandler = (err: any) => {
console.error(err)
process.exit(1)
}
const serializableCtx: WorkerSerializableBuildCtx = { const serializableCtx: WorkerSerializableBuildCtx = {
buildId: ctx.buildId, buildId: ctx.buildId,
argv: ctx.argv, argv: ctx.argv,
@@ -185,37 +180,40 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
virtualPages: [], virtualPages: [],
} }
const textToMarkdownPromises: WorkerPromise<MarkdownContent[]>[] = [] try {
let processedFiles = 0 const textToMarkdownPromises: WorkerPromise<MarkdownContent[]>[] = []
for (const chunk of chunks(fps, CHUNK_SIZE)) { let processedFiles = 0
textToMarkdownPromises.push(pool.exec("parseMarkdown", [serializableCtx, chunk])) for (const chunk of chunks(fps, CHUNK_SIZE)) {
textToMarkdownPromises.push(pool.exec("parseMarkdown", [serializableCtx, chunk]))
}
const mdResults: Array<MarkdownContent[]> = 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<ProcessedContent[]>[] = []
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<MarkdownContent[]> = 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<ProcessedContent[]>[] = []
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()}`) log.end(`Parsed ${res.length} Markdown files in ${perf.timeSince()}`)