Skip to content

Native classes (Dirent, Readable, Writable) returned with no instance methods on prototype #631

Closed

Description

@proggeramlug

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:

  1. fs.readdirSync(path, { withFileTypes: true }) silently ignores the option and returns string entries instead of Dirent objects. Code patterns like const dirs = entries.filter(e => e.isDirectory()) crash with (string).isDirectory is not a function.

  2. stream.Readable.from(...) and new stream.Readable({read}) return undefined (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 of test-files/test_parity_stream.ts reaching 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) and test-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 process numeric tag) — a class-instance value getting a wrong NaN-box tag at the construction boundary so downstream property lookups fall through.

Activity

  1. added a commit that references this issue on May 9, 2026
  2. proggeramlug commented on May 10, 2026

    @proggeramlug
    ContributorAuthor

    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:

    entry[0] typeof: string         ← Node: object
    entry[0] is string: true        ← Node: false
    entry[0].isFile type: undefined ← Node: function
    Readable.from typeof: undefined ← Node: object
    Readable.from .on type: undefined  ← Node: function
    Readable.from .read type: undefined  ← Node: function
    Readable.from .pause type: undefined  ← Node: function
    new Readable .on type: undefined  ← Node: function
    new Readable .pipe type: undefined  ← Node: function
    

    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). Every Readable.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 WHATWG ReadableStream/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 undefined and Perry doesn't throw).

  3. added a commit that references this issue on May 10, 2026
  4. proggeramlug commented on May 10, 2026

    @proggeramlug
    ContributorAuthor

    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:

    • HIR/codegen routing for `Readable.from(...)` (2-deep) and `stream.Readable.from(...)` (3-deep) and `new Readable({read})`
    • Runtime helpers that build a Node-shape Readable object with stub method closures (`.on`, `.pipe`, `.read`, `.pause`, `.resume`)
    • Same shape needed for Writable / Duplex / Transform / PassThrough

    — 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.

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