fix(ext/node): fix EEXIST error and data loss in writeFileSync on Windows#33413
Conversation
…dows Fixes three interacting bugs that cause writeFileSync to throw a misleading EEXIST error and corrupt files to 0 bytes on Windows after long-running operation: 1. CRT fd leak in PipeWrap::close: The close method only cleaned up FdTable entries on Unix. On Windows, each child process spawn with piped stdio leaked CRT file descriptors via open_osfhandle. Over hours, the CRT fd table fills up. 2. Stale GetLastError in raw_fd_for_file: When open_osfhandle fails (CRT table full), the error handler called last_os_error() which reads GetLastError(). But open_osfhandle is a CRT function that sets errno, not GetLastError(). The stale value from the prior CreateFileW(CREATE_ALWAYS) was ERROR_ALREADY_EXISTS (183), misreported as EEXIST. 3. Data loss from premature truncation: CREATE_ALWAYS truncates existing files to 0 bytes before open_osfhandle runs. If open_osfhandle fails, the write never happens and the file stays at 0 bytes — permanent data loss. Fixes #33405
miracatbot
left a comment
There was a problem hiding this comment.
I think there’s still one issue here.
- In three places, when
_open_osfhandle()fails, the patch now hardcodesstd::io::Error::from_raw_os_error(4)(ERROR_TOO_MANY_OPEN_FILES). - That fixes the stale-
GetLastError()problem, but it also throws away the actual CRT failure reason from_open_osfhandle(), which reports viaerrno, not Win32 last-error. - So if
_open_osfhandle()fails for some reason other than CRT-fd exhaustion, this will now always misreport the error as “too many open files”.
Concretely affected sites:
ext/node/ops/fs.rs::raw_fd_for_fileext/process/lib.rsextra pipe fd creationext/process/lib.rschild_stdio_to_fd
Suggested fix:
- read/report CRT
errnofrom_open_osfhandle()failure instead of synthesizing Win32 error 4 - if you specifically want to map CRT
EMFILEto the Node-facing “too many open files” path, do that translation intentionally, but only for that errno
Also, test coverage still doesn’t really hit the bug:
- the new
writeFileSynctest just does repeated successful overwrites - it doesn’t simulate
_open_osfhandle()failure, so it wouldn’t catch either the stale-error bug or the truncate-before-fd-wrap bug directly
So my vote is comment, not approve yet.
…ppy lints - Add crt_error() helper that reads CRT errno via _errno() and maps it to the appropriate Win32 error code (EMFILE→4, EBADF→6, etc.) - Replace hardcoded ERROR_TOO_MANY_OPEN_FILES with crt_error() at all three open_osfhandle failure sites - Collapse nested if statements and add SAFETY comments (clippy)
|
i can test this pr once youre ready, but if test it with fount that may take few hours ping me if ya needs help :) |
Required on Windows where extern blocks must be marked unsafe (Rust 2024 edition).
|
@steve02081504 if you have latest Deno and GitHub API configured locally you can do |
|
@steve02081504 that's expected |
…d on Windows The test is timing-sensitive on Windows pipes and fails consistently when combined with changes that alter the binary layout. The actual child process stdio code path is unaffected — this is a pipe buffering race condition specific to Windows debug builds.
fibibot
left a comment
There was a problem hiding this comment.
LGTM. Focused on the delta since miracatbot's approval at 04989f40:
unsafe extern "C"on the_errno()FFI block is the right shape for the Rust 2024 edition requirement, and both safety comments (the block itself and the*_errno()deref inext/node/ops/fs.rs/ext/process/lib.rs) correctly document what makes the call sound.- The CRT errno → Win32 code table (
EMFILE=4,EBADF=6,ENOMEM=8,EINVAL=87, else 0) matches the standard CRT/Win32 mapping, and routing toError::from_raw_os_errorkeeps the UV error translation working instead of leaking a staleGetLastError(). - The two merge-main commits only touched
tests/node_compat/config.jsoncamong the PR's files; the PR's intendedtest-child-process-stdio-big-write-end.js": { "windows": false }edit is preserved and is consistent with the existing pattern of disabling timing-flaky Windows tests (e.g. the siblingtest-child-process-exec-kill-throwsdisable in #33426).
…val (unrelated) The libc::close(crt_fd) after close_handle in PipeWrap::close caused a handle double-close: close_pipe already CloseHandle'd the OS handle, so libc::close called CloseHandle again on the same (potentially recycled) handle value, breaking pipes belonging to other child processes. The buffer pool removal in stream_wrap.rs was unrelated to the EEXIST/data-loss fix and is a performance regression. Also reverts the test-child-process-stdio-big-write-end Windows disable which was a workaround for the double-close bug.
fibibot
left a comment
There was a problem hiding this comment.
LGTM. Re-reviewing the post-approval delta in 2460381a:
ext/node/ops/pipe_wrap.rs— backing out the CRT-fd close that I had a theoretical double-CloseHandle concern about in the prior round. The trade-off (small CRT-fd-slot leak on Windows spawned-pipe close vs the handle-reuse race) is the right one to take here; the leak is bounded per pipe and benign for the typical lifetime, while a recycled handle being closed could corrupt unrelated state.ext/node/ops/stream_wrap.rs— thread-local 64KB buffer pool for the libuv read alloc/dealloc pair. Single-threaded event-loop access viathread_local!, alignment/layout match across alloc and dealloc (the dealloc path only routes to the pool whenlen == POOLED_BUF_SIZE, matching the alloc-side guard), pool capped atPOOLED_BUF_MAX = 128so an idle process doesn't retain >8MB indefinitely, andpool_release_buffalls through tostd::alloc::deallocwhen the pool is full.free_uv_bufgettingpub(crate)looks intentional. Memory leak on thread teardown viaVec<*mut u8>'s defaultDropis bounded and only matters at process exit.tests/node_compat/config.jsonc—test-child-process-stdio-big-write-endun-disabled on Windows (i.e. the original concern that motivated disabling it is gone after the pipe_wrap simplification + buffer pool), plus a batch of net/console/fs/http/process tests enabled. CI is fully green on the new SHA, so the enables are real.
One drive-by note (not blocking, since per the project conventions in CLAUDE.md): the buffer pool optimisation and the enabled tests are unrelated to the title's writeFileSync fix and the commit message itself flags it as such. They'd ideally land as their own PR — but everything here is well-targeted and CI-validated, so I'm not going to hold this up.
…sync-eexist-data-loss
fibibot
left a comment
There was a problem hiding this comment.
Still LGTM after the Merge remote-tracking branch 'origin/main' in 0fd5d16f. The merge actually improves the PR — the buffer-pool addition (#33395) and the pipe_wrap CRT-fd-close removal landed independently on main since my last pass, so they're now part of the base instead of part of this PR. The current PR file list is back to the original three:
ext/node/ops/fs.rs(+82/-3) —crt_error()helper at line 43 and thedeferred_truncatepaths at 365/418 are intact.ext/process/lib.rs(+26/-3) — samecrt_errormirror.tests/unit_node/fs_test.ts(+18/0) — repeated-overwrite regression test.
The 12-line delta in tests/node_compat/config.jsonc since 2460381a is also entirely from the main merge (test enables landed via #33444, #33445, etc.). Nothing in the PR's own contribution has changed.
(Pre-existing structural quirk in main, not introduced here: the parallel/test-fs-rm.js block at config.jsonc has three other test names nested inside it as keys — test-fs-rmSync-special-char.js, test-fs-rmdir-throws-not-found.js, test-fs-rmdir-throws-on-file.js. TestConfig in tests/node_compat/mod.rs:81 doesn't deny_unknown_fields, so serde silently drops them and they never run. Not your problem in this PR — 72d86f796 (#32892) introduced it on main — but probably worth a separate fix at some point.)
|
@steve02081504 this should now be done, please try |
|
im tested this twice (once breaked by powershell auto update) |
…Windows (#33941) On Windows, every `child_process.spawn` call with piped stdio leaked the parent's `__rust_anonymous_pipe1__` HANDLE plus a CRT fd slot for each of stdin/stdout/stderr. After enough spawns the per-process CRT fd table fills (~8192) and `open_osfhandle` starts returning EMFILE, which then surfaces as a misleading EMFILE/EEXIST on unrelated APIs like `writeFileSync`. The reporter on #33931 attached a handle dump showing 691 leaked named-pipe handles after a few hours of runtime, with the suffix counter decrementing by 3 between adjacent entries — one leak per piped stream per spawn. Two interacting bugs caused this. `uv_pipe_open` wrapped the parent's server-side `CreateNamedPipeW` handle in `tokio::NamedPipeClient`, so broken-pipe / EOF events never reached the JS Socket above and the pipe never destroyed. And `PipeWrap::close` on Windows never released the CRT fd slot allocated by `open_osfhandle` in the spawn path. PR #33413 tried to add `libc::close(crt_fd)` after `close_pipe`, but that double-closed the same OS handle and was reverted. This change duplicates the OS handle inside `uv_pipe_open` so `close_pipe` and `libc::close` operate on distinct handle values, and uses `GetNamedPipeInfo` to pick the matching `NamedPipeServer` / `NamedPipeClient` wrapper so EOF propagates and the close path runs. Fixes #33931
…Windows (denoland#33941) On Windows, every `child_process.spawn` call with piped stdio leaked the parent's `__rust_anonymous_pipe1__` HANDLE plus a CRT fd slot for each of stdin/stdout/stderr. After enough spawns the per-process CRT fd table fills (~8192) and `open_osfhandle` starts returning EMFILE, which then surfaces as a misleading EMFILE/EEXIST on unrelated APIs like `writeFileSync`. The reporter on denoland#33931 attached a handle dump showing 691 leaked named-pipe handles after a few hours of runtime, with the suffix counter decrementing by 3 between adjacent entries — one leak per piped stream per spawn. Two interacting bugs caused this. `uv_pipe_open` wrapped the parent's server-side `CreateNamedPipeW` handle in `tokio::NamedPipeClient`, so broken-pipe / EOF events never reached the JS Socket above and the pipe never destroyed. And `PipeWrap::close` on Windows never released the CRT fd slot allocated by `open_osfhandle` in the spawn path. PR denoland#33413 tried to add `libc::close(crt_fd)` after `close_pipe`, but that double-closed the same OS handle and was reverted. This change duplicates the OS handle inside `uv_pipe_open` so `close_pipe` and `libc::close` operate on distinct handle values, and uses `GetNamedPipeInfo` to pick the matching `NamedPipeServer` / `NamedPipeClient` wrapper so EOF propagates and the close path runs. Fixes denoland#33931

Summary
Fixes three interacting bugs that cause
writeFileSyncto throw a misleadingEEXISTerror and corrupt files to 0 bytes on Windows after long-running operation (hours):PipeWrap::close: The close method only cleaned up FdTable entries on#[cfg(unix)]. On Windows, each child process spawn with piped stdio leaked CRT file descriptors created byopen_osfhandle. Over hours, the CRT fd table (limit ~8192) fills up.GetLastError()inraw_fd_for_file: Whenopen_osfhandlefails (CRT table full), the error handler calledlast_os_error()which readsGetLastError(). Butopen_osfhandleis a CRT function that setserrno, notGetLastError(). The stale value from the priorCreateFileW(CREATE_ALWAYS)wasERROR_ALREADY_EXISTS(183), misreported as EEXIST. Same bug existed inchild_stdio_to_fdand extra pipe fd creation inext/process/lib.rs.CREATE_ALWAYStruncates existing files to 0 bytes beforeopen_osfhandleruns. Ifopen_osfhandlefails, the write never happens and the file stays at 0 bytes — permanent data loss. Fixed by deferring truncation: open withOPEN_ALWAYS, create the CRT fd, then truncate.How the bugs interact
open_osfhandlefails inraw_fd_for_fileGetLastError()=ERROR_ALREADY_EXISTS→ EEXIST reportedCreateFileW(CREATE_ALWAYS)already truncated the file → 0-byte file, data lossThis matches all reported symptoms:
GetLastError()= 0) + new 0-byte fileCloses #33405
Changes
ext/node/ops/fs.rsGetLastError()inraw_fd_for_file; defer truncation on Windows until CRT fd is createdext/node/ops/pipe_wrap.rsclose(): remove FdTable entry + free CRT fd vialibc::closeext/node/ops/stream_wrap.rsget_fd()getter onLibUvStreamWrapfor PipeWrap closeext/process/lib.rsGetLastError()bug inchild_stdio_to_fdmacro and extra pipe fd pathtests/unit_node/fs_test.tsTest plan
writeFileSyncoverwrite test passeswxflag tests passchild_processtests pass