Skip to content

fix(runtime): defer void fs callback delivery to a later tick (#6401)#6549

Open
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/6401-fs-promisified-first-rejection-errcode
Open

fix(runtime): defer void fs callback delivery to a later tick (#6401)#6549
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/6401-fs-promisified-first-rejection-errcode

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #6401.

Perry's callback-form fs.* void ops (ftruncate/futimes/fchown/fsync/fdatasync/fchmod/mkdir/unlink/rm/writeFile/appendFile/access/link/symlink/utimes/lutimes) invoked their completion callback synchronously, in the same turn the op was called. Node's async fs.* functions dispatch to the libuv threadpool and never call the callback in the calling turn. This reorders execution relative to Node.

The issue #6401 exposed this via a promisified fd rejection: when main() is invoked from inside an fs callback (which, under Perry, fires synchronously during the fs.fchown(...) call), main() runs before the module-top-level const ftruncateP = promisify(fs.ftruncate) line has executed. Its first await then evaluates ftruncateP(fd, 1) while ftruncateP is still uninitialized, so the promise rejects with TypeError: value is not a function — whose .code/.syscall are naturally undefined. The second and third awaits run as microtasks after top-level completes and the consts are assigned, so they resolve correctly. That is why only the first promisified rejection looked broken.

The reporter's diagnosis (a lost err.code/err.syscall on a real EBADF error) was a red herring — the first rejection is not an fs error at all; it's a TypeError from calling an uninitialized const.

Root cause, precisely

js_closure_call0/call1 were invoked inline from the js_fs_*_callback entry points. With main() reached synchronously from the third callback:

CB fired: ftruncate closed cb (typeof ftruncateP=undefined)   ← callbacks fire synchronously
CB fired: futimes closed cb (typeof ftruncateP=undefined)
CB fired: fchown closed cb (typeof ftruncateP=undefined)
ftruncate closed promise: undefined undefined (msg=value is not a function)
TOP: about to assign promisified consts                       ← consts assigned AFTER main's 1st await

Node fires the callbacks in a later tick, after top-level (and the consts) are done.

Fix

Defer the void fs op callback delivery to a microtask instead of firing it inline, mirroring the already-deferred fs.opendir/Dir.read path (dir_schedule_read_callback). The syscall still runs eagerly; only the (err) / (null) delivery is deferred. Implemented at the two shared helpers call_cb0/call_cb_err1 (used by every void fs callback op), via a small trampoline that captures the callback (NaN-boxed as a POINTER value) and its single argument in tag-aware closure capture slots — so the GC keeps them live and rewrites their addresses across any collection between enqueue and drain (the TASK_QUEUE roots the queued trampoline).

Value-returning ops (stat/read/readdir/fstat) still deliver synchronously and are left unchanged here (their success path is a direct js_closure_call2); making them consistent is a natural follow-up.

Validation

Compiled + run against node --experimental-strip-types (Node 26.3 local):

  • The fs: first promisified fd rejection awaited from inside an fs callback loses err.code/err.syscall #6401 repro now matches Node byte-for-byte, deterministically (40/40 → all EBADF <syscall>).
  • test_gap_fs_fd_2749 (the shipped test) still passes.
  • No regressions across fs + ordering-sensitive gap tests: fs_errprop/fs_errprop2/fs_write_error_propagation/node_fs/zlib_fs, promise_tick_parity/promise_tick_parity2/promise_resolve_proxy_then_trap, timer_batch_order, unhandled_rejection_event, stream tee/transform tick-parity, async_advanced, asynchooks, readline, encoding_timers.

Summary by CodeRabbit

  • Bug Fixes
    • Filesystem operation callbacks are now delivered asynchronously, after the current task completes.
    • Void-style filesystem operations consistently defer both successful completion and error notifications.
    • Prevents callbacks from running synchronously in certain filesystem error scenarios.

…S#6401)

Perry's callback-form `fs.*` void ops (ftruncate/futimes/fchown/fsync/
fdatasync/fchmod/mkdir/unlink/rm/writeFile/appendFile/access/link/symlink/
utimes/lutimes) invoked their completion callback synchronously, in the same
turn the op was called. Node's async `fs.*` functions dispatch to the libuv
threadpool and never call back in the calling turn, so this reordered
execution relative to Node.

PerryTS#6401 exposed it via a promisified fd rejection: when `main()` is invoked from
inside an fs callback (which fired synchronously during the `fs.fchown(...)`
call), `main()` ran before the module-top-level `const ftruncateP =
promisify(fs.ftruncate)` had executed. Its first `await` then called an
uninitialized `ftruncateP`, rejecting with `TypeError: value is not a
function` — hence the "undefined undefined" for `.code`/`.syscall` on only the
first promisified rejection. The reported "lost err.code/err.syscall on an
EBADF error" was a red herring: the first rejection is a TypeError, not an fs
error.

Fix: run the syscall eagerly (unchanged) but hand the `(err)` / `(null)`
delivery to a microtask, mirroring the already-deferred `fs.opendir`/`Dir.read`
path. Implemented at the shared `call_cb0`/`call_cb_err1` helpers via a small
trampoline that captures the callback (NaN-boxed as POINTER) and its argument
in tag-aware closure slots, so the GC keeps them live and rewrites their
addresses across any collection between enqueue and drain (TASK_QUEUE roots the
queued trampoline).

Value-returning ops (stat/read/readdir/fstat) still deliver synchronously and
are left unchanged; making them consistent is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 23288ef7-c762-4f15-a397-7de78583c9e5

📥 Commits

Reviewing files that changed from the base of the PR and between 520894f and 33f6482.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/fs/callbacks.rs

📝 Walkthrough

Walkthrough

Void filesystem callbacks now execute through a microtask-queued trampoline. Both successful completions and pre-flight io::Error paths use deferred delivery instead of synchronous callback invocation.

Changes

Filesystem callback deferral

Layer / File(s) Summary
Deferred callback scheduling and delivery
crates/perry-runtime/src/fs/callbacks.rs
Adds a boxed callback trampoline and microtask scheduler, then routes call_cb0 success delivery and call_cb_err1 error delivery through the deferred path.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FsCompletion
  participant defer_fs_cb1
  participant MicrotaskQueue
  participant deferred_fs_cb1_impl
  participant Callback
  FsCompletion->>defer_fs_cb1: box callback and argument
  defer_fs_cb1->>MicrotaskQueue: queue deferred_fs_cb1_impl
  MicrotaskQueue->>deferred_fs_cb1_impl: invoke with captured values
  deferred_fs_cb1_impl->>Callback: call with error or undefined argument
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: deferring void fs callback delivery for runtime parity.
Description check ✅ Passed The description covers the problem, root cause, fix, and validation, with only minor template-section differences.
Linked Issues check ✅ Passed The change defers void fs callbacks as required, matching the issue's timing objective and addressing the first promisified rejection.
Out of Scope Changes check ✅ Passed The diff appears focused on the fs callback timing fix and does not introduce unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

fs: first promisified fd rejection awaited from inside an fs callback loses err.code/err.syscall

1 participant