Skip to content

fix(fs): openSync dropped O_NONBLOCK, and readSync reported errors as EOF#6403

Merged
proggeramlug merged 4 commits into
PerryTS:mainfrom
proggeramlug:fix/fs-open-flags-read-errors
Jul 14, 2026
Merged

fix(fs): openSync dropped O_NONBLOCK, and readSync reported errors as EOF#6403
proggeramlug merged 4 commits into
PerryTS:mainfrom
proggeramlug:fix/fs-open-flags-read-errors

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The bugs

Two defects that compound: a non-blocking read is impossible, and a failing read
is indistinguishable from EOF.

1. openSync discards every flag it does not model.

fn apply_numeric_open_flags(opts: &mut fs::OpenOptions, flags: i32) -> bool {
    match flags & libc::O_ACCMODE {}
    if flags & libc::O_CREAT  != 0 {}
    if flags & libc::O_TRUNC  != 0 {}
    let append_mode = flags & libc::O_APPEND != 0;// O_NONBLOCK, O_NOCTTY, O_NOFOLLOW, O_SYNC … dropped
}

Node hands the flag word to open(2) verbatim. Perry rebuilds it from the handful
of bits OpenOptions models, so O_NONBLOCK never reaches the syscall:

const fd = fs.openSync("/dev/tty", fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
fs.readSync(fd, buf, 0, 1024, null);
// Node : throws EAGAIN (nothing to read)
// Perry: blocks forever — the descriptor is not actually non-blocking

2. readSync swallows the syscall error.

let result = match file.read() {
    Ok(read) => read as f64,
    Err(_) => 0.0,          // EAGAIN / EISDIR / EIO … all become "0 bytes read"
};

0 means end-of-file to every caller, so a failure is silently reported as a
clean EOF — and with flag #1 fixed, a non-blocking read with no data would report
EOF instead of EAGAIN.

The fix

  • Forward the unmodeled flags through OpenOptions::custom_flags. std masks
    O_ACCMODE out of custom flags, so the access mode cannot be clobbered.
  • Thread the io::Error out of a read_sync_result core. fs.readSync throws
    the Node-shaped error (code/errno/syscall), and the callback forms
    (fs.read(fd, …, cb) / options form) report it as cb(err) rather than
    throwing, which is what Node does.

Test

tests/issue_fs_nonblocking_read.js + tests/test_fs_nonblocking_read.sh: open a
writer-less FIFO with O_RDONLY | O_NONBLOCK and assert readSync raises
EAGAIN. The harness caps the run, so the old behaviour surfaces as a failure
rather than a hung suite.

Summary by CodeRabbit

  • Bug Fixes

    • Updated filesystem readSync and callback-based reads to preserve and surface underlying syscall errors (no longer collapse failures into EOF/0-like results).
    • Adjusted callback error propagation so failures throw/dispatch proper JS errors while keeping success byte-count behavior.
    • Improved Unix open flag handling to preserve nonblocking and other non-modeled flags, reducing the risk of hangs.
  • Tests

    • Added a regression test and a shell-runner script to confirm nonblocking reads throw EAGAIN and do not hang.

… EOF

Two defects that together make a non-blocking read impossible.

`apply_numeric_open_flags` translated only O_ACCMODE/O_CREAT/O_EXCL/O_TRUNC/
O_APPEND into Rust's `OpenOptions` and silently discarded everything else, so
O_NONBLOCK never reached open(2). The descriptor came back blocking and

    const fd = openSync("/dev/tty", O_RDONLY | O_NONBLOCK);
    readSync(fd, buf, 0, 1024, null);        // Node: throws EAGAIN

parked forever instead of raising EAGAIN. Forward the flags `OpenOptions` does
not model via `custom_flags` — Node passes the flag word to open(2) verbatim.
(std masks O_ACCMODE out of custom flags, so the access mode cannot be clobbered.)

`js_fs_read_sync` then mapped every read error to `0.0`, which a caller cannot
distinguish from a clean end-of-file. Thread the `io::Error` out of the read core
so the sync surface throws the Node-shaped error (EAGAIN, EISDIR, EIO…) and the
callback surface reports it as `cb(err)` — the callback form must not throw.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Filesystem open flags now preserve unmodeled Unix bits, read operations retain syscall errors, callbacks dispatch those errors as filesystem values, and a FIFO regression test validates nonblocking EAGAIN behavior without hangs.

Changes

Filesystem read error handling

Layer / File(s) Summary
Open flag forwarding
crates/perry-runtime/src/fs/fd_ops.rs
Unix open handling forwards unmodeled flags through custom_flags while retaining modeled flags.
Synchronous read result propagation
crates/perry-runtime/src/fs/fd_ops.rs
read_sync_result preserves syscall errors, and readSync converts them into filesystem errors instead of returning zero bytes.
Callback read error dispatch
crates/perry-runtime/src/fs/callbacks.rs
Callback-based reads convert syscall failures into filesystem error values and invoke callbacks with zero bytes.
Nonblocking FIFO regression validation
tests/issue_fs_nonblocking_read.js, tests/test_fs_nonblocking_read.sh
The regression test verifies O_NONBLOCK, EAGAIN, and the absence of blocking hangs.

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

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the fixes and test, but it does not follow the required template sections like Summary, Changes, Related issue, or Test plan. Rewrite the PR description using the repo template, including Summary, Changes, Related issue, Test plan, optional Screenshots/output, and Checklist sections.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the two main fs fixes: preserving O_NONBLOCK and surfacing read errors instead of EOF.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/issue_fs_nonblocking_read.js`:
- Around line 14-38: Wrap the temporary FIFO test flow beginning after
mkdtempSync in a try/finally block, and call rmSync on the created dir in
finally so cleanup occurs even when assertions or reads fail. Keep the existing
readSync error validation and closeSync behavior intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f52e028-7f52-4bda-a593-eb463c3533ed

📥 Commits

Reviewing files that changed from the base of the PR and between 9e91316 and 171698d.

📒 Files selected for processing (4)
  • crates/perry-runtime/src/fs/callbacks.rs
  • crates/perry-runtime/src/fs/fd_ops.rs
  • tests/issue_fs_nonblocking_read.js
  • tests/test_fs_nonblocking_read.sh

Comment thread tests/issue_fs_nonblocking_read.js Outdated
Ralph Küpper and others added 3 commits July 14, 2026 19:42
The fixture created a mkdtemp directory and an fd and freed neither on the
throwing path, so a failing run leaked a temp directory (and an open descriptor)
on every retry. Wrap both in `finally`.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/issue_fs_nonblocking_read.js`:
- Around line 31-41: Restructure the try/catch around readSync in the test so
the explicit “readSync resolved instead of raising EAGAIN” failure is thrown
after the catch block, not from inside it. Preserve assigning the caught system
error’s code to code and keep the existing EAGAIN validation unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: af3b851c-80ba-44c1-93f6-a4bf64827163

📥 Commits

Reviewing files that changed from the base of the PR and between e7b251b and 2f0fb5d.

📒 Files selected for processing (1)
  • tests/issue_fs_nonblocking_read.js

Comment on lines +31 to +41
let code = null;
try {
readSync(fd, buf, 0, 16, null);
throw new Error("readSync resolved instead of raising EAGAIN");
} catch (err) {
code = err.code;
}

if (code !== "EAGAIN") {
throw new Error(`readSync on an empty non-blocking FIFO reported ${code}, expected EAGAIN`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the intended test failure message.

Currently, if readSync successfully resolves instead of throwing, the script intentionally throws new Error("readSync resolved..."). However, the catch block immediately intercepts this error. Because standard error objects don't have a .code property, code is assigned undefined. The script then throws the generic second error (readSync on an empty non-blocking FIFO reported undefined, expected EAGAIN), and the original, more descriptive failure message is completely lost.

To avoid catching the test's own failure error, move the explicit throw outside the catch block.

💚 Proposed fix to properly report test failures
     let code = null;
     try {
       readSync(fd, buf, 0, 16, null);
-      throw new Error("readSync resolved instead of raising EAGAIN");
     } catch (err) {
       code = err.code;
     }
 
+    if (code === null) {
+      throw new Error("readSync resolved instead of raising EAGAIN");
+    }
     if (code !== "EAGAIN") {
       throw new Error(`readSync on an empty non-blocking FIFO reported ${code}, expected EAGAIN`);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let code = null;
try {
readSync(fd, buf, 0, 16, null);
throw new Error("readSync resolved instead of raising EAGAIN");
} catch (err) {
code = err.code;
}
if (code !== "EAGAIN") {
throw new Error(`readSync on an empty non-blocking FIFO reported ${code}, expected EAGAIN`);
}
let code = null;
try {
readSync(fd, buf, 0, 16, null);
} catch (err) {
code = err.code;
}
if (code === null) {
throw new Error("readSync resolved instead of raising EAGAIN");
}
if (code !== "EAGAIN") {
throw new Error(`readSync on an empty non-blocking FIFO reported ${code}, expected EAGAIN`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/issue_fs_nonblocking_read.js` around lines 31 - 41, Restructure the
try/catch around readSync in the test so the explicit “readSync resolved instead
of raising EAGAIN” failure is thrown after the catch block, not from inside it.
Preserve assigning the caught system error’s code to code and keep the existing
EAGAIN validation unchanged.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

The test_gap_node_fs failure here is not caused by this PR — the test is flaky.

Its scratch directory is '/tmp/perry_fs_test_' + Date.now(), which is not unique (millisecond resolution), and the test ends with a recursive rmSync. Two runs starting in the same millisecond share a directory and one deletes it under the other, which CI reports as EACCES … stat '/tmp/perry_fs_test_<ms>/test.txt' — a filesystem race, not a parity failure.

The giveaway: this PR failed one run and passed the next on the same commit. Running 8 copies of the binary concurrently reproduces it — 2 of 8 fail before the fix, 0 of 8 after.

Fix is up as #6410 (mkdtempSync, atomic and unique; expected output unchanged). Rerunning here in the meantime.

@proggeramlug proggeramlug merged commit 481182f into PerryTS:main Jul 14, 2026
26 checks passed
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.

1 participant