Skip to content

fix(fs): propagate write errors#6415

Merged
proggeramlug merged 3 commits into
PerryTS:mainfrom
ShiroKSH:fix/fs-write-error-propagation
Jul 15, 2026
Merged

fix(fs): propagate write errors#6415
proggeramlug merged 3 commits into
PerryTS:mainfrom
ShiroKSH:fix/fs-write-error-propagation

Conversation

@ShiroKSH

@ShiroKSH ShiroKSH commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Preserve operating-system write errors in fs.writeSync and callback fs.write forms.
  • Cover string and Buffer writes through a read-only, still-open file descriptor.

Root cause

The write cores converted every std::io::Error into a zero-byte success. The synchronous and callback APIs therefore could not distinguish a failed write from a genuine zero-byte result.

Checks

  • node --experimental-strip-types test-files/test_gap_fs_write_error_propagation.ts
  • rustfmt --edition 2021 --check crates/perry-runtime/src/fs/fd_ops.rs crates/perry-runtime/src/fs/callbacks.rs
  • git diff --check

Summary by CodeRabbit

  • Bug Fixes

    • File write operations now correctly propagate underlying system errors for both sync and callback-based APIs (string and Buffer).
    • Synchronous writes now throw on syscall failures instead of returning an incorrect success-like value.
    • Callback-based writes now deliver errors to the callback while preserving expected return semantics.
    • Improved Linux/Unix error reporting with more accurate code mappings.
  • Tests

    • Added coverage to verify error propagation for read-only file descriptor writes (sync and callback, string and Buffer).

@coderabbitai

coderabbitai Bot commented Jul 14, 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: e7b8ee92-5304-4980-8b38-75a93bc90d20

📥 Commits

Reviewing files that changed from the base of the PR and between 5b6889e and 081afb8.

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

📝 Walkthrough

Walkthrough

Synchronous and callback-based filesystem string and buffer writes now preserve syscall failures. Synchronous APIs throw filesystem errors, while callback APIs receive errors. Unix errno mappings and read-only descriptor coverage are also expanded.

Changes

Filesystem write error propagation

Layer / File(s) Summary
Result-returning write helpers
crates/perry-runtime/src/fs/fd_ops.rs
String and buffer write helpers now preserve underlying write errors while retaining existing successful zero-result cases.
Synchronous write error throws
crates/perry-runtime/src/fs/fd_ops.rs
Synchronous string and buffer writes now throw filesystem errors when the underlying write fails.
Callback write error dispatch
crates/perry-runtime/src/fs/callbacks.rs
Callback string and buffer writes now deliver converted filesystem errors and return undefined.
Filesystem errno mapping
crates/perry-runtime/src/fs/errors.rs
Additional Unix errno values now map to specific Node-style error codes and numeric errno values.
Read-only descriptor coverage
test-files/test_gap_fs_write_error_propagation.ts
Adds coverage for synchronous and callback string and buffer writes against a read-only descriptor.

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

Suggested reviewers: andrewtdiz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description includes a summary and checks, but it omits required template sections like Changes, Related issue, Test plan, and Checklist. Rewrite the PR body to match the template, adding Changes, Related issue, Test plan with commands, and the checklist items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: propagating filesystem write errors.
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.

🧹 Nitpick comments (1)
test-files/test_gap_fs_write_error_propagation.ts (1)

6-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move file setup into the try...finally block to ensure robust cleanup.

Currently, if fs.writeFileSync or fs.openSync throws an error during setup, the script will exit before main() is executed, leaving the temporary directory uncleaned on disk. Additionally, prepending os.tmpdir() to the prefix prevents the temporary directory from being created in the current working directory.

Consider refactoring the setup and cleanup flow:

♻️ Proposed refactor
-import * as fs from "node:fs";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
 
-const dir = fs.mkdtempSync("perry-fs-write-error-");
-const file = dir + "/file.txt";
-fs.writeFileSync(file, "seed");
-const fd = fs.openSync(file, "r");
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), "perry-fs-write-error-"));
+let fd: number | undefined;
 
 function syncCode(label: string, fn: () => void): void {
   try {
     fn();
     console.log(label + ": OK");
   } catch (err: any) {
     console.log(label + ": " + err.code + " " + err.syscall);
   }
 }
 
 function callbackCode(
   label: string,
   invoke: (callback: (err: any) => void) => void,
 ): Promise<void> {
   return new Promise((resolve) => {
     invoke((err: any) => {
       console.log(label + ": " + (err ? err.code + " " + err.syscall : "OK"));
       resolve();
     });
   });
 }
 
 async function main(): Promise<void> {
   try {
+    const file = path.join(dir, "file.txt");
+    fs.writeFileSync(file, "seed");
+    fd = fs.openSync(file, "r");
+
-    syncCode("write string sync", () => fs.writeSync(fd, "x"));
-    syncCode("write buffer sync", () => fs.writeSync(fd, Buffer.from("x"), 0, 1, null));
-    await callbackCode("write string callback", (callback) => fs.write(fd, "x", callback));
+    syncCode("write string sync", () => fs.writeSync(fd!, "x"));
+    syncCode("write buffer sync", () => fs.writeSync(fd!, Buffer.from("x"), 0, 1, null));
+    await callbackCode("write string callback", (callback) => fs.write(fd!, "x", callback));
     await callbackCode("write buffer callback", (callback) =>
-      fs.write(fd, Buffer.from("x"), 0, 1, null, callback),
+      fs.write(fd!, Buffer.from("x"), 0, 1, null, callback),
     );
   } finally {
-    fs.closeSync(fd);
+    if (fd !== undefined) {
+      try { fs.closeSync(fd); } catch {}
+    }
     fs.rmSync(dir, { recursive: true, force: true });
   }
 }
🤖 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 `@test-files/test_gap_fs_write_error_propagation.ts` around lines 6 - 44, Move
temporary directory, file, and descriptor setup into main’s try block so
failures during fs.writeFileSync or fs.openSync still reach finally; initialize
the descriptor safely and close it only when opened, then remove the directory
during cleanup. Create the temporary directory with an os.tmpdir()-based prefix
rather than relying on the current working directory.
🤖 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.

Nitpick comments:
In `@test-files/test_gap_fs_write_error_propagation.ts`:
- Around line 6-44: Move temporary directory, file, and descriptor setup into
main’s try block so failures during fs.writeFileSync or fs.openSync still reach
finally; initialize the descriptor safely and close it only when opened, then
remove the directory during cleanup. Create the temporary directory with an
os.tmpdir()-based prefix rather than relying on the current working directory.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ff1c0284-5193-4fa1-be2b-469d83785c8a

📥 Commits

Reviewing files that changed from the base of the PR and between 7b6369a and 5b6889e.

📒 Files selected for processing (3)
  • crates/perry-runtime/src/fs/callbacks.rs
  • crates/perry-runtime/src/fs/fd_ops.rs
  • test-files/test_gap_fs_write_error_propagation.ts

Ralph Küpper and others added 2 commits July 15, 2026 00:23
The propagation this PR adds worked, but the CODE was wrong: a write to a
closed descriptor reported

    write string sync: EIO write      (node: EBADF write)

`io_error_code` matches the raw errno first, but its table had no EBADF arm —
and Rust has no `ErrorKind` for it either, so it fell through to the catch-all
`_ => "EIO"`. `io_error_errno` already returned the raw errno, so only the code
string was lost.

Add EBADF plus the other descriptor/write-side errnos that hit the same hole
(EPIPE, EROFS, EFBIG, ESPIPE, EBUSY, EMFILE, ENFILE, EXDEV). Each only replaces
a wrong "EIO" with the correct code.

test_gap_fs_write_error_propagation is now byte-identical to node, and
test_gap_fs_fd_2749 / test_gap_fs_errprop_2735plus / test_gap_fs_errprop2_2745plus
/ test_gap_node_fs still pass.
@proggeramlug

Copy link
Copy Markdown
Contributor

Nice catch on the propagation — that was a real hole. CI surfaced one more thing on top of it, so I pushed a fix to your branch (081afb8b0, maintainer-edit) rather than leave it red.

What failed

Your new test_gap_fs_write_error_propagation failed on the error code, not on the propagation:

Node.js:  write string sync: EBADF write
Perry:    write string sync: EIO write

So the error did reach the callback — your change works. It just arrived wearing the wrong name.

Why

io_error_code matches the raw errno first, but its table has no EBADF arm:

code if code == libc::ETIMEDOUT => return "ETIMEDOUT",
code if code == libc::EAGAIN    => return "EAGAIN",
_ => {}          // <- EBADF falls through here

…and Rust has no ErrorKind for EBADF either, so it fell through to the ErrorKind match and hit the catch-all _ => "EIO". Note io_error_errno was already fine — it returns the raw errno directly — so only the code string was being lost. Nothing you did; the hole was pre-existing and your test is simply the first thing to walk into it.

The fix

Added EBADF plus the other descriptor/write-side errnos that fall in the same hole: EPIPE, EROFS, EFBIG, ESPIPE, EBUSY, EMFILE, ENFILE, EXDEV. Each one only replaces a wrong "EIO" with the correct code, so there's no behaviour change for anything that was already right.

Verified locally against node:

  • test_gap_fs_write_error_propagation → byte-identical ✅
  • test_gap_fs_fd_2749, test_gap_fs_errprop_2735plus, test_gap_fs_errprop2_2745plus, test_gap_node_fs → still pass ✅

Also merged main in, which picks up #6410 (a temp-dir race that was making test_gap_node_fs fail at random on unrelated PRs — nothing to do with your change).

@proggeramlug proggeramlug merged commit 7e59add into PerryTS:main Jul 15, 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.

2 participants