From 5b6889e995fb95cc4486433a69b3858e642a8842 Mon Sep 17 00:00:00 2001 From: ShiroKSH Date: Tue, 14 Jul 2026 23:33:33 +0300 Subject: [PATCH 1/2] fix(fs): propagate write errors --- crates/perry-runtime/src/fs/callbacks.rs | 41 ++++++++++++-- crates/perry-runtime/src/fs/fd_ops.rs | 55 ++++++++++++++----- .../test_gap_fs_write_error_propagation.ts | 46 ++++++++++++++++ 3 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 test-files/test_gap_fs_write_error_propagation.ts diff --git a/crates/perry-runtime/src/fs/callbacks.rs b/crates/perry-runtime/src/fs/callbacks.rs index f07397e33..91b9547d8 100644 --- a/crates/perry-runtime/src/fs/callbacks.rs +++ b/crates/perry-runtime/src/fs/callbacks.rs @@ -1052,7 +1052,18 @@ pub extern "C" fn js_fs_write_callback(fd_value: f64, data_value: f64, callback: crate::closure::js_closure_call3(cb, err_val, 0.0, data_value); return f64::from_bits(TAG_UNDEFINED); } - let bytes = js_fs_write_sync(fd_value, data_value); + let bytes = match crate::fs::write_string_sync_result( + fd_value as i32, + data_value, + f64::from_bits(crate::value::TAG_UNDEFINED), + ) { + Ok(bytes) => bytes, + Err(err) => { + let err_val = unsafe { build_fs_error_value_no_path(&err, "write") }; + crate::closure::js_closure_call3(cb, err_val, 0.0, data_value); + return f64::from_bits(TAG_UNDEFINED); + } + }; if !cb.is_null() { crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, data_value); } @@ -1081,7 +1092,20 @@ pub extern "C" fn js_fs_write_buffer_callback_options( .unwrap_or_else(|| (buffer_len - offset).max(0.0)); let position = unsafe { options_number_field(options_value, b"position") } .unwrap_or(f64::from_bits(crate::value::TAG_NULL)); - let bytes = js_fs_write_buffer_sync(fd_value, buffer_value, offset, length, position); + let bytes = match crate::fs::write_buffer_sync_result( + fd_value as i32, + buffer_value, + offset, + length, + position, + ) { + Ok(bytes) => bytes, + Err(err) => { + let err_val = unsafe { build_fs_error_value_no_path(&err, "write") }; + crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + return f64::from_bits(TAG_UNDEFINED); + } + }; if !cb.is_null() { crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, buffer_value); } @@ -1106,13 +1130,20 @@ pub extern "C" fn js_fs_write_buffer_callback( crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); return f64::from_bits(TAG_UNDEFINED); } - let bytes = js_fs_write_buffer_sync( - fd_value, + let bytes = match crate::fs::write_buffer_sync_result( + fd_value as i32, buffer_value, offset_value, length_value, position_value, - ); + ) { + Ok(bytes) => bytes, + Err(err) => { + let err_val = unsafe { build_fs_error_value_no_path(&err, "write") }; + crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + return f64::from_bits(TAG_UNDEFINED); + } + }; if !cb.is_null() { crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, buffer_value); } diff --git a/crates/perry-runtime/src/fs/fd_ops.rs b/crates/perry-runtime/src/fs/fd_ops.rs index f1243b52f..74b90b55c 100644 --- a/crates/perry-runtime/src/fs/fd_ops.rs +++ b/crates/perry-runtime/src/fs/fd_ops.rs @@ -313,10 +313,23 @@ pub extern "C" fn js_fs_write_string_sync_options( position_value: f64, ) -> f64 { crate::fs::validate::validate_fd_open(fd_value, "write"); - write_string_sync_inner(fd_value as i32, data_value, position_value) + match write_string_sync_result(fd_value as i32, data_value, position_value) { + Ok(bytes) => bytes, + Err(err) => unsafe { + crate::exception::js_throw(build_fs_error_value_no_path(&err, "write")) + }, + } } pub(crate) fn write_string_sync_inner(fd: i32, data_value: f64, position_value: f64) -> f64 { + write_string_sync_result(fd, data_value, position_value).unwrap_or(0.0) +} + +pub(crate) fn write_string_sync_result( + fd: i32, + data_value: f64, + position_value: f64, +) -> Result { let bytes = bytes_from_value(data_value); let position = if position_value.is_finite() && position_value >= 0.0 { Some(position_value as u64) @@ -326,16 +339,13 @@ pub(crate) fn write_string_sync_inner(fd: i32, data_value: f64, position_value: FD_REGISTRY.with(|r| { let mut reg = r.borrow_mut(); let Some(file) = reg.get_mut(&fd) else { - return 0.0; + return Ok(0.0); }; let restore_pos = position.and_then(|_| file.stream_position().ok()); if let Some(pos) = position { let _ = file.seek(SeekFrom::Start(pos)); } - let result = match file.write(&bytes) { - Ok(n) => n as f64, - Err(_) => 0.0, - }; + let result = file.write(&bytes).map(|written| written as f64); if let Some(pos) = restore_pos { let _ = file.seek(SeekFrom::Start(pos)); } @@ -353,13 +363,18 @@ pub extern "C" fn js_fs_write_buffer_sync( position_value: f64, ) -> f64 { crate::fs::validate::validate_fd_open(fd_value, "write"); - write_buffer_sync_inner( + match write_buffer_sync_result( fd_value as i32, buffer_value, offset_value, length_value, position_value, - ) + ) { + Ok(bytes) => bytes, + Err(err) => unsafe { + crate::exception::js_throw(build_fs_error_value_no_path(&err, "write")) + }, + } } pub(crate) fn write_buffer_sync_inner( @@ -369,6 +384,17 @@ pub(crate) fn write_buffer_sync_inner( length_value: f64, position_value: f64, ) -> f64 { + write_buffer_sync_result(fd, buffer_value, offset_value, length_value, position_value) + .unwrap_or(0.0) +} + +pub(crate) fn write_buffer_sync_result( + fd: i32, + buffer_value: f64, + offset_value: f64, + length_value: f64, + position_value: f64, +) -> Result { let offset = offset_value.max(0.0) as usize; let length = length_value.max(0.0) as usize; let position = if position_value.is_finite() && position_value >= 0.0 { @@ -378,12 +404,12 @@ pub(crate) fn write_buffer_sync_inner( }; let buf = buffer_ptr_from_value(buffer_value); if buf.is_null() { - return 0.0; + return Ok(0.0); } FD_REGISTRY.with(|r| { let mut reg = r.borrow_mut(); let Some(file) = reg.get_mut(&fd) else { - return 0.0; + return Ok(0.0); }; let restore_pos = position.and_then(|_| file.stream_position().ok()); if let Some(pos) = position { @@ -395,14 +421,13 @@ pub(crate) fn write_buffer_sync_inner( if let Some(pos) = restore_pos { let _ = file.seek(SeekFrom::Start(pos)); } - return 0.0; + return Ok(0.0); } let n = length.min(cap - offset); let data = crate::buffer::buffer_data(buf).add(offset); - let result = match file.write(std::slice::from_raw_parts(data, n)) { - Ok(written) => written as f64, - Err(_) => 0.0, - }; + let result = file + .write(std::slice::from_raw_parts(data, n)) + .map(|written| written as f64); if let Some(pos) = restore_pos { let _ = file.seek(SeekFrom::Start(pos)); } diff --git a/test-files/test_gap_fs_write_error_propagation.ts b/test-files/test_gap_fs_write_error_propagation.ts new file mode 100644 index 000000000..291a66c9a --- /dev/null +++ b/test-files/test_gap_fs_write_error_propagation.ts @@ -0,0 +1,46 @@ +// `fs.write*` must distinguish a real OS write failure from a successful +// zero-byte write. A read-only fd is still open, so it bypasses EBADF +// preflight and exercises the syscall-error path. +import * as fs from "node:fs"; + +const dir = fs.mkdtempSync("perry-fs-write-error-"); +const file = dir + "/file.txt"; +fs.writeFileSync(file, "seed"); +const fd = fs.openSync(file, "r"); + +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 { + return new Promise((resolve) => { + invoke((err: any) => { + console.log(label + ": " + (err ? err.code + " " + err.syscall : "OK")); + resolve(); + }); + }); +} + +async function main(): Promise { + try { + 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), + ); + } finally { + fs.closeSync(fd); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +main(); From 081afb8b03ec63e8154964d04ebf49ff7ebbc80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 15 Jul 2026 00:28:21 +0200 Subject: [PATCH 2/2] fix(fs): map EBADF and the write-side errnos to their Node codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/perry-runtime/src/fs/errors.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/perry-runtime/src/fs/errors.rs b/crates/perry-runtime/src/fs/errors.rs index 0618b547d..a3cda4f5a 100644 --- a/crates/perry-runtime/src/fs/errors.rs +++ b/crates/perry-runtime/src/fs/errors.rs @@ -21,6 +21,21 @@ pub(crate) fn io_error_code(err: &std::io::Error) -> &'static str { code if code == libc::ENOSPC => return "ENOSPC", code if code == libc::ETIMEDOUT => return "ETIMEDOUT", code if code == libc::EAGAIN => return "EAGAIN", + // Descriptor- and write-side errnos. Rust has no `ErrorKind` for + // these, so without an arm here they fall through to the + // `ErrorKind` match below and come back as the catch-all "EIO" — + // `fs.write()` to a closed fd reported `EIO` where Node reports + // `EBADF`. `io_error_errno` already returns the raw errno, so only + // the code string was wrong. + code if code == libc::EBADF => return "EBADF", + code if code == libc::EPIPE => return "EPIPE", + code if code == libc::EROFS => return "EROFS", + code if code == libc::EFBIG => return "EFBIG", + code if code == libc::ESPIPE => return "ESPIPE", + code if code == libc::EBUSY => return "EBUSY", + code if code == libc::EMFILE => return "EMFILE", + code if code == libc::ENFILE => return "ENFILE", + code if code == libc::EXDEV => return "EXDEV", _ => {} } } @@ -59,6 +74,15 @@ pub(crate) fn io_error_errno(err: &std::io::Error) -> i32 { "ENOSPC" => -libc::ENOSPC, "ETIMEDOUT" => -libc::ETIMEDOUT, "EAGAIN" => -libc::EAGAIN, + "EBADF" => -libc::EBADF, + "EPIPE" => -libc::EPIPE, + "EROFS" => -libc::EROFS, + "EFBIG" => -libc::EFBIG, + "ESPIPE" => -libc::ESPIPE, + "EBUSY" => -libc::EBUSY, + "EMFILE" => -libc::EMFILE, + "ENFILE" => -libc::ENFILE, + "EXDEV" => -libc::EXDEV, _ => -libc::EIO, } #[cfg(not(unix))]