Recursive mkdir ENOENT

On Windows, Bun's recursive mkdir of "./", ".\" or "sub/.." throws ENOENT. The open fix PRs #42014 and #42635 do not fix it

📅 Created: 2026-10-12 / Updated: 2026-10-31

Summary

Filed as oven-sh/bun#45059 on 2026-10-12.

Repro script: research/repro-enoent.cjs. The same cases are in tests/mkdir-recursive.test.ts. The full write-up of the related EEXIST bug is in the results.

Evidence: runs

Environment and builds

Windows 11 Home 10.0.26300 x64. The PR builds are bun-windows-x64.zip from each PR's Buildkite CI; each SHA-256 matched the value listed by Buildkite.

RuntimeVersionSource
Bun 1.4.31.4.3+c6da4a4d3release (bun upgrade)
PR #420141.4.3-canary.1+cfff26e9aBuildkite #113149, SHA-256 6f3415d871f3645620b01e22f27eb708a7267510277dd96a4816d455d5c8323e
PR #426351.4.3-canary.1+891eb8df5Buildkite #115200, SHA-256 6621af0f0afabbcfe8036403e0ff70a341eae63b741be8389992edfed019c0d2
PR #405361.4.1-canary.1+8c04559b9Buildkite #106239, SHA-256 8c8e13a081f26b85e9c0c11c65a2be8687237d02e61e669fb71ce04c5b164d95
PR #454101.4.4-canary.1+94fbd3674Buildkite #126187, SHA-256 8a0b522fb15843a3d38ddd019c179894c961dcfdf79c083d2f02a5c0522b79ee (the SHA-1 matched the value listed by Buildkite)
PR #45410 (head on 2026-10-31)1.4.4-canary.1+4a119e097Buildkite #127041, SHA-256 02ef8f1b6d7a9cf6d7f5340c36fb21917e7001118096a60cf92fb540cd1815f8 (matched the value listed by Buildkite; same result as 94fbd3674)
Node.jsv26.11.1release
Deno2.9.7release

Results

The same result for mkdirSync, fs.promises.mkdir and callback fs.mkdir.

PathBun 1.4.3PR #42014PR #42635PR #40536PR #45410Node.jsDeno
.❌ EEXIST✅✅✅✅✅✅
./❌ ENOENT❌ ENOENT❌ ENOENT✅✅✅✅
.\❌ ENOENT❌ ENOENT❌ ENOENT✅✅✅✅
sub/..❌ ENOENT❌ ENOENT❌ ENOENT✅✅✅✅
sub/.✅✅✅✅✅✅✅

Output of repro-enoent.cjs (verbatim)

Bun 1.4.3:

NG . EEXIST EEXIST: file already exists, mkdir '.'
NG ./ ENOENT ENOENT: no such file or directory, mkdir
NG .\ ENOENT ENOENT: no such file or directory, mkdir
NG sub/.. ENOENT ENOENT: no such file or directory, mkdir
OK sub/.
NG promises . EEXIST
NG callback . EEXIST
NG promises ./ ENOENT
NG callback ./ ENOENT
NG promises .\ ENOENT
NG callback .\ ENOENT
NG promises sub/.. ENOENT
NG callback sub/.. ENOENT
OK promises sub/.
OK callback sub/.

PR #42014 and PR #42635 (identical output):

OK .
NG ./ ENOENT ENOENT: no such file or directory, mkdir
NG .\ ENOENT ENOENT: no such file or directory, mkdir
NG sub/.. ENOENT ENOENT: no such file or directory, mkdir
OK sub/.
OK promises .
OK callback .
NG promises ./ ENOENT
NG callback ./ ENOENT
NG promises .\ ENOENT
NG callback .\ ENOENT
NG promises sub/.. ENOENT
NG callback sub/.. ENOENT
OK promises sub/.
OK callback sub/.

PR #40536, PR #45410, Node.js and Deno (identical output):

OK .
OK ./
OK .\
OK sub/..
OK sub/.
OK promises .
OK callback .
OK promises ./
OK callback ./
OK promises .\
OK callback .\
OK promises sub/..
OK callback sub/..
OK promises sub/.
OK callback sub/.

Evidence: source

Where the path becomes empty

src/runtime/node/types.rs L1072-L1088 (Bun 1.4.3, c6da4a4d3), in os_path_kernel32 for a relative path:

            // Handle "." specially since normalizeStringBuf strips it to an empty string
            if s.len() == 1 && s[0] == b'.' {
                // SAFETY: see alignment note above (PathBuffer reinterpreted as [u16]).
                let buf_u16 = unsafe { bun_core::bytes_as_slice_mut::<u16>(&mut buf[..]) };
                return Ok(strings::to_kernel32_path(buf_u16, b"."));
            }
            let normal = bun_paths::resolve_path::normalize_string_buf::<
                true,
                bun_paths::platform::Windows,
                false,
            >(s, &mut b[..]);
            if !strings::fits_in_wide_path_buffer(normal) {
                return Err(NameTooLong);
            }
            // SAFETY: see alignment note above (PathBuffer reinterpreted as [u16]).
            let buf_u16 = unsafe { bun_core::bytes_as_slice_mut::<u16>(&mut buf[..]) };
            return Ok(strings::to_kernel32_path(buf_u16, normal));

Why each PR fixes it or not

PRWhat it changesENOENT cases
#42014Resolves . and .. in names passed to NT against the directory handle (nt_object_name_at in src/sys/lib.rs)❌ os_path_kernel32 unchanged
#42635Replaces the existence check with libuv's Win32 stat (stat_w)❌ os_path_kernel32 unchanged
#40536At the entry of recursive mkdir in src/runtime/node/node_fs.rs, joins a relative path onto the cwd (join_cwd_windows) and normalizes the joined path✅ the joined path normalizes to the cwd, not an empty string
#45410In os_path_kernel32 itself: if the normalized relative path is empty and the input was not, uses the cwd from getcwd. The exact-"." special case is removed✅ the empty string never reaches kernel32. It leaves the ..-prefixed EEXIST cases to #42014

#42635's description also notes that child\.. still reports ENOENT with that PR and says #40536 covers it. #40536 is based on 1.4.1 and has not been updated since 2026-08-26.