Skip to content

Fix deadlock of renames within a sub-directory with concurrent lookups - #111

Merged
xoofx merged 2 commits into
xoofx:mainfrom
lahma:fix/memfs-same-folder-rename-deadlock
Oct 19, 2026
Merged

xoofx merged 2 commits into
xoofx:mainfrom
lahma:fix/memfs-same-folder-rename-deadlock

Conversation

@lahma

@lahma lahma commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

A MoveFile, MoveDirectory or ReplaceFile whose paths are all in the same sub-directory can deadlock with any concurrent lookup in that directory, such as FileExists, OpenFile or CreateDirectory.

Problem

When the paths share a parent, MoveFileOrDirectory and ReplaceFileImpl take the filesystem lock shared. They then look each path up with EnterFindNode(..., KeepParentNodeExclusive | ...) and pass the results of the previous lookups as existingNodes. Take MoveFile("/d/a.txt", "/d/b.txt"):

  1. The source lookup locks /, then /d (exclusive), releases / and locks /d/a.txt. It keeps /d.
  2. The destination lookup starts again from the root. / is not in existingNodes, so it locks / again while it holds /d.

Every other lookup takes the directory locks hand-over-hand from the root down. It holds / until it has /d. So:

  • thread A (move) holds /d and waits for /;
  • thread B (FileExists("/d/other.txt")) holds / and waits for /d;

and both block forever. Moves within / are not affected (/ is then in existingNodes), and neither are moves across directories (they take the filesystem lock exclusively).

A move loop in /d with a FileExists("/d/other.txt") loop on another thread deadlocks on main within the first few hundred lookups, before the first move completes.

Fix

The second (and third) lookup of a same-directory rename no longer walks from the root. The new EnterFindChild looks the name up directly in the parent that the first lookup holds, and locks only the node it finds. The result never releases the parent; the first lookup's result still does. This is how the hierarchical locking scheme of the Unix kernel that MemoryFileSystem follows handles same-directory renames: the parent is locked once, and both names are looked up in it.

  • MoveFileOrDirectory: when the source and destination are in the same folder, the destination is looked up with EnterFindChild in the source's parent.
  • ReplaceFileImpl: when source, destination and backup are in the same folder, the first path in sorted order is looked up as before, and the others with EnterFindChild in its parent.

The locks taken, and the order they are taken in, are otherwise unchanged. The async counterpart (MemoryFileSystemAsync.gen.cs) is regenerated with Zio.AsyncCodeGen.

A coarser alternative would be to take the filesystem lock exclusively for every move and replace, as is already done when the directories differ. That would make same-directory renames exclude every other operation on the filesystem, so I went with the fine-grained fix.

Tests

The first commit adds TestMemoryFileSystemRenameConcurrency on its own, so it can be checked against main. Each test runs 2000 renames in /d against a concurrent FileExists("/d/other.txt") loop, and fails if the renames don't complete within 30 s. The tests cover MoveFile, MoveDirectory, ReplaceFile with a backup, and MoveFileAsync on MemoryFileSystemAsync.

🤖 Generated with Claude Code

lahma and others added 2 commits October 19, 2026 11:25
A same-directory MoveFile, MoveDirectory or ReplaceFile in a sub-directory
deadlocks with a concurrent lookup in that directory: these tests hang until
their timeout on the current code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A move or replace whose paths share a parent locked that parent through the
lookup of the first path, then looked the other paths up from the root again.
That second walk takes the locks of the parent's ancestors while the parent
is held, the opposite order to every other lookup, so a concurrent lookup
below an ancestor that waits for the parent blocks the rename forever.

Look the other names up directly in the parent the first lookup holds, as
the Unix kernel hierarchical locking scheme does for same-directory renames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xoofx
xoofx merged commit 9e30324 into xoofx:main Oct 19, 2026
0 of 2 checks passed
@lahma
lahma deleted the fix/memfs-same-folder-rename-deadlock branch October 19, 2026 19:38
lahma added a commit to lahma/test262-harness-dotnet that referenced this pull request Oct 20, 2026
0.25.0 contains only our three upstream changes to MemoryFileSystem:
a lock-free fast path for node locks (xoofx/zio#109), faster
EnumerateItems (xoofx/zio#110) and the fix for a deadlock between a
rename inside a sub-directory and concurrent lookups (xoofx/zio#111).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants