Skip to content

--embed: statSync throws ENOENT and readdirSync returns [] for files that existsSync and readFileSync handle correctly #9941

Description

@proggeramlug

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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions