Repository navigation
Native classes (Dirent, Readable, Writable) returned with no instance methods on prototype #631
Description
Repro
import * as fs from "node:fs";
import { Readable } from "node:stream";
// fs.readdirSync withFileTypes:true should return Dirent[]
const ents = fs.readdirSync("/tmp", { withFileTypes: true });
console.log("entry[0] typeof:", typeof ents[0]);
console.log("entry[0] is string:", typeof ents[0] === "string");
console.log("entry[0].isFile type:", typeof (ents[0] as any).isFile);
// Readable.from(...) should return a Readable with .on / .read / .pause
const r = Readable.from([1, 2, 3]);
console.log("Readable.from typeof:", typeof r);
console.log("Readable.from .on type:", typeof (r as any)?.on);
console.log("Readable.from .pipe type:", typeof (r as any)?.pipe);
// new Readable({read}) similarly
const r2 = new Readable({ read() {} });
console.log("new Readable .on type:", typeof (r2 as any)?.on);
console.log("new Readable .pipe type:", typeof (r2 as any)?.pipe);Actual (Perry)
entry[0] typeof: string
entry[0] is string: true
entry[0].isFile type: undefined
Readable.from typeof: undefined
Readable.from .on type: undefined
Readable.from .pipe type: undefined
new Readable .on type: undefined
new Readable .pipe type: undefined
Expected (Node)
entry[0] typeof: object
entry[0] is string: false
entry[0].isFile type: function
Readable.from typeof: object
Readable.from .on type: function
Readable.from .pipe type: function
new Readable .on type: function
new Readable .pipe type: function
Impact
Two related symptoms:
-
fs.readdirSync(path, { withFileTypes: true })silently ignores the option and returns string entries instead of Dirent objects. Code patterns likeconst dirs = entries.filter(e => e.isDirectory())crash with(string).isDirectory is not a function. -
stream.Readable.from(...)andnew stream.Readable({read})returnundefined(or an object with no prototype methods) — the entire Readable / Writable / Duplex / Transform class hierarchy is unusable downstream, even though the constructors exist. This was the dominant cause oftest-files/test_parity_stream.tsreaching 97% divergence: nothing built on top of these primitives works.
The grouping of these as "one issue" is the architectural shape: native Perry classes that wrap runtime objects need their prototype-method dispatch wired to the underlying instance (the FFI registry / handle table). Without that wiring, the constructor returns either a raw handle (Dirent → string) or undefined (Readable.from), and downstream property access fails.
Acceptance
The repro above prints output byte-identical to Node.
Related
- Surfaced via
test-files/test_parity_fs.ts(Stats / Dirent / fsPromises FileHandle methods all undefined) andtest-files/test_parity_stream.ts(Readable / Writable / Duplex / Transform instance methods all undefined). - May share root cause with typeof process returns "number" instead of "object" (default-import + global) #623 (
typeof processnumeric tag) — a class-instance value getting a wrong NaN-box tag at the construction boundary so downstream property lookups fall through.
Activity
fix(codegen): refs #420 #631 — intermediate-class field inits run aft…
proggeramlug commentedon May 10, 2026
Status update from re-running parity sweep on v0.5.761
Still reproduces unchanged. The 14-line repro from the issue body produces the same output today:
Cascade impact (from
test-files/test_parity_*runs):test_parity_fs— 91 of 301 lines lost to broken Stats / Dirent prototype binding (out of 409 total diff lines).test_parity_stream— 76 of 77 expected output lines diverge (97% of file). EveryReadable.from(...),new Readable({read}),new Writable({write}),new Duplex(...),new Transform(...)returns an object without.on/.read/.write/.pipe— the entire stream surface is unusable downstream.test_parity_stream_web— same shape on WHATWGReadableStream/WritableStream/TransformStream(96 diff lines).This continues to be one of the highest-leverage gap closers. Refs: #648 (separately filed for the related "calling undefined as a function silently no-ops" bug, which cascades from this one — when the prototype method is missing, calling it as a method returns
undefinedand Perry doesn't throw).fix: closes #556 #640 #631 (part A) — refs #644 — 4 coordinated fixes…
proggeramlug commentedon May 10, 2026
Partial fix in 5746eac (v0.5.765).
Part A — `fs.readdirSync(path, {withFileTypes:true})` → `Dirent[]` ✓ (closed)
Now byte-identical to Node:
```
$ ./perry compile test_issue_631_dirent.ts -o t631 && ./t631
entry[0] typeof: object
entry[0] is string: false
entry[0].isFile type: function
default entry typeof: string
```
Each Dirent carries `name`, `parentPath` / `path`, `isFile()`, `isDirectory()`, `isSymbolicLink()`. Implementation mirrors the existing `build_stats_object` pattern in `crates/perry-runtime/src/fs.rs` — same closure-per-predicate technique. ABI of `js_fs_readdir_sync` extended to take a second `options_value: f64` arg, threaded through codegen at three sites (`crates/perry-codegen/src/lower_call/native.rs::readdirSync`, `crates/perry-codegen/src/expr.rs::readdirSync`, `crates/perry-codegen/src/runtime_decls.rs`) and the dynamic dispatch in `crates/perry-runtime/src/object.rs`. Regression test at `test-files/test_issue_631_dirent.ts`.
Part B — `Readable.from` / `new Readable` ⏸ (deferred)
`stream.Readable` / `Writable` / `Duplex` / `Transform` are registered as classes in the API manifest but have no native runtime backing. Currently `Readable.from(...)` triggers the unimplemented-API gate (`stream.from is not implemented`). Even satisfying just the typeof contract requires:
— substantially more plumbing than fits in this fix sweep, and a real implementation (event emitter machinery, async iteration, pipe back-pressure) is even bigger. Leaving #631 open until Part B lands, and documenting as a known gap in CLAUDE.md's "Known Limitations" section.
If `Readable.from` is the load-bearing blocker for a specific package port, please mention which one — it'd help prioritize the typeof-contract-only stub vs a real implementation.