Title: `fs.mkdir` with `recursive: true` retries without a progress bound: spins forever on `/proc`, livelocks under concurrent `rmdir` · Issue #66268 · nodejs/node · GitHub
Open Graph Title: `fs.mkdir` with `recursive: true` retries without a progress bound: spins forever on `/proc`, livelocks under concurrent `rmdir` · Issue #66268 · nodejs/node
X Title: `fs.mkdir` with `recursive: true` retries without a progress bound: spins forever on `/proc`, livelocks under concurrent `rmdir` · Issue #66268 · nodejs/node
Description: Version v22.23.3 and v24.21.0 (both reproduce) Platform Linux 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:39:21 UTC 2026 aarch64 GNU/Linux Official node:22 and node:24 images (Debian 12 userland). /proc is an ordinary...
Open Graph Description: Version v22.23.3 and v24.21.0 (both reproduce) Platform Linux 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:39:21 UTC 2026 aarch64 GNU/Linux Official node:22 and node:24 images (D...
X Description: Version v22.23.3 and v24.21.0 (both reproduce) Platform Linux 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:39:21 UTC 2026 aarch64 GNU/Linux Official node:22 and node:24 images (D...
Opengraph URL: https://github.com/nodejs/node/issues/66268
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"`fs.mkdir` with `recursive: true` retries without a progress bound: spins forever on `/proc`, livelocks under concurrent `rmdir`","articleBody":"### Version\n\nv22.23.3 and v24.21.0 (both reproduce)\n\n### Platform\n\n```text\nLinux 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:39:21 UTC 2026 aarch64 GNU/Linux\n```\n\nOfficial `node:22` and `node:24` images (Debian 12 userland). `/proc` is an ordinary procfs mount with\ndefault options: `proc /proc proc rw,nosuid,nodev,noexec,relatime`.\n\nAlso reproduced independently on x86_64, on GitHub-hosted `ubuntu-latest` runners with Node 22 and 24,\nwhere the hung job hit the 6-hour ceiling twice.\n\n### Subsystem\n\nfs\n\n### What steps will reproduce the bug?\n\n```console\n$ node -e \"require('node:fs').mkdirSync('/proc/zzz', { recursive: true })\"\n```\n\nNo output, no error, 100% CPU until killed. `fs.promises.mkdir` and the callback form of `fs.mkdir`\nbehave the same way. Dropping `recursive: true` returns `ENOENT` immediately.\n\nA second reproduction needs no pseudo-filesystem. On an ordinary disk filesystem (I used ext4; `T`\nbelow is a directory on it, not a tmpfs or overlayfs), run this loop:\n\n```js\nconst fs = require('node:fs');\nconst p = `${T}/parent/child`;\nfor (let i = 0; i \u003c 5000; i++) {\n fs.mkdirSync(p, { recursive: true });\n fs.rmSync(p, { recursive: true, force: true });\n}\n```\n\nwhile N other processes spin on `fs.rmdirSync(T + '/parent/child')` and\n`fs.rmdirSync(T + '/parent')`. Latency per call degrades with N:\n\n| concurrent removers | mean per call | worst call | calls over 50ms (of 5000) |\n|---|---|---|---|\n| 0 | 0.006ms | 0.1ms | 0 |\n| 8 | 0.158ms | 14.8ms | 0 |\n| 32 | 2.115ms | 168ms | 93 |\n\n(4 vCPUs. Racers must issue the `rmdir` syscall directly; shell `rmdir(1)` racers are too slow to win\nthe race because of fork+exec overhead.)\n\n### How often does it reproduce? Is there a required condition?\n\nThe `/proc` case is 100% reproducible on Linux with no special conditions or privileges. It does not\nreproduce on macOS or Windows, which have no `/proc`.\n\nThe concurrent-removal case is timing-dependent and scales with contention. The window is narrow on\nfast local storage, so it shows up as latency rather than a permanent hang at the numbers above.\nWidening the window by running the `mkdir` side under `strace` drove it to 67 `mkdirat` syscalls per\nattempted `mkdirp`, sustained, and 2000 iterations did not finish inside a 60-second timeout.\n\n### What is the expected behavior? Why is that the expected behavior?\n\n`mkdirSync(p, { recursive: true })` should either create the directory or fail with an errno, in\nbounded time. `mkdir -p` and `mkdirSync` without `recursive` both do: the latter returns `ENOENT`\nimmediately for `/proc/zzz`. Recursive mode should not be able to turn a failing `mkdir` into an\nunbounded loop.\n\n### What do you see instead?\n\nAn unbounded retry loop. `strace -f -e trace=mkdir,mkdirat` over 5 seconds of the `/proc` case shows\n22,872 `mkdirat` calls in a repeating 2-call cycle:\n\n```\nmkdirat(AT_FDCWD, \"/proc/zzz\", 0777) = -1 ENOENT (No such file or directory)\nmkdirat(AT_FDCWD, \"/proc\", 0777) = -1 EEXIST (File exists)\nmkdirat(AT_FDCWD, \"/proc/zzz\", 0777) = -1 ENOENT (No such file or directory)\nmkdirat(AT_FDCWD, \"/proc\", 0777) = -1 EEXIST (File exists)\n...\n```\n\n### Additional information\n\n### Mechanism\n\n`MKDirpSync` in [`src/node_file.cc`](https://github.com/nodejs/node/blob/v24.x/src/node_file.cc#L1874-L1904)\ntreats `ENOENT` as \"the parent does not exist yet\", pushes the failed path back onto its stack, pushes\nthe parent ahead of it, and retries:\n\n```c++\ncase UV_ENOENT: {\n std::string dirname =\n next_path.substr(0, next_path.find_last_of(kPathSeparator));\n if (dirname != next_path) {\n req_wrap->continuation_data()->PushPath(std::move(next_path));\n req_wrap->continuation_data()->PushPath(std::move(dirname));\n } else if (req_wrap->continuation_data()->paths().empty()) {\n err = UV_EEXIST;\n continue;\n }\n break;\n}\n```\n\n`ENOENT` is the only errno that re-queues work. `EACCES`, `ENOSPC`, `ENOTDIR` and `EPERM` return\nimmediately, and everything else reaches `default:`, where a failing `stat` ends the walk. So the loop\nterminates only where `mkdir` returning `ENOENT` implies a missing parent, and POSIX does not\nguarantee that.\n\nTwo cases where it does not hold:\n\n* Under `/proc`, `mkdir` returns `ENOENT` for a name that can never be created, even though the parent\n exists. Each cycle pops `/proc/zzz` (`ENOENT`), pushes it back with `/proc`, pops `/proc`\n (`EEXIST`, `stat` confirms a directory, falls through), and finds `/proc/zzz` still on the stack.\n* Under concurrent removal, the `ENOENT` is real but transient: a peer removed the parent after the\n last check, so the retry recreates it, and the child create can miss again.\n\nNeither `MKDirpSync` nor `MKDirpAsync` records whether an iteration made progress, so there is no\nstate that can end the loop.\n\nFor contrast, `/sys` does not hang: `mkdir` there fails with `EROFS`, which reaches `default:` and\nterminates. `/etc/hostname/zzz` returns `ENOTDIR`. Only the `ENOENT` path re-queues.\n\n### Impact\n\nA recursive `mkdir` on a path the caller does not fully control, or one contended by another process,\ncan go from microseconds to unbounded. On the sync path the spin is inside a C++ call, so no JS\ntimeout, `AbortSignal` or signal handler interrupts it; only `SIGKILL` from outside the process ends\nit. Running the one-liner under `docker run` and pressing Ctrl-C three times is enough to see this:\nthe CLI gives up forwarding signals and detaches, and the container keeps running and keeps spinning.\nThe async path occupies a libuv threadpool worker the same way.\n\nI hit the `/proc` case in CI, where a test used a path under `/proc` as an unwritable fixture. Nothing\ncrashed and nothing was logged, so the run was killed only by GitHub Actions' own 6-hour job limit. I\ndid not notice the hang itself: what surfaced was every later job being refused for billing, because\nthose 6 hours had exhausted the account's Actions budget. The test had always passed on macOS, where\n`/proc` does not exist.\n\n### Possible fix\n\nBound the retry on lack of progress. If a path is popped a second time with no successful `mkdir` in\nbetween, return the original error rather than pushing its parent again. The uncontended path stays as\ncheap as it is today, and both `MKDirpSync` and `MKDirpAsync` then terminate regardless of what a\nfilesystem returns.\n\nA retry cap would also stop the race case, but it leaves `/proc` looping unless the cap is small\nenough to apply there too, which makes it the same fix with a number attached.\n\nI am happy to open a PR with tests for both cases if that direction seems right.\n\n### Related\n\nTwo earlier reports of this loop, both on Windows, both fixed by correcting one filesystem's errno\nrather than by bounding the loop:\n\n* #27198, fixed by #27207 (\"fs: fix infinite loop with async recursive mkdir on Windows\").\n* #28599, recursive `mkdir` hanging on invalid Windows filenames. The diagnosis there matches:\n `uv_fs_mkdir` returns `UV_ENOENT`, which \"confuses the `fs.mkdir` function. It constantly creates\n the parent directory then tries to create the invalid folder.\"\n\nAI-assisted. Reproduced and measured in a Linux container before filing; the one-liner cannot\nreproduce on macOS, which has no `/proc`.\n","author":{"url":"https://github.com/rndjams","@type":"Person","name":"rndjams"},"datePublished":"2026-09-24T23:18:23Z","url":"https://github.com/nodejs/node/issues/66268"}
| react-profiling | 0 |
| react-import-map | react,react-dom,react-dom/client,react-dom/profiling,react-is,react-reconciler,react/compiler-runtime,react/jsx-dev-runtime,react/jsx-runtime,scheduler@d5750093facf |
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:51c2b9c3-7522-8243-7fa7-445ef509d839 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | D80A:10B16D:9EA946:D32722:6AED316D |
| html-safe-nonce | 0450e747e192c58f79460e426ee27d8d7576c10a48a4e66c88b5997af3790c01 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJEODBBOjEwQjE2RDo5RUE5NDY6RDMyNzIyOjZBRUQzMTZEIiwidmlzaXRvcl9pZCI6Ijg2NzAzNTA0MzQ3MDgwNDIwOTMiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | b552a56500884e2e91d59f8efa2f7f614a83f5eb8380c256778ad9badd0c8b6e |
| hovercard-subject-tag | issue:5575784932 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/nodejs/node/66268/issue_layout |
| twitter:image | https://opengraph.githubassets.com/8dc7263849c102dc49cc4a3dfacc75612ea128bcc957207a4b2cac0709c5dae7/nodejs/node/issues/66268 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/8dc7263849c102dc49cc4a3dfacc75612ea128bcc957207a4b2cac0709c5dae7/nodejs/node/issues/66268 |
| og:image:alt | Version v22.23.3 and v24.21.0 (both reproduce) Platform Linux 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:39:21 UTC 2026 aarch64 GNU/Linux Official node:22 and node:24 images (D... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | rndjams |
| hostname | github.com |
| expected-hostname | github.com |
| None | e51d0b7b5ab34b1688c05b54c3ed17b8658b2fa9bca973a9c740683ccb12cf4a |
| turbo-cache-control | no-preview |
| go-import | github.com/nodejs/node git https://github.com/nodejs/node.git |
| octolytics-dimension-user_id | 9950313 |
| octolytics-dimension-user_login | nodejs |
| octolytics-dimension-repository_id | 27193779 |
| octolytics-dimension-repository_nwo | nodejs/node |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 27193779 |
| octolytics-dimension-repository_network_root_nwo | nodejs/node |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 59ebb7b901cc154ecc179a44f266fcf69f343b30 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width