From fd216207a71925eaa907eb951322fed9fa64354a Mon Sep 17 00:00:00 2001 From: Slavov88 Date: Mon, 19 Oct 2026 20:55:18 +0300 Subject: [PATCH 1/3] Validate cached PDFs and recover missing artifacts --- apps/server/src/compiler.ts | 13 ++- apps/server/tests/compiler-cache.test.ts | 106 +++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 apps/server/tests/compiler-cache.test.ts diff --git a/apps/server/src/compiler.ts b/apps/server/src/compiler.ts index 7ca216f..979f43b 100644 --- a/apps/server/src/compiler.ts +++ b/apps/server/src/compiler.ts @@ -127,7 +127,7 @@ export class Compiler { const cachedPdf = join(this.cacheDir, `${sourceHash}.pdf`); const cachedLog = join(this.cacheDir, `${sourceHash}.log`); - if (await fileExists(cachedPdf)) { + if (await fileExists(cachedPdf) && await validCachedPdf(cachedPdf)) { const cachedSynctex = join(this.cacheDir, `${sourceHash}.synctex.gz`); const synctexPath = await fileExists(cachedSynctex) ? cachedSynctex : null; const now = new Date().toISOString(); @@ -495,6 +495,17 @@ async function fileExists(path: string): Promise { } } +async function validCachedPdf(path: string): Promise { + try { + return await hasPdfSignature(path); + } catch (error) { + // A cache artifact can disappear between stat and open. Recompile the + // already frozen snapshot instead of reporting a successful broken cache hit. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + async function readOptional(path: string): Promise { try { return await readFile(path, 'utf8'); diff --git a/apps/server/tests/compiler-cache.test.ts b/apps/server/tests/compiler-cache.test.ts new file mode 100644 index 0000000..c2bb0b8 --- /dev/null +++ b/apps/server/tests/compiler-cache.test.ts @@ -0,0 +1,106 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { buildApp } from '../src/app.js'; +import { config } from '../src/config.js'; +import type { AppContext } from '../src/context.js'; + +const cacheIo = vi.hoisted(() => ({ + beforeOpen: null as ((path: string) => Promise) | null, + writes: new Map() +})); +vi.mock('node:fs/promises', async (importOriginal) => { + const fs = await importOriginal(); + return { ...fs, open: async (...args: Parameters) => { + await cacheIo.beforeOpen?.(String(args[0])); + return fs.open(...args); + }, writeFile: async (...args: Parameters) => { + await fs.writeFile(...args); + if (typeof args[0] === 'string' && (typeof args[1] === 'string' || args[1] instanceof Uint8Array)) { + cacheIo.writes.set(args[0], Buffer.from(args[1])); + } + } }; +}); + +const previous = { fake: config.fakeCompiler, jobs: config.compileMaxJobs }; +let app: Awaited>['app']; +let context: AppContext; +let dir: string; + +beforeEach(() => { + config.fakeCompiler = true; + config.compileMaxJobs = 1; +}); +afterEach(async () => { + cacheIo.beforeOpen = null; + cacheIo.writes.clear(); + if (app) await app.close(); + vi.restoreAllMocks(); + config.fakeCompiler = previous.fake; + config.compileMaxJobs = previous.jobs; + if (dir) await rm(dir, { recursive: true, force: true }); +}); + +async function setup() { + dir = await mkdtemp(join(tmpdir(), 'overroot-cache-')); + ({ app, context } = await buildApp({ databasePath: join(dir, 'test.sqlite3'), logger: false })); + const user = await context.auth.createUser('cache-test', 'cache-test-password', true); + const actor = { type: 'user' as const, id: user.id, name: user.username }; + const project = context.projects.createImportedProject(user.id, 'Cache recovery', [ + { path: 'main.tex', content: Buffer.from('Frozen revision') } + ]); + const request = () => context.compiler.request(project, actor); + const first = await request(); + await vi.waitFor(() => expect(context.compiler.get(first.id, project.id)?.status).toBe('succeeded')); + // A completed job's final staging cleanup still owns its admission slot. + await vi.waitFor(async () => expect((await request()).status).toBe('succeeded')); + const original = context.compiler.get(first.id, project.id)!; + const pdf = await readFile(original.pdf_path!); + const events = vi.spyOn(context.events, 'emit'); + return { request, project, actor, original, pdf, events }; +} + +it.each(['', '%PD', 'broken cache artifact'])('recompiles a corrupt cached PDF %j and reuses the repaired output', async corruption => { + const h = await setup(); + await writeFile(h.original.pdf_path!, corruption); + const repaired = await h.request(); + expect(repaired.status).toBe('queued'); + expect(h.events.mock.calls.some(([, event]) => event.type === 'compile.ready' && event.cached === true)).toBe(false); + await vi.waitFor(() => expect(context.compiler.get(repaired.id, h.project.id)?.status).toBe('succeeded')); + expect(await readFile(h.original.pdf_path!)).toEqual(h.pdf); + const cached = await vi.waitFor(() => h.request()); + expect(cached.status).toBe('succeeded'); + expect(h.events.mock.calls.some(([, event]) => event.type === 'compile.ready' && event.cached === false)).toBe(true); + expect(h.events.mock.calls.at(-1)?.[1]).toMatchObject({ type: 'compile.ready', cached: true }); +}); + +it('recompiles the frozen snapshot when a cached PDF disappears between stat and validation', async () => { + const h = await setup(); + let removed = false; + cacheIo.beforeOpen = async path => { + if (path !== h.original.pdf_path || removed) return; + removed = true; + await rm(path); + const main = context.projects.getFile(h.project.id, 'main.tex')!; + context.projects.saveFile(main.id, Buffer.from('A later revision'), h.actor); + }; + const repaired = await h.request(); + expect(removed).toBe(true); + expect(repaired.status).toBe('queued'); + expect(repaired.source_hash).toBe(h.original.source_hash); + await vi.waitFor(() => expect(context.compiler.get(repaired.id, h.project.id)?.status).toBe('succeeded')); + expect(await readFile(h.original.pdf_path!)).toEqual(h.pdf); + expect(cacheIo.writes.get(join(dir, 'jobs', repaired.id, 'main.tex'))?.toString()).toBe('Frozen revision'); + expect(Buffer.from(context.projects.getFile(h.project.id, 'main.tex')!.content).toString()).toBe('A later revision'); +}); + +it('reports a current cache validation I/O failure and releases admission for a retry', async () => { + const h = await setup(); + cacheIo.beforeOpen = async path => { + if (path === h.original.pdf_path) throw Object.assign(new Error('Controlled cache read failure'), { code: 'EACCES' }); + }; + await expect(h.request()).rejects.toThrow('Controlled cache read failure'); + cacheIo.beforeOpen = null; + expect((await h.request()).status).toBe('succeeded'); +}); From 9151de1a4b40df4cc4a927d19de384bcda84fcdd Mon Sep 17 00:00:00 2001 From: Slavov88 Date: Mon, 19 Oct 2026 20:57:13 +0300 Subject: [PATCH 2/3] Clean temporary compiler artifacts after publication failures --- apps/server/src/compiler.ts | 17 +++- apps/server/tests/compiler-cleanup.test.ts | 108 +++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 apps/server/tests/compiler-cleanup.test.ts diff --git a/apps/server/src/compiler.ts b/apps/server/src/compiler.ts index 979f43b..92f0c64 100644 --- a/apps/server/src/compiler.ts +++ b/apps/server/src/compiler.ts @@ -263,6 +263,7 @@ export class Compiler { const workDir = join(this.jobsDir, job.id); const outputDir = join(workDir, '.overroot-output'); + const temporaryArtifacts: string[] = []; try { await stageCompileSources(workDir, sources); @@ -313,6 +314,8 @@ export class Compiler { const synctexPath = (await fileExists(generatedSynctex)) ? join(this.cacheDir, `${job.source_hash}.synctex.gz`) : null; const pdfTemp = `${pdfPath}.${job.id}.tmp`; const logTemp = `${logPath}.${job.id}.tmp`; + // Register before writing: even a failed copy can leave a partial file. + temporaryArtifacts.push(pdfTemp, logTemp); await copyFile(generatedPdf, pdfTemp); if (!(await hasPdfSignature(pdfTemp))) throw new Error('Compiler produced an invalid PDF'); await writeFile(logTemp, log, { mode: 0o600 }); @@ -320,6 +323,7 @@ export class Compiler { await rename(logTemp, logPath); if (synctexPath) { const synctexTemp = `${synctexPath}.${job.id}.tmp`; + temporaryArtifacts.push(synctexTemp); await copyFile(generatedSynctex, synctexTemp); await rename(synctexTemp, synctexPath); } @@ -349,7 +353,7 @@ export class Compiler { const failed = this.get(job.id, project.id)!; this.events.emit(project.id, { type: 'compile.failed', job: publicCompileJob(failed) }); } finally { - await rm(workDir, { recursive: true, force: true }); + await cleanupCompileFiles(workDir, temporaryArtifacts); } } @@ -506,6 +510,17 @@ async function validCachedPdf(path: string): Promise { } } +async function cleanupCompileFiles(workDir: string, temporaryArtifacts: readonly string[]): Promise { + const cleanup = await Promise.allSettled([ + rm(workDir, { recursive: true, force: true }), + ...temporaryArtifacts.map(path => rm(path, { force: true })) + ]); + // Drain every cleanup operation before releasing the job's admission slot. + // Final cache artifacts and other jobs' temporary files are never removed. + const failure = cleanup.find(result => result.status === 'rejected'); + if (failure?.status === 'rejected') throw failure.reason; +} + async function readOptional(path: string): Promise { try { return await readFile(path, 'utf8'); diff --git a/apps/server/tests/compiler-cleanup.test.ts b/apps/server/tests/compiler-cleanup.test.ts new file mode 100644 index 0000000..93ce468 --- /dev/null +++ b/apps/server/tests/compiler-cleanup.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, sep } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { buildApp } from '../src/app.js'; +import { config } from '../src/config.js'; +import type { AppContext } from '../src/context.js'; + +type Failure = 'pdf-copy' | 'invalid-pdf' | 'log-write' | 'pdf-rename' | 'log-rename' | 'synctex-copy' | 'synctex-rename'; +const io = vi.hoisted(() => ({ failure: null as Failure | null, injected: false })); +vi.mock('node:fs/promises', async (importOriginal) => { + const fs = await importOriginal(); + function fail(type: Failure, path: string) { + if (io.failure === type && !io.injected && path.includes(`${sep}compile-cache${sep}`)) { + io.injected = true; + throw new Error(`Controlled ${type} failure`); + } + } + return { ...fs, + copyFile: async (...args: Parameters) => { + await fs.copyFile(...args); + const destination = String(args[1]); + if (destination.includes('.pdf.') && destination.endsWith('.tmp')) { + if (io.failure === 'invalid-pdf' && !io.injected) { + io.injected = true; + await fs.writeFile(destination, 'Invalid compiler output'); + } + fail('pdf-copy', destination); + } + if (destination.includes('.synctex.gz.') && destination.endsWith('.tmp')) fail('synctex-copy', destination); + }, + writeFile: async (...args: Parameters) => { + await fs.writeFile(...args); + const path = String(args[0]); + if (path.includes(`${sep}jobs${sep}`) && path.endsWith(`${sep}.overroot-output${sep}main.pdf`)) { + // The fake compiler normally has no SyncTeX. Supply an artifact so the + // real publication path can exercise copy/rename failures for it too. + await fs.writeFile(join(dirname(path), 'main.synctex.gz'), Buffer.from('SyncTeX publication fixture')); + } + if (path.includes('.log.') && path.endsWith('.tmp')) fail('log-write', path); + }, + rename: async (...args: Parameters) => { + const destination = String(args[1]); + if (destination.endsWith('.pdf')) fail('pdf-rename', destination); + if (destination.endsWith('.log')) fail('log-rename', destination); + if (destination.endsWith('.synctex.gz')) fail('synctex-rename', destination); + return fs.rename(...args); + } + }; +}); + +const previous = { fake: config.fakeCompiler, jobs: config.compileMaxJobs }; +let app: Awaited>['app']; +let context: AppContext; +let dir: string; +beforeEach(() => { + config.fakeCompiler = true; + config.compileMaxJobs = 1; +}); +afterEach(async () => { + io.failure = null; + io.injected = false; + if (app) await app.close(); + config.fakeCompiler = previous.fake; + config.compileMaxJobs = previous.jobs; + if (dir) await rm(dir, { recursive: true, force: true }); +}); + +it.each(['pdf-copy', 'invalid-pdf', 'log-write', 'pdf-rename', 'log-rename', 'synctex-copy', 'synctex-rename'])( + 'cleans only owned temporary artifacts after %s and recovers the queue', async failure => { + dir = await mkdtemp(join(tmpdir(), 'overroot-cleanup-')); + ({ app, context } = await buildApp({ databasePath: join(dir, 'test.sqlite3'), logger: false })); + const user = await context.auth.createUser('cleanup-test', 'cleanup-test-password', true); + const actor = { type: 'user' as const, id: user.id, name: user.username }; + const project = context.projects.createImportedProject(user.id, 'Cleanup recovery', [ + { path: 'main.tex', content: Buffer.from('First revision') } + ]); + const cacheDir = join(dir, 'compile-cache'); + const unownedTemp = join(cacheDir, 'another-job.pdf.keep.tmp'); + await writeFile(unownedTemp, 'Another pending job owns this file'); + io.failure = failure; + const failed = await context.compiler.request(project, actor); + await vi.waitFor(() => expect(context.compiler.get(failed.id, project.id)?.status).toBe('failed')); + expect(io.injected).toBe(true); + expect(context.compiler.get(failed.id, project.id)?.error).toContain( + failure === 'invalid-pdf' ? 'Compiler produced an invalid PDF' : `Controlled ${failure} failure` + ); + await vi.waitFor(async () => { + expect((await readdir(cacheDir)).filter(path => path.endsWith(`.${failed.id}.tmp`))).toEqual([]); + expect(await readdir(join(dir, 'jobs'))).toEqual([]); + }); + expect(await readFile(unownedTemp, 'utf8')).toBe('Another pending job owns this file'); + // If publication advanced past the PDF rename, cleanup must preserve that + // final cache artifact, rather than deleting a path shared by other jobs. + if (['log-rename', 'synctex-copy', 'synctex-rename'].includes(failure)) { + expect((await readFile(join(cacheDir, `${failed.source_hash}.pdf`))).subarray(0, 5).toString()).toBe('%PDF-'); + } + io.failure = null; + context.projects.saveFile(context.projects.getFile(project.id, 'main.tex')!.id, Buffer.from('Retry revision'), actor); + const retry = await vi.waitFor(() => context.compiler.request(project, actor)); + await context.compiler.close(); + const complete = context.compiler.get(retry.id, project.id)!; + expect(complete.status, complete.error ?? complete.log).toBe('succeeded'); + expect((await readFile(complete.pdf_path!)).subarray(0, 5).toString()).toBe('%PDF-'); + expect((await stat(complete.synctex_path!)).isFile()).toBe(true); + expect((await readdir(cacheDir)).filter(path => path.endsWith(`.${retry.id}.tmp`))).toEqual([]); + } +); From 245991ff74216cf4faa245ed5eb1ac7cebd241bf Mon Sep 17 00:00:00 2001 From: Slavov88 Date: Mon, 19 Oct 2026 21:02:32 +0300 Subject: [PATCH 3/3] Reject truncated compiler PDFs with bounded footer checks --- apps/server/src/compile-artifacts.ts | 45 ++++++++++++++---- apps/server/src/compiler.ts | 6 +-- apps/server/tests/compiler-artifacts.test.ts | 50 ++++++++++++++++++-- apps/server/tests/compiler-cache.test.ts | 2 +- apps/server/tests/compiler-cleanup.test.ts | 21 ++++++-- 5 files changed, 105 insertions(+), 19 deletions(-) diff --git a/apps/server/src/compile-artifacts.ts b/apps/server/src/compile-artifacts.ts index 3aed042..34838b6 100644 --- a/apps/server/src/compile-artifacts.ts +++ b/apps/server/src/compile-artifacts.ts @@ -1,17 +1,46 @@ -import { open } from 'node:fs/promises'; +import { open, type FileHandle } from 'node:fs/promises'; + +const PDF_TAIL_BYTES = 1024; +const PDF_WHITESPACE = new Set([0, 9, 10, 12, 13, 32]); export async function hasPdfSignature(path: string): Promise { const file = await open(path, 'r'); try { const signature = Buffer.alloc(5); - let offset = 0; - while (offset < signature.length) { - const { bytesRead } = await file.read(signature, offset, signature.length - offset, offset); - if (!bytesRead) return false; - offset += bytesRead; - } - return signature.toString('utf8') === '%PDF-'; + return await readAt(file, signature, 0) && signature.toString('utf8') === '%PDF-'; + } finally { + await file.close(); + } +} + +// A bounded completeness check for compiler-generated artifacts. This rejects +// missing headers/terminal EOF markers, without claiming semantic PDF validation. +export async function hasPdfEnvelope(path: string): Promise { + const file = await open(path, 'r'); + try { + const signature = Buffer.alloc(5); + if (!await readAt(file, signature, 0) || signature.toString('utf8') !== '%PDF-') return false; + const { size } = await file.stat(); + const tailPosition = Math.max(signature.length, size - PDF_TAIL_BYTES); + if (size - tailPosition < 5) return false; + const tail = Buffer.alloc(size - tailPosition); + if (!await readAt(file, tail, tailPosition)) return false; + let end = tail.length; + while (end > 0 && PDF_WHITESPACE.has(tail[end - 1]!)) end--; + const marker = end - 5; + return marker >= 0 && tail.subarray(marker, end).toString('latin1') === '%%EOF' && + (marker === 0 || tail[marker - 1] === 10 || tail[marker - 1] === 13); } finally { await file.close(); } } + +async function readAt(file: FileHandle, buffer: Buffer, position: number): Promise { + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, position + offset); + if (!bytesRead) return false; + offset += bytesRead; + } + return true; +} diff --git a/apps/server/src/compiler.ts b/apps/server/src/compiler.ts index 92f0c64..f666f60 100644 --- a/apps/server/src/compiler.ts +++ b/apps/server/src/compiler.ts @@ -9,7 +9,7 @@ import { HttpError } from './errors.js'; import type { ProjectStore } from './project-store.js'; import type { ActorIdentity, ProjectRecord } from './types.js'; import { stageCompileSources, type CompileSource } from './compile-staging.js'; -import { hasPdfSignature } from './compile-artifacts.js'; +import { hasPdfEnvelope } from './compile-artifacts.js'; export interface CompileJob { id: string; @@ -317,7 +317,7 @@ export class Compiler { // Register before writing: even a failed copy can leave a partial file. temporaryArtifacts.push(pdfTemp, logTemp); await copyFile(generatedPdf, pdfTemp); - if (!(await hasPdfSignature(pdfTemp))) throw new Error('Compiler produced an invalid PDF'); + if (!(await hasPdfEnvelope(pdfTemp))) throw new Error('Compiler produced an invalid PDF'); await writeFile(logTemp, log, { mode: 0o600 }); await rename(pdfTemp, pdfPath); await rename(logTemp, logPath); @@ -501,7 +501,7 @@ async function fileExists(path: string): Promise { async function validCachedPdf(path: string): Promise { try { - return await hasPdfSignature(path); + return await hasPdfEnvelope(path); } catch (error) { // A cache artifact can disappear between stat and open. Recompile the // already frozen snapshot instead of reporting a successful broken cache hit. diff --git a/apps/server/tests/compiler-artifacts.test.ts b/apps/server/tests/compiler-artifacts.test.ts index 97736a4..77fb87b 100644 --- a/apps/server/tests/compiler-artifacts.test.ts +++ b/apps/server/tests/compiler-artifacts.test.ts @@ -2,11 +2,12 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, expect, it, vi } from 'vitest'; -import { hasPdfSignature } from '../src/compile-artifacts.js'; +import { hasPdfEnvelope, hasPdfSignature } from '../src/compile-artifacts.js'; const control = vi.hoisted(() => ({ - read: null as (() => Promise) | null, + read: null as ((position: number) => Promise) | null, reads: [] as Array<{ offset: number; length: number; position: number }>, + bytesRead: 0, closes: 0 })); vi.mock('node:fs/promises', async (importOriginal) => { @@ -16,10 +17,13 @@ vi.mock('node:fs/promises', async (importOriginal) => { return { read: async (buffer: Buffer, offset: number, length: number, position: number) => { control.reads.push({ offset, length, position }); - if (control.read) return control.read(); + await control.read?.(position); // Exercise legal short reads too. - return handle.read(buffer, offset, Math.min(length, 2), position); + const result = await handle.read(buffer, offset, Math.min(length, 2), position); + control.bytesRead += result.bytesRead; + return result; }, + stat: () => handle.stat(), close: async () => { control.closes++; await handle.close(); } }; } }; @@ -29,6 +33,7 @@ let dir: string; afterEach(async () => { control.read = null; control.reads.length = 0; + control.bytesRead = 0; control.closes = 0; if (dir) await rm(dir, { recursive: true, force: true }); }); @@ -62,3 +67,40 @@ it('closes the PDF handle if reading fails', async () => { await expect(hasPdfSignature(path)).rejects.toThrow('Controlled read failure'); expect(control.closes).toBe(1); }); + +it.each(['%PDF-1.7\ntruncated', '%PDF-1.7\n%%EO', '%PDF-1.7\n%%EOF\ntrailing', 'not PDF\n%%EOF\n'])( + 'rejects an incomplete compiler artifact %j', async content => { + dir = await mkdtemp(join(tmpdir(), 'overroot-pdf-')); + const path = join(dir, 'incomplete.pdf'); + await writeFile(path, content); + expect(await hasPdfEnvelope(path)).toBe(false); + expect(control.closes).toBe(1); + } +); + +it.each(['\n', '\r\n', '\n \t\f\r\n', '\n\0'])('accepts a terminal compiler PDF marker followed by %j', async whitespace => { + dir = await mkdtemp(join(tmpdir(), 'overroot-pdf-')); + const path = join(dir, 'envelope.pdf'); + await writeFile(path, `%PDF-1.7\ntrailer\n<< /Size 1 >>\nstartxref\n9\n%%EOF${whitespace}`); + expect(await hasPdfEnvelope(path)).toBe(true); + expect(control.closes).toBe(1); +}); + +it('checks only five header bytes and at most 1024 tail bytes even with short reads', async () => { + dir = await mkdtemp(join(tmpdir(), 'overroot-pdf-')); + const path = join(dir, 'large.pdf'); + await writeFile(path, Buffer.concat([Buffer.from('%PDF-1.7\n'), Buffer.alloc(1024 * 1024), Buffer.from('\nstartxref\n9\n%%EOF\n')])); + expect(await hasPdfEnvelope(path)).toBe(true); + expect(control.bytesRead).toBe(5 + 1024); + expect(control.closes).toBe(1); +}); + +it('closes the PDF handle if reading the footer fails after a valid header', async () => { + dir = await mkdtemp(join(tmpdir(), 'overroot-pdf-')); + const path = join(dir, 'footer-failure.pdf'); + await writeFile(path, '%PDF-1.7\nstartxref\n9\n%%EOF\n'); + control.read = async position => { if (position >= 5) throw new Error('Controlled footer read failure'); }; + await expect(hasPdfEnvelope(path)).rejects.toThrow('Controlled footer read failure'); + expect(control.bytesRead).toBe(5); + expect(control.closes).toBe(1); +}); diff --git a/apps/server/tests/compiler-cache.test.ts b/apps/server/tests/compiler-cache.test.ts index c2bb0b8..c01199f 100644 --- a/apps/server/tests/compiler-cache.test.ts +++ b/apps/server/tests/compiler-cache.test.ts @@ -61,7 +61,7 @@ async function setup() { return { request, project, actor, original, pdf, events }; } -it.each(['', '%PD', 'broken cache artifact'])('recompiles a corrupt cached PDF %j and reuses the repaired output', async corruption => { +it.each(['', '%PD', 'broken cache artifact', '%PDF-1.7\ntruncated'])('recompiles a corrupt cached PDF %j and reuses the repaired output', async corruption => { const h = await setup(); await writeFile(h.original.pdf_path!, corruption); const repaired = await h.request(); diff --git a/apps/server/tests/compiler-cleanup.test.ts b/apps/server/tests/compiler-cleanup.test.ts index 93ce468..b05bd1b 100644 --- a/apps/server/tests/compiler-cleanup.test.ts +++ b/apps/server/tests/compiler-cleanup.test.ts @@ -6,8 +6,8 @@ import { buildApp } from '../src/app.js'; import { config } from '../src/config.js'; import type { AppContext } from '../src/context.js'; -type Failure = 'pdf-copy' | 'invalid-pdf' | 'log-write' | 'pdf-rename' | 'log-rename' | 'synctex-copy' | 'synctex-rename'; -const io = vi.hoisted(() => ({ failure: null as Failure | null, injected: false })); +type Failure = 'pdf-copy' | 'invalid-pdf' | 'footer-read' | 'log-write' | 'pdf-rename' | 'log-rename' | 'synctex-copy' | 'synctex-rename'; +const io = vi.hoisted(() => ({ failure: null as Failure | null, injected: false, closes: 0 })); vi.mock('node:fs/promises', async (importOriginal) => { const fs = await importOriginal(); function fail(type: Failure, path: string) { @@ -17,6 +17,19 @@ vi.mock('node:fs/promises', async (importOriginal) => { } } return { ...fs, + open: async (...args: Parameters) => { + const handle = await fs.open(...args); + const path = String(args[0]); + if (io.failure !== 'footer-read' || !path.endsWith('.tmp')) return handle; + return { + read: (buffer: Buffer, offset: number, length: number, position: number) => { + if (position >= 5) fail('footer-read', path); + return handle.read(buffer, offset, length, position); + }, + stat: () => handle.stat(), + close: async () => { io.closes++; await handle.close(); } + }; + }, copyFile: async (...args: Parameters) => { await fs.copyFile(...args); const destination = String(args[1]); @@ -60,13 +73,14 @@ beforeEach(() => { afterEach(async () => { io.failure = null; io.injected = false; + io.closes = 0; if (app) await app.close(); config.fakeCompiler = previous.fake; config.compileMaxJobs = previous.jobs; if (dir) await rm(dir, { recursive: true, force: true }); }); -it.each(['pdf-copy', 'invalid-pdf', 'log-write', 'pdf-rename', 'log-rename', 'synctex-copy', 'synctex-rename'])( +it.each(['pdf-copy', 'invalid-pdf', 'footer-read', 'log-write', 'pdf-rename', 'log-rename', 'synctex-copy', 'synctex-rename'])( 'cleans only owned temporary artifacts after %s and recovers the queue', async failure => { dir = await mkdtemp(join(tmpdir(), 'overroot-cleanup-')); ({ app, context } = await buildApp({ databasePath: join(dir, 'test.sqlite3'), logger: false })); @@ -82,6 +96,7 @@ it.each(['pdf-copy', 'invalid-pdf', 'log-write', 'pdf-rename', 'log-ren const failed = await context.compiler.request(project, actor); await vi.waitFor(() => expect(context.compiler.get(failed.id, project.id)?.status).toBe('failed')); expect(io.injected).toBe(true); + if (failure === 'footer-read') expect(io.closes).toBe(1); expect(context.compiler.get(failed.id, project.id)?.error).toContain( failure === 'invalid-pdf' ? 'Compiler produced an invalid PDF' : `Controlled ${failure} failure` );