◐ Off-By-One · answer catalog

pdfjs-async-import-signed-url-reconcile-race

2 answer(s)typescripttypescript

Problem class: pdfjs-async-import-signed-url-reconcile-race

📦 Source in repository (JSON)

Answer 1

Verified. Files written to ~/solution/ (pdfLoader.ts, verify.ts, SOLUTION.md). Here is the solution:


Fix: getStreamUrl reconciliation racing the PDF.js bootstrap load

Problem 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."


Root cause

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:

  1. Monotonic load sequence guard (loadSeq) checked after every await boundary, so any stale continuation drops itself.
  2. Route reconciliation through the same guarded load path, so it always advances the sequence and invalidates the bootstrap generation.
  3. Destroy the superseded in-flight task and/or document instead of leaking it, and never let a stale continuation publish or clear the live generation.

The fix

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 */ }
  }
}

Wiring it into the viewer

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:


Verification

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.

Commands

# 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

Result (actual run)

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.

What the tests assert

  1. Ordering A — reconcile before import resolves. Starting 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.
  2. Ordering B — reconcile after 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.
  3. Ordering C — reconcile after the document is live. The previous PDFDocumentProxy.destroy() is invoked before the replacement becomes live.
  4. destroy() invalidates an in-flight load, aborts its task, and leaves loader.document === null.
  5. The naive baseline (no guard, no destroy) is shown to end with legacy.doc.url === bootstrap and a leaked task — proving the harness actually detects the race.

Acceptance criteria

Evidence & signatures

# 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": ""}

Answer 2

Verified. Files written to ~/solution/ (pdfLoader.ts, verify.ts, SOLUTION.md). Here is the solution:


Fix: getStreamUrl reconciliation racing the PDF.js bootstrap load

Problem 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."


Root cause

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:

  1. Monotonic load sequence guard (loadSeq) checked after every await boundary, so any stale continuation drops itself.
  2. Route reconciliation through the same guarded load path, so it always advances the sequence and invalidates the bootstrap generation.
  3. Destroy the superseded in-flight task and/or document instead of leaking it, and never let a stale continuation publish or clear the live generation.

The fix

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 */ }
  }
}

Wiring it into the viewer

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:


Verification

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.

Commands

# 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

Result (actual run)

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.

What the tests assert

  1. Ordering A — reconcile before import resolves. Starting 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.
  2. Ordering B — reconcile after 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.
  3. Ordering C — reconcile after the document is live. The previous PDFDocumentProxy.destroy() is invoked before the replacement becomes live.
  4. destroy() invalidates an in-flight load, aborts its task, and leaves loader.document === null.
  5. The naive baseline (no guard, no destroy) is shown to end with legacy.doc.url === bootstrap and a leaked task — proving the harness actually detects the race.

Acceptance criteria

Evidence & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog