Summary
For a file embedded with --embed, existsSync() and readFileSync() both
work correctly, but statSync() throws ENOENT and readdirSync() returns an
empty array. So an embedded file simultaneously exists, reads back with exactly
the right bytes, and cannot be stat'd or listed.
Environment
|
|
| Perry |
d36a1af0c (2026-09-05), reports 0.5.1520 |
| Platform |
Linux x86_64 |
| Build |
perry compile probe.ts --embed "src/assets/**" -o probe |
Repro
import { existsSync, statSync, readFileSync, readdirSync } from "node:fs";
const p = "$perryfs/src/assets/documents/document.typ";
console.log("exists ", existsSync(p));
console.log("read ", readFileSync(p).length);
console.log("stat ", (() => { try { return statSync(p).size; } catch (e) { return String(e); } })());
console.log("readdir ", readdirSync("$perryfs/src/assets/documents"));
Observed:
$perryfs/src/assets/documents/document.typ exists=true stat=THREW ENOENT read=10493
$perryfs/src/assets/documents/logo.png exists=true stat=THREW ENOENT read=27234
$perryfs/src/assets/documents/fonts/Inter-Regular.ttf exists=true stat=THREW ENOENT read=411640
readdir $perryfs → []
readdir $perryfs/src → []
readdir $perryfs/src/assets → []
readdir $perryfs/src/assets/documents → []
The three read lengths are byte-exact against the files on disk, so the
content is embedded correctly and reachable — only the metadata calls disagree.
readdirSync returning [] rather than throwing is the more dangerous half:
code that enumerates a directory gets "the directory is empty" instead of an
error, which is a silent wrong answer rather than a failure.
Impact
Any "copy the embedded assets out to disk" step breaks, which is the required
pattern whenever a sidecar process needs the files — it cannot see the
embedded filesystem, so the binary has to write them out first.
That is exactly our case: we render PDFs by spawning typst, and materialising
the template plus fonts used stat(source) to skip files already present at the
same size. Under Node it works; on the deployed binary every render failed with
scheduler: job "archiveInvoicePdfs" failed
Error: ENOENT: No such file or directory (os error 2), stat '$perryfs/src/assets/documents/document.typ'
once per scheduler tick, so no invoice PDF was ever produced. existsSync
returning true is what makes it land here rather than on the source-tree
fallback path.
Workaround
Read the bytes and use .length instead of stat().size. Filed so the
workaround can be removed rather than becoming folklore.
Summary
For a file embedded with
--embed,existsSync()andreadFileSync()bothwork correctly, but
statSync()throwsENOENTandreaddirSync()returns anempty array. So an embedded file simultaneously exists, reads back with exactly
the right bytes, and cannot be stat'd or listed.
Environment
d36a1af0c(2026-09-05), reports0.5.1520perry compile probe.ts --embed "src/assets/**" -o probeRepro
Observed:
The three
readlengths are byte-exact against the files on disk, so thecontent is embedded correctly and reachable — only the metadata calls disagree.
readdirSyncreturning[]rather than throwing is the more dangerous half:code that enumerates a directory gets "the directory is empty" instead of an
error, which is a silent wrong answer rather than a failure.
Impact
Any "copy the embedded assets out to disk" step breaks, which is the required
pattern whenever a sidecar process needs the files — it cannot see the
embedded filesystem, so the binary has to write them out first.
That is exactly our case: we render PDFs by spawning
typst, and materialisingthe template plus fonts used
stat(source)to skip files already present at thesame size. Under Node it works; on the deployed binary every render failed with
once per scheduler tick, so no invoice PDF was ever produced.
existsSyncreturning true is what makes it land here rather than on the source-tree
fallback path.
Workaround
Read the bytes and use
.lengthinstead ofstat().size. Filed so theworkaround can be removed rather than becoming folklore.