Skip to content

Fix race condition on large invoices - #9

Open
valeriansaliou wants to merge 1 commit into
baptistejamin:masterfrom
crisp-dev:master
Open

Fix race condition on large invoices#9
valeriansaliou wants to merge 1 commit into
baptistejamin:masterfrom
crisp-dev:master

Conversation

@valeriansaliou

@valeriansaliou valeriansaliou commented Aug 7, 2026

Copy link
Copy Markdown

Seeing on Crisp billing system frequent race condition (about ~80 at last billing cycle) where the invoice PDF gets written to disk by this library, and then immediately read for Factur-X stamping. But the PDF file is not yet fully written, and fails parsing.

We did not see this error before adding Factur-X, or maybe it got silenced as users were not complaining about seeing invalid invoices uploaded to S3. The Factur-X is very strict, so it throws right away.

Seeing errors like Error: Failed to parse PDF document (line:145 col:24 offset=2367): Failed to parse invalid PDF object from the Factur-X library. Re-playing the event shows that it works (re-generating the invoice and re-stamping Factur-X). So I suspected a race condition on the return of MicroInvoice.generate().

Claude Code identified a race condition on using the close vs end event (we need to wait that all buffers are flushed to disk, that is, the file is fully closed to return the Promise).

Claude managed to reproduce it w/o the fix, and confirm that the fix works.


Claude Code output & fix:

The cause

The promise resolves on this.document.on("end", ...). this.document is a PDFKit document, which is a Readable stream. Its "end" event fires when the last chunk has been read out of the document — i.e. handed to the pipe. It says nothing about whether fs.createWriteStream(filePath) has flushed those bytes through the libuv threadpool and closed the fd.

So the ordering is:

  1. document emits "end" → your promise resolves
  2. …some time later, the write stream drains its internal buffer to disk
  3. write stream emits "finish", then closes the fd

Between 1 and 3 you can fs.readFile() the path and get a truncated file — which parses as invalid PDF (the xref table and %%EOF trailer are at the end of the file, so a truncated PDF is exactly the failure mode you're seeing). Bigger invoices = more buffered data = wider window.

Second, smaller issue: this.document.end() is called on line 307, before the listeners are attached on line 314. PDFKit defers the actual flush, so in practice it works, but it's fragile. And reject() passes no error, plus write-stream errors (ENOSPC, EACCES, bad directory) are never observed at all — a failed write resolves or hangs rather than rejecting.

Confirmed — and it's not subtle

I built a 120-line invoice, awaited generate({type: "file", path}), then immediately readFileSync'd it:

┌─────────────────┬────────────┬─────────────────────┐
│                 │ bytes read │      valid PDF      │
├─────────────────┼────────────┼─────────────────────┤
│ master (v2.0.2) │ 0          │ ✗ no %PDF, no %%EOF │
├─────────────────┼────────────┼─────────────────────┤
│ with fix        │ 8179       │ ✓                   │
└─────────────────┴────────────┴─────────────────────┘

On the current release the awaited promise resolved with nothing yet flushed to disk. Your intermittent "invalid PDF data" is exactly this — you're racing the write stream, and whether you get 0 bytes, a truncated file, or (luckily) a complete one depends on scheduling and invoice size.

What changed in lib/index.ts

  • Resolve on _stream.on("close") instead of document.on("end"). close is the only event that guarantees the bytes are flushed and the fd is closed. (finish would also work but leaves the fd briefly open, which can bite on Windows.)
  • Attach listeners before pipe()/end(), not after. The old code called document.end() on line 307 and only built the promise on line 313 — it happened to work because PDFKit defers its flush, but there was no guarantee.
  • Propagate real errors. _stream's error is now observed, so ENOSPC/EACCES/nonexistent-directory reject instead of hanging forever. reject() also now passes the actual Error rather than undefined.

The non-file branch (generate() returning the raw PDFKit.PDFDocument) is untouched, and tsc --noEmit passes.

One note on your call site: since generate() is overloaded, the {type: "file"} path returns Promise — so return new MicroInvoice(...).generate(...) is correct as long as the caller awaits it. Once you pick up this fix, awaiting it will actually mean what you expect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant