diff --git a/changelog.d/10539-fs-read-error-shapes.md b/changelog.d/10539-fs-read-error-shapes.md new file mode 100644 index 0000000000..250930eb45 --- /dev/null +++ b/changelog.d/10539-fs-read-error-shapes.md @@ -0,0 +1,44 @@ +**Buffer-mode `fs` reads throw on failure, and read-stream errors carry Node's +`code`/`errno`/`syscall`/`path`** (#10452, #10451). + +`fs.readFileSync(path)`, `readFileSync(path, {})`, `fs.promises.readFile(path)` +and `import { readFile } from "node:fs/promises"` returned or resolved +`null`/`undefined` for a missing file. `try { cfg = readFileSync(optional) } +catch { defaults }` took the wrong branch, and `.catch(e => e.code === "ENOENT")` +never ran. The `'utf8'` and callback forms did report the error. + +Every `readFile` form reads through `read_file_bytes_with_options`, which folded +each failure into `None`. The Buffer entry points +(`js_fs_read_file_binary{,_options}`) turned that into a null `BufferHeader`, so +the value was `null` or `undefined` depending on the call shape. The string +entry point re-read the file to get an `io::Error` back and always reported it +as `open`. The reader now returns the OS error and the syscall that failed, and +every form reports it the way Node does: ENOENT or EACCES as `open ''`, +and a directory as `EISDIR: illegal operation on a directory, read` with no +path. Before, a directory read returned a Buffer or reported `open`. The +callback form now reads once. Its old `stat` pre-check missed directories, so a +directory gave `(null, undefined)` in Buffer mode and a synchronous throw in +string mode. `FileHandle.readFile()` now rejects when the read fails. + +`fs.createReadStream` open and read failures emitted a plain `Error` with only +the Rust message (`No such file or directory (os error 2)`) and no own +properties. The write side already stored a Node-shaped `error_value` (#9493). +The read side now does too. The constructor's open failure is held as the OS +error until the stream state is registered (the registry is the GC root), then +turned into the error value. The pump's read failures are stored the same way. +`fs.writeFile(dest, failingReadStream)` also rejects with that error now. The +stream error helpers moved to `fs/stream/stream_errors.rs` so `stream.rs` stays +under the 2000-line cap. + +fs error messages used Rust's `Display` text. They now use libuv's description +of the errno (`ENOENT: no such file or directory, open ''`), as Node +does, for every error built by `build_fs_error_value*`. + +Validation: `test_gap_10452_fs_read_error_shapes` covers sync, callback and +promise reads (with and without an encoding), `FileHandle.readFile`, and read +and write streams, for a missing file and a directory, plus successful-read +controls. It matches Node byte for byte and fails on the parent commit. EACCES +was checked by hand as an unprivileged user. New `fs::errors` unit tests cover +the libuv text and the open-vs-read failure shape. User-space instructions for +50k `readFileSync` calls on a small file: Buffer form −0.6 %, UTF-8 form −9.5 % +(it no longer decodes the path twice per call). diff --git a/crates/perry-runtime/src/fs/callbacks.rs b/crates/perry-runtime/src/fs/callbacks.rs index 7e3804d845..0813110a5f 100644 --- a/crates/perry-runtime/src/fs/callbacks.rs +++ b/crates/perry-runtime/src/fs/callbacks.rs @@ -10,34 +10,18 @@ pub extern "C" fn js_fs_read_file_callback(path_value: f64, encoding: f64, callb const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; let cb_ptr = callback_from_options_arg(encoding, callback); - unsafe { - if let Some(err_val) = fs_callback_read_error(path_value, "open") { - if !cb_ptr.is_null() { - defer_fs_callback_chain(cb_ptr, &[err_val, f64::from_bits(TAG_UNDEFINED)], 4); - } - return f64::from_bits(TAG_UNDEFINED); - } - } let encoding_is_callback = !extract_closure_ptr(encoding).is_null(); let want_buffer = encoding_is_callback || read_file_encoding(encoding).is_none(); - let data_val = if want_buffer { - let buf = js_fs_read_file_binary_options(path_value, encoding); - if buf.is_null() { - f64::from_bits(TAG_UNDEFINED) - } else { - f64::from_bits(crate::value::JSValue::pointer(buf as *const u8).bits()) - } - } else { - let str_ptr = js_fs_read_file_sync_options(path_value, encoding); - if str_ptr.is_null() { - f64::from_bits(TAG_UNDEFINED) - } else { - f64::from_bits(crate::value::js_nanbox_string(str_ptr as i64).to_bits()) - } + // One read decides both arms. The old pre-flight `stat` probe only saw a + // missing path, so a directory reached the reader, whose failure became + // `(null, undefined)` for a Buffer and a synchronous throw for a string + // (#10452); Node reports `EISDIR ... read` through the callback. + let args = match unsafe { read_file_value_result(path_value, encoding, !want_buffer) } { + Ok(data_val) => [f64::from_bits(TAG_NULL), data_val], + Err(err_val) => [err_val, f64::from_bits(TAG_UNDEFINED)], }; - if !cb_ptr.is_null() { - defer_fs_callback_chain(cb_ptr, &[f64::from_bits(TAG_NULL), data_val], 4); + defer_fs_callback_chain(cb_ptr, &args, 4); } f64::from_bits(TAG_UNDEFINED) } diff --git a/crates/perry-runtime/src/fs/errors.rs b/crates/perry-runtime/src/fs/errors.rs index 16df1c3226..e8d6eaf188 100644 --- a/crates/perry-runtime/src/fs/errors.rs +++ b/crates/perry-runtime/src/fs/errors.rs @@ -4,6 +4,15 @@ use super::*; pub(crate) fn io_error_code(err: &std::io::Error) -> &'static str { + // #10539 review: on Windows `raw_os_error` is a Win32 code, not an errno, + // so it goes through libuv's own translation table. + #[cfg(windows)] + if let Some((_, code)) = err + .raw_os_error() + .and_then(crate::util_syserr::win32_error_to_uv) + { + return code; + } #[cfg(unix)] if let Some(raw) = err.raw_os_error() { match raw { @@ -59,6 +68,13 @@ pub(crate) fn io_error_errno(err: &std::io::Error) -> i32 { if let Some(raw) = err.raw_os_error() { return -raw; } + // Windows has no errno to negate: libuv gives each code a fixed negative + // number there (`ENOENT` is -4058, not -2), and that is what node reports. + #[cfg(windows)] + { + const UV_WINDOWS_EIO: i32 = -4070; + return crate::util_syserr::uv_windows_errno(io_error_code(err)).unwrap_or(UV_WINDOWS_EIO); + } #[cfg(unix)] match io_error_code(err) { "ENOENT" => -libc::ENOENT, @@ -84,7 +100,7 @@ pub(crate) fn io_error_errno(err: &std::io::Error) -> i32 { "EXDEV" => -libc::EXDEV, _ => -libc::EIO, } - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] match io_error_code(err) { "ENOENT" => -2, "EACCES" => -13, @@ -150,6 +166,20 @@ unsafe fn attach_fs_error_props( } } +/// The description Node puts in an fs error message: libuv's fixed lowercase +/// phrasing for the errno ("no such file or directory"), not Rust's `Display`, +/// which reads "No such file or directory (os error 2)" (#10451). An error +/// synthesized without an OS errno keeps its own text. +fn fs_error_description(err: &std::io::Error) -> String { + if err.raw_os_error().is_some() { + let code = io_error_errno(err) as i64; + if let Some(message) = crate::util_syserr::system_error_message_for_code(code) { + return message.to_string(); + } + } + err.to_string() +} + pub(crate) unsafe fn build_fs_error_value( err: &std::io::Error, syscall: &'static str, @@ -157,7 +187,8 @@ pub(crate) unsafe fn build_fs_error_value( ) -> f64 { let code = io_error_code(err); let errno = io_error_errno(err); - let msg = format!("{}: {}, {} '{}'", code, err, syscall, path); + let desc = fs_error_description(err); + let msg = format!("{}: {}, {} '{}'", code, desc, syscall, path); let msg_ptr = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = crate::error::js_error_new_with_message(msg_ptr); attach_fs_error_props(err_ptr, code, errno, syscall, Some(path), None); @@ -175,7 +206,8 @@ pub(crate) unsafe fn build_fs_error_value_with_dest( ) -> f64 { let code = io_error_code(err); let errno = io_error_errno(err); - let msg = format!("{}: {}, {} '{}' -> '{}'", code, err, syscall, path, dest); + let desc = fs_error_description(err); + let msg = format!("{}: {}, {} '{}' -> '{}'", code, desc, syscall, path, dest); let msg_ptr = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = crate::error::js_error_new_with_message(msg_ptr); attach_fs_error_props(err_ptr, code, errno, syscall, Some(path), Some(dest)); @@ -188,13 +220,73 @@ pub(crate) unsafe fn build_fs_error_value_no_path( ) -> f64 { let code = io_error_code(err); let errno = io_error_errno(err); - let msg = format!("{}: {}, {}", code, err, syscall); + let desc = fs_error_description(err); + let msg = format!("{}: {}, {}", code, desc, syscall); let msg_ptr = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = crate::error::js_error_new_with_message(msg_ptr); attach_fs_error_props(err_ptr, code, errno, syscall, None, None); crate::value::js_nanbox_pointer(err_ptr as i64) } +/// An OS "no such file or directory" error. `libc::ENOENT` is 2 on Windows too, +/// where `from_raw_os_error` reads it as `ERROR_FILE_NOT_FOUND` — which libuv +/// also translates to `ENOENT`. +pub(crate) fn enoent_os_error() -> std::io::Error { + std::io::Error::from_raw_os_error(libc::ENOENT) +} + +/// An OS "bad file descriptor" error. On Windows this must be +/// `ERROR_INVALID_HANDLE`, the Win32 error libuv translates to `EBADF`: +/// `libc::EBADF` (9) is `ERROR_INVALID_BLOCK` there and translates to nothing. +pub(crate) fn ebadf_os_error() -> std::io::Error { + #[cfg(windows)] + { + const ERROR_INVALID_HANDLE: i32 = 6; + std::io::Error::from_raw_os_error(ERROR_INVALID_HANDLE) + } + #[cfg(not(windows))] + { + std::io::Error::from_raw_os_error(libc::EBADF) + } +} + +/// A failed file read (`readFile`, a read stream): the OS error plus the +/// syscall Node reports it under. Node opens before it reads, so a missing file +/// fails the `open` and names the path, while a directory opens fine and fails +/// the `read`, which Node reports without a path +/// (`EISDIR: illegal operation on a directory, read`). +pub(crate) struct FsReadFailure { + pub(super) err: std::io::Error, + syscall: &'static str, + path: Option, +} + +impl FsReadFailure { + pub(crate) fn open(err: std::io::Error, path: &str) -> Self { + let path = Some(path.to_string()); + Self { + err, + syscall: "open", + path, + } + } + + pub(crate) fn read(err: std::io::Error) -> Self { + Self { + err, + syscall: "read", + path: None, + } + } + + pub(crate) unsafe fn error_value(&self) -> f64 { + match &self.path { + Some(path) => build_fs_error_value(&self.err, self.syscall, path), + None => build_fs_error_value_no_path(&self.err, self.syscall), + } + } +} + /// Probe a path for read access and produce a NaN-boxed Error if the /// underlying syscall would fail. Returns `None` on success. pub(crate) unsafe fn fs_callback_read_error(path_value: f64, syscall: &'static str) -> Option { @@ -237,3 +329,65 @@ pub(crate) unsafe fn fs_callback_write_parent_error( Err(err) => Some(build_fs_error_value(&err, syscall, &path)), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// #10451: an fs error message carries libuv's description of the errno, + /// as Node's does, not Rust's "No such file or directory (os error 2)". + #[cfg(unix)] + #[test] + fn fs_error_description_uses_libuv_text() { + let described = |errno| fs_error_description(&std::io::Error::from_raw_os_error(errno)); + assert_eq!(described(libc::ENOENT), "no such file or directory"); + assert_eq!(described(libc::EISDIR), "illegal operation on a directory"); + assert_eq!(described(libc::EACCES), "permission denied"); + // A synthesized error has no errno to describe and keeps its own text. + let custom = std::io::Error::new(std::io::ErrorKind::NotFound, "parent is not a directory"); + assert_eq!(fs_error_description(&custom), "parent is not a directory"); + } + + /// #10452: every `readFile` form reads through `read_file_bytes_with_options`, + /// whose failures used to be a bare `None` the Buffer forms returned as + /// `null`/`undefined`. A missing file must fail the `open` and name the path; + /// a directory opens and must fail the `read`, which Node reports pathless. + #[cfg(unix)] + #[test] + fn read_file_failures_keep_the_os_error_and_failing_syscall() { + let _global = crate::gc::global_side_table_test_lock(); + let dir = + std::env::temp_dir().join(format!("perry_fs_read_failure_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ok.txt"); + std::fs::write(&file, b"hello").unwrap(); + let missing = dir.join("missing.txt"); + let path_value = |path: &std::path::Path| { + let path = path.to_str().unwrap(); + let ptr = js_string_from_bytes(path.as_ptr(), path.len() as u32); + crate::value::js_nanbox_string(ptr as i64) + }; + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + + let failure = read_file_bytes_with_options(path_value(&missing), undefined) + .err() + .expect("a missing file must fail"); + assert_eq!(failure.err.raw_os_error(), Some(libc::ENOENT)); + assert_eq!( + (failure.syscall, failure.path.as_deref()), + ("open", missing.to_str()) + ); + + let failure = read_file_bytes_with_options(path_value(&dir), undefined) + .err() + .expect("a directory must fail"); + assert_eq!(failure.err.raw_os_error(), Some(libc::EISDIR)); + assert_eq!((failure.syscall, failure.path.as_deref()), ("read", None)); + + let bytes = read_file_bytes_with_options(path_value(&file), undefined) + .ok() + .expect("a regular file reads"); + assert_eq!(bytes, b"hello"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/perry-runtime/src/fs/filehandle.rs b/crates/perry-runtime/src/fs/filehandle.rs index be05274c27..d01cef4e87 100644 --- a/crates/perry-runtime/src/fs/filehandle.rs +++ b/crates/perry-runtime/src/fs/filehandle.rs @@ -1144,7 +1144,11 @@ pub(crate) extern "C" fn filehandle_read_file_impl( return promise_value_fs(f64::from_bits(crate::value::TAG_UNDEFINED)); }; let mut bytes = Vec::new(); - let _ = file.read_to_end(&mut bytes); + if let Err(err) = file.read_to_end(&mut bytes) { + // #10452: a failed read rejects (`EISDIR ... read` for a directory) + // instead of resolving with whatever was read before it failed. + return promise_rejected_fs(unsafe { build_fs_error_value_no_path(&err, "read") }); + } if read_file_encoding(encoding).is_none() { let buf = crate::buffer::js_buffer_alloc(bytes.len() as i32, 0); if !buf.is_null() { diff --git a/crates/perry-runtime/src/fs/mod.rs b/crates/perry-runtime/src/fs/mod.rs index 7875ec484e..91cf60bb17 100644 --- a/crates/perry-runtime/src/fs/mod.rs +++ b/crates/perry-runtime/src/fs/mod.rs @@ -301,9 +301,8 @@ fn numeric_fd_value(value: f64) -> Option { } } -/// Read a file synchronously and return its contents as a string -/// Returns null pointer on error -/// Accepts NaN-boxed string path +/// Read a file synchronously and return its contents as a string. +/// Throws a Node-shaped fs error on failure. Accepts NaN-boxed string path. // These readFileSync entry points intentionally throw on I/O failure. They // must permit the generated landingpad transport to cross their Rust FFI // frames so Node-style `try { readFileSync(optional) } catch { ... }` works @@ -321,15 +320,14 @@ pub extern "C-unwind" fn js_fs_read_file_sync_options( validate::validate_path_or_fd("path", path_value, "read"); validate::validate_string_or_object_options("options", options_value); unsafe { - let _path_str_for_log = decode_path_value(path_value).unwrap_or_default(); - // Debug: log path on Android #[cfg(target_os = "android")] { extern "C" { fn __android_log_print(prio: i32, tag: *const u8, fmt: *const u8, ...) -> i32; } - let c_path = std::ffi::CString::new(_path_str_for_log).unwrap_or_default(); + let path_str_for_log = decode_path_value(path_value).unwrap_or_default(); + let c_path = std::ffi::CString::new(path_str_for_log).unwrap_or_default(); __android_log_print( 3, b"PerryFS\0".as_ptr(), @@ -339,7 +337,7 @@ pub extern "C-unwind" fn js_fs_read_file_sync_options( } match read_file_bytes_with_options(path_value, options_value) { - Some(bytes) => { + Ok(bytes) => { #[cfg(target_os = "android")] { extern "C" { @@ -359,7 +357,7 @@ pub extern "C-unwind" fn js_fs_read_file_sync_options( } js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) } - None => { + Err(failure) => { #[cfg(target_os = "android")] { extern "C" { @@ -389,13 +387,7 @@ pub extern "C-unwind" fn js_fs_read_file_sync_options( // a Node-shaped fs error instead. This is a real, catchable JS // throw (caught by JS try/catch) — NOT the null-pointer segfault // the previous empty-string workaround was guarding against. - let path_str = decode_path_value(path_value).unwrap_or_default(); - let io_err = std::fs::read(&path_str) - .err() - .unwrap_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound)); - crate::exception::js_throw(crate::fs::errors::build_fs_error_value( - &io_err, "open", &path_str, - )) + crate::exception::js_throw(failure.error_value()) } } } @@ -407,15 +399,33 @@ pub extern "C-unwind" fn js_fs_read_file_dispatch(path_value: f64, options_value let str_ptr = js_fs_read_file_sync_options(path_value, options_value); f64::from_bits(crate::value::JSValue::string_ptr(str_ptr).bits()) } else { + // Throws on failure (#10452) — never null, so never `undefined`. let buf = js_fs_read_file_binary_options(path_value, options_value); - if buf.is_null() { - f64::from_bits(crate::value::TAG_UNDEFINED) - } else { - f64::from_bits(crate::value::JSValue::pointer(buf as *const u8).bits()) - } + f64::from_bits(crate::value::JSValue::pointer(buf as *const u8).bits()) } } +/// `readFile`'s shared core for the sync, callback and promise forms (#10452): +/// the contents as a string (`as_string`) or a Buffer, or the Node-shaped +/// error value to throw, hand to the callback, or reject with. +pub(crate) unsafe fn read_file_value_result( + path_value: f64, + options_value: f64, + as_string: bool, +) -> Result { + validate::validate_path_or_fd("path", path_value, "read"); + validate::validate_string_or_object_options("options", options_value); + let bytes = read_file_bytes_with_options(path_value, options_value) + .map_err(|failure| failure.error_value())?; + Ok(if as_string { + let str_ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + f64::from_bits(crate::value::JSValue::string_ptr(str_ptr).bits()) + } else { + let buf = buffer_from_file_bytes(&bytes); + f64::from_bits(crate::value::JSValue::pointer(buf as *const u8).bits()) + }) +} + /// Write content to a file synchronously /// Returns 1 on success, 0 on failure /// Accepts NaN-boxed string values @@ -439,7 +449,7 @@ fn js_string_value(value: f64) -> Option { } } -fn read_file_encoding(options_value: f64) -> Option { +pub(crate) fn read_file_encoding(options_value: f64) -> Option { let value = crate::value::JSValue::from_bits(options_value.to_bits()); if value.is_undefined() || value.is_null() { return None; @@ -500,18 +510,28 @@ fn open_file_for_read_flag(path: &str, flag: &str) -> std::io::Result opts.open(path) } -fn read_file_bytes_with_options(path_value: f64, options_value: f64) -> Option> { +/// The bytes behind every `readFile` form. Failures used to collapse into +/// `None`, which the Buffer forms turned into `null`/`undefined` instead of an +/// error (#10452); they now carry the OS error and the failing syscall. +fn read_file_bytes_with_options( + path_value: f64, + options_value: f64, +) -> Result, FsReadFailure> { unsafe { if let Some(fd) = numeric_fd_value(path_value) { let mut bytes = Vec::new(); - FD_REGISTRY.with(|r| { - if let Some(file) = r.borrow_mut().get_mut(&fd) { - let _ = file.read_to_end(&mut bytes); - } + let read = FD_REGISTRY.with(|r| { + r.borrow_mut() + .get_mut(&fd) + .map(|file| file.read_to_end(&mut bytes)) }); - return Some(bytes); + // `validate_path_or_fd` already threw EBADF for an unknown fd. + let read = read.unwrap_or_else(|| Err(ebadf_os_error())); + return read.map(|_| bytes).map_err(FsReadFailure::read); } - let path_str = decode_path_value(path_value)?; + let Some(path_str) = decode_path_value(path_value) else { + return Err(FsReadFailure::open(enoent_os_error(), "")); + }; // #5731 — virtual filesystem: a `$perryfs/...` path (or a bare key that // matches an embedded asset) is served from the in-binary registry // before any disk access, so `fs.readFileSync`/`readFile` (text and @@ -519,16 +539,19 @@ fn read_file_bytes_with_options(path_value: f64, options_value: f64) -> Option file, + Err(err) => return Err(FsReadFailure::open(err, &path_str)), + }; let mut bytes = Vec::new(); - file.read_to_end(&mut bytes).ok()?; - Some(bytes) + file.read_to_end(&mut bytes).map_err(FsReadFailure::read)?; + Ok(bytes) } } @@ -877,15 +900,18 @@ pub extern "C" fn js_fs_chmod_sync(path_value: f64, mode: f64) -> i32 { } /// Read a file synchronously as binary and return a Buffer (binary-safe, works for PNG etc.) -/// Returns a *mut BufferHeader on success, null on error -/// Accepts NaN-boxed string path +/// Returns a *mut BufferHeader on success and throws a Node-shaped fs error on +/// failure, like the string form (#10452 — it used to return null, which the +/// callers surfaced as `null`/`undefined`). Accepts NaN-boxed string path. #[no_mangle] -pub extern "C" fn js_fs_read_file_binary(path_value: f64) -> *mut crate::buffer::BufferHeader { +pub extern "C-unwind" fn js_fs_read_file_binary( + path_value: f64, +) -> *mut crate::buffer::BufferHeader { js_fs_read_file_binary_options(path_value, f64::from_bits(crate::value::TAG_UNDEFINED)) } #[no_mangle] -pub extern "C" fn js_fs_read_file_binary_options( +pub extern "C-unwind" fn js_fs_read_file_binary_options( path_value: f64, options_value: f64, ) -> *mut crate::buffer::BufferHeader { @@ -893,21 +919,24 @@ pub extern "C" fn js_fs_read_file_binary_options( validate::validate_string_or_object_options("options", options_value); unsafe { match read_file_bytes_with_options(path_value, options_value) { - Some(bytes) => { - let buf = crate::buffer::js_buffer_alloc(bytes.len() as i32, 0); - if !buf.is_null() { - let buf_data = - (buf as *mut u8).add(std::mem::size_of::()); - std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf_data, bytes.len()); - (*buf).length = bytes.len() as u32; - } - buf - } - None => std::ptr::null_mut(), + Ok(bytes) => buffer_from_file_bytes(&bytes), + Err(failure) => crate::exception::js_throw(failure.error_value()), } } } +/// A fresh Buffer holding `bytes`. A new allocation is never a view or +/// foreign-backed, so its data sits directly after the header. +unsafe fn buffer_from_file_bytes(bytes: &[u8]) -> *mut crate::buffer::BufferHeader { + let buf = crate::buffer::js_buffer_alloc(bytes.len() as i32, 0); + if !buf.is_null() { + let buf_data = (buf as *mut u8).add(std::mem::size_of::()); + std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf_data, bytes.len()); + (*buf).length = bytes.len() as u32; + } + buf +} + /// Recursively remove a directory or file. /// Returns 1 on success, 0 on failure. /// Accepts NaN-boxed string path. diff --git a/crates/perry-runtime/src/fs/stream.rs b/crates/perry-runtime/src/fs/stream.rs index 5b982175e1..a341d43bc0 100644 --- a/crates/perry-runtime/src/fs/stream.rs +++ b/crates/perry-runtime/src/fs/stream.rs @@ -88,6 +88,9 @@ pub(crate) struct StreamState { /// deferred open, handed to the pending callbacks and to `'error'`. /// `undefined` until then; `error_msg` stays the "errored" flag. error_value: f64, + /// #10451: a read stream's constructor-time open failure, held as the OS + /// error until `store_open_failure` turns it into `error_value`. + open_failure: Option, /// #9493: a turn is already parked on the callback-timer queue. turn_pending: bool, bytes_read: u64, @@ -167,6 +170,7 @@ impl StreamState { pending_writes: Vec::new(), end_callback: f64::from_bits(crate::value::TAG_UNDEFINED), error_value: f64::from_bits(crate::value::TAG_UNDEFINED), + open_failure: None, turn_pending: false, bytes_read: 0, bytes_written: 0, @@ -572,13 +576,6 @@ fn refresh_props(id: usize) { }); } -fn make_error_value(message: &str) -> f64 { - let msg = message.as_bytes(); - let err_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - let err_obj = crate::error::js_error_new_with_message(err_str); - crate::value::js_nanbox_pointer(err_obj as i64) -} - fn event_name(value: f64) -> String { String::from_utf8_lossy(&bytes_from_value(value)).into_owned() } @@ -709,36 +706,6 @@ fn call_js_method2(receiver: f64, name: &[u8], arg0: f64, arg1: f64) -> f64 { } } -/// The stream's stored error as a JS value: the node-shaped value the deferred -/// open produced when there is one (#9493), else an `Error` over `error_msg`. -fn stored_error_value(state: &StreamState) -> Option { - if !JSValue::from_bits(state.error_value.to_bits()).is_undefined() { - return Some(state.error_value); - } - state.error_msg.as_deref().map(make_error_value) -} - -fn emit_stored_error(id: usize) { - let error_value = STREAM_REGISTRY.with(|registry| { - let registry = registry.borrow(); - registry.get(&id).and_then(stored_error_value) - }); - if let Some(err) = error_value { - emit_event1(id, "error", err); - } -} - -fn record_stream_error(id: usize, message: String) { - STREAM_REGISTRY.with(|registry| { - if let Some(state) = registry.borrow_mut().get_mut(&id) { - state.errored = true; - state.error_msg = Some(message); - } - }); - refresh_props(id); - emit_stored_error(id); -} - fn close_fd_for_state(state: &mut StreamState) { let Some(fd) = state.fd else { state.closed = true; @@ -1271,6 +1238,8 @@ fn throw_plain_type_error_value(message: &str) -> ! { mod options_init; use options_init::*; +mod stream_errors; +use stream_errors::*; mod utf8_stream; pub(crate) use utf8_stream::*; @@ -1301,7 +1270,7 @@ fn read_chunk_value(bytes: &[u8], encoding: Option<&str>) -> f64 { } } -fn read_next_chunk(id: usize) -> Result, Option)>, String> { +fn read_next_chunk(id: usize) -> Result, Option)>, FsReadFailure> { let (fd, pos, amount, encoding) = STREAM_REGISTRY.with(|registry| { let registry = registry.borrow(); let Some(state) = registry.get(&id) else { @@ -1325,18 +1294,19 @@ fn read_next_chunk(id: usize) -> Result, Option)>, Strin if amount == 0 { return Ok(None); } + let ebadf = || FsReadFailure::read(ebadf_os_error()); let Some(fd) = fd else { - return Err("bad file descriptor".to_string()); + return Err(ebadf()); }; let result = FD_REGISTRY.with(|registry| { let mut registry = registry.borrow_mut(); let Some(file) = registry.get_mut(&fd) else { - return Err("bad file descriptor".to_string()); + return Err(ebadf()); }; file.seek(SeekFrom::Start(pos)) - .map_err(|err| err.to_string())?; + .map_err(FsReadFailure::read)?; let mut buffer = vec![0; amount]; - let read = file.read(&mut buffer).map_err(|err| err.to_string())?; + let read = file.read(&mut buffer).map_err(FsReadFailure::read)?; buffer.truncate(read); Ok(buffer) })?; @@ -1481,13 +1451,13 @@ fn read_stream_pump(id: usize) { finish_read_stream(id); return; } - Err(message) => { + Err(failure) => { STREAM_REGISTRY.with(|registry| { if let Some(state) = registry.borrow_mut().get_mut(&id) { state.pumping = false; } }); - record_stream_error(id, message); + record_read_failure(id, failure); maybe_close_stream(id, false); return; } @@ -1745,6 +1715,7 @@ fn create_write_stream_with_state(state: StreamState) -> f64 { fn create_read_stream_with_state(state: StreamState) -> f64 { register_stream_method_arities(); let id = alloc_stream(state); + store_open_failure(id); let method_funcs: [(&str, extern "C" fn()); 10] = [ ("on", unsafe { std::mem::transmute::< diff --git a/crates/perry-runtime/src/fs/stream/options_init.rs b/crates/perry-runtime/src/fs/stream/options_init.rs index edb0681842..51eb810a77 100644 --- a/crates/perry-runtime/src/fs/stream/options_init.rs +++ b/crates/perry-runtime/src/fs/stream/options_init.rs @@ -3,25 +3,6 @@ use super::*; -/// Starting a ReadStream must deliver a constructor-time open failure. Without -/// this transition, event-backed consumers wait forever for data/end/error -/// after `createReadStream()` recorded an invalid path (#9616). -pub(super) fn emit_pending_read_error(id: usize) -> bool { - let pending = STREAM_REGISTRY.with(|registry| { - registry.borrow().get(&id).and_then(|state| { - (state.kind == StreamKind::Read && !state.errored) - .then(|| state.error_msg.clone()) - .flatten() - }) - }); - let Some(message) = pending else { - return false; - }; - record_stream_error(id, message); - maybe_close_stream(id, false); - true -} - pub(super) fn register_stream_method_arities() { crate::closure::js_register_closure_arity(write_stream_write_impl as *const u8, 3); crate::closure::js_register_closure_arity(write_stream_end_impl as *const u8, 3); @@ -105,8 +86,9 @@ pub(super) fn init_read_state_from_options( state.owner = FdOwner::Path; state.opened = true; } - Err((err, _path)) => { + Err((err, path)) => { state.error_msg = Some(err.to_string()); + state.open_failure = Some(FsReadFailure::open(err, &path)); } } state diff --git a/crates/perry-runtime/src/fs/stream/stream_errors.rs b/crates/perry-runtime/src/fs/stream/stream_errors.rs new file mode 100644 index 0000000000..5f2e37fadb --- /dev/null +++ b/crates/perry-runtime/src/fs/stream/stream_errors.rs @@ -0,0 +1,109 @@ +//! A stream's stored `'error'` value and its delivery, split out of +//! `stream.rs` to keep it under the 2000-line cap. + +use super::*; + +pub(super) fn make_error_value(message: &str) -> f64 { + let msg = message.as_bytes(); + let err_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err_obj = crate::error::js_error_new_with_message(err_str); + crate::value::js_nanbox_pointer(err_obj as i64) +} + +/// The stream's stored error as a JS value: the node-shaped value the deferred +/// open produced when there is one (#9493), else an `Error` over `error_msg`. +pub(super) fn stored_error_value(state: &StreamState) -> Option { + if !JSValue::from_bits(state.error_value.to_bits()).is_undefined() { + return Some(state.error_value); + } + state.error_msg.as_deref().map(make_error_value) +} + +pub(super) fn emit_stored_error(id: usize) { + let error_value = STREAM_REGISTRY.with(|registry| { + let registry = registry.borrow(); + registry.get(&id).and_then(stored_error_value) + }); + if let Some(err) = error_value { + emit_event1(id, "error", err); + } +} + +pub(super) fn record_stream_error(id: usize, message: String) { + STREAM_REGISTRY.with(|registry| { + if let Some(state) = registry.borrow_mut().get_mut(&id) { + state.errored = true; + state.error_msg = Some(message); + } + }); + refresh_props(id); + emit_stored_error(id); +} + +/// #10451: a read stream's failure as Node reports it. `error_msg` alone +/// became a bare `Error` with the Rust text ("No such file or directory (os +/// error 2)") and no `code`/`errno`/`syscall`/`path`, while the write side +/// already stored a node-shaped `error_value` (#9493). +fn store_read_failure(id: usize, failure: &FsReadFailure) { + let error_value = unsafe { failure.error_value() }; + // No allocation between building the value and storing it: from here the + // registry is the GC root that keeps it (and rewrites it if it moves). + STREAM_REGISTRY.with(|registry| { + if let Some(state) = registry.borrow_mut().get_mut(&id) { + state.error_value = error_value; + } + }); +} + +/// Turn the constructor's open failure into `error_value` once the state is +/// registered, so the `'error'` replay to a listener attached right after +/// construction and the pump's delivery both hand out the node-shaped value. +pub(super) fn store_open_failure(id: usize) { + let failure = STREAM_REGISTRY.with(|registry| { + let mut registry = registry.borrow_mut(); + registry + .get_mut(&id) + .and_then(|state| state.open_failure.take()) + }); + if let Some(failure) = failure { + store_read_failure(id, &failure); + } +} + +/// The node-shaped error a read or write stream has already stored, if any. +/// Unlike `stored_error_value` this never synthesizes a bare `Error` from +/// `error_msg`: the caller wants the failure's `code`/`syscall`/`path` or +/// nothing. +pub(super) fn stored_node_error_value(id: usize) -> Option { + STREAM_REGISTRY.with(|registry| { + let registry = registry.borrow(); + let state = registry.get(&id)?; + (!JSValue::from_bits(state.error_value.to_bits()).is_undefined()) + .then_some(state.error_value) + }) +} + +/// A failed read while pumping (`EISDIR ... read` for a directory). +pub(super) fn record_read_failure(id: usize, failure: FsReadFailure) { + store_read_failure(id, &failure); + record_stream_error(id, failure.err.to_string()); +} + +/// Starting a ReadStream must deliver a constructor-time open failure. Without +/// this transition, event-backed consumers wait forever for data/end/error +/// after `createReadStream()` recorded an invalid path (#9616). +pub(super) fn emit_pending_read_error(id: usize) -> bool { + let pending = STREAM_REGISTRY.with(|registry| { + registry.borrow().get(&id).and_then(|state| { + (state.kind == StreamKind::Read && !state.errored) + .then(|| state.error_msg.clone()) + .flatten() + }) + }); + let Some(message) = pending else { + return false; + }; + record_stream_error(id, message); + maybe_close_stream(id, false); + true +} diff --git a/crates/perry-runtime/src/fs/stream/write_file_input.rs b/crates/perry-runtime/src/fs/stream/write_file_input.rs index 600aae4ccd..e8042a059a 100644 --- a/crates/perry-runtime/src/fs/stream/write_file_input.rs +++ b/crates/perry-runtime/src/fs/stream/write_file_input.rs @@ -313,6 +313,13 @@ fn consume_fs_read_stream_for_write_file( where F: FnMut(&[u8]) -> Result<(), f64>, { + // A stream whose constructor open failed has no fd, so reading it would + // report EBADF and lose the failure the constructor stored — node rejects + // `fs.promises.writeFile(out, createReadStream(missing))` with that + // stream's `ENOENT ... open ''` (#10451 review). + if let Some(error_value) = stored_node_error_value(id) { + return Err(error_value); + } loop { check_write_file_aborted(signal)?; match read_next_chunk(id) { @@ -324,7 +331,7 @@ where finish_read_stream(id); return Ok(()); } - Err(message) => return Err(make_error_value(&message)), + Err(failure) => return Err(unsafe { failure.error_value() }), } } } diff --git a/crates/perry-runtime/src/node_submodules/fs_promises.rs b/crates/perry-runtime/src/node_submodules/fs_promises.rs index 6d89c57642..96da1cd208 100644 --- a/crates/perry-runtime/src/node_submodules/fs_promises.rs +++ b/crates/perry-runtime/src/node_submodules/fs_promises.rs @@ -105,7 +105,11 @@ pub(crate) extern "C" fn thunk_fs_promises_readFile( path: f64, encoding: f64, ) -> f64 { - promise_from_sync_value(|| crate::fs::js_fs_read_file_dispatch(path, encoding)) + // #10452: the Buffer form used to resolve `undefined` for a missing file. + let as_string = crate::fs::read_file_encoding(encoding).is_some(); + promise_from_result_value(|| unsafe { + crate::fs::read_file_value_result(path, encoding, as_string) + }) } pub(crate) extern "C" fn thunk_fs_promises_open( diff --git a/crates/perry-runtime/src/util_syserr.rs b/crates/perry-runtime/src/util_syserr.rs index d115e4f642..758ece9dcd 100644 --- a/crates/perry-runtime/src/util_syserr.rs +++ b/crates/perry-runtime/src/util_syserr.rs @@ -148,11 +148,168 @@ fn errno_backed() -> Vec<(i32, &'static str, &'static str)> { t } -#[cfg(not(unix))] +#[cfg(windows)] +fn errno_backed() -> Vec<(i32, &'static str, &'static str)> { + // Keyed by the POSITIVE magnitude, like the unix table above: `lookup` + // negates it. Codes `uv_internal` already carries are skipped so the two + // tables cannot disagree about a key. + UV_WINDOWS_ERRNOS + .iter() + .filter(|(_, name, _)| !uv_internal().iter().any(|(_, other, _)| other == name)) + .map(|(errno, name, message)| (-errno, *name, *message)) + .collect() +} + +#[cfg(not(any(unix, windows)))] fn errno_backed() -> Vec<(i32, &'static str, &'static str)> { Vec::new() } +/// libuv's error numbers on Windows, with libuv's messages. +/// +/// `include/uv/errno.h` defines each `UV__E*` as `-errno` only on a platform +/// that has that errno and is not `_WIN32`; on Windows every code falls back to +/// a fixed negative number, so `UV__ENOENT` is `-4058` rather than `-2`. Node +/// reports those as `err.errno` there. The messages are libuv's own +/// (`UV_ERRNO_MAP` in `uv.h`) and are the same text the unix table carries — +/// `windows_and_unix_tables_agree` pins that. +/// +/// The set is the filesystem-relevant one: every code `win32_error_to_uv` can +/// produce, plus the ones `io_error_code`'s `ErrorKind` fallback can name. +/// +/// Only Windows reads these tables; `cfg(test)` keeps them (and the pure +/// translation below) compiled — and asserted — on every host. +#[cfg(any(windows, test))] +const UV_WINDOWS_ERRNOS: &[(i32, &str, &str)] = &[ + (-4093, "E2BIG", "argument list too long"), + (-4092, "EACCES", "permission denied"), + (-4088, "EAGAIN", "resource temporarily unavailable"), + (-4083, "EBADF", "bad file descriptor"), + (-4082, "EBUSY", "resource busy or locked"), + (-4081, "ECANCELED", "operation canceled"), + (-4080, "ECHARSET", "invalid Unicode character"), + (-4075, "EEXIST", "file already exists"), + (-4074, "EFAULT", "bad address in system call argument"), + (-4028, "EFTYPE", "inappropriate file type or format"), + (-4071, "EINVAL", "invalid argument"), + (-4070, "EIO", "i/o error"), + (-4068, "EISDIR", "illegal operation on a directory"), + (-4067, "ELOOP", "too many symbolic links encountered"), + (-4066, "EMFILE", "too many open files"), + (-4064, "ENAMETOOLONG", "name too long"), + (-4058, "ENOENT", "no such file or directory"), + (-4057, "ENOMEM", "not enough memory"), + (-4055, "ENOSPC", "no space left on device"), + (-4052, "ENOTDIR", "not a directory"), + (-4051, "ENOTEMPTY", "directory not empty"), + (-4049, "ENOTSUP", "operation not supported on socket"), + (-4048, "EPERM", "operation not permitted"), + (-4047, "EPIPE", "broken pipe"), + (-4043, "EROFS", "read-only file system"), + (-4039, "ETIMEDOUT", "connection timed out"), + (-4037, "EXDEV", "cross-device link not permitted"), + (-4095, "EOF", "end of file"), + (-4094, "UNKNOWN", "unknown error"), +]; + +/// libuv's `uv_translate_sys_error` (`src/win/error.c` in libuv v1.52.1, the +/// libuv node 26.5.1 ships) restricted to the Win32 errors a filesystem call +/// returns. The socket (`WSAE*`) and network `ERROR_*` arms are left out: +/// nothing on this path produces them, and an unmapped code keeps the existing +/// `ErrorKind` fallback. The Win32 names are documentation; the numbers are +/// `windows-sys`' `Win32::Foundation` values. +#[cfg(any(windows, test))] +const WIN32_TO_UV: &[(i32, &str, &str)] = &[ + (740, "ERROR_ELEVATION_REQUIRED", "EACCES"), + (1920, "ERROR_CANT_ACCESS_FILE", "EACCES"), + (232, "ERROR_NO_DATA", "EAGAIN"), + (1004, "ERROR_INVALID_FLAGS", "EBADF"), + (6, "ERROR_INVALID_HANDLE", "EBADF"), + (33, "ERROR_LOCK_VIOLATION", "EBUSY"), + (231, "ERROR_PIPE_BUSY", "EBUSY"), + (32, "ERROR_SHARING_VIOLATION", "EBUSY"), + (995, "ERROR_OPERATION_ABORTED", "ECANCELED"), + (1113, "ERROR_NO_UNICODE_TRANSLATION", "ECHARSET"), + (183, "ERROR_ALREADY_EXISTS", "EEXIST"), + (80, "ERROR_FILE_EXISTS", "EEXIST"), + (998, "ERROR_NOACCESS", "EFAULT"), + (122, "ERROR_INSUFFICIENT_BUFFER", "EINVAL"), + (13, "ERROR_INVALID_DATA", "EINVAL"), + (87, "ERROR_INVALID_PARAMETER", "EINVAL"), + (1464, "ERROR_SYMLINK_NOT_SUPPORTED", "EINVAL"), + (1102, "ERROR_BEGINNING_OF_MEDIA", "EIO"), + (1111, "ERROR_BUS_RESET", "EIO"), + (23, "ERROR_CRC", "EIO"), + (1166, "ERROR_DEVICE_DOOR_OPEN", "EIO"), + (1165, "ERROR_DEVICE_REQUIRES_CLEANING", "EIO"), + (1393, "ERROR_DISK_CORRUPT", "EIO"), + (1129, "ERROR_EOM_OVERFLOW", "EIO"), + (1101, "ERROR_FILEMARK_DETECTED", "EIO"), + (31, "ERROR_GEN_FAILURE", "EIO"), + (1106, "ERROR_INVALID_BLOCK_LENGTH", "EIO"), + (1117, "ERROR_IO_DEVICE", "EIO"), + (1104, "ERROR_NO_DATA_DETECTED", "EIO"), + (205, "ERROR_NO_SIGNAL_SENT", "EIO"), + (110, "ERROR_OPEN_FAILED", "EIO"), + (1103, "ERROR_SETMARK_DETECTED", "EIO"), + (156, "ERROR_SIGNAL_REFUSED", "EIO"), + (1921, "ERROR_CANT_RESOLVE_FILENAME", "ELOOP"), + (4, "ERROR_TOO_MANY_OPEN_FILES", "EMFILE"), + (111, "ERROR_BUFFER_OVERFLOW", "ENAMETOOLONG"), + (206, "ERROR_FILENAME_EXCED_RANGE", "ENAMETOOLONG"), + (161, "ERROR_BAD_PATHNAME", "ENOENT"), + // libuv maps ERROR_DIRECTORY to ENOENT, not ENOTDIR. + (267, "ERROR_DIRECTORY", "ENOENT"), + (203, "ERROR_ENVVAR_NOT_FOUND", "ENOENT"), + (2, "ERROR_FILE_NOT_FOUND", "ENOENT"), + (123, "ERROR_INVALID_NAME", "ENOENT"), + (15, "ERROR_INVALID_DRIVE", "ENOENT"), + (4392, "ERROR_INVALID_REPARSE_DATA", "ENOENT"), + (126, "ERROR_MOD_NOT_FOUND", "ENOENT"), + (3, "ERROR_PATH_NOT_FOUND", "ENOENT"), + (8, "ERROR_NOT_ENOUGH_MEMORY", "ENOMEM"), + (14, "ERROR_OUTOFMEMORY", "ENOMEM"), + (82, "ERROR_CANNOT_MAKE", "ENOSPC"), + (112, "ERROR_DISK_FULL", "ENOSPC"), + (277, "ERROR_EA_TABLE_FULL", "ENOSPC"), + (1100, "ERROR_END_OF_MEDIA", "ENOSPC"), + (39, "ERROR_HANDLE_DISK_FULL", "ENOSPC"), + (145, "ERROR_DIR_NOT_EMPTY", "ENOTEMPTY"), + (50, "ERROR_NOT_SUPPORTED", "ENOTSUP"), + (109, "ERROR_BROKEN_PIPE", "EOF"), + // libuv reports a denied Win32 access as EPERM, not EACCES. + (5, "ERROR_ACCESS_DENIED", "EPERM"), + (1314, "ERROR_PRIVILEGE_NOT_HELD", "EPERM"), + (230, "ERROR_BAD_PIPE", "EPIPE"), + (233, "ERROR_PIPE_NOT_CONNECTED", "EPIPE"), + (19, "ERROR_WRITE_PROTECT", "EROFS"), + (121, "ERROR_SEM_TIMEOUT", "ETIMEDOUT"), + (17, "ERROR_NOT_SAME_DEVICE", "EXDEV"), + (1, "ERROR_INVALID_FUNCTION", "EISDIR"), + (208, "ERROR_META_EXPANSION_TOO_LONG", "E2BIG"), + (193, "ERROR_BAD_EXE_FORMAT", "EFTYPE"), +]; + +/// The libuv error number a code has on Windows (`UV__ENOENT` → `-4058`). +#[cfg(any(windows, test))] +pub(crate) fn uv_windows_errno(code: &str) -> Option { + UV_WINDOWS_ERRNOS + .iter() + .find_map(|(errno, name, _)| (*name == code).then_some(*errno)) +} + +/// The libuv `(errno, code)` for a Win32 error, or `None` when libuv's table +/// has no filesystem arm for it (the caller then keeps its `ErrorKind` +/// fallback). Pure, so it is unit tested on every host; only `io_error_code` +/// and `io_error_errno` call it, under `cfg(windows)`. +#[cfg(any(windows, test))] +pub(crate) fn win32_error_to_uv(win32: i32) -> Option<(i32, &'static str)> { + let code = WIN32_TO_UV + .iter() + .find_map(|(value, _, code)| (*value == win32).then_some(*code))?; + Some((uv_windows_errno(code)?, code)) +} + /// libuv-internal codes with no system errno — fixed negative keys. fn uv_internal() -> &'static [(i32, &'static str, &'static str)] { &[ @@ -288,6 +445,12 @@ pub(crate) fn system_error_name_for_code(code: i64) -> String { } } +/// libuv's message for a libuv-style code (`-2` → "no such file or +/// directory"), if mapped. Shared with the fs error builders. +pub(crate) fn system_error_message_for_code(code: i64) -> Option<&'static str> { + lookup(code).map(|(_, message)| message) +} + fn system_error_name(value: f64) -> String { let code = validate_system_error_code(value); system_error_name_for_code(code) @@ -340,6 +503,73 @@ pub extern "C" fn js_util_get_system_error_map() -> f64 { mod tests { use super::*; + /// libuv's Windows translation is pure, so it is checked on every host — + /// this repo cannot run Windows. + #[test] + fn win32_errors_translate_to_libuv_windows_codes() { + // (Win32 code, libuv code) pairs read off `uv_translate_sys_error`. + assert_eq!(win32_error_to_uv(2), Some((-4058, "ENOENT"))); // ERROR_FILE_NOT_FOUND + assert_eq!(win32_error_to_uv(3), Some((-4058, "ENOENT"))); // ERROR_PATH_NOT_FOUND + assert_eq!(win32_error_to_uv(123), Some((-4058, "ENOENT"))); // ERROR_INVALID_NAME + // libuv maps a denied Win32 access to EPERM and ERROR_DIRECTORY to + // ENOENT — neither is the errno name a unix reader would guess. + assert_eq!(win32_error_to_uv(5), Some((-4048, "EPERM"))); // ERROR_ACCESS_DENIED + assert_eq!(win32_error_to_uv(267), Some((-4058, "ENOENT"))); // ERROR_DIRECTORY + assert_eq!(win32_error_to_uv(183), Some((-4075, "EEXIST"))); // ERROR_ALREADY_EXISTS + assert_eq!(win32_error_to_uv(145), Some((-4051, "ENOTEMPTY"))); // ERROR_DIR_NOT_EMPTY + assert_eq!(win32_error_to_uv(6), Some((-4083, "EBADF"))); // ERROR_INVALID_HANDLE + assert_eq!(win32_error_to_uv(4), Some((-4066, "EMFILE"))); // ERROR_TOO_MANY_OPEN_FILES + assert_eq!(win32_error_to_uv(32), Some((-4082, "EBUSY"))); // ERROR_SHARING_VIOLATION + assert_eq!(win32_error_to_uv(17), Some((-4037, "EXDEV"))); // ERROR_NOT_SAME_DEVICE + assert_eq!(win32_error_to_uv(1), Some((-4068, "EISDIR"))); // ERROR_INVALID_FUNCTION + assert_eq!(win32_error_to_uv(112), Some((-4055, "ENOSPC"))); // ERROR_DISK_FULL + // A socket/network arm, or anything libuv does not map, declines so the + // caller keeps its `ErrorKind` fallback. + assert_eq!(win32_error_to_uv(10061), None); // WSAECONNREFUSED + assert_eq!(win32_error_to_uv(0), None); + } + + #[test] + fn the_windows_tables_are_consistent() { + for (win32, win32_name, code) in WIN32_TO_UV { + assert!( + uv_windows_errno(code).is_some(), + "{win32_name} maps to {code}, which UV_WINDOWS_ERRNOS does not carry" + ); + assert_eq!( + WIN32_TO_UV.iter().filter(|(v, _, _)| v == win32).count(), + 1, + "{win32_name} ({win32}) is listed twice" + ); + } + for (errno, name, _) in UV_WINDOWS_ERRNOS { + assert!(*errno < 0, "{name} must be a negative libuv code"); + assert_eq!( + UV_WINDOWS_ERRNOS + .iter() + .filter(|(_, n, _)| n == name) + .count(), + 1, + "{name} is listed twice" + ); + } + } + + /// The Windows table and the tables serving `util.getSystemErrorMessage` + /// must not drift: a code in both says the same thing. + #[cfg(unix)] + #[test] + fn windows_and_unix_tables_agree_on_messages() { + for (_, name, message) in UV_WINDOWS_ERRNOS { + if let Some((_, _, unix)) = errno_backed().iter().find(|(_, n, _)| n == name) { + assert_eq!(message, unix, "{name} message drifted"); + } + if let Some((_, _, internal)) = uv_internal().iter().find(|(_, n, _)| n == name) { + assert_eq!(message, internal, "{name} message drifted"); + } + } + } + #[cfg(unix)] #[test] fn names_and_messages_match_libuv() { diff --git a/test-files/test_gap_10452_fs_read_error_shapes.ts b/test-files/test_gap_10452_fs_read_error_shapes.ts new file mode 100644 index 0000000000..2094d8ef81 --- /dev/null +++ b/test-files/test_gap_10452_fs_read_error_shapes.ts @@ -0,0 +1,183 @@ +// Gap test: #10452 / #10451 — node:fs read failures must surface Node's error. +// +// #10452: the Buffer-mode reads (no encoding) swallowed the failure. +// `fs.readFileSync(missing)` returned null, `readFileSync(missing, {})` +// returned undefined and `fs.promises.readFile(missing)` resolved undefined, +// so `try { readFileSync(optional) } catch { defaults }` took the wrong +// branch. A directory read succeeded or reported `open` instead of Node's +// `EISDIR ... read`. +// #10451: a `fs.createReadStream` open/read failure emitted a bare Error with +// no code/errno/syscall/path and the Rust `(os error N)` message text, and a +// failed stream handed to `fs.promises.writeFile` reported EBADF for its +// missing fd instead of the constructor's failure. +// +// Every failure prints code/errno/syscall/path/message and the own-key order; +// the successful reads are the controls. +import * as fs from "node:fs"; +import fsDefault from "node:fs"; +import { readFileSync } from "node:fs"; +import * as fsp from "node:fs/promises"; +import { readFile as readFileP } from "node:fs/promises"; + +const base = "/tmp/perry_gap_10452_fs_read_error_shapes"; +fs.rmSync(base, { recursive: true, force: true }); +fs.mkdirSync(base + "/dir", { recursive: true }); +const missing = base + "/missing.txt"; +const missingParent = base + "/no-such-dir/child.txt"; +const dir = base + "/dir"; +const ok = base + "/ok.txt"; +fs.writeFileSync(ok, "hello"); + +function shape(e: any): string { + const fields = { code: e.code, errno: e.errno, syscall: e.syscall, path: e.path, message: e.message }; + return JSON.stringify(fields) + " keys=" + Object.keys(e).join(",") + " isError=" + (e instanceof Error); +} + +function show(v: any): string { + if (Buffer.isBuffer(v)) return "Buffer<" + v.toString() + ">"; + return typeof v + " " + String(v); +} + +function sync(label: string, f: () => any): void { + try { + console.log(label, "returned", show(f())); + } catch (e: any) { + console.log(label, "threw", shape(e)); + } +} + +async function promised(label: string, f: () => Promise): Promise { + try { + console.log(label, "resolved", show(await f())); + } catch (e: any) { + console.log(label, "rejected", shape(e)); + } +} + +function callback(label: string, start: (cb: (err: any, data?: any) => void) => void): Promise { + return new Promise((resolve) => { + start((err: any, data?: any) => { + if (err) console.log(label, "err", shape(err), "data", String(data)); + else console.log(label, "err", String(err), "data", show(data)); + resolve(); + }); + }); +} + +function readStream(label: string, target: string): Promise { + return new Promise((resolve) => { + const s = fs.createReadStream(target); + const chunks: string[] = []; + s.on("error", (e: any) => { + console.log(label, "error", shape(e)); + resolve(); + }); + s.on("data", (chunk: any) => chunks.push(String(chunk))); + s.on("end", () => { + console.log(label, "end", chunks.join("")); + resolve(); + }); + }); +} + +// --- sync --- +sync("readFileSync(missing)", () => fs.readFileSync(missing)); +sync("readFileSync(missing, {})", () => fs.readFileSync(missing, {})); +sync("readFileSync(missing, {flag:'r'})", () => fs.readFileSync(missing, { flag: "r" })); +sync("readFileSync(missing, 'utf8')", () => fs.readFileSync(missing, "utf8")); +sync("readFileSync(missing, {encoding})", () => fs.readFileSync(missing, { encoding: "utf8" })); +sync("readFileSync(missing, 'latin1')", () => fs.readFileSync(missing, "latin1")); +sync("readFileSync(missingParent, {flag:'a+'})", () => fs.readFileSync(missingParent, { flag: "a+" })); +sync("named readFileSync(missing)", () => readFileSync(missing)); +sync("default fs.readFileSync(missing)", () => fsDefault.readFileSync(missing)); +sync("readFileSync(dir)", () => fs.readFileSync(dir)); +sync("readFileSync(dir, 'utf8')", () => fs.readFileSync(dir, "utf8")); +const dirFd = fs.openSync(dir, "r"); +sync("readFileSync(dirFd)", () => fs.readFileSync(dirFd)); +fs.closeSync(dirFd); +sync("readFileSync(ok)", () => fs.readFileSync(ok)); +sync("readFileSync(ok, {})", () => fs.readFileSync(ok, {})); +sync("readFileSync(ok, 'utf8')", () => fs.readFileSync(ok, "utf8")); +sync("named readFileSync(ok)", () => readFileSync(ok)); +sync("default fs.readFileSync(ok)", () => fsDefault.readFileSync(ok)); +const okFd = fs.openSync(ok, "r"); +sync("readFileSync(okFd)", () => fs.readFileSync(okFd)); +fs.closeSync(okFd); +let config: any; +try { + config = fs.readFileSync(missing); +} catch { + config = "defaults"; +} +console.log("optional-file fallback:", show(config)); + +async function main(): Promise { + // --- callback --- + await callback("readFile(missing, cb)", (cb) => fs.readFile(missing, cb)); + await callback("readFile(missing, {}, cb)", (cb) => fs.readFile(missing, {}, cb)); + await callback("readFile(missing, 'utf8', cb)", (cb) => fs.readFile(missing, "utf8", cb)); + await callback("readFile(dir, cb)", (cb) => fs.readFile(dir, cb)); + await callback("readFile(dir, 'utf8', cb)", (cb) => fs.readFile(dir, "utf8", cb)); + await callback("readFile(ok, cb)", (cb) => fs.readFile(ok, cb)); + await callback("readFile(ok, 'utf8', cb)", (cb) => fs.readFile(ok, "utf8", cb)); + + // --- promises --- + await promised("fs.promises.readFile(missing)", () => fs.promises.readFile(missing)); + await promised("fsp.readFile(missing, {})", () => fsp.readFile(missing, {})); + await promised("fsp.readFile(missing, 'utf8')", () => fsp.readFile(missing, "utf8")); + await promised("named readFile(missing)", () => readFileP(missing)); + await promised("fsp.readFile(dir)", () => fsp.readFile(dir)); + await promised("fsp.readFile(dir, 'utf8')", () => fsp.readFile(dir, "utf8")); + await promised("fsp.readFile(ok)", () => fsp.readFile(ok)); + await promised("named readFile(ok, 'utf8')", () => readFileP(ok, "utf8")); + const missingIsEnoent = await fsp.readFile(missing).then( + () => false, + (e: any) => e.code === "ENOENT", + ); + console.log("readFile(missing).catch sees ENOENT:", missingIsEnoent); + + // --- FileHandle --- + await promised("fsp.open(missing)", () => fsp.open(missing)); + const dirHandle = await fsp.open(dir); + await promised("dirHandle.readFile()", () => dirHandle.readFile()); + await dirHandle.close(); + const okHandle = await fsp.open(ok); + await promised("okHandle.readFile()", () => okHandle.readFile()); + await okHandle.close(); + + // --- streams --- + await readStream("createReadStream(missing)", missing); + await readStream("createReadStream(dir)", dir); + await readStream("createReadStream(ok)", ok); + await new Promise((resolve) => { + fs.createWriteStream(missingParent).on("error", (e: any) => { + console.log("createWriteStream(missingParent) error", shape(e)); + resolve(); + }); + }); + + // A read stream consumed by fs.promises.writeFile must report the failure the + // constructor saw, not a later EBADF for its missing fd. Node's callback and + // sync writeFile reject a stream outright (ERR_INVALID_ARG_TYPE), so only the + // promise form is covered; the `'error'` listener keeps the failure handled, + // which is what makes the ordering deterministic. + const handled = (target: string) => { + const stream = fs.createReadStream(target); + stream.on("error", () => {}); + return stream; + }; + await promised("writeFile(out, readStream(missing))", () => + fsp.writeFile(base + "/copy-missing.txt", handled(missing)), + ); + await promised("writeFile(out, readStream(dir))", () => + fsp.writeFile(base + "/copy-dir.txt", handled(dir)), + ); + await promised("writeFile(out, readStream(ok))", () => + fsp.writeFile(base + "/copy-ok.txt", handled(ok)), + ); + console.log("copy-ok.txt:", show(fs.readFileSync(base + "/copy-ok.txt"))); + + fs.rmSync(base, { recursive: true, force: true }); +} + +main();