Skip to content

sys(windows): stat/futime HANDLE-backed fds directly instead of leaking a CRT fd - #33713

Merged
dylan-conway merged 7 commits into
mainfrom
farm/c8d0c20f/fix-windows-fstat-crt-fd-leak
Jul 8, 2026
Merged

dylan-conway merged 7 commits into
mainfrom
farm/c8d0c20f/fix-windows-fstat-crt-fd-leak

Conversation

@robobun

@robobun robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

On Windows, bun_sys::fstat and bun_sys::futimens on a HANDLE-backed Fd (FdKind::System, what openat/NtCreateFile return) converted the HANDLE to a CRT fd via _open_osfhandle so uv_fs_fstat/uv_fs_futime could consume it, then never released that CRT slot. The in-code comment said as much: "acknowledged CRT-fd leak, a leak is strictly better than a guaranteed panic".

The slot cannot be released: _close on a CRT fd also closes the underlying HANDLE, which the caller still owns. The CRT fd table is capped at 8192 (UCRT default), so long-running processes that repeatedly hit this path drift into EMFILE on unrelated file operations.

bun_sys::fstat is bun's cross-platform wrapper; on Windows Fd is intentionally two-kind (CRT int from uv_fs_open, or raw HANDLE from NtCreateFile) because openat has no CRT/libuv equivalent and must go through NtCreateFile with ObjectAttributes.RootDirectory. Call sites that pass a HANDLE-backed fd today: Bun.Image(path) pipeline, bun pm pack bundled deps, bun create (Windows Dir::copy_file), bundler get_hashed_filename, and MiniEventLoop::stdout().

Repro

import fs from "node:fs";
fs.writeFileSync("probe.bin", "x");
for (let i = 0; i < 10000; i++) {
  try { await new Bun.Image("probe.bin").metadata(); } catch {}
}
fs.openSync("probe.bin", "r"); // EMFILE

Before this change, on Windows:

EMFILE at iteration 8189 syscall=uv_open_osfhandle
fs.openSync failed: EMFILE: too many open files, open '...'

Fix

libuv's uv_fs_fstat/uv_fs_futime immediately convert the CRT fd back to a HANDLE and do all their work on it; the CRT layer is API-shape friction. This change ports the HANDLE-based logic into bun_sys and calls it directly for HANDLE-backed fds; uv-kind fds continue through sys_uv::fstat unchanged.

  • fstat: fstat_handle() mirrors libuv's fs__fstat_handle + fs__stat_handle + fs__stat_assign_statbuf: GetFileType dispatch (pipe/console get a synthetic stat), then NtQueryVolumeInformationFile(FileFsDeviceInformation) + NtQueryInformationFile(FileAllInformation) + NtQueryVolumeInformationFile(FileFsVolumeInformation) to fill uv_stat_t with the same field semantics (mode bits, timestamps, ino, dev, nlink, blocks).
  • futimens: SetFileTime on the HANDLE, with a TimeLike → FILETIME conversion.

Supporting additions: FILE_ALL_INFORMATION, FILE_FS_DEVICE_INFORMATION, FILE_FS_VOLUME_INFORMATION, FS_INFORMATION_CLASS, NtQueryVolumeInformationFile, SetFileTime, NT_ERROR, and filetime_to_timespec/timespec_to_filetime helpers.

fsync/fdatasync/fchmod/fchown in the same module still delegate to sys_uv::* and would panic on a HANDLE-backed fd, but no caller passes one today; that is a separate pre-existing issue (panic, not leak) and not what this change fixes.

Verification

Windows (x64), fail-before with canary bun:

(fail) fstat on HANDLE-backed fd does not leak CRT fd slots
  Received: { exitCode: 1, stderr: "EMFILE at iteration 8189 syscall=uv_open_osfhandle", stdout: "" }

With this change (bun bd test):

(pass) fstat on HANDLE-backed fd does not leak CRT fd slots [2597.15ms]

Also verified on Windows x64 with the change applied:

  • test/js/bun/image/image.test.ts: 94 pass (exercises the HANDLE-path fstat end-to-end)
  • test/js/bun/util/bun-file.test.ts: 6 pass
  • test/js/node/fs/fs.test.ts -t stat: 16 pass
  • Stat parity probe: Bun.Image(png).metadata() returns correct dimensions (st_size correct) and a directory is rejected with ENODEV (ISREG/ISDIR correct)

The test is Windows-only (test.skipIf(!isWindows)); Linux has no CRT fd layer and the windows_impl module is #[cfg(windows)], so the gate's linux fail-before check will see a skip both ways.

@github-actions github-actions Bot added the claude label Jul 7, 2026
@robobun

robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:38 PM PT - Jul 7th, 2026

⏳ @robobun, your commit ddc62e1 is still building in Build #70318, but has 1 failures so far (All Failures):

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 37830651-bb10-4425-b3fa-adb4cb222890

📥 Commits

Reviewing files that changed from the base of the PR and between db72278 and ddc62e1.

📒 Files selected for processing (2)
  • src/sys/windows/mod.rs
  • src/windows_sys/externs.rs

Walkthrough

Windows fstat and futimens now use HANDLE-based paths directly. Supporting Windows ABI types, time conversion helpers, zeroable FFI impls, and a Windows-only regression test were added.

Changes

Windows HANDLE stat and time updates

Layer / File(s) Summary
Windows ABI and zeroable support
src/windows_sys/externs.rs, src/sys/windows/mod.rs, src/bun_core/lib.rs, src/libuv_sys/libuv.rs
Adds Windows filesystem query types, NT helpers, file-time conversion helpers, and zeroable FFI impls for the new Windows structs and uv_stat_t.
HANDLE-based fstat and futimens
src/sys/lib.rs
Changes Windows fstat to build Stat from HANDLE-backed descriptors directly and changes futimens to call SetFileTime on the HANDLE.
CRT fd leak regression test
test/js/bun/sys/fstat-windows-crt-fd-leak.test.ts
Adds a Windows-only regression test that stresses Bun.Image(...).metadata() and verifies the descriptor remains usable afterward.

Possibly related PRs:

Suggested labels: windows, regression-test, filesystem

Suggested reviewers: None identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the Windows HANDLE-backed fstat/futime change.
Description check ✅ Passed The description covers the problem, fix, and verification, though it uses custom headings instead of the template's exact section names.
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.

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun.write Errors will be reported when writing densely(EMFILE:Too many open files)【密集写入文件会报错】 #10328 - Reports EMFILE ("Too many open files") on Windows when performing dense concurrent Bun.write calls, which matches the CRT fd table exhaustion caused by the leaked _open_osfhandle fds in fstat/futimens

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #10328

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Remove libuv on Windows #33160 - Removes libuv on Windows entirely, eliminating the _open_osfhandle / CRT fd layer that this PR patches, making the borrow_as_uv_fd fix redundant

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/bun/sys/fstat-windows-crt-fd-leak.test.ts (1)

10-56: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider an explicit per-test timeout for the 9000-iteration loop.

bun:test's default timeout is 5000ms. This test spawns a subprocess that performs 9000 failing Bun.Image(...).metadata() calls (each doing an openat+fstat+decode-failure round trip) plus 10 more fs open/fstat/close calls, on Windows where debug/ASAN builds run materially slower. Since the iteration count is dictated by the CRT fd table size (8192) and can't be meaningfully shrunk without weakening the regression, this is the same class of heavy subprocess-iteration test where the repo's precedent (e.g., cc.test.ts, glob/leak.test.ts) adds an explicit generous per-test timeout to avoid spurious CI failures on slow lanes.

🕒 Suggested fix
-test.skipIf(!isWindows)("fstat on HANDLE-backed fd does not leak CRT fd slots", async () => {
+test.skipIf(!isWindows)("fstat on HANDLE-backed fd does not leak CRT fd slots", async () => {
   ...
-});
+}, 60_000);

Based on learnings, explicit per-test timeouts are accepted for RSS/heavy subprocess-iteration regression tests (e.g., "do not treat explicit per-test timeouts as a guideline violation when the test is an RSS-leak regression that spawns a subprocess and performs many iterations"), which is analogous to this CRT-fd-exhaustion regression test.

🤖 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/js/bun/sys/fstat-windows-crt-fd-leak.test.ts` around lines 10 - 56, The
Windows CRT fd leak regression test in
test/js/bun/sys/fstat-windows-crt-fd-leak.test.ts can exceed bun:test’s default
5000ms timeout because it runs a slow subprocess loop with 9000
Bun.Image(...).metadata() iterations plus fs fstat checks. Add an explicit
generous per-test timeout on the test.skipIf(!isWindows) block for the
fstat-on-HANDLE-backed-fd case, matching the existing heavy regression-test
pattern used elsewhere, so the test remains stable on slower CI lanes without
changing the iteration count.

Source: Learnings

🤖 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.

Outside diff comments:
In `@test/js/bun/sys/fstat-windows-crt-fd-leak.test.ts`:
- Around line 10-56: The Windows CRT fd leak regression test in
test/js/bun/sys/fstat-windows-crt-fd-leak.test.ts can exceed bun:test’s default
5000ms timeout because it runs a slow subprocess loop with 9000
Bun.Image(...).metadata() iterations plus fs fstat checks. Add an explicit
generous per-test timeout on the test.skipIf(!isWindows) block for the
fstat-on-HANDLE-backed-fd case, matching the existing heavy regression-test
pattern used elsewhere, so the test remains stable on slower CI lanes without
changing the iteration count.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fabc9ba7-2568-4ea0-b705-499e8028af37

📥 Commits

Reviewing files that changed from the base of the PR and between a7c0b24 and 71a2ee1.

📒 Files selected for processing (1)
  • test/js/bun/sys/fstat-windows-crt-fd-leak.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/sys/lib.rs:3786-3790 — On the error path, sys_uv::fstat(uvfd) attaches uvfd — the temporary CRT fd — to the returned Error, and fstat then closes that fd and returns the error unchanged, so the caller sees an Error.fd referencing a now-closed CRT slot. futimens in this same PR handles this correctly with err.with_fd(fd) (line 4242); mirroring that here — e.g. let result = sys_uv::fstat(uvfd).map_err(|e| e.with_fd(fd)); — would keep the two consistent. Diagnostic quality only.

    Extended reasoning...

    What the bug is

    windows_impl::fstat now calls borrow_as_uv_fd to obtain a temporary CRT fd (uvfd) for HANDLE-backed inputs, passes it to sys_uv::fstat, then closes the temp fd and returns the result. But sys_uv::fstat builds its error as Error::new(errno, Tag::fstat).with_fd(fd) where fd is its argument (src/sys/sys_uv.rs:492) — i.e. the temporary uvfd, not the caller's original Fd. windows_impl::fstat returns that error unchanged after closing the temp fd, so the error's .fd field references a CRT fd number that no longer exists (and may already be reused).

    Code path

    For a HANDLE-backed fd (FdKind::System):

    1. borrow_as_uv_fd(fd, Tag::fstat) duplicates the HANDLE, wraps the dup in a CRT fd, and returns (uvfd, Some(uvfd)) — where uvfd is a fresh CRT fd number wrapping the duplicated HANDLE.
    2. sys_uv::fstat(uvfd) runs uv_fs_fstat. Suppose it fails with, say, EBADF. It returns Err(Error { errno: EBADF, syscall: Tag::fstat, fd: uvfd, .. }).
    3. to_close is Some(uvfd), so close(uvfd) runs — the CRT slot and duplicated HANDLE are released.
    4. result is returned as-is. The caller receives an error whose .fd is the numeric value of the now-closed uvfd.

    The pass-through case (FdKind::Uv, to_close == None) is unaffected because there uvfd == fd and nothing is closed.

    Why existing code doesn't prevent it

    sys_uv::fstat has no way to know its argument is a temporary; it always attaches whatever Fd it was called with. The wrapper is the only layer that knows uvfd is ephemeral and that the caller's real handle is fd. futimens in this same diff already handles this: at src/sys/lib.rs:4242 it does return Err(err.with_fd(fd)), overriding whatever fd libuv-layer error construction attached. fstat simply doesn't do the equivalent.

    Before this PR the attached fd was also the temp uvfd, but that fd was leaked (still open) and wrapped the caller's own HANDLE (via make_libuv_owned on the original), so it at least pointed at the right kernel object. After this PR the attached fd is a closed dup — strictly less useful for diagnostics, though still not a correctness issue.

    Impact

    Diagnostic quality only. Error.fd flows into SystemError formatting and the JS-visible .fd property on the thrown error; a stale CRT fd number there is misleading but doesn't affect program behavior. No memory-safety or correctness impact.

    Step-by-step example

    • Caller holds a HANDLE-backed fd (say the openat result inside Bun.Image(path).metadata()).
    • borrow_as_uv_fd returns (uvfd = Fd(42, Uv), Some(Fd(42, Uv))) — 42 is a fresh CRT slot wrapping a duplicated HANDLE.
    • uv_fs_fstat fails; sys_uv::fstat returns Err(Error { fd: Fd(42, Uv), .. }).
    • close(Fd(42, Uv)) runs — CRT slot 42 is released and may be immediately reused by the next _open_osfhandle.
    • The error propagates to JS with .fd == 42, which now refers to nothing (or to an unrelated file).

    Fix

    Mirror futimens:

    let result = sys_uv::fstat(uvfd).map_err(|e| e.with_fd(fd));

    One line, keeps the two call sites consistent.

  • 🟣 src/sys/lib.rs:3771-3783 — Nice — borrow_as_uv_fd is exactly the shape the neighboring fsync/fdatasync/fchmod/fchown (lines 3810-3814, 3851-3855) also need: they still call sys_uv::* → fd.uv() directly and panic on FdKind::System fds (the ftruncate comment at line 3819 already calls this out for that site). This is pre-existing and not what this PR is fixing, so no need to block on it — but since the helper now sits right next to them it'd be an easy follow-up, or worth a line in the description on why they're intentionally left for later.

    Extended reasoning...

    What this is. The PR introduces borrow_as_uv_fd at src/sys/lib.rs:3771 whose doc comment states the problem plainly: "sys_uv::* helpers do fd.uv(), which PANICS for HANDLE-backed (FdKind::System) fds such as openat() results." The helper is applied to fstat and futimens. But four sibling wrappers in the same windows_impl module still delegate straight to sys_uv::* with a raw Fd:

    • fchmod (line 3810) → sys_uv::fchmod → fd.uv() (sys_uv.rs:196)
    • fchown (line 3813) → sys_uv::fchown → fd.uv() (sys_uv.rs:295)
    • fsync (line 3851) → sys_uv::fsync → fd.uv() (sys_uv.rs:514)
    • fdatasync (line 3854) → sys_uv::fdatasync → fd.uv() (sys_uv.rs:500)

    Per bun_core::Fd::uv() (bun_core/util.rs:1034-1044), on Windows a non-stdio FdKind::System fd hits the unreachable!/panic branch. The adjacent ftruncate at line 3816 already carries a comment acknowledging exactly this ("sys_uv::ftruncate requires a CRT fd via fd.uv(), which fails for HANDLE-backed Fds") and works around it via NtSetInformationFile — so the codebase already recognizes this as a real hazard for these sibling functions.

    Concrete path. File::openat(...) on Windows routes through openat_windows_a → NtCreateFile, returning a HANDLE-backed Fd with kind() == FdKind::System (see line 3859-3866). If any caller then does bun_sys::fsync(that_fd) (or fdatasync/fchmod/fchown), sys_uv::fsync calls fd.uv() on it and panics. Nothing in the call chain converts the fd first — unlike fstat/futimens, these four never had the make_libuv_owned() workaround.

    Why this is pre-existing, not introduced. The bug this PR fixes is specifically the CRT-fd leak in the two sites that had already been converted to make_libuv_owned() (the old comment said "acknowledged CRT-fd leak — a leak is strictly better than a guaranteed panic"). fsync/fdatasync/fchmod/fchown never received that conversion in the first place; they panicked on HANDLE-backed fds before this PR and still do after it. The PR neither adds callers to them nor changes their behavior — it's a strict improvement that happens to leave a pre-existing adjacent defect in place.

    Impact. Any code path that obtains a HANDLE-backed fd (via openat) and then calls one of these four functions crashes the process on Windows. Whether such a path is currently reachable from user JS wasn't established here (it may well not be, which would explain why these were left as-is), but the hazard is identical to the one the helper's doc comment describes.

    Suggested follow-up. Route the four sites through the new helper the same way fstat now does, e.g.:

    pub fn fsync(fd: Fd) -> Maybe<()> {
        let (uvfd, to_close) = borrow_as_uv_fd(fd, Tag::fsync)?;
        let result = sys_uv::fsync(uvfd);
        if let Some(tmp) = to_close { let _ = close(tmp); }
        result
    }

    Alternatively, if these are intentionally excluded (e.g. no caller ever passes a HANDLE-backed fd to them today, or fchown is a no-op on Windows anyway), a note in the PR description satisfies the "if a site is intentionally excluded, say so" convention from CLAUDE.md's Fix the whole class guidance.

Comment thread test/js/bun/sys/fstat-windows-crt-fd-leak.test.ts Outdated
@robobun
robobun force-pushed the farm/c8d0c20f/fix-windows-fstat-crt-fd-leak branch from eaa39c3 to 741c726 Compare July 8, 2026 00:10
@robobun robobun changed the title sys(windows): release temporary CRT fd in fstat/futimens on HANDLE-backed fds sys(windows): stat/futime HANDLE-backed fds directly instead of leaking a CRT fd Jul 8, 2026
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked per review: dropped the DuplicateHandle approach. Instead, a small libuv patch exports uv__bun_fstat_handle / uv__bun_futime_handle (thin wrappers around libuv's existing internal fs__stat_handle / fs__utime_handle), and bun_sys::fstat/futimens call those directly on the raw HANDLE for FdKind::System fds. No CRT fd is allocated at all; no extra syscalls.

On the earlier bot findings:

  • stderr-empty assertion: switched to a combined { stdout, stderr, exitCode } object.
  • Error.fd attaching the temp fd: moot now that there is no temp fd; the new code does err.with_fd(fd) with the caller's fd.
  • fsync/fdatasync/fchmod/fchown still panicking on HANDLE-backed fds: pre-existing and separate (panic, not leak); no caller passes a HANDLE-backed fd to those today. Noted in the PR body.
  • Per-test timeout: measured 2.67s in a Windows debug build; leaving as-is per the repo convention of not setting explicit test timeouts.

Verified on Windows x64: test fails with canary bun (EMFILE at iteration 8189), passes with this change; image.test.ts and bun-file.test.ts still pass.

Comment thread patches/libuv/win-export-stat-utime-handle.patch Outdated
Comment thread test/js/bun/sys/fstat-windows-crt-fd-leak.test.ts
…ng a CRT fd

On Windows, bun_sys::fstat and bun_sys::futimens on a HANDLE-backed fd
(FdKind::System, what openat/NtCreateFile return) converted the HANDLE
to a CRT fd via _open_osfhandle so uv_fs_fstat/uv_fs_futime could
consume it, then never released that CRT slot. It cannot be released:
_close on a CRT fd also closes the underlying HANDLE, which the caller
still owns. The CRT fd table is capped at 8192, so long-running
processes that repeatedly hit this path (e.g. Bun.Image(path)) drift
into EMFILE on unrelated file operations.

libuv's uv_fs_fstat/uv_fs_futime immediately convert the CRT fd back to
a HANDLE and do all their work on it; the CRT layer is API-shape
friction. Port the HANDLE-based stat logic (NtQueryInformationFile /
NtQueryVolumeInformationFile, matching libuv's fs__stat_handle) and
SetFileTime into bun_sys and call them directly for HANDLE-backed fds.
No CRT fd is allocated and no extra syscalls are added. Uv-kind fds
continue through sys_uv::fstat unchanged.
@robobun
robobun force-pushed the farm/c8d0c20f/fix-windows-fstat-crt-fd-leak branch from 741c726 to 8980bab Compare July 8, 2026 00:31
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked again per review: dropped the libuv patch. Both helpers are now implemented in Rust inside bun_sys.

  • fstat_handle() in src/sys/lib.rs ports libuv's fs__fstat_handle + fs__stat_handle + fs__stat_assign_statbuf (GetFileType dispatch, three NT queries, fill uv_stat_t).
  • futimens calls SetFileTime directly with a TimeLike → FILETIME conversion.

Verified on Windows x64 at 8980bab508: leak test fails with canary (EMFILE at iteration 8189), passes with this change; image.test.ts 94/94; bun-file.test.ts 6/6; fs.test.ts -t stat 16/16. A stat correctness probe confirms Bun.Image(png).metadata() returns correct dimensions and directories are rejected with ENODEV.

Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff itself is green. The remaining red on builds 70244 and 70272 is test/js/sql/postgres-binary-array-bounds.test.ts on Windows 2019 hitting ERR_POSTGRES_CONNECTION_REFUSED (mock TCP server not accepting), plus assorted flaky-tagged tests (bake HMR timing, update_interactive, fetch-backpressure, node-http timeouts).

None of these touch this change: the diff is entirely #[cfg(windows)] inside bun_sys::fstat/futimens for FdKind::System fds. The postgres mock uses net.createServer (sockets, not file handles); darwin lanes don't compile the changed code at all. The same ERR_POSTGRES_CONNECTION_REFUSED flake is showing on other unrelated PR builds in the same window (70258, 70259, 70261, 70262, 70263, 70264, 70268, 70269, 70271, 70273).

The new test (fstat-windows-crt-fd-leak.test.ts) does not appear in any failure annotation and was verified passing locally on Windows x64 debug. Ready for review.

Comment thread src/windows_sys/externs.rs
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Line-by-line verification

Struct layouts (verified on Windows against real SDK headers)

Compiled verify-layout.c with clang against the Windows SDK on the CI Windows box and added matching #[cfg(windows)] const-asserts in src/windows_sys/externs.rs. All pass:

Type C size Rust size Key offsets
FILE_BASIC_INFORMATION 40 40 (existing struct, unchanged)
FILE_STANDARD_INFORMATION 24 24
FILE_INTERNAL_INFORMATION 8 8
FILE_ALL_INFORMATION 104 104 Standard@40, Internal@64, Position@80, Name@96
FILE_FS_DEVICE_INFORMATION 8 8
FILE_FS_VOLUME_INFORMATION 24 24 VolumeSerialNumber@8

Constants (verified against SDK headers)

Constant SDK value Rust value
FILE_INFORMATION_CLASS::FileAllInformation 18 18
FS_INFORMATION_CLASS::FileFsVolumeInformation 1 1
FS_INFORMATION_CLASS::FileFsDeviceInformation 4 4
FILE_DEVICE_NAMED_PIPE 0x11 0x11
FILE_DEVICE_NULL 0x15 0x15
FILE_DEVICE_CONSOLE 0x50 0x50
NTSTATUS::NOT_IMPLEMENTED 0xC0000002 0xC0000002
FILE_TYPE_DISK/CHAR/PIPE 1/2/3 1/2/3
_S_IFCHR/IFIFO/IFREG/IFDIR 0o20000/10000/100000/40000 bun_core::S::IFCHR/IFIFO/IFREG/IFDIR (same)
_S_IREAD/_S_IWRITE 0o400/200 S::IRUSR/IWUSR (same)
NT_ERROR(s) (s >> 30) == 3 (ntdef.h) (status.0 >> 30) == 3
EPOCH_DIFFERENCE_100NS 116444736000000000 11_644_473_600 * 10_000_000

Arithmetic (verified against libuv source at oven-sh/libuv@4dcfac47)

  • filetime_to_timespec: t = filetime - EPOCH; sec = t/10M; nsec = (t - sec*10M)*100; normalize nsec<0. Identical to uv__filetime_to_timespec.
  • timespec_to_filetime: sec*10M + nsec/100 + EPOCH. Matches libuv's TIME_T_TO_FILETIME for integer inputs.
  • from_sys_time refactor: (x + (-A)) → (x - A) is algebraically identical. Two callers in bunx_command.rs get identical results.

fstat_handle branch-by-branch vs libuv fs__fstat_handle + fs__stat_handle + fs__stat_assign_statbuf

Branch libuv This PR Match
FdKind::Uv n/a (public uv_fs_fstat) early return to sys_uv::fstat no change from before
Pipe _S_IFIFO, nlink=1, rdev=FILE_DEVICE_NAMED_PIPE<<16, ino=handle same yes
Console _S_IFCHR, nlink=1, rdev=FILE_DEVICE_CONSOLE<<16, ino=handle same (gated on GetConsoleMode success) yes
Non-console char falls through to fs__stat_handle falls through to NT-query path yes
FILE_DEVICE_NULL fs__stat_assign_statbuf_null: _S_IFCHR|rw-rw-rw-, nlink=1, blksize=4096, rdev=NULL<<16 same yes
Directory _S_IFDIR, size=0 same yes
Regular _S_IFREG, size=EndOfFile same yes
Readonly mode += r--r--r-- same yes
Writable mode += rw-rw-rw- same yes
Timestamps 4× uv__filetime_to_timespec 4× filetime_to_timespec yes
st_dev VolumeSerialNumber.LowPart VolumeSerialNumber as u64 (u32 widened) yes
st_ino/nlink/blocks/blksize IndexNumber / NumberOfLinks / Alloc>>9 / 4096 same yes
Reparse point (do_lstat=0) falls through to DIR/REG falls through (no IFLNK arm) yes

Error handling: all three NT queries use NT_ERROR (excludes warnings like STATUS_BUFFER_OVERFLOW), matching libuv. The NOT_IMPLEMENTED check reads rc instead of io_status.Status; these are documented-identical for synchronous NtQuery* calls.

futimens

SetFileTime(handle, NULL, &a, &m) with TimeLike → FILETIME. Matches libuv's fs__utime_handle → SetFileTime path.

Callers and blast radius

windows_impl::fstat with a FdKind::System fd is reached from exactly 6 sites: Bun.Image(path) pipeline, bun pm pack bundledDependencies, bun create Dir::copy_file, bundler get_hashed_filename, ModKey::generate (no live callers), MiniEventLoop::stdout(). All other fstat callers pass FdKind::Uv (early return, unchanged) or are #[cfg(unix)].

windows_impl::futimens has zero callers (node:fs futimes on Windows calls uv_fs_futime directly).

Non-regressions

  • futimens does not handle UTIME_NOW/UTIME_OMIT sentinels. Neither did the previous code (it computed 0.0 + (-1.0/1e9) which is not inf/nan, so libuv's sentinel handling never fired). Zero callers.
  • Pre-existing: fsync/fdatasync/fchmod/fchown in the same module still panic on FdKind::System fds. Separate issue (panic, not leak), no callers pass one today.

Thread safety

All buffers are stack-local. NtQuery*/GetFileType/GetConsoleMode/SetFileTime are stateless syscalls. Bun.Image already ran sys_uv::fstat from a worker thread before this change; same thread context now.

Perf

Old (FdKind::System): _open_osfhandle (CRT lock) + uv_fs_fstat (= uv__get_osfhandle + GetFileType + 3× NT query). New: GetFileType + 3× NT query. Two fewer calls, no CRT lock contention. FdKind::Uv: unchanged.

End-to-end Windows verification at ddc62e1a4d

  • Leak test: fails with canary (EMFILE at iteration 8189 syscall=uv_open_osfhandle), passes with this change (9000 iterations, 2.6s debug).
  • image.test.ts: 94/94
  • bun-file.test.ts: 6/6
  • fs.test.ts -t stat: 16/16
  • Stat correctness probe: Bun.Image(png).metadata() → 2×2; directory → ENODEV.

@dylan-conway
dylan-conway merged commit d4bae54 into main Jul 8, 2026
75 of 78 checks passed
@dylan-conway
dylan-conway deleted the farm/c8d0c20f/fix-windows-fstat-crt-fd-leak branch July 8, 2026 05:09
robobun added a commit that referenced this pull request Jul 10, 2026
- externs.rs: main and PR both added FILE_ALL_INFORMATION /
  FS_INFORMATION_CLASS / FILE_FS_* types independently; auto-merge kept
  both. Removed main's copies (PR's have quirk tags and are what winfs
  consumes).
- sys/windows/mod.rs: removed main's filetime_to_timespec /
  timespec_to_filetime (added for #33713's stat path; superseded by
  winfs's own definitions and now unused after the merge).
- node_fs.rs mkdtemp: Windows arm now returns encode_path_result(out,
  encoding) to match the StringOrBuffer return type main introduced.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants