With recursive: true, fs.mkdir returns without an error when the directory already exists (Node.js behavior). On Windows, Bun throws EEXIST when the path, after . and .. are collapsed, starts with .. (points above the current directory) and the directory already exists. Paths that name the current directory itself (., ./, inner/..) fail too. Node.js and Deno do not.
| Item | Value |
|---|---|
| OS | Windows 11 Home (10.0.26300) x64 |
| Bun | 1.4.3 (c6da4a4d3), the latest per bun upgrade |
| Node.js | v26.11.1 (same result on v26.10.0) |
| Deno | 2.9.7, the latest per deno upgrade |
The current directory is sub. exists (already there) and new-dir (not yet there) are siblings of sub.
| API and path | Bun 1.4.3 | Node.js v26.11.1 | Deno 2.9.7 |
|---|---|---|---|
mkdirSync('../exists') | ❌ EEXIST | ✅ | ✅ |
mkdirSync('..\\exists') | ❌ EEXIST | ✅ | ✅ |
mkdirSync('./../exists') / '.\\..\\exists' (starts with ./) | ❌ EEXIST | ✅ | ✅ |
mkdirSync('inner/../../exists') (an inner .. climbs above) | ❌ EEXIST | ✅ | ✅ |
mkdirSync('../sub/inner') / '../sub/../exists' | ❌ EEXIST | ✅ | ✅ |
mkdirSync('.') (the current directory itself) | ❌ EEXIST | ✅ | ✅ |
mkdirSync('./') / '.\\' / 'inner/..' (the current directory itself) | ❌ ENOENT | ✅ | ✅ |
mkdirSync(absolute path) | ✅ | ✅ | ✅ |
mkdirSync('inner') / './inner' / 'inner/../inner' (stays inside) | ✅ | ✅ | ✅ |
mkdirSync('../new-dir') 1st call (not there yet) | ✅ created | ✅ | ✅ |
mkdirSync('../new-dir') 2nd call (already there) | ❌ EEXIST | ✅ | ✅ |
fs.promises.mkdir('../exists') | ❌ EEXIST | ✅ | ✅ |
fs.mkdir('../exists', cb) | ❌ EEXIST | ✅ | ✅ |
Bun output (verbatim, absolute path masked):
NG ../exists EEXIST EEXIST: file already exists, mkdir '..\exists'
NG ..\exists EEXIST EEXIST: file already exists, mkdir '..\exists'
NG ./../exists EEXIST EEXIST: file already exists, mkdir '..\exists'
NG .\..\exists EEXIST EEXIST: file already exists, mkdir '..\exists'
NG inner/../../exists EEXIST EEXIST: file already exists, mkdir '..\exists'
NG ../sub/inner EEXIST EEXIST: file already exists, mkdir '..\sub\inner'
NG ../sub/../exists EEXIST EEXIST: file already exists, mkdir '..\exists'
OK <absolute path>\exists
OK inner
OK ./inner
OK inner/../inner
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 inner/.. ENOENT ENOENT: no such file or directory, mkdir
OK ../new-dir
NG ../new-dir EEXIST EEXIST: file already exists, mkdir '..\new-dir'
NG fs.promises.mkdir ../exists EEXIST
NG fs.mkdir ../exists EEXIST
. and .. are collapsed, starts with .. (points above the current directory) and the directory exists, with either / or \ as the separator..: ./../exists and inner/../../exists (an inner .. that climbs above) fail too. The path in the error message is the collapsed form (..\exists)inner/../inner, ./inner, inner) and absolute paths work. throws EEXIST: file already exists, mkdir '.'; ./ and .\ (with a trailing separator) and inner/.. (collapses to the current directory) throw ENOENT: no such file or directory, mkdir (with an empty path). inner/. workswarn: internal error: OBJECT_NAME_INVALID line reported in #17332 (Bun 1.2.2) is no longer printed on 1.4.3path.resolve()An issue with the same symptom already exists, and a fix PR is open. Instead of filing a new issue, we added a comment to the existing one (the comment posted on 2026-10-12).
bug windows node:fs). It reports mkdir from fs/promises only. and .. EEXIST only./, .\ or sub/.. throws ENOENT with an empty path (opened 2026-10-12, OPEN, labels bug windows node:fs). Filed from this projectFixes #45059, and that the ..-prefixed paths are left to #42014.. firstchild\.. ENOENT remains and is covered by #40536Fixes #17332According to the PR description:
fs.mkdir calls CreateDirectoryW; on EEXIST it asks directory_exists_at_w whether the existing entry is a directoryto_nt_path, which builds the name, only flips separators and does not resolve . or ... NT takes .. as a literal name, so the probe failsEEXIST is returned. and .. lexically. A .. left at the start after collapsing cannot be resolved without the directory's path, so it reaches NT as isThis matches our results: inner/../inner collapses to inner and works, while inner/../../exists collapses to ..\exists and fails.
We read the diff at the head commit cfff26e9a. The fix is in two files, src/paths/classify.rs and src/sys/lib.rs; the rest are tests.
classify.rs: classify_rel_t, which scans path components, gains has_dot_component (some component is exactly . or ..). a.b and ... are treated as nameslib.rs: new nt_object_name_at and nt_object_name_at_w. If a . or .. component exists, the path is resolved against the directory handle with normalize_path_windows; otherwise to_nt_path only flips separators, as beforemkdirat, unlinkat, renameat (the target), exists_at_type and exists_at_type_w now use it. The directory probe (directory_exists_at_w) goes through exists_at_type_w, which is where this bug is fixedexists_at_type_nt that trimmed a leading .\ is removed (resolution makes it unnecessary).. it keeps, only the comment changes to "mkdirat resolves it"fs-mkdir.test.ts covers ., .., ../cwd, ../cwd/sub, ./sub and a file. It does not cover ./ with a trailing separator, or sub/.. that collapses to the current directoryWhat we read matches the PR description.
We downloaded the Windows x64 build bun-windows-x64.zip from the PR's CI (Buildkite #113149). Its SHA-256 matched the listed value, and its version was 1.4.3-canary.1+cfff26e9a (the PR head commit). With this build, every path pointing above the current directory and . pass. The three paths that name the current directory, ./, .\ and inner/.., still throw ENOENT with this build (see the table in How to reproduce). The cause is in "The ENOENT cause" below.
In the same way, we downloaded the Windows x64 builds of #40536 and #42635. Both SHA-256 values matched the listed ones.
| PR | Build | Above the cwd | . | ./, .\, inner/.. |
|---|---|---|---|---|
| #45410 | Buildkite #127041, 1.4.4-canary.1+4a119e097 | ❌ EEXIST | ✅ | ✅ |
| #45410 | Buildkite #126187, 1.4.4-canary.1+94fbd3674 | ❌ EEXIST | ✅ | ✅ |
| #40536 | Buildkite #106239, 1.4.1-canary.1+8c04559b9 | ✅ | ✅ | ✅ |
| #42635 | Buildkite #115200, 1.4.3-canary.1+891eb8df5 | ✅ | ✅ | ❌ ENOENT |
| #42014 | Buildkite #113149, 1.4.3-canary.1+cfff26e9a | ✅ | ✅ | ❌ ENOENT |
Of the four PRs, only #40536 fixes every case. Its build is based on 1.4.1, and the PR has not been updated since 2026-08-26. #45410 fixes . and the three ENOENT paths, but the 10 paths above the current directory still throw EEXIST with its build (the PR description says #42014 covers them). #42014 and #42635 are the other way round.
The #45410 build was checked the same way. It is the PR head commit 94fbd3674, and the download matched the SHA-1 that Buildkite lists. The SHA-256 of the downloaded bun-windows-x64.zip is 8a0b522fb15843a3d38ddd019c179894c961dcfdf79c083d2f02a5c0522b79ee. The Buildkite build as a whole is marked failed (3 of 184 jobs), but the Windows x64 build job finished and produced the zip.
After that, two commits were added to #45410 (9d1de7022 and 4a119e097). In the diff, the code in os_path_kernel32 is the same as before; only a comment was shortened and the test was tightened (toStrictEqual, and an error without a code now keeps its message). We checked the build of the new head commit 4a119e097 (Buildkite #127041) in the same way. The SHA-256 of the downloaded bun-windows-x64.zip is 02ef8f1b6d7a9cf6d7f5340c36fb21917e7001118096a60cf92fb540cd1815f8, which matched the value Buildkite lists, and the version was 1.4.4-canary.1+4a119e097. The result is the same as with 94fbd3674: . and the three ENOENT paths pass, and the 10 paths above the current directory still throw EEXIST.
On 2026-10-24 the author of #42635 rebased it onto main (commit e3d1ac77c). In the diff, only line positions and a doc comment changed. The author wrote that the Linux tests pass and that the Windows runtime behavior was not re-verified. We could not test the new head commit on Windows: its Buildkite build (#131940) is blocked and has no Windows x64 zip. The row above is still the build of the earlier commit 891eb8df5.
In Bun 1.4.3 (c6da4a4d3), os_path_kernel32 in src/runtime/node/types.rs normalizes the path lexically (normalize_string_buf) before it reaches mkdir.
./, .\ and inner/.. into an empty string. The source comment says so too: "normalizeStringBuf strips it to an empty string". is special-cased and passed as .. ./ and the others miss that check and reach mkdir empty..\exists)A possible fix: if the normalized result is empty, use . (move the current . special case after the normalization). PR #45410 takes a different route: it resolves the empty result to the absolute path of the cwd with getcwd. Its description says that mapping to . would only move ./ from ENOENT to EEXIST, because the NT probe cannot resolve .. We did not test the . variant.
| PR | What it changes | The three ENOENT cases |
|---|---|---|
| #45410 | In os_path_kernel32 itself: when the normalized relative path is empty and the input was not, uses the cwd from getcwd. The exact-. special case is removed. existsSync uses the same helper | ✅ The empty string never reaches kernel32. ❌ It does not touch the paths above the cwd (the PR leaves them to #42014) |
| #40536 | At the entry of recursive mkdir in node_fs.rs, joins a relative path onto the cwd (join_cwd_windows) before normalizing | ✅ After the join, normalizing gives the absolute path of the cwd instead of an empty string |
| #42635 | Replaces the existence check, from the NT API to libuv's Win32 stat (stat_w) | ❌ The normalization in os_path_kernel32, which runs before the check, is unchanged |
| #42014 | Resolves . and .. in names passed to NT against the directory handle (nt_object_name_at) | ❌ Likewise, the normalization in os_path_kernel32 is unchanged |
Run research/repro.ts in an empty folder.
bun repro.ts
node repro.ts
deno run -A repro.ts
tests/mkdir-recursive.test.ts is written with node:test, so the same file runs on all three runtimes. tools\40_test\run-tests.cmd runs it on node, deno and bun in that order and prints a summary.
| Runtime | Result |
|---|---|
| Node.js v26.11.1 | ✅ 18 passed |
| Deno 2.9.7 | ✅ 18 passed |
| Bun 1.4.3 | ❌ 4 passed, 14 failed (the 10 paths pointing above the current directory and the 4 naming the current directory) |
Bun 1.4.4 (38afee6c8, official release of 2026-10-22) | ❌ 4 passed, 14 failed (same as 1.4.3: the 10 paths pointing above the current directory and the 4 naming the current directory) |
| Bun 1.4.4-canary.1+4a119e097 (PR #45410 build, head commit as of 2026-10-31) | ❌ 8 passed, 10 failed (the 10 paths pointing above the current directory) |
| Bun 1.4.4-canary.1+94fbd3674 (PR #45410 build) | ❌ 8 passed, 10 failed (the 10 paths pointing above the current directory) |
| Bun 1.4.3-canary.1+cfff26e9a (PR #42014 build) | ❌ 15 passed, 3 failed (./, .\, inner/..) |
| Bun 1.4.3-canary.1+891eb8df5 (PR #42635 build) | ❌ 15 passed, 3 failed (./, .\, inner/..) |
| Bun 1.4.1-canary.1+8c04559b9 (PR #40536 build) | ✅ 18 passed |