Skip to content

fix(fuse): close git open-unlink straddle race at handle registration - #819

Open
mornyx wants to merge 3 commits into
mainfrom
fix/git-open-unlink-straddle
Open

fix(fuse): close git open-unlink straddle race at handle registration#819
mornyx wants to merge 3 commits into
mainfrom
fix/git-open-unlink-straddle

Conversation

@mornyx

@mornyx mornyx commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes the residual git-workspace open-unlink race tracked in #817 (follow-up to #804).

A git Open prepares its handle before registering it in openHandles. An unlink's whiteout can therefore commit after prepareGitOpenHandle read the overlay but before the handle is visible to either marking pass. The escaped handle (fh.Unlinked=false) could then upsert over the whiteout on a later write/flush and resurrect the deleted name.

Three cooperating changes close the window end to end:

  1. Registration-time whiteout check, atomic with registrationallocateGitWorkspaceFileHandle re-checks the overlay and adds the handle under a single openHandles critical section. Either the Add precedes the unlink's post-commit SnapshotPath (and that pass marks the handle), or the re-check runs after the whiteout was applied (and the handle adopts fh.Unlinked=true itself). The open still succeeds with POSIX anonymous-fd semantics: reads/writes stay local, and the existing fh.Unlinked guards in flushGitHandleLockedWithPolicy / flushGitLocalFileHandleLockedWithPolicy / applyGitOverlayMirrorEntry suppress any republish.
  2. Stale read-through caching no longer clobbers a whiteoutreadGitOverlayFile's single-entry GET can return a pre-whiteout snapshot. It is now cached via applyGitOverlayEntryUnlessWhiteout (check-and-set under one rt.mu section, atomic against putGitWhiteout's apply), so the local overlay keeps the whiteout and the registration check above isn't misled. The fetched bytes are still served to the racing reader.
  3. O_TRUNC open-time mirror loses atomically against a whiteoutprepareGitOpenHandle's dirty-mirror upsert runs before registration, so the fh.Unlinked guard in applyGitOverlayMirrorEntry can't cover it. The new applyGitOverlayMirrorEntryUnlessWhiteout variant drops the upsert (and marks the handle unlinked) when a whiteout is authoritative. gitCreateHandle keeps the unconditional variant because Create must overwrite whiteouts to recreate a name.

markOpenHandlesUnlinked semantics, failed-whiteout rollback, unlinked LocalFile mirror suppression, and pass-2 marking from #804 are unchanged.

Tests

  • TestGitWorkspaceOpenStraddlingWhiteoutMarkedUnlinked — the exact FUSE git workspace unlink: in-flight Open can escape whiteout marking #817 timeline: fixture parks the open's overlay GET with a pre-whiteout snapshot already taken, unlink commits the whiteout and finishes both marking passes, the open resumes, and the resulting handle provably cannot resurrect the path (no new overlay PUTs, whiteout preserved locally and remotely, anonymous-fd reads still serve pre-unlink content).
  • TestPrepareGitOpenHandleTruncOnWhiteoutAdoptsUnlinked — prepare-level O_TRUNC-over-whiteout coverage.
  • TestApplyGitOverlayMirrorEntryUnlessWhiteout — the mirror primitive directly.
  • Negative controls: each of the three fixes was individually reverted and the corresponding test fails (e.g. reverting the read-through guard alone makes the registration check miss, because the stale snapshot had resurrected the local overlay entry first).
  • Full pkg/fuse suite green except TestShouldContinueAfterWaitMountPermissionErrorWhenProbePasses/Fails, which fail identically on unmodified origin/main (environmental).
  • go test -race on the new and neighboring unlink tests, golangci-lint run pkg/fuse/... → 0 issues.

Fixes #817

Summary by CodeRabbit

  • Bug Fixes
    • Improved Git workspace file handling during concurrent overlay updates.
    • Prevented deleted files from being unexpectedly recreated after whiteouts.
    • Ensured open handles correctly reflect files removed during concurrent operations.
    • Improved consistency for reads, writes, truncation, and unlink operations racing with deletions.
    • Preserved access to content that was opened before a concurrent deletion.
    • Removed stale file state so subsequent operations reflect the latest workspace contents.

A git-workspace Open prepares its handle before registering it, so an
unlink's whiteout can commit after the prepare read but before the
handle reaches openHandles, escaping both marking passes; a later
write/flush then upserts over the whiteout and resurrects the name.

Close the window end to end:

- allocateGitWorkspaceFileHandle re-checks the overlay for a whiteout
  and adds the handle under one openHandles critical section, so the
  registration either precedes the unlink's post-commit snapshot (and
  gets marked by it) or observes the whiteout and adopts the unlinked
  state itself. The open keeps POSIX anonymous-fd semantics.
- readGitOverlayFile no longer caches a single-entry GET response back
  over an authoritative whiteout: the response may be a pre-whiteout
  snapshot, and applying it would resurrect the name locally and
  mislead the registration check. The bytes are still served to the
  racing reader.
- The O_TRUNC dirty-mirror upsert in prepareGitOpenHandle now loses
  atomically against a whiteout via
  applyGitOverlayMirrorEntryUnlessWhiteout instead of upserting over
  it; gitCreateHandle keeps the unconditional variant because Create
  must overwrite whiteouts to recreate a name.

Regressions: a fixture-gated straddling-Open test (parked pre-whiteout
GET, unlink completes, open resumes and cannot resurrect), a direct
prepare-level O_TRUNC-on-whiteout test, and unit coverage for the
mirror primitive. Each fails with its corresponding fix reverted.

Fixes #817
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Git workspace opens and overlay mirroring now preserve authoritative whiteouts during races. Handle registration rechecks whiteouts atomically, marks affected handles unlinked, removes stale mirror state, and preserves pre-unlink content for read-only handles. Tests cover delayed reads, truncation, writes, and replacement paths.

Changes

Git workspace whiteout safety

Layer / File(s) Summary
Whiteout-aware overlay propagation
pkg/fuse/git_workspace.go
Overlay reads and mirror updates no longer replace authoritative whiteouts. Blocked handles become unlinked.
Atomic Git workspace handle registration
pkg/fuse/handle.go, pkg/fuse/open_handles.go, pkg/fuse/git_workspace.go, pkg/fuse/dat9fs.go
Git workspace opens retain read-only overlay content and re-check whiteouts during registration under the open-handle index lock. Registration removes stale pending and dirty mirror state.
Whiteout race regression coverage
pkg/fuse/git_workspace_test.go
Fixture controls delayed overlay GETs and synthesizes deterministic content. Tests cover straddling opens, mirror races, truncation, stale pending entries, read-only reads, and dirty mirrors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: qiffang

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the fix for the Git workspace open-unlink registration race.
Linked Issues check ✅ Passed The changes address issue #817 through registration-time whiteout checks, unlinked handles, mirror cleanup, read preservation, and regression tests.
Out of Scope Changes check ✅ Passed The implementation and tests remain focused on Git workspace open-unlink race handling and its required whiteout and mirror behaviors.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/git-open-unlink-straddle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
pkg/fuse/git_workspace.go (1)

3557-3580: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fold the duplicated entry construction into a shared helper.

applyGitOverlayMirrorEntryUnlessWhiteout duplicates the guard clauses and the entire client.GitOverlayEntry literal from applyGitOverlayMirrorEntry (Lines 3520-3545); the two will drift when a new overlay field is added.

Also worth documenting: unlike applyGitOverlayMirrorEntry (called from Write under fh.Lock()), this variant mutates fh.Unlinked without fh.mu. That's safe only because prepareGitOpenHandle runs before the handle is registered — stating that precondition in the doc comment protects it from a future caller added post-registration.

♻️ Sketch: single builder + a whiteout-aware flag
+func gitMirrorOverlayEntry(fh *FileHandle, size int64) client.GitOverlayEntry {
+	return client.GitOverlayEntry{
+		WorkspaceID:    fh.GitWorkspaceID,
+		Path:           fh.GitRelPath,
+		Op:             "upsert",
+		Kind:           fh.GitKind,
+		Mode:           gitModeForHandle(fh),
+		SizeBytes:      size,
+		BaseObjectSHA:  fh.GitBaseObjectSHA,
+		ChecksumSHA256: "",
+		UpdatedAt:      time.Now(),
+	}
+}

Both mirror functions then reduce to guards plus the apply/remember calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/fuse/git_workspace.go` around lines 3557 - 3580, Extract the shared guard
checks and client.GitOverlayEntry construction from applyGitOverlayMirrorEntry
and applyGitOverlayMirrorEntryUnlessWhiteout into one helper, optionally
parameterized for whiteout handling, and have both callers use it so new overlay
fields remain centralized. Add a doc comment to
applyGitOverlayMirrorEntryUnlessWhiteout stating that it must run before handle
registration, since it mutates fh.Unlinked without fh.mu; preserve the existing
locked behavior of applyGitOverlayMirrorEntry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@pkg/fuse/git_workspace.go`:
- Around line 3557-3580: Extract the shared guard checks and
client.GitOverlayEntry construction from applyGitOverlayMirrorEntry and
applyGitOverlayMirrorEntryUnlessWhiteout into one helper, optionally
parameterized for whiteout handling, and have both callers use it so new overlay
fields remain centralized. Add a doc comment to
applyGitOverlayMirrorEntryUnlessWhiteout stating that it must run before handle
registration, since it mutates fh.Unlinked without fh.mu; preserve the existing
locked behavior of applyGitOverlayMirrorEntry.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ba44a47c-d3e1-4e59-921d-0744858d6f48

📥 Commits

Reviewing files that changed from the base of the PR and between 41503f7 and b618bf9.

📒 Files selected for processing (4)
  • pkg/fuse/dat9fs.go
  • pkg/fuse/git_workspace.go
  • pkg/fuse/git_workspace_test.go
  • pkg/fuse/open_handles.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b618bf9aaa

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread pkg/fuse/git_workspace.go
fh.Unlinked = true
return
}
fs.rememberPendingGitOverlayEntry(fh.GitWorkspaceID, entry)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep pending mirror upserts behind whiteouts

In an O_TRUNC git open racing an unlink, this records the pending upsert after the live-overlay whiteout check has already completed. If the unlink's whiteout is applied in the gap between applyGitOverlayEntryUnlessWhiteout(...) and this call, allocateGitWorkspaceFileHandle will mark the handle unlinked, but this stale pending upsert remains; the next workspace refresh merges pending entries over the freshly loaded server overlay in mergePendingGitOverlayEntries, so the deleted path can reappear locally. The pending registration needs to be part of the same whiteout-checked critical section, or be rechecked, so a whiteout cannot be followed by a pending upsert.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dat9-dev2 independent verification on exact head 40390d480c7d8e9d13f081cf01934b02e092332b: CONFIRMED / blocking.

I added a temporary deterministic gate immediately after applyGitOverlayEntryUnlessWhiteout succeeds and before rememberPendingGitOverlayEntry. While the mirror path was parked there, a synchronous whiteout completed through putGitOverlayWithPolicy(..., forceSync=true); then the mirror resumed and registered the stale upsert. A subsequent mergePendingGitOverlayEntries changed the freshly loaded server whiteout back to upsert exactly as described:

merged overlay op = "upsert", want whiteout; stale mirror pending resurrected path

This is a valid production interleaving, not a synthetic invalid state. The live-overlay whiteout check/apply and pending-map registration must be ordered as one operation (or the pending entry must be rechecked/removed after registration). Please add a regression that parks this exact gap and proves refresh cannot resurrect the path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c295f5 using the sanctioned "removed after registration" ordering: allocateGitWorkspaceFileHandle now calls forgetPendingGitOverlayEntry(GitWorkspaceID, rel, 0) when it adopts the unlinked state. A pending upsert coexisting with an authoritative whiteout for the same path is stale by definition — a legitimate recreate applies its own overlay entry first, which would have made the registration re-check see an upsert and skip the marking/cleanup entirely.

Regression: TestGitWorkspaceRegistrationDropsStalePendingMirrorEntry replays your exact parked gap at the mechanism level — mirror applyGitOverlayEntryUnlessWhiteout succeeds → the whiteout's sync commit (forgetPending + applyGitOverlayEntry) lands → the mirror's straggling rememberPendingGitOverlayEntry runs → registration. It then asserts the pending map no longer has the path and that mergePendingGitOverlayEntries over the server whiteout keeps Op == "whiteout".

Negative control: reverting just the forgetPendingGitOverlayEntry line fails the test with stale pending mirror upsert survived registration and merged overlay op = "upsert" — your repro shape.

Comment thread pkg/fuse/git_workspace.go
fhID := fs.fileHandles.Allocate(fh)
fs.openHandles.mu.Lock()
if e, ok := rt.overlayEntry(rel); ok && e.Op == "whiteout" {
fh.Unlinked = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Snapshot read-only handles before marking them unlinked

When a read-only git workspace open straddles an unlink, prepareGitOpenHandle returns before loading any content, but this registration-time whiteout check can still set fh.Unlinked. Later Read handles fh.Unlinked && fh.Dirty == nil before the git read path; because this path did not populate UnlinkedData or even UnlinkedSize, a successfully opened non-empty file returns EOF instead of the pre-unlink contents. Only mark clean read-only handles unlinked after preserving a snapshot or a usable backing source for anonymous-fd reads.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dat9-dev2 independent verification on exact head 40390d480c7d8e9d13f081cf01934b02e092332b: CONFIRMED / blocking.

I gated the real Open(O_RDONLY) immediately before registration, completed Unlink (both mark passes saw no handle), then resumed registration. allocateGitWorkspaceFileHandle correctly set fh.Unlinked, but the first Read returned OK with zero bytes instead of the pre-unlink non-empty contents:

read content = "", want "read-only-before-unlink"

The cause is the current control flow: read-only prepareGitOpenHandle returns without a local buffer/snapshot, while Read handles fh.Unlinked && fh.Dirty == nil before the git workspace read path; registration only sets Unlinked and leaves UnlinkedSize == 0. Please preserve a usable anonymous-fd backing/snapshot before marking this handle unlinked, and add a deterministic O_RDONLY straddle regression that reads the original bytes after unlink.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c295f5. Read-only prepares now preserve a backing before the handle can be marked:

  • prepareGitOpenHandle stashes the overlay content it already observed into a new fh.GitOpenSnapshot (read-only opens stay preload-free; this is a zero-copy slice of the entry read at the top of prepare, so it costs nothing on the happy path).
  • When registration adopts the unlinked state, preserveGitUnlinkedReadBacking materializes that stash as the standard UnlinkedData/UnlinkedSize/UnlinkedSnapshot read backing, so Read's existing unlinked-snapshot branch serves the pre-unlink bytes. If there was no inline overlay content, it falls back to the clean tree via readGitCleanFile — object packs are content-addressed and unaffected by the whiteout.

Regression: TestGitWorkspaceReadonlyOpenStraddlingWhiteoutReadsPreUnlinkContent models the straddle at the prepare/register boundary (prepare → whiteout commits → allocateGitWorkspaceFileHandle) and reads the full original contents through the anonymous fd. Negative control: reverting the preserve call fails with read content = "", want "read-only-before-unlink" — byte-identical to your repro output.

Documented residual: an overlay entry whose content was never fetched locally (metadata-only entry, no clean-tree base carrying the upsert bytes) is unreachable once the whiteout is authoritative, and keeps the prior EOF behavior — fetching it at prepare would force every read-only open into a content GET, which the mmap/FOPEN_KEEP_CACHE path intentionally avoids.

Comment thread pkg/fuse/git_workspace.go
UpdatedAt: time.Now(),
}
if _, blocked := fs.applyGitOverlayEntryUnlessWhiteout(fh.GitWorkspaceID, entry); blocked {
fh.Unlinked = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unlink dirty mirrors when adopting anonymous state

In the O_TRUNC straddle path, openGitDirtyMirrorFile has already created the dirty mirror file before this branch marks the handle unlinked. Since git unlinked handles suppress flush on release and never remove that mirror, the pathname remains under the dirty-mirror root; if the same path is later recreated or refreshed without a whiteout, gitWorkspaceDirtyMirrorStat/readGitDirtyMirror will prefer the stale anonymous-fd file and can surface old deleted contents. When the whiteout wins, unlink the mirror path while keeping the fd open, matching the normal unlink path's anonymous-fd behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dat9-dev2 independent verification on exact head 40390d480c7d8e9d13f081cf01934b02e092332b: CONFIRMED / blocking.

I replayed the post-whiteout O_TRUNC prepare path: openGitDirtyMirrorFile created/truncated the mirror, applyGitOverlayMirrorEntryUnlessWhiteout adopted fh.Unlinked, and Release closed the local fd without removing the mirror. After installing a legitimate same-path replacement upsert with content replacement, readGitFile still returned the stale empty mirror:

read replacement = "", want "replacement"; stale trunc mirror shadowed recreated path

The anonymous fd can remain open after unlink, but its pathname under the dirty-mirror root must be removed when the whiteout wins so later lookup/refresh/recreate cannot prefer it. Please add a regression covering whiteout-winning O_TRUNC → release/recreate → replacement bytes visible.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c295f5: when registration adopts the unlinked state it now calls removeGitDirtyMirror(rt, rel), mirroring putGitWhiteout's own cleanup. The open fd stays valid as the handle's anonymous backing (reads and the write-sync guards are fd-based, and the unlinked flush guards return before ever touching the pathname).

Regression: TestGitWorkspaceStraddledOpenRemovesStaleDirtyMirror — whiteout-winning O_TRUNC prepare (mirror created, upsert blocked, handle marked), registration, then a legitimate same-path replacement upsert. It asserts the mirror pathname is gone and readGitFile serves the replacement bytes. Negative control: reverting the removal fails with dirty mirror pathname survived registration and the replacement read returning the stale empty mirror (""), matching your repro.

Note the removal is unconditional-on-adoption rather than re-checked against the overlay, consistent with putGitWhiteout's blind removal: a concurrent same-path recreate racing the adoption has the same ambiguity as one racing a plain Unlink, which the codebase already accepts.

Adds TestGitWorkspaceOpenSpanningWhiteoutCommitMarkedUnlinked, a
deterministic replay of the runtime reproduction from
#817 (comment):
whiteout POST parked first, open's overlay GET snapshots the old upsert
and parks, whiteout commits and Unlink returns, then the open resumes
and registers. With the registration-time check reverted this fails
exactly as observed on the PR #804 base (registered handle keeps
Unlinked == false); with the fix the handle is marked and the whiteout
survives write/flush/release.

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev-Lead review (dat9-dev1) — PR #819, fix commits b618bf9 (fix) + 40390d4 (test)

This is the #817 C-straggler fix (git open-unlink straddle race). I diagnosed this race and specified the fix direction (cover the REGISTRATION moment under one lock, not another point-in-time scan). The fix matches that direction and is correct on my axis. GREEN (one dependency note below).

✅ Registration-time atomic whiteout re-check — truly closes the window (not just narrows)

allocateGitWorkspaceFileHandle (git_workspace.go:4214):

fhID := fs.fileHandles.Allocate(fh)
fs.openHandles.mu.Lock()
if e, ok := rt.overlayEntry(rel); ok && e.Op == "whiteout" {
    fh.Unlinked = true
}
fs.openHandles.addLocked(fh)   // registration ATOMIC with the re-check, same openHandles.mu section
fs.openHandles.mu.Unlock()

open_handles.go adds addLocked so the re-check + register share ONE openHandles.mu critical section. This is why it closes the window rather than narrowing it (the pass-2 failure mode): the check is AT the registration moment, so there is no "registered-but-not-yet-scanned" gap. Both orderings are safe — (a) register precedes the unlink's post-commit SnapshotPath → that pass marks it; (b) the re-check runs after the whiteout is in rt.overlay → the handle adopts fh.Unlinked=true itself. markOpenHandlesUnlinked uses openHandles.SnapshotPath (same lock), so the two are mutually ordered.

✅ The two cooperating changes are necessary, not redundant

  • applyGitOverlayEntryUnlessWhiteout (read-through GET cache): a racing single-entry GET could cache a pre-whiteout snapshot back over the authoritative whiteout in rt.overlay, which would mislead the registration re-check. Check-and-set under one rt.mu section keeps the whiteout — this is load-bearing for change 1's correctness.
  • applyGitOverlayMirrorEntryUnlessWhiteout (O_TRUNC open-time mirror before registration): the fh.Unlinked guard can't cover a mirror upsert that runs before registration; the UnlessWhiteout variant drops it + marks unlinked. gitCreateHandle correctly keeps the unconditional variant (Create must overwrite whiteouts).

✅ Discriminating regressions (I verified)

  • TestGitWorkspaceOpenStraddlingWhiteoutMarkedUnlinked replays the exact #817 timeline. I mutation-tested it: neutralizing the registration-time fh.Unlinked=true makes it FAIL ("straddling open handle not marked unlinked at registration"); with the fix it PASSES. Genuinely discriminating, not happy-path.
  • -race on the straddle + ...UnlinkMarksHandlesOpenedDuringWhiteout tests passes; broad pkg/fuse git-workspace/open-unlink suite green (only the known Darwin mount_wait env fails remain).

⚠️ Merge-ordering note (not a correctness blocker on the straddle fix)

This branch is stacked on #816 (commit 41503f7 — the #815 lazy-buffer fix), which is currently REVISE with two open blockers (new-file ShadowSpill zero-fill; Path A layer cross-revision-splice via materialize-before-pin). So #819's diff carries #816's unmerged content. Before merge, either #816's blockers must be resolved (and #819 rebased onto the fixed #816), or #819 rebased onto a clean main — otherwise merging #819 would drag in #816's still-broken code. Please confirm the intended merge order.

Net: the #817 straddle fix (my axis) = GREEN, closes the registration-moment window with a discriminating regression. Gate on the #816 stacking before merge. drive9 is qiffang-owned → merge on qiffang's「合」.

@qiffang

qiffang commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

adversary-2 — GREEN @ 40390d48 (closes the #817 straddle race exhaustively; no deadlock)

This is the fix for the #817 C-straggler I diagnosed — cover the registration instant (not another point-in-time scan). Reviewed subagent + personal source-verify; all 5 axes CONFIRM.

✅ Axis 1 — registration-instant closure is AIRTIGHT (load-bearing)

allocateGitWorkspaceFileHandle (git_workspace.go:4214): under one openHandles.mu critical section it re-checks rt.overlayEntry(rel).Op=="whiteout"fh.Unlinked=true, then addLocked(fh) registers — atomic. Exhaustiveness proof:

  • Unlink program order (dat9fs.go:8098→8099→8108): pass-1 mark → putGitWhiteout (writes rt.overlay=whiteout synchronously before return, both write-back and sync policies: applyGitOverlayEntry at git_workspace.go:3690/3726) → pass-2 mark. So the whiteout is authoritative in rt.overlay strictly before pass-2.
  • Serialization: pass-2's SnapshotPath (openHandles.mu.RLock) and the Open's {re-check, addLocked} (openHandles.mu.Lock) are mutually exclusive.
  • Ordering A (Add before pass-2 snapshot): pass-2 sees the registered handle → marks it. ✓
  • Ordering B (pass-2 snapshot before Open-CS): pass-2 ran after the rt.overlay whiteout write (program order), so the Open-CS re-check (strictly later) reads the committed whiteout under rt.mu → marks it. ✓
  • The "stale re-check under independent locks" gap does not exist: Ordering B transitively implies re-check-after-whiteout-write (program order 8099→8108 + rt.mu). No ordering leaves the handle registered, escaping pass-2, with a stale re-check.

✅ Axis 2 — applyGitOverlayEntryUnlessWhiteout (read-cache / open-mirror guard)

Check+update atomic under one rt.mu critical section (no unlock between); a racing putGitWhiteout either lands first (upsert dropped, blockedByWhiteout=true) or after (whiteout overwrites). Caller audit: read-through cache (readGitOverlayFile:1442/1458) + open-time mirror converted; server-ordered writes (putGitOverlayWithPolicy) + gitCreateHandle (Create must overwrite whiteout) correctly left on bare applyGitOverlayEntry.

✅ Axis 3 — open-time O_TRUNC mirror covered

prepareGitOpenHandle O_TRUNC branch now uses applyGitOverlayMirrorEntryUnlessWhiteout → on blocked sets fh.Unlinked=true + skips pending remember; idempotent with the registration re-check; a pre-whiteout upsert is unconditionally superseded by putGitWhiteout's applyGitOverlayEntry.

✅ Axis 4 — regression discriminates (in-memory, not remote-only)

TestGitWorkspaceOpenStraddlingWhiteoutMarkedUnlinked + ...SpanningWhiteoutCommit... replay the exact #817 schedule (park the open's overlay GET / whiteout POST via channel gates), assert fh.Unlinked==true at registration (FAILs pre-fix with bare allocateFileHandle), assert in-memory rt.overlayEntry(rel).Op=="whiteout" (avoids the #804-round-2 remote-only trap) + overlayPuts==1 (a resurrecting flush → 2). Plus direct primitive tests. Gate-based → -race clean by construction.

✅ Axis 5 — no deadlock (no lock-order inversion)

allocateGitWorkspaceFileHandle is the SOLE site nesting {openHandles.mu, rt.mu}, order openHandles.mu → rt.mu. markOpenHandlesUnlinked snapshots-then-releases openHandles.mu and never takes rt.mu. No path acquires rt.mu (or git.mu) then blocks on openHandles.mu. addLocked extraction is a pure refactor.

⚠️ One non-blocking follow-up (NOT introduced by this PR, out of scope)

readGitOverlayFile:1421 retains a bare applyGitOverlayEntry on the local-dirty-mirror-hit branch. It caches LOCAL dirty bytes (not a stale server snapshot) and putGitWhiteout calls removeGitDirtyMirror, so it is not a resurrection vector for the #817 straddle — but it's the one remaining un-guarded overlay write in that function; recommend a defensive conversion or a comment noting why it's whiteout-safe. Non-blocking.

Verdict: GREEN. The straddle race is closed exhaustively at the registration instant, no deadlock, discriminating regression. Merge authority = qiffang. (Separately noted in the review thread: #819 carries the FIXED #816 code (Path A CAS + Path B fromShadow, byte-identical to the dual-adversary-GREEN'd a32d97bc) — it does not regress #816; the only residual is merge-hygiene between two diverged branches carrying the same #816 fix.)

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adversary-1 review — GREEN @ 40390d480c7d8e9d13f081cf01934b02e092332b

No blocking findings.

This closes the #817 git-workspace open-unlink straddle race at the registration boundary rather than adding another best-effort scan. The important property is in allocateGitWorkspaceFileHandle: openHandles.mu orders the registration against unlink pass-2, while the registration path re-checks the live git overlay whiteout before addLocked. Therefore the two possible orderings are covered: if registration happens before pass-2, pass-2 marks the handle; if pass-2 already missed the handle, the later registration re-check observes the committed whiteout and sets fh.Unlinked=true before exposing the handle in openHandles.

The cooperating changes are load-bearing and correct:

  • applyGitOverlayEntryUnlessWhiteout prevents a stale single-entry overlay GET from caching a pre-whiteout upsert back over an authoritative whiteout, which would otherwise defeat the registration-time re-check.
  • applyGitOverlayMirrorEntryUnlessWhiteout covers the O_TRUNC dirty-mirror path that runs before handle registration, where the ordinary fh.Unlinked guard cannot yet help.
  • The normal create/server-ordered paths correctly keep the unconditional overlay apply semantics, because explicit create after unlink must be able to recreate the name.

Regression coverage is discriminating. The tests park the exact stale-overlay-GET and whiteout schedules, assert the handle becomes Unlinked, assert both local and remote overlay remain whiteout, and prove write/flush/release from the anonymous fd does not emit another overlay PUT. I also checked the concern that a registration-marked handle remains path-indexed during fd lifetime; I do not see a resurrection path because git lookup consults authoritative git overlay before open-handle reconstruction, git write/flush paths short-circuit on fh.Unlinked, and release removes the handle from both indexes.

Validation:

  • Hosted PR checks at this head: ci SUCCESS, drive9-server-local e2e smoke SUCCESS, CodeRabbit SUCCESS.
  • go test ./pkg/fuse -run 'TestGitWorkspace(OpenStraddlingWhiteoutMarkedUnlinked|OpenSpanningWhiteoutCommitMarkedUnlinked|PrepareGitOpenHandleTruncOnWhiteoutAdoptsUnlinked)|TestApplyGitOverlayMirrorEntryUnlessWhiteout' -count=20
  • go test -race ./pkg/fuse -run 'TestGitWorkspace(OpenStraddlingWhiteoutMarkedUnlinked|OpenSpanningWhiteoutCommitMarkedUnlinked|PrepareGitOpenHandleTruncOnWhiteoutAdoptsUnlinked)|TestApplyGitOverlayMirrorEntryUnlessWhiteout' -count=5
  • go test ./pkg/fuse -run 'TestGitWorkspace(UnlinkedHandle|UnlinkWhiteoutFailureRollsBackHandleMarks|UnlinkMarksHandlesOpenedDuringWhiteout|OpenStraddlingWhiteoutMarkedUnlinked|OpenSpanningWhiteoutCommitMarkedUnlinked|PrepareGitOpenHandleTruncOnWhiteoutAdoptsUnlinked)|TestApplyGitOverlayMirrorEntryUnlessWhiteout' -count=10
  • git diff --check 41503f74493abd4c48ffe3085d7890dcc31a056c...HEAD
  • gofmt -l pkg/fuse/dat9fs.go pkg/fuse/git_workspace.go pkg/fuse/git_workspace_test.go pkg/fuse/open_handles.go
  • go vet ./pkg/fuse

Non-blocking follow-up: readGitOverlayFile still has one local dirty-mirror-hit branch that uses bare applyGitOverlayEntry. It is not a #817 resurrection vector because it caches local dirty bytes and putGitWhiteout removes the dirty mirror before a post-whiteout read can hit it. Still, converting it to the guarded helper or documenting the invariant would reduce future drift.

Merge-order note is hygiene only: #819 carries the already-fixed #816 content, not the stale broken version. Rebase/merge-order cleanup may still be useful to avoid duplicated diverged ancestry, but I am not treating it as a correctness blocker for this PR.

@qiffang

qiffang commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

dat9-dev2 independent review — REVISE @ 40390d480c7d8e9d13f081cf01934b02e092332b

The core #817 registration-window fix is correct: the whiteout re-check and addLocked share openHandles.mu, the unlink pass-2 snapshot uses the same lock, and I found no reverse rt.mu -> openHandles.mu acquisition. The exact regression is discriminating: removing only fh.Unlinked = true makes TestGitWorkspaceOpenStraddlingWhiteoutMarkedUnlinked fail at registration; restored exact head passes the focused suite under -race -count=10.

However, three unresolved correctness threads are independently reproduced and remain merge-blocking:

  1. Pending mirror upsert can outlive the whiteout — deterministic gate between live-overlay apply and pending registration; refresh merged the stale pending upsert over a server whiteout.
  2. Read-only straddling opens lose their anonymous-fd bytes — real Open(O_RDONLY) straddle returned OK with "" instead of the pre-unlink non-empty contents.
  3. Whiteout-winning O_TRUNC leaves a dirty-mirror pathname — after Release and a legitimate same-path replacement upsert, readGitFile returned the stale empty mirror rather than replacement bytes.

I posted exact-head runtime evidence and required regressions in each thread. Please fix all three and add deterministic tests before merge.

Stacking correction: current origin/main already contains #816 as merge-base 41503f7; the prior “#819 carries unfixed #816” concern is stale and is not a blocker.

@qiffang

qiffang commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

adversary-2 — RETRACTING my GREEN. @dat9-dev2's three findings are real; #819 = REVISE @ 40390d48.

My GREEN was correct on the registration-instant mark (the openHandles.mu re-check + addLocked atomicity is airtight — that part stands, and dev2/dev1/adversary-1 concur) — but I over-scoped to "does fh.Unlinked get set" and did not chase the downstream consequences of a straddling handle. dev2's deterministic repros caught three real hazards I missed. This is a [[failure-path-completeness]] miss: I verified the primary invariant, not the sibling paths the straddling handle participates in.

Finding #1 — CONFIRMED at source by me (resurrection vector, the load-bearing one): applyGitOverlayMirrorEntryUnlessWhiteout (git_workspace.go) does:

if _, blocked := fs.applyGitOverlayEntryUnlessWhiteout(...); blocked { fh.Unlinked=true; return }
fs.rememberPendingGitOverlayEntry(fh.GitWorkspaceID, entry)   // ← OUTSIDE the rt.mu critical section

The whiteout re-check + live-overlay apply are atomic under rt.mu, but rememberPendingGitOverlayEntry runs after that lock is released. Interleaving: apply sees no whiteout → returns applied → releases rt.mu → putGitWhiteout lands (rt.mu)rememberPendingGitOverlayEntry registers a stale pending upsert → later mergePendingGitOverlayEntries replays it over the server whiteout → resurrection. My subagent's "blocked → skips remember" was right only for the blocked path; the non-blocked path remembers pending post-lock, which is exactly where a whiteout slips in. The UnlessWhiteout guard must cover the pending-remember in the SAME critical section (or re-check whiteout before/at merge time).

Findings #2 & #3 — corroborated structurally, deferring to dev2's runtime repros:

  • docs: add project README #2 (O_RDONLY straddle reads empty): allocateGitWorkspaceFileHandle only sets fh.Unlinked=true, takes NO content snapshot/size; a straddling O_RDONLY handle's first Read resolves via readGitFile→overlay(now whiteout)→empty instead of pre-unlink bytes. POSIX anonymous-fd semantics require the reader keep seeing the pre-unlink content. Consistent with dev2's OK+"" repro.
  • MountableFS routing layer not implemented — server uses raw http.ServeMux instead of AGFS radix-tree dispatch #3 (O_TRUNC mirror pathname leak): whiteout-winning O_TRUNC created a dirty mirror; Release closes but does not removeGitDirtyMirror, so the stale empty mirror file survives and a later legitimate same-name replacement's readGitFile reads it (dirty-mirror-hit branch) instead of replacement bytes — cross-generation stale-mirror. This is adjacent to the readGitOverlayFile:1421 follow-up I flagged, but dev2's repro shows it's a live blocker, not a defensive nicety.

Retracted. #819 = REVISE, 3 blockers (all downstream of the correct registration mark). The core registration-window fix is sound; these are the straddling-handle consequence paths — pending-remember seam, read-path content, mirror lifecycle. Fix all three + deterministic regressions (asserting in-memory gitOverlayPending/rt.overlay/read-bytes, not just remote). Credit @dat9-dev2 — dynamic repro caught what my static exhaustiveness proof (correct on the mark) missed on the consequences. My stacking-is-stale correction stands (not a blocker).

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adversary-1 superseding review — REVISE @ 40390d480c7d8e9d13f081cf01934b02e092332b

This supersedes my earlier APPROVE on the same head. The core registration-time marking fix is still correct, but it is not sufficient: three source-visible side channels can still break POSIX anonymous-fd semantics or locally resurrect the deleted git overlay path. These need deterministic regressions before merge.

Blocking findings

  1. Pending mirror upsert can outlive the winning whiteoutpkg/fuse/git_workspace.go:3575-3580

applyGitOverlayMirrorEntryUnlessWhiteout performs the whiteout-checked live overlay write under rt.mu, then only afterward records the pending entry with rememberPendingGitOverlayEntry under gitOverlayMu. A whiteout can land in that gap. Registration may still mark the handle unlinked, but the stale pending upsert remains; mergePendingGitOverlayEntries blindly overlays pending entries onto refreshed server overlay state, so a later workspace refresh can turn the loaded server whiteout back into a local upsert. The pending registration must be whiteout-ordered too, or be rechecked/removed after registration.

  1. Read-only straddling opens lose their pre-unlink bytespkg/fuse/git_workspace.go:4216-4219 with pkg/fuse/dat9fs.go:10471-10542

For O_RDONLY, prepareGitOpenHandle does not preload a local buffer. If the handle registers after the whiteout, allocateGitWorkspaceFileHandle sets only fh.Unlinked=true; it does not set UnlinkedData, UnlinkedSnapshot, or UnlinkedSize. Read then takes the fh.Unlinked && fh.Dirty == nil path before the git read path and returns EOF for a non-empty file. A successful read-only open that raced unlink must keep reading the pre-unlink file contents, not "".

  1. Whiteout-winning O_TRUNC leaves a dirty-mirror pathname behindpkg/fuse/git_workspace.go:3557-3578 and pkg/fuse/git_workspace.go:1418-1425

In the O_TRUNC straddle path, openGitDirtyMirrorFile creates/truncates the dirty mirror before applyGitOverlayMirrorEntryUnlessWhiteout sees the whiteout and marks the handle unlinked. Git unlinked flush/release intentionally suppresses overlay writes, but it never unlinks that mirror pathname. Later, after a legitimate same-path replacement upsert, readGitOverlayFile checks readGitDirtyMirror first and can return the stale anonymous mirror bytes instead of the replacement content. The mirror pathname must be unlinked when the whiteout wins while preserving the open fd's anonymous backing.

What remains valid

The registration lock ordering itself is sound: openHandles.mu orders the add against unlink pass-2, and the whiteout re-check closes the original #817 window. The existing straddle tests cover that narrow path. The blockers above are additional same-PR side effects that the current tests do not cover.

Required before merge: fix all three and add deterministic regressions for pending-merge resurrection, read-only straddle anonymous reads, and O_TRUNC mirror cleanup across release/recreate.

Addresses the three adversarial-review blockers on the #817 straddle
fix, all side effects of adopting the unlinked state at registration:

- Drop stale pending mirror upserts: a whiteout landing between the
  open-time mirror's whiteout-checked apply and its pending registration
  left a pending upsert that mergePendingGitOverlayEntries replayed over
  the server whiteout on refresh. Registration now forgets the pending
  entry for the adopted path — a pending upsert coexisting with an
  authoritative whiteout is stale by definition.
- Read-only straddled opens keep pre-unlink bytes: read-only prepares do
  not preload, so the handle now stashes the observed overlay content in
  prepareGitOpenHandle and registration materializes it as the
  UnlinkedData/UnlinkedSnapshot read backing (falling back to the
  content-addressed clean tree, which whiteouts cannot touch). Read no
  longer returns EOF for the anonymous fd.
- Whiteout-winning opens remove the dirty-mirror pathname (mirroring
  putGitWhiteout): a stale mirror would otherwise shadow a legitimate
  same-path replacement, since readGitDirtyMirror is consulted before
  the overlay entry. The open fd remains the anonymous backing.

Deterministic regressions for each: the parked pending-registration gap
with a refresh merge, a read-only straddle reading the original bytes
after unlink, and an O_TRUNC straddle whose replacement serves its own
content after the mirror is gone. Each fails with its cleanup reverted.
@mornyx

mornyx commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up: all three CHANGES_REQUESTED blockers addressed @ 6c295f5

The registration-time marking core is unchanged; this commit completes the whiteout adoption with the three cleanups the adversarial review identified, each with a deterministic regression and a negative control (reverting just the cleanup fails the test in the exact repro shape):

  1. Pending mirror upsert can outlive the winning whiteout — registration-time adoption now forgets the pending entry for the adopted path. A pending upsert coexisting with an authoritative whiteout is stale by definition (a legitimate recreate applies its own overlay entry first, which would have made the re-check see an upsert). TestGitWorkspaceRegistrationDropsStalePendingMirrorEntry replays the parked gap and proves a refresh merge keeps the whiteout.
  2. Read-only straddling opens lost pre-unlink bytesprepareGitOpenHandle stashes the observed overlay content (fh.GitOpenSnapshot); adoption materializes it as the standard UnlinkedData/UnlinkedSnapshot read backing, with a clean-tree fallback (content-addressed, whiteout-immune). TestGitWorkspaceReadonlyOpenStraddlingWhiteoutReadsPreUnlinkContent reads the original bytes through the anonymous fd after unlink; the negative control reproduces the review's exact read content = "" failure. Documented residual: content never fetched locally (metadata-only entry, no clean-tree base) stays EOF — preloading it would force a content GET into every read-only open, which the mmap/FOPEN_KEEP_CACHE path intentionally avoids.
  3. Whiteout-winning O_TRUNC left the dirty-mirror pathname behind — adoption now removes the pathname (mirroring putGitWhiteout); the open fd remains the anonymous backing. TestGitWorkspaceStraddledOpenRemovesStaleDirtyMirror covers whiteout-winning O_TRUNC → registration → replacement bytes visible; negative control reproduces the review's read replacement = "" shadowing.

Merge-ordering note from the dev-lead review: #816 is already merged (squash 41503f7, 2026-07-30T12:07:53Z) and this branch is based directly on main at that commit, so the diff carries no unmerged stacked content.

Validation on 6c295f5:

  • go test ./pkg/fuse -run 'TestGitWorkspace|TestApplyGitOverlay|TestPrepareGitOpen' -count=1 — pass
  • Straddle/unlink/mirror-focused set -count=10, plus CGO_ENABLED=1 go test -race -count=3 — pass
  • Full go test ./pkg/fuse -count=1 — only the known Darwin environmental failures (TestShouldContinueAfterWaitMountPermissionErrorWhenProbePasses/Fails), which reproduce identically on unmodified origin/main
  • golangci-lint run pkg/fuse/... — 0 issues; gofmt clean on all touched files; git diff --check clean

Please re-review at 6c295f5.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/fuse/git_workspace.go`:
- Around line 4244-4260: Update allocateGitWorkspaceFileHandle so the whiteout
check and fh.Unlinked assignment occur while holding fh.mu, while preserving the
existing openHandles.mu critical section and re-check. Confirm
markOpenHandlesUnlinked uses the same openHandles.mu-then-fh.mu order, then
allocate the file handle ID only after the handle has been marked and published
to openHandles.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 299648a1-1b64-43c1-babe-52c759572f3e

📥 Commits

Reviewing files that changed from the base of the PR and between 40390d4 and 6c295f5.

📒 Files selected for processing (4)
  • pkg/fuse/dat9fs.go
  • pkg/fuse/git_workspace.go
  • pkg/fuse/git_workspace_test.go
  • pkg/fuse/handle.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/fuse/dat9fs.go

Comment thread pkg/fuse/git_workspace.go
Comment on lines +4244 to +4260
func (fs *Dat9FS) allocateGitWorkspaceFileHandle(ctx context.Context, fh *FileHandle, rt *gitWorkspaceRuntime, rel string) uint64 {
fhID := fs.fileHandles.Allocate(fh)
fs.openHandles.mu.Lock()
marked := false
if e, ok := rt.overlayEntry(rel); ok && e.Op == "whiteout" {
fh.Unlinked = true
marked = true
}
fs.openHandles.addLocked(fh)
fs.openHandles.mu.Unlock()
if marked {
fs.forgetPendingGitOverlayEntry(fh.GitWorkspaceID, rel, 0)
fs.removeGitDirtyMirror(rt, rel)
fs.preserveGitUnlinkedReadBacking(ctx, fh, rt, rel)
}
return fhID
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Set fh.Unlinked under fh.mu, or allocate the handle ID after marking.

Line 4245 publishes fh in fs.fileHandles before line 4249 writes fh.Unlinked. That write is not protected by fh.mu, while other readers of fh.Unlinked (Read, Write, Flush, drain paths) take fh.mu. A concurrent handle-table iteration (for example FlushAll or SyncFs drain) can therefore read fh.Unlinked while this goroutine writes it. The simplest fix keeps the whiteout re-check inside the openHandles.mu critical section but allocates the ID last.

Verify the lock order first: if markOpenHandlesUnlinked takes openHandles.mu before fh.mu, then taking fh.Lock() inside this critical section keeps the same order and is safe.

🔒 Proposed fix: mark under `fh.mu` and publish the ID last
 func (fs *Dat9FS) allocateGitWorkspaceFileHandle(ctx context.Context, fh *FileHandle, rt *gitWorkspaceRuntime, rel string) uint64 {
-	fhID := fs.fileHandles.Allocate(fh)
 	fs.openHandles.mu.Lock()
 	marked := false
 	if e, ok := rt.overlayEntry(rel); ok && e.Op == "whiteout" {
-		fh.Unlinked = true
+		fh.Lock()
+		fh.Unlinked = true
+		fh.Unlock()
 		marked = true
 	}
 	fs.openHandles.addLocked(fh)
 	fs.openHandles.mu.Unlock()
 	if marked {
 		fs.forgetPendingGitOverlayEntry(fh.GitWorkspaceID, rel, 0)
 		fs.removeGitDirtyMirror(rt, rel)
 		fs.preserveGitUnlinkedReadBacking(ctx, fh, rt, rel)
 	}
+	fhID := fs.fileHandles.Allocate(fh)
 	return fhID
 }
#!/bin/bash
# Inspect lock order between OpenHandleIndex.mu and FileHandle.mu.
fd -t f 'open_handles.go' -x ast-grep outline {} --items all
rg -nP -C 12 'func \(fs \*Dat9FS\) markOpenHandlesUnlinked' --type=go
rg -nP -C 6 'func \(idx \*OpenHandleIndex\) addLocked' --type=go
# Find handle-table iterations that read Unlinked under fh.Lock().
rg -nP -C 6 '\bfileHandles\.(All|Range|Snapshot|List)\w*\(' --type=go
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/fuse/git_workspace.go` around lines 4244 - 4260, Update
allocateGitWorkspaceFileHandle so the whiteout check and fh.Unlinked assignment
occur while holding fh.mu, while preserving the existing openHandles.mu critical
section and re-check. Confirm markOpenHandlesUnlinked uses the same
openHandles.mu-then-fh.mu order, then allocate the file handle ID only after the
handle has been marked and published to openHandles.

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.

FUSE git workspace unlink: in-flight Open can escape whiteout marking

2 participants