fix(runtime): defer void fs callback delivery to a later tick (#6401)#6549
Open
proggeramlug wants to merge 1 commit into
Open
fix(runtime): defer void fs callback delivery to a later tick (#6401)#6549proggeramlug wants to merge 1 commit into
proggeramlug wants to merge 1 commit into
Conversation
…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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughVoid filesystem callbacks now execute through a microtask-queued trampoline. Both successful completions and pre-flight ChangesFilesystem callback deferral
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
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 asyncfs.*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 anfscallback (which, under Perry, fires synchronously during thefs.fchown(...)call),main()runs before the module-top-levelconst ftruncateP = promisify(fs.ftruncate)line has executed. Its firstawaitthen evaluatesftruncateP(fd, 1)whileftruncatePis still uninitialized, so the promise rejects withTypeError: value is not a function— whose.code/.syscallare naturallyundefined. 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.syscallon a realEBADFerror) was a red herring — the first rejection is not an fs error at all; it's aTypeErrorfrom calling an uninitializedconst.Root cause, precisely
js_closure_call0/call1were invoked inline from thejs_fs_*_callbackentry points. Withmain()reached synchronously from the third callback: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.readpath (dir_schedule_read_callback). The syscall still runs eagerly; only the(err)/(null)delivery is deferred. Implemented at the two shared helperscall_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 (theTASK_QUEUEroots the queued trampoline).Value-returning ops (
stat/read/readdir/fstat) still deliver synchronously and are left unchanged here (their success path is a directjs_closure_call2); making them consistent is a natural follow-up.Validation
Compiled + run against
node --experimental-strip-types(Node 26.3 local):EBADF <syscall>).test_gap_fs_fd_2749(the shipped test) still passes.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