mkdir recursive EEXIST / ENOENT results

On Windows, Bun's fs.mkdir(path, { recursive: true }) throws EEXIST or ENOENT for an existing directory when a relative path points above the current directory or names the current directory itself. Compared with Node.js and Deno

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

Summary and environment

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.

ItemValue
OSWindows 11 Home (10.0.26300) x64
Bun1.4.3 (c6da4a4d3), the latest per bun upgrade
Node.jsv26.11.1 (same result on v26.10.0)
Deno2.9.7, the latest per deno upgrade

Results

The current directory is sub. exists (already there) and new-dir (not yet there) are siblings of sub.

API and pathBun 1.4.3Node.js v26.11.1Deno 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

Findings

Existing issues and fix PRs

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

The cause given in PR #42014

According to the PR description:

  1. Recursive fs.mkdir calls CreateDirectoryW; on EEXIST it asks directory_exists_at_w whether the existing entry is a directory
  2. That probe calls NT directly. to_nt_path, which builds the name, only flips separators and does not resolve . or ... NT takes .. as a literal name, so the probe fails
  3. Because the probe fails, the entry is not treated as a directory, and EEXIST is returned
  4. The recursive walk first collapses . and .. lexically. A .. left at the start after collapsing cannot be resolved without the directory's path, so it reaches NT as is

This matches our results: inner/../inner collapses to inner and works, while inner/../../exists collapses to ..\exists and fails.

Reading the PR #42014 source

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.

What we read matches the PR description.

Checked with the PR #42014 build

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.

Checked with other PR builds

In the same way, we downloaded the Windows x64 builds of #40536 and #42635. Both SHA-256 values matched the listed ones.

PRBuildAbove the cwd../, .\, inner/..
#45410Buildkite #127041, 1.4.4-canary.1+4a119e097❌ EEXIST✅✅
#45410Buildkite #126187, 1.4.4-canary.1+94fbd3674❌ EEXIST✅✅
#40536Buildkite #106239, 1.4.1-canary.1+8c04559b9✅✅✅
#42635Buildkite #115200, 1.4.3-canary.1+891eb8df5✅✅❌ ENOENT
#42014Buildkite #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.

The ENOENT cause (checked in the source)

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.

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.

What each PR changes, and why it fixes the ENOENT or not

PRWhat it changesThe three ENOENT cases
#45410In 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)
#40536At 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
#42635Replaces 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
#42014Resolves . and .. in names passed to NT against the directory handle (nt_object_name_at)❌ Likewise, the normalization in os_path_kernel32 is unchanged
Not verified: whether it happens on Linux or macOS (the PR says its tests pass on Linux before and after the change).

How to reproduce

Repro script

Run research/repro.ts in an empty folder.

bun repro.ts
node repro.ts
deno run -A repro.ts

Tests

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.

RuntimeResult
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