Problem class: pdfjs-async-import-signed-url-reconcile-race
Verified. Files written to ~/solution/ (pdfLoader.ts, verify.ts, SOLUTION.md). Here is the solution:
getStreamUrl reconciliation racing the PDF.js bootstrap loadProblem class: pdfjs-async-import-signed-url-reconcile-race
Symptom: "getStreamUrl reconciliation could supersede bootstrap URL while module import or document load was still in flight."
The viewer bootstraps from a signed bootstrapUrl, then asynchronously calls getStreamUrl() to obtain a fresh signed URL and "reconciles" the viewer onto it. The load path has several suspension points:
const pdfjs = await import("pdfjs-dist"); // suspension #1
const task = pdfjs.getDocument({ url }); // old URL captured here
const doc = await task.promise; // suspension #2
this.doc = doc; // unconditional publish
A guard that compares only the current URL string is not sufficient. Once getDocument({ url }) has been called, changing this.url does not abort the already-created loading task. That task can resolve later and overwrite the newer document, while the superseded task/document is never destroyed. Three concrete interleavings:
| Ordering | What happens without a generation guard |
|---|---|
Reconciliation resolves before await import() returns |
Bootstrap continuation resumes with the stale captured url and calls getDocument on it. |
Reconciliation resolves after getDocument but before task.promise resolves |
Bootstrap task is not aborted. If it resolves last, this.doc = bootstrapDoc clobbers the reconciled document. Both tasks leak. |
| Reconciliation arrives after the bootstrap document is live | Old PDFDocumentProxy is never destroyed (worker/memory leak), and a late bootstrap continuation can still republish. |
The fix must do three things:
loadSeq) checked after every await boundary, so any stale continuation drops itself.pdfLoader.ts — drop-in, framework-agnostic, no PDF.js import at module scope (works with dynamic import()):
export interface PdfDocumentProxy {
destroy(): Promise<void>;
[key: string]: unknown;
}
export interface PDFDocumentLoadingTask {
promise: Promise<PdfDocumentProxy>;
destroy(): Promise<void>;
}
export interface PdfjsModule {
getDocument(src: { url: string }): PDFDocumentLoadingTask;
}
export type PdfjsImporter = () => Promise<PdfjsModule>;
interface Generation {
seq: number;
task: PDFDocumentLoadingTask;
doc?: PdfDocumentProxy;
}
export class PdfLoader {
/** Monotonic generation counter. Never reused, never reset. */
private loadSeq = 0;
private current: Generation | null = null;
constructor(private readonly importPdfjs: PdfjsImporter) {}
get document(): PdfDocumentProxy | null {
return this.current?.doc ?? null;
}
/** Bootstrap load or refresh load. Superseded by any later load/reconcile. */
load(url: string): Promise<PdfDocumentProxy | null> {
return this.run(url);
}
/** Called when getStreamUrl() resolves. Same guard as load(), never raced. */
reconcile(url: string): Promise<PdfDocumentProxy | null> {
return this.run(url);
}
private async run(url: string): Promise<PdfDocumentProxy | null> {
// 1. Claim the newest generation and invalidate the previous one.
const seq = ++this.loadSeq;
const previous = this.current;
this.current = null;
await this.dispose(previous);
if (seq !== this.loadSeq) return null; // newer generation won during dispose
// 2. Async module import — check the guard afterwards.
const pdfjs = await this.importPdfjs();
if (seq !== this.loadSeq) return null; // stale: superseded during import
// 3. Start the loading task and publish this generation.
const task = pdfjs.getDocument({ url });
this.current = { seq, task };
let doc: PdfDocumentProxy;
try {
doc = await task.promise;
} catch (err) {
if (seq !== this.loadSeq) return null; // aborted on purpose by a newer gen
this.current = null;
throw err;
}
// 4. Document resolved — check the guard once more.
if (seq !== this.loadSeq) {
await this.dispose({ seq, task, doc }); // stale: never clobber the live doc
return null;
}
this.current = { seq, task, doc };
return doc;
}
/** Tear down and permanently invalidate all continuations (call on unmount). */
async destroy(): Promise<void> {
this.loadSeq++;
const previous = this.current;
this.current = null;
await this.dispose(previous);
}
private async dispose(gen: Generation | null): Promise<void> {
if (!gen) return;
if (gen.doc) {
try { await gen.doc.destroy(); } catch { /* already gone */ }
}
try { await gen.task.destroy(); } catch { /* already gone */ }
}
}
const loader = new PdfLoader(() => import("pdfjs-dist"));
// bootstrap — pass the already-known signed URL
await loader.load(bootstrapUrl);
// reconciliation — MUST go through the same loader so it advances loadSeq
const freshUrl = await getStreamUrl();
await loader.reconcile(freshUrl);
// teardown
await loader.destroy();
Key rules:
reconcile(...) so the monotonic guard fires and the old task is destroyed.loadSeq is incremented before any await, so a newer call always wins even if the older one is suspended on a slow import.this.current is cleared before dispose() and only republished while still current, so a losing continuation can never null out or overwrite the winner.task.destroy() aborts an unresolved load (PDF.js rejects with AbortException); a stale generation swallows that rejection and returns null instead of surfacing an error.The verification harness (verify.ts) drives a deterministic fake pdfjs runtime so both reconcile orderings are exercised without timing flakiness. It first reproduces the bug on a naive loader, then asserts the fixed loader's invariants.
# from the directory containing pdfLoader.ts and verify.ts
tsx verify.ts
# strict typecheck of the fix alone
tsc --noEmit --strict --target es2022 --module esnext \
--moduleResolution bundler --skipLibCheck pdfLoader.ts
Legacy (buggy) loader: reproducing the race
PASS Ordering B: late bootstrap document clobbers reconciled document
Fixed loader: monotonic sequence guards
PASS Ordering A: reconcile before module import -> only reconciled URL reaches getDocument
PASS Ordering B: reconcile after getDocument but before resolve -> bootstrap task destroyed, reconciled wins
PASS Ordering C: reconcile after bootstrap document is live -> old document destroyed
PASS destroy(): invalidates an in-flight load and destroys its task
All 5 checks passed.
tsc --strict exits 0.
load(bootstrap) then reconcile(fresh) before releasing the import gate must produce exactly one getDocument call, using the reconciled URL. The bootstrap continuation returns null at the post-import guard.getDocument, before task.promise. The bootstrap loading task must be destroyed === true, the reconciled document becomes loader.document, and a late bootstrap document can never clobber it.PDFDocumentProxy.destroy() is invoked before the replacement becomes live.destroy() invalidates an in-flight load, aborts its task, and leaves loader.document === null.legacy.doc.url === bootstrap and a leaked task — proving the harness actually detects the race.null and mutate no shared state.tsx verify.ts → All 5 checks passed.; tsc --strict → exit 0.# Evidence - Problem class: pdfjs-async-import-signed-url-reconcile-race - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-14T14:40:48.486Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Use monotonic load sequence guards; destroy in-flight loading tasks and documents; drop stale async continuations; test both reconcile orderings.", "environment": "sandboxed PDF.js viewer", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "pdfjs-async-import-signed-url-reconcile-race", "provider": "openrouter", "solved_at": "2026-09-14T14:40:48.486Z", "version": ""}Verified. Files written to ~/solution/ (pdfLoader.ts, verify.ts, SOLUTION.md). Here is the solution:
getStreamUrl reconciliation racing the PDF.js bootstrap loadProblem class: pdfjs-async-import-signed-url-reconcile-race
Symptom: "getStreamUrl reconciliation could supersede bootstrap URL while module import or document load was still in flight."
The viewer bootstraps from a signed bootstrapUrl, then asynchronously calls getStreamUrl() to obtain a fresh signed URL and "reconciles" the viewer onto it. The load path has several suspension points:
const pdfjs = await import("pdfjs-dist"); // suspension #1
const task = pdfjs.getDocument({ url }); // old URL captured here
const doc = await task.promise; // suspension #2
this.doc = doc; // unconditional publish
A guard that compares only the current URL string is not sufficient. Once getDocument({ url }) has been called, changing this.url does not abort the already-created loading task. That task can resolve later and overwrite the newer document, while the superseded task/document is never destroyed. Three concrete interleavings:
| Ordering | What happens without a generation guard |
|---|---|
Reconciliation resolves before await import() returns |
Bootstrap continuation resumes with the stale captured url and calls getDocument on it. |
Reconciliation resolves after getDocument but before task.promise resolves |
Bootstrap task is not aborted. If it resolves last, this.doc = bootstrapDoc clobbers the reconciled document. Both tasks leak. |
| Reconciliation arrives after the bootstrap document is live | Old PDFDocumentProxy is never destroyed (worker/memory leak), and a late bootstrap continuation can still republish. |
The fix must do three things:
loadSeq) checked after every await boundary, so any stale continuation drops itself.pdfLoader.ts — drop-in, framework-agnostic, no PDF.js import at module scope (works with dynamic import()):
export interface PdfDocumentProxy {
destroy(): Promise<void>;
[key: string]: unknown;
}
export interface PDFDocumentLoadingTask {
promise: Promise<PdfDocumentProxy>;
destroy(): Promise<void>;
}
export interface PdfjsModule {
getDocument(src: { url: string }): PDFDocumentLoadingTask;
}
export type PdfjsImporter = () => Promise<PdfjsModule>;
interface Generation {
seq: number;
task: PDFDocumentLoadingTask;
doc?: PdfDocumentProxy;
}
export class PdfLoader {
/** Monotonic generation counter. Never reused, never reset. */
private loadSeq = 0;
private current: Generation | null = null;
constructor(private readonly importPdfjs: PdfjsImporter) {}
get document(): PdfDocumentProxy | null {
return this.current?.doc ?? null;
}
/** Bootstrap load or refresh load. Superseded by any later load/reconcile. */
load(url: string): Promise<PdfDocumentProxy | null> {
return this.run(url);
}
/** Called when getStreamUrl() resolves. Same guard as load(), never raced. */
reconcile(url: string): Promise<PdfDocumentProxy | null> {
return this.run(url);
}
private async run(url: string): Promise<PdfDocumentProxy | null> {
// 1. Claim the newest generation and invalidate the previous one.
const seq = ++this.loadSeq;
const previous = this.current;
this.current = null;
await this.dispose(previous);
if (seq !== this.loadSeq) return null; // newer generation won during dispose
// 2. Async module import — check the guard afterwards.
const pdfjs = await this.importPdfjs();
if (seq !== this.loadSeq) return null; // stale: superseded during import
// 3. Start the loading task and publish this generation.
const task = pdfjs.getDocument({ url });
this.current = { seq, task };
let doc: PdfDocumentProxy;
try {
doc = await task.promise;
} catch (err) {
if (seq !== this.loadSeq) return null; // aborted on purpose by a newer gen
this.current = null;
throw err;
}
// 4. Document resolved — check the guard once more.
if (seq !== this.loadSeq) {
await this.dispose({ seq, task, doc }); // stale: never clobber the live doc
return null;
}
this.current = { seq, task, doc };
return doc;
}
/** Tear down and permanently invalidate all continuations (call on unmount). */
async destroy(): Promise<void> {
this.loadSeq++;
const previous = this.current;
this.current = null;
await this.dispose(previous);
}
private async dispose(gen: Generation | null): Promise<void> {
if (!gen) return;
if (gen.doc) {
try { await gen.doc.destroy(); } catch { /* already gone */ }
}
try { await gen.task.destroy(); } catch { /* already gone */ }
}
}
const loader = new PdfLoader(() => import("pdfjs-dist"));
// bootstrap — pass the already-known signed URL
await loader.load(bootstrapUrl);
// reconciliation — MUST go through the same loader so it advances loadSeq
const freshUrl = await getStreamUrl();
await loader.reconcile(freshUrl);
// teardown
await loader.destroy();
Key rules:
reconcile(...) so the monotonic guard fires and the old task is destroyed.loadSeq is incremented before any await, so a newer call always wins even if the older one is suspended on a slow import.this.current is cleared before dispose() and only republished while still current, so a losing continuation can never null out or overwrite the winner.task.destroy() aborts an unresolved load (PDF.js rejects with AbortException); a stale generation swallows that rejection and returns null instead of surfacing an error.The verification harness (verify.ts) drives a deterministic fake pdfjs runtime so both reconcile orderings are exercised without timing flakiness. It first reproduces the bug on a naive loader, then asserts the fixed loader's invariants.
# from the directory containing pdfLoader.ts and verify.ts
tsx verify.ts
# strict typecheck of the fix alone
tsc --noEmit --strict --target es2022 --module esnext \
--moduleResolution bundler --skipLibCheck pdfLoader.ts
Legacy (buggy) loader: reproducing the race
PASS Ordering B: late bootstrap document clobbers reconciled document
Fixed loader: monotonic sequence guards
PASS Ordering A: reconcile before module import -> only reconciled URL reaches getDocument
PASS Ordering B: reconcile after getDocument but before resolve -> bootstrap task destroyed, reconciled wins
PASS Ordering C: reconcile after bootstrap document is live -> old document destroyed
PASS destroy(): invalidates an in-flight load and destroys its task
All 5 checks passed.
tsc --strict exits 0.
load(bootstrap) then reconcile(fresh) before releasing the import gate must produce exactly one getDocument call, using the reconciled URL. The bootstrap continuation returns null at the post-import guard.getDocument, before task.promise. The bootstrap loading task must be destroyed === true, the reconciled document becomes loader.document, and a late bootstrap document can never clobber it.PDFDocumentProxy.destroy() is invoked before the replacement becomes live.destroy() invalidates an in-flight load, aborts its task, and leaves loader.document === null.legacy.doc.url === bootstrap and a leaked task — proving the harness actually detects the race.null and mutate no shared state.tsx verify.ts → All 5 checks passed.; tsc --strict → exit 0.# Evidence - Problem class: pdfjs-async-import-signed-url-reconcile-race - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-14T14:40:48.486Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Use monotonic load sequence guards; destroy in-flight loading tasks and documents; drop stale async continuations; test both reconcile orderings.", "environment": "sandboxed PDF.js viewer", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "pdfjs-async-import-signed-url-reconcile-race", "provider": "openrouter", "solved_at": "2026-09-14T14:40:48.486Z", "version": ""}