Skip to content

fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix#628

Merged
kevincodex1 merged 11 commits into
Gitlawb:mainfrom
euxaristia:fix/lock-reclaim-posix-overwrite
Jul 13, 2026
Merged

fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix#628
kevincodex1 merged 11 commits into
Gitlawb:mainfrom
euxaristia:fix/lock-reclaim-posix-overwrite

Conversation

@euxaristia

@euxaristia euxaristia commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a High-severity concurrency/safety issue in lock file reclamation.

When reclaiming a suspected stale lock, the lock file is renamed aside, checked, and restored if it turns out fresh (a holder reacquired it in the gap). The restore step must never overwrite a competing lock created at the original path in the meantime.

  1. On POSIX, Go's os.Rename silently overwrites the target if it already exists, so the old restore could clobber a new holder's lock and violate mutual exclusion.
  2. On Windows, os.Rename also overwrites: it passes MOVEFILE_REPLACE_EXISTING to match POSIX rename semantics cross-platform. The same bug, plus the sidelined .stale.* file could be leaked.

This PR introduces internal/lockutil with a no-overwrite restore:

  • On POSIX, RestoreLockFile uses os.Link, which fails with os.ErrExist if the target lock file has been recreated.
  • On Windows, it calls MoveFileEx with no flags via golang.org/x/sys/windows, which fails with ERROR_ALREADY_EXISTS (satisfies errors.Is(err, os.ErrExist)) instead of overwriting.
  • If the primary no-replace primitive fails for any reason other than the target existing (hardlink-incapable filesystems, ENOSPC, Windows sharing violations), RestoreLockFile falls back to an O_EXCL copy, so the lock path is never left missing while the live holder's lock is stranded in the sidelined file.
  • Callers remove the sidelined file on any restore failure (once the restore has failed it has no protocol function; release only consults the lock path). When the restore fails for any reason other than the target existing, the acquisition fails closed with an error instead of re-acquiring a possibly missing lock path.
  • RemoveLockFile has one cross-platform contract: removing a missing file reports nil, and Windows retries transient sharing violations.

Changes

  • Updated lock reclamation in internal/cron/lock.go, internal/daemon/lock.go, internal/hooks/lock.go, internal/oauth/lock.go, and internal/swarm/mailbox.go to use lockutil.
  • Extracted swarm's inline reclaim into a reclaimStaleLock helper mirroring cron, hooks, and oauth, so its restore path is testable.
  • Regression tests: lockutil restore, copy-fallback, and remove behavior on all platforms; Windows-tagged tests pinning the ERROR_ALREADY_EXISTS mapping to os.ErrExist and exercising the RemoveLockFile retry loop; per-package stale-reclaim tests for cron, daemon, hooks, oauth, and swarm.

Test plan

  • go test ./internal/lockutil/... ./internal/cron/... ./internal/daemon/... ./internal/hooks/... ./internal/oauth/... ./internal/swarm/...
  • Run locally on Windows (exercises the Windows-tagged lockutil tests); Smoke (ubuntu, macos, windows) in CI covers the rest.

Summary by CodeRabbit

  • Bug Fixes
    • Improved lock-file cleanup and stale-lock reclamation across background jobs, services, hooks, authentication, and messaging by centralizing remove/restore logic.
    • Safer behavior: deleting missing locks is treated as success, and lock removal only occurs when the on-disk token still matches.
    • Stale-lock reclamation is now fail-closed on errors to reduce mutual-exclusion risk, with improved Windows no-overwrite restore and bounded retries when files are temporarily contended.
  • Tests
    • Added unit tests covering restore, remove, reclaim, contention/sharing errors, and failure modes on both non-Windows and Windows.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b6fe8f8c-f59b-491f-a428-97cb9abb4306

📥 Commits

Reviewing files that changed from the base of the PR and between 01bb58a and d8f2943.

📒 Files selected for processing (2)
  • internal/lockutil/reclaim.go
  • internal/lockutil/reclaim_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/lockutil/reclaim.go
  • internal/lockutil/reclaim_test.go

Walkthrough

Lock lifecycle code now delegates lock-file removal and stale-lock reclamation to internal/lockutil. Platform-specific restoration and deletion handle contention, while acquisition paths fail closed when reclamation cannot safely complete.

Changes

Stale-lock restoration

Layer / File(s) Summary
Platform-aware restoration and removal helpers
internal/lockutil/*
Adds cross-platform restoration, copy fallback, missing-file handling, Windows retry logic, and filesystem tests.
Shared fail-closed stale-lock reclamation
internal/lockutil/reclaim.go, internal/lockutil/*_test.go
Adds shared rename-aside reclamation with liveness callbacks, restoration error handling, cleanup, and platform-specific race tests.
Lock-path integration and fail-closed handling
internal/{cron,daemon,hooks,oauth}/lock.go, internal/swarm/mailbox.go
Routes cleanup and stale-lock handling through lockutil, updates release paths, and aborts acquisition on reclamation errors.
Updated reclamation assertions
internal/cron/lock_reclaim_test.go, internal/daemon/lock_test.go
Updates tests for (bool, error) results and verifies stale, live, fresh, and missing lock behavior.

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

Possibly related PRs

  • Gitlawb/zero#207: Touches the same mailbox lock lifecycle and stale-lock reclamation logic.

Suggested reviewers: vasanthdev2004, gnanam1990, anandh8x

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the cross-platform lock overwrite and cleanup fixes in this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/cron/lock.go (1)

121-129: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider hoisting this helper into one shared internal package.

restoreLockFile is byte-for-byte identical across cron, daemon, hooks, oauth, and swarm. Safety-critical concurrency logic duplicated 5× will drift — and the Windows fix above now has to land in all five copies. A single internal/lockutil.RestoreLockFile would keep the invariant in one place.

🤖 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 `@internal/cron/lock.go` around lines 121 - 129, Consolidate the duplicated
restoreLockFile helper into a shared internal lock utility. Add exported
lockutil.RestoreLockFile containing the existing Windows rename and non-Windows
link/remove behavior, then remove the local copies in cron, daemon, hooks,
oauth, and swarm and update all callers to use the shared function.
🤖 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 `@internal/cron/lock.go`:
- Around line 121-129: Update restoreLockFile to use a Windows restore operation
that does not replace an existing destination, ensuring it returns an error when
path already exists; preserve the existing hard-link/remove behavior on
non-Windows platforms.

---

Nitpick comments:
In `@internal/cron/lock.go`:
- Around line 121-129: Consolidate the duplicated restoreLockFile helper into a
shared internal lock utility. Add exported lockutil.RestoreLockFile containing
the existing Windows rename and non-Windows link/remove behavior, then remove
the local copies in cron, daemon, hooks, oauth, and swarm and update all callers
to use the shared function.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cb074b82-00ea-484a-b2f9-af563bdefb9c

📥 Commits

Reviewing files that changed from the base of the PR and between aa73a76 and b1434e7.

📒 Files selected for processing (5)
  • internal/cron/lock.go
  • internal/daemon/lock.go
  • internal/hooks/lock.go
  • internal/oauth/lock.go
  • internal/swarm/mailbox.go

Comment thread internal/cron/lock.go Outdated

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Complete CodeRabbit's no-overwrite restoration request on Windows
    internal/cron/lock.go:121
    The new Windows branch is still os.Rename(reclaimed, path), but Go documents that os.Rename replaces an existing non-directory destination. If another process acquires path after it is moved aside, this restore overwrites that new lock and recreates the split-brain race the PR is intended to eliminate. Please complete CodeRabbit's request with a no-replace restoration primitive on Windows (and apply it to the four identical helpers), then add a regression that creates the competing destination before restore.

  • [P1] Preserve a live sidelined lock when restoration fails for another reason
    internal/cron/lock.go:112
    Every changed reclaim path removes reclaimed after any restoreLockFile error. That is safe only when a newly-created path conclusively caused the failure; an I/O/space error, a filesystem without hard-link support, or another unrelated rename failure instead deletes the verified live holder's lock and allows a new acquirer through. Please keep the sidelined lock and fail closed for errors that do not prove a replacement lock exists, and add a failure-path regression test.

During lock reclamation, reclaiming a suspected stale lock renames the file aside and renames it back if not stale. On POSIX systems, the rename back silently overwrites any new lock file created in the gap. On Windows, it fails to rename back and leaks the stale file.

Introduce a cross-platform restoreLockFile helper:
- On POSIX: Use os.Link to link the file back (which atomic-fails if the target already exists), then remove the temporary link.
- On Windows: Use os.Rename (which safely fails if the target exists).
If restoring fails, delete the temporary file immediately to prevent leaks.
@euxaristia
euxaristia force-pushed the fix/lock-reclaim-posix-overwrite branch from b1434e7 to f544d18 Compare July 10, 2026 03:50

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the update. I rechecked the changed paths and found an issue that still needs to be addressed.

Findings

  • [P1] Fix the Windows smoke failure in the no-replace restore helper
    internal/lockutil/lockutil_windows.go:20
    The required Smoke (windows-latest) check fails because syscall.MoveFileEx is undefined, so go test ./... cannot compile internal/lockutil or any package that imports it on Windows. Please complete the prior Windows no-overwrite restoration fix with a supported Windows API binding or implementation, add Windows coverage, and rerun the Windows smoke check.

Addresses jatmn's follow-up review: the required Windows smoke check was
failing because syscall.MoveFileEx is not exported by the standard
library's syscall package on this platform, so internal/lockutil (and
everything importing it: cron, daemon, hooks, oauth) failed to compile
on Windows.

Switched to golang.org/x/sys/windows, which does export MoveFileEx with
the same signature, already a direct module dependency. Verified on a
real Windows machine that the no-flags call still fails with
ERROR_ALREADY_EXISTS when the destination exists, and that this
satisfies errors.Is(err, os.ErrExist), which cron/daemon/hooks/oauth's
reclaim paths all depend on to decide whether the sidelined lock was
genuinely superseded (safe to discard) versus some other, unrelated
failure (must be preserved).

TestRestoreLockFile already covered both the successful-restore and
fail-when-target-exists cases; it now passes on Windows.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I traced the lock acquire/release/reclaim path across cron, daemon, hooks, oauth, and swarm, and this is a sound fix.

The mechanism is an O_EXCL-created lock file (not flock), released by remove-if-token-matches, with stale reclaim via rename-aside.

The real bug was that the restore step used os.Rename(reclaimed, lockPath). On POSIX, this atomically replaces the destination, and on Windows, it passes MOVEFILE_REPLACE_EXISTING. If a fresh holder O_EXCL-created lockPath in that tiny gap, the restore would clobber it, creating a two-writer / lost-lock risk.

The new RestoreLockFile correctly fails closed when the target exists (os.Link on POSIX, MoveFileEx with no flags on Windows). On os.ErrExist, the caller drops the sidelined file, which is correct and ensures no leaks. Failed-acquire paths still clean up via RemoveLockFile, and the Windows delete-retry loop handles delete-pending/sharing-violations without skipping the unlock path.

Build, vet, and the touched package tests all pass on Windows (the lockutil test exercises the real MoveFileEx path and confirms errors.Is(err, os.ErrExist) holds), and it cross-compiles for linux.

Minor nit only: the first commit message's Windows rationale is slightly stale (it says os.Rename safely fails), but the final code is correct.

@euxaristia

Copy link
Copy Markdown
Contributor Author

Fixed the Windows build failure: internal/lockutil/lockutil_windows.go used syscall.MoveFileEx, which the standard library does not export on Windows. Switched to golang.org/x/sys/windows.MoveFileEx (same signature, already a direct dependency) and verified on real Windows hardware that its no-overwrite failure still satisfies errors.Is(err, os.ErrExist), since the four call sites that reclaim a stale lock depend on that exact behavior.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Findings

H1 — High: non-os.ErrExist restore failure can still lose a live lock / cause split-brain

Files: internal/lockutil/lockutil_other.go:11-16 and the identical caller branches
internal/cron/lock.go:108-116, internal/hooks/lock.go:102-108,
internal/oauth/lock.go:97-103, internal/daemon/lock.go:90-98,
internal/swarm/mailbox.go:384-388.

POSIX RestoreLockFile is os.Link(reclaimed, path) then os.Remove(reclaimed).
os.Link can fail with errors other than os.ErrExist:

  • EXDEV — cross-device link (overlayfs/bind-mount boundaries, a sidelined temp on a
    different mount, some container layouts, exFAT/some FUSE/network mounts);
  • ENOSPC / EPERM / ENOSYS / ELOOP — space, permission, unsupported FS, symlink loop.

On such a failure the caller only cleans reclaimed when errors.Is(err, os.ErrExist),
so it does nothing: reclaimed (the live holder's lock) is left on disk and
lockPath stays missing. The caller returns false; the surrounding acquire loop
then executes its next os.OpenFile(lockPath, O_CREATE|O_EXCL|O_WRONLY, 0o600) — which
succeeds because the file is gone — and the acquirer takes the lock. The original
holder (whose lock is in reclaimed) is still inside its critical section, so two
processes now believe they hold the lock
→ split-brain / lost mutual exclusion
(corrupted mailbox JSON, duplicate job-metadata read-modify-write, or two
single-instance daemons).

Why this is reachable: the FRESH branch is entered when a holder "reacquired in the
gap" (the post-rename Stat shows mtime <= staleAfter). That race is exactly the
reacquire-in-gap scenario this PR targets, and on any link-incapable/under-pressure
filesystem os.Link fails there. Concrete scenario: A ages out holder B's lock, renames
it aside, re-stats and sees it FRESH, calls RestoreLockFile; os.Link fails (EXDEV);
lockPath is empty; A's loop re-acquires it; B and A both hold.

Same class on Windows: MoveFileEx(...,0) can also fail with ERROR_SHARING_VIOLATION
(32) / ERROR_ACCESS_DENIED (5) / ERROR_NOT_SAME_DEVICE / a UTF16PtrFromString encoding
error — none of which is os.ErrExist — producing the identical "preserve sidelined +
empty lockPath + re-acquire" split-brain.

Not the partial-success case: if os.Link succeeds but os.Remove(reclaimed) fails,
lockPath is correctly restored (still a hard link); only an orphan .stale.* remains
(Low, see L2). The High is specifically when os.Link itself fails with non-os.ErrExist
and lockPath ends up empty.

Recommended fix (closes it on both platforms): RestoreLockFile should not surface a
non-os.ErrExist error when the lock can still be handed back safely. After a
non-os.ErrExist link failure, fall back to an O_EXCL copy (which still fails EEXIST
if a new holder appeared, preserving the no-overwrite guarantee), and on a successful link
but failed remove, return nil so the lock is treated as restored:

func RestoreLockFile(reclaimed, path string) error {
    if err := os.Link(reclaimed, path); err == nil {
        return os.Remove(reclaimed)
    } else if errors.Is(err, os.ErrExist) {
        return err
    }
    return restoreByCopy(reclaimed, path) // O_CREATE|O_EXCL|O_WRONLY copy, then remove reclaimed
}

// restoreByCopy copies reclaimed -> path without overwriting an existing path.
func restoreByCopy(reclaimed, path string) error {
    src, err := os.Open(reclaimed)
    if err != nil {
        return err
    }
    defer src.Close()
    dst, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
    if err != nil {
        return err // EEXIST -> caller drops reclaimed (new holder won); other -> safe fallback
    }
    defer dst.Close()
    if _, err := io.Copy(dst, src); err != nil {
        dst.Close()
        os.Remove(path)
        return err
    }
    if err := dst.Close(); err != nil {
        os.Remove(path)
        return err
    }
    return os.Remove(reclaimed)
}

(A copy resets mtime to now, which keeps the lock "fresh" — safe; just note it.) As
defense-in-depth, callers could also os.Stat(lockPath) after any RestoreLockFile
error before deciding to re-acquire.


M1 — Medium: PR description and commit messages are inaccurate about the Windows mechanism

  • PR body and first commit: "On Windows, it uses os.Rename (which safely fails if
    the target exists)."
    false. The final code uses
    windows.MoveFileEx(from, to, 0) (lockutil_windows.go:31), and os.Rename on Windows
    actually overwrites (passes MOVEFILE_REPLACE_EXISTING) — the very bug this PR fixes
    (correctly stated in the file's own comment at lockutil_windows.go:19-21). Doubly wrong;
    confirms the prior reviewer's "stale Windows rationale" note.
  • PR body: "If restoring fails, the temporary file is deleted rather than leaked."
    overstated: cleanup happens only on os.ErrExist (cron/lock.go:111-116 and
    siblings); other errors leak (this is the H1 leak aspect).
  • Third commit subject is truncated in metadata ("…ndows retry loop").
  • lockutil_windows.go:18 claims the os.ErrExist mapping is "verified against the real
    Win32 call"
    — but there is no Windows-tagged test backing it (see M2).
  • Stated test plan go test -race ./internal/cron/... ./internal/daemon/... ./internal/hooks/... ./internal/oauth/... ./internal/swarm/... omits ./internal/lockutil/..., where the primary TestRestoreLockFile lives.

M2 — Medium: test-coverage gaps leave H1 and the Windows path unverified

  • The no-overwrite guarantee when a competing destination exists IS covered on POSIX
    (internal/lockutil/lockutil_test.go:29-48), and the FRESH restore success path IS
    covered
    by internal/cron/lock_reclaim_test.go:29-39 (writes a fresh lock, asserts it
    is restored intact — note it does not pre-create a competing destination before
    restore; that exact race is exercised only at the lockutil level).
  • Windows MoveFileEx path is entirely untested — no //go:build windows test; the
    load-bearing errors.Is(err, os.ErrExist) coupling (ERROR_ALREADY_EXISTS) is asserted
    only in a comment. Must be verified on a Windows runner / Wine cross-build.
  • RemoveLockFile (including the Windows 15×5ms retry loop on errno 32/5) has ZERO
    tests
    on any platform.
  • No test for the non-os.ErrExist preserve branch, no EXDEV-fallback test, no
    -race/concurrency test for the reclaim→restore path.
  • Only cron has a reclaim/restore test; daemon, hooks, oauth, swarm have none for
    this logic. The earlier reviewer's requested "competing destination before restore"
    regression should be added per-package (mirroring cron).

L1 — Low: RemoveLockFile contract diverges across platforms

POSIX returns fs.ErrNotExist on a missing file; Windows swallows it to nil
(lockutil_windows.go:41). daemon/lock.go:109 relies on the POSIX behavior
(!errors.Is(err, fs.ErrNotExist)). Today benign (other callers use _ =), but a shared
package should present one uniform contract — make both swallow ErrNotExist, or document
the divergence explicitly on both doc comments.

L2 — Low: orphan .stale.* leak on Windows retry exhaustion / generic non-os.ErrExist

The genuinely-stale RemoveLockFile(reclaimed) is _ =-ignored
(cron/lock.go:118, hooks/lock.go:110, oauth/lock.go:105, swarm/mailbox.go:382). On
Windows, if the 15-retry sharing-violation loop exhausts, reclaimed is left on disk
permanently (no code ever sweeps *.stale.*). Harmless to correctness but a directory
leak; at least propagate/log the error. (The POSIX partial-success orphan from H1's
link-ok/remove-fail case is the same class, Low.)

L3 — Low/Nit: predictable tokens & un-hardened lock dirs (defense-in-depth)

Tokens are pid+nanotime+seq (not crypto/rand) and lock dirs are created 0o700 but
MkdirAll leaves a pre-existing world/group-writable dir untouched
(cron/lock.go:38, hooks/lock.go:37, oauth/lock.go:30; daemon has no MkdirAll). Not
exploitable given the rename-first invariant, but swarm's ensureInboxDir tightens dirs
and does a symlink-aware confinement check — the other callers should match for
consistency. Also add a // Package lockutil doc comment (revive will flag its absence).


RestoreLockFile could fail with errors other than os.ErrExist (hardlink-
incapable filesystems such as FAT or some FUSE/network mounts, ENOSPC,
Windows sharing violations). Callers only clean up on os.ErrExist, so any
other failure left the lock path missing with the live holder's lock
stranded in the sidelined file; the next O_EXCL create then succeeded and
two processes held the lock. RestoreLockFile now falls back to an O_EXCL
copy in that case, which preserves the no-overwrite guarantee, and a
successful restore with a failed sidelined-file cleanup reports nil
instead of a failed restore.

RemoveLockFile now has one cross-platform contract: removing an already
missing file reports nil on POSIX too, matching Windows, and daemon
release() drops its caller-side ErrNotExist filter.

Tests: TestRestoreByCopy and TestRemoveLockFile run on all platforms;
Windows-tagged tests pin the ERROR_ALREADY_EXISTS mapping to os.ErrExist
and exercise the RemoveLockFile retry loop to success and to bounded
exhaustion. The package doc comment documents the shared contract.
The stale-lock reclaim in acquireLock was inline, so its restore path
(a holder rotated a fresh lock in the gap between the stale check and
the rename) could not be regression-tested the way cron, daemon, hooks,
and oauth test theirs. Extract it into reclaimStaleLock with the same
shape as the other callers, behavior unchanged, and add the mirrored
regression test.
Mirror the cron reclaim test in the two packages that lacked coverage
for reclaimStaleLock: a genuinely stale lock is reclaimed, a fresh lock
(reacquired in the gap) is restored intact rather than stolen, and a
missing lock reports no reclaim. daemon already had equivalent tests in
lock_test.go.
@euxaristia

Copy link
Copy Markdown
Contributor Author

@jatmn Pushed fixes for this review: 2df309d, b59c370, 1f79311.

H1 (fixed): Implemented the recommended fallback. RestoreLockFile now falls back to an O_EXCL copy when the primary no-replace primitive (link on POSIX, MoveFileEx on Windows) fails with anything other than os.ErrExist, so the lock path is never left missing while the live holder's lock sits in the sidelined file. A successful link with a failed cleanup of the sidelined name now returns nil, since the lock is restored either way. Covered by TestRestoreByCopy. One correction to the writeup: EXDEV is not reachable here, because the sidelined name is always a sibling of the lock path in the same directory. The reachable failures are EPERM/ENOSYS on hardlink-incapable filesystems, ENOSPC, and the Windows sharing-violation class, all of which the fallback covers.

M1 (fixed): Rewrote the PR body. It now says the Windows mechanism is MoveFileEx with no flags (and that os.Rename on Windows overwrites), states that the sidelined file is deleted only on os.ErrExist and preserved otherwise, and includes internal/lockutil in the test plan. The "verified against the real Win32 call" comment now points at TestMoveFileNoReplaceMapsAlreadyExistsToErrExist, which pins that mapping on the Windows runner. On the truncated commit subject: the underlying message is complete, GitHub truncates the display; I have kept the new subjects shorter.

M2 (fixed): Added TestRestoreByCopy and TestRemoveLockFile in internal/lockutil (run on every platform, including the windows-latest smoke), Windows-tagged tests pinning the ERROR_ALREADY_EXISTS mapping to os.ErrExist and exercising the RemoveLockFile retry loop both to success (contending handle released mid-retry) and to bounded exhaustion (ERROR_SHARING_VIOLATION surfaced), and per-package reclaim regression tests for hooks, oauth, and swarm mirroring cron's. To make swarm's restore path testable I extracted its inline reclaim into a reclaimStaleLock helper matching the other callers. Two notes: daemon already had these tests (TestReclaimStaleLockRemovesDeadHolder and TestReclaimStaleLockRestoresLiveHolder in internal/daemon/lock_test.go), and the untagged lockutil tests already ran on the windows-latest smoke, so the os.ErrExist assertion was exercised against the real MoveFileEx there; the tagged tests make the pinning explicit rather than incidental.

L1 (fixed): POSIX RemoveLockFile now swallows ErrNotExist like Windows, both doc comments state the shared contract, and daemon release() drops its caller-side filter.

L2 (partly addressed): After the H1 fix, the sidelined file can only be orphaned when a remove fails after the lock is already safely restored or reclaimed, for example Windows retry exhaustion. There is no logging facility at this layer to report it to, and a *.stale.* sweep would risk deleting another racer's in-flight sidelined file, so I left the cleanup best-effort. Happy to add an mtime-gated sweep in a follow-up if you think it is worth it.

L3 (partly addressed): Added the // Package lockutil doc comment. The token scheme and lock dir permissions are pre-existing behavior shared with the current callers and, as you note, not exploitable given the rename-first invariant. Aligning cron/hooks/oauth with swarm's ensureInboxDir hardening is a real change to those packages' setup paths, so I would rather do it in a dedicated follow-up than fold it into this fix. I can file an issue for it.

@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: 2

🧹 Nitpick comments (1)
internal/lockutil/lockutil_windows.go (1)

60-64: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the named Windows error constants here. windows.ERROR_SHARING_VIOLATION and windows.ERROR_ACCESS_DENIED are already referenced in the Windows tests, and they make this retry path self-documenting.

🤖 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 `@internal/lockutil/lockutil_windows.go` around lines 60 - 64, Update the retry
condition in the lock acquisition logic to compare errno against
windows.ERROR_SHARING_VIOLATION and windows.ERROR_ACCESS_DENIED instead of
numeric literals 32 and 5. Preserve the existing errors.As check, sleep
duration, and retry behavior.
🤖 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 `@internal/cron/lock.go`:
- Around line 111-118: Update the RestoreLockFile error handling in the lock
recovery flow to surface every non-os.ErrExist error instead of silently
ignoring it; retain the existing reclaimed-file removal only for ErrExist. Apply
the same correction to the corresponding lock recovery patterns in daemon,
hooks, and oauth, using their RestoreLockFile call sites and existing logging or
propagation conventions.

In `@internal/swarm/mailbox.go`:
- Around line 395-412: Update reclaimStaleLock and its counterparts in cron,
hooks, and oauth so every RestoreLockFile error removes the reclaimed .stale.*
file, not only errors.Is(err, os.ErrExist). Preserve the existing return
behavior while ensuring cleanup runs for both ErrExist and non-ErrExist
failures.

---

Nitpick comments:
In `@internal/lockutil/lockutil_windows.go`:
- Around line 60-64: Update the retry condition in the lock acquisition logic to
compare errno against windows.ERROR_SHARING_VIOLATION and
windows.ERROR_ACCESS_DENIED instead of numeric literals 32 and 5. Preserve the
existing errors.As check, sleep duration, and retry behavior.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 60a913e3-8eac-403c-a5d5-66ca7ef5700b

📥 Commits

Reviewing files that changed from the base of the PR and between 7e9cc8e and 1f79311.

📒 Files selected for processing (13)
  • internal/cron/lock.go
  • internal/daemon/lock.go
  • internal/hooks/lock.go
  • internal/hooks/lock_reclaim_test.go
  • internal/lockutil/lockutil.go
  • internal/lockutil/lockutil_other.go
  • internal/lockutil/lockutil_test.go
  • internal/lockutil/lockutil_windows.go
  • internal/lockutil/lockutil_windows_test.go
  • internal/oauth/lock.go
  • internal/oauth/lock_reclaim_test.go
  • internal/swarm/mailbox.go
  • internal/swarm/mailbox_test.go

Comment thread internal/cron/lock.go Outdated
Comment thread internal/swarm/mailbox.go Outdated
A non-os.ErrExist error from RestoreLockFile means both the no-replace
restore and its O_EXCL copy fallback failed, so the lock path may be
missing while the sidelined holder is live. Callers previously swallowed
that error and their next O_EXCL create would succeed, breaking mutual
exclusion. reclaimStaleLock in cron, daemon, hooks, oauth, and swarm now
returns an error for that case and the acquire loops fail closed instead
of re-acquiring.

The sidelined .stale.* file is now removed on every restore failure, not
only os.ErrExist: once the restore has failed it has no protocol
function (release only consults the lock path), so keeping it only
leaked files.

Also replace the numeric errno literals in the Windows RemoveLockFile
retry loop with windows.ERROR_SHARING_VIOLATION and
windows.ERROR_ACCESS_DENIED.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed 13b729d addressing the latest CodeRabbit review.

  • Non-os.ErrExist restore failures are now propagated: reclaimStaleLock in cron, daemon, hooks, oauth, and swarm returns an error and the acquire loops fail closed instead of re-acquiring a possibly missing lock path. This also picks up the defense-in-depth suggestion from the earlier human review.
  • The sidelined .stale.* file is removed on every restore failure, not only os.ErrExist, since it has no protocol function once the restore has failed. This closes most of the earlier L2 as well; the only remaining orphan case is a failed remove itself.
  • The Windows RemoveLockFile retry loop now uses windows.ERROR_SHARING_VIOLATION and windows.ERROR_ACCESS_DENIED instead of the numeric literals (nitpick).

PR body updated to match the new caller semantics.

@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 (2)
internal/cron/lock_reclaim_test.go (1)

10-46: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

No coverage for the new fail-closed error path.

Tests cover stale-reclaimed, fresh-restored, and missing-lock cases, but none exercise reclaimStaleLock returning a non-nil error (i.e., RestoreLockFile failing for a reason other than os.ErrExist). That branch is the actual safety-critical addition in this PR (fail closed instead of re-acquiring), and it's currently only reachable/verifiable indirectly through lower-level lockutil tests, not through this package's integration path. Same gap applies to internal/daemon/lock_test.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 `@internal/cron/lock_reclaim_test.go` around lines 10 - 46, Extend
TestReclaimStaleLock to force RestoreLockFile to return a non-os.ErrExist error
and verify reclaimStaleLock returns that error without re-acquiring the lock.
Add the equivalent fail-closed integration case to the relevant lock test in
internal/daemon, preserving existing stale, fresh, and missing-lock coverage.
internal/cron/lock.go (1)

109-133: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fail-closed restore logic is correct; consider consolidating duplicated reclaim logic.

This addresses both prior review comments (Windows overwrite risk, swallowed non-ErrExist errors) correctly: the no-replace restore + copy fallback + fail-closed-on-error chain matches the documented lockutil contract.

Separately: hooks/lock.go and oauth/lock.go carry byte-for-byte identical reclaimStaleLock bodies (mtime-based stale check → rename-aside → restore-if-fresh → fail-closed). This triplication of critical mutual-exclusion logic means any future fix has to land in three places (as the past-review history for this exact function shows). Worth extracting a shared helper into internal/lockutil that takes the lock path, a reclaimed-suffix, and a "still live" predicate.

♻️ Illustrative shared helper
// internal/lockutil/reclaim.go
func ReclaimStaleLock(lockPath, suffix string, isLive func(reclaimedPath string) bool) (bool, error) {
	reclaimed := lockPath + ".stale." + suffix
	if err := os.Rename(lockPath, reclaimed); err != nil {
		return false, nil
	}
	if isLive(reclaimed) {
		if rerr := RestoreLockFile(reclaimed, lockPath); rerr != nil {
			_ = RemoveLockFile(reclaimed)
			if !errors.Is(rerr, os.ErrExist) {
				return false, rerr
			}
		}
		return false, nil
	}
	_ = RemoveLockFile(reclaimed)
	return true, nil
}

Each package would then supply its own isLive (mtime check for cron/hooks/oauth, PID-aliveness for daemon).

🤖 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 `@internal/cron/lock.go` around lines 109 - 133, Extract the duplicated
reclaimStaleLock logic from cron, hooks, and oauth into a shared internal
lockutil helper that accepts the lock path, stale suffix/token, and an isLive
predicate. Preserve the existing rename-aside, restore-if-live, cleanup,
ErrExist handling, and fail-closed error behavior; update each caller to provide
its package-specific liveness check, including PID-based checking where
applicable.
🤖 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 `@internal/cron/lock_reclaim_test.go`:
- Around line 10-46: Extend TestReclaimStaleLock to force RestoreLockFile to
return a non-os.ErrExist error and verify reclaimStaleLock returns that error
without re-acquiring the lock. Add the equivalent fail-closed integration case
to the relevant lock test in internal/daemon, preserving existing stale, fresh,
and missing-lock coverage.

In `@internal/cron/lock.go`:
- Around line 109-133: Extract the duplicated reclaimStaleLock logic from cron,
hooks, and oauth into a shared internal lockutil helper that accepts the lock
path, stale suffix/token, and an isLive predicate. Preserve the existing
rename-aside, restore-if-live, cleanup, ErrExist handling, and fail-closed error
behavior; update each caller to provide its package-specific liveness check,
including PID-based checking where applicable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2ada8a5d-a175-4181-bb2b-b590b2406965

📥 Commits

Reviewing files that changed from the base of the PR and between 1f79311 and 13b729d.

📒 Files selected for processing (11)
  • internal/cron/lock.go
  • internal/cron/lock_reclaim_test.go
  • internal/daemon/lock.go
  • internal/daemon/lock_test.go
  • internal/hooks/lock.go
  • internal/hooks/lock_reclaim_test.go
  • internal/lockutil/lockutil_windows.go
  • internal/oauth/lock.go
  • internal/oauth/lock_reclaim_test.go
  • internal/swarm/mailbox.go
  • internal/swarm/mailbox_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • internal/oauth/lock_reclaim_test.go
  • internal/swarm/mailbox_test.go
  • internal/swarm/mailbox.go
  • internal/lockutil/lockutil_windows.go
  • internal/hooks/lock_reclaim_test.go

Consolidate the duplicated reclaimStaleLock functions from cron, hooks,
oauth, and swarm into lockutil.ReclaimStaleLock. Each caller now passes
an mtime-based isLive predicate to the shared helper, which owns the
rename-aside, restore-if-live, cleanup, ErrExist handling, and
fail-closed error propagation.

Daemon keeps its own reclaimStaleLock because it uses PID-based liveness
(different predicate shape and no staleAfter duration).

The per-package reclaim regression tests are removed since the shared
helper is thoroughly tested in lockutil/reclaim_test.go (dead lock
reclaimed, live lock restored, missing lock, fail-closed on restore
error, sidelined file dropped when new holder wins).

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/swarm/mailbox.go (1)

370-383: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Gate the retry on cleared
ReclaimStaleLock returns false, nil when it loses the rename race or restores a live holder, so this unconditional continue skips the sleep/backoff and can hot-spin under contention. Match the other lock callers by continuing only when cleared is true.

🤖 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 `@internal/swarm/mailbox.go` around lines 370 - 383, Capture the boolean result
from lockutil.ReclaimStaleLock in the stale-lock branch of the mailbox
acquisition loop, and only execute continue when cleared is true. When the call
returns false with no error, fall through to the existing wait/sleep backoff
path; preserve the current fail-closed return for errors.
🤖 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 `@internal/lockutil/reclaim.go`:
- Around line 29-31: Update the os.Rename error handling in reclaim so only
os.ErrNotExist and Windows sharing-related os.ErrPermission errors return the
benign lost-race result; propagate all other errors to the caller instead of
returning false, nil.

---

Outside diff comments:
In `@internal/swarm/mailbox.go`:
- Around line 370-383: Capture the boolean result from lockutil.ReclaimStaleLock
in the stale-lock branch of the mailbox acquisition loop, and only execute
continue when cleared is true. When the call returns false with no error, fall
through to the existing wait/sleep backoff path; preserve the current
fail-closed return for errors.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3917b0f3-9db7-4581-a466-5df56ed9110c

📥 Commits

Reviewing files that changed from the base of the PR and between 13b729d and fd6f664.

📒 Files selected for processing (7)
  • internal/cron/lock.go
  • internal/cron/lock_reclaim_test.go
  • internal/hooks/lock.go
  • internal/lockutil/reclaim.go
  • internal/lockutil/reclaim_test.go
  • internal/oauth/lock.go
  • internal/swarm/mailbox.go
💤 Files with no reviewable changes (1)
  • internal/cron/lock_reclaim_test.go

Comment thread internal/lockutil/reclaim.go
…taleLock

A rename-aside failure that is not a lost race previously mapped to the
benign lost-race result, so acquirers spun until their deadline and
reported a timeout instead of the real failure. Keep only a missing
source and the Windows sharing-violation/access-denied contention
errnos benign (neither Windows errno maps to os.ErrExist, and
ERROR_SHARING_VIOLATION does not map to os.ErrPermission either, so the
classification compares raw errnos) and return everything else so
callers fail closed.

Also convert daemon's private reclaimStaleLock to the shared helper so
it picks up the same contract, and reword the caller error messages
from "restore reclaimed" to "reclaim stale" since the error can now
come from the rename aside as well.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Do not expose a partially restored live lock
    internal/lockutil/lockutil.go:27
    The fallback creates path with O_EXCL before copying the live lock's contents. On a filesystem where the link/move fallback is used, another daemon can observe the empty or truncated PID file, classify it as stale, reclaim it, and acquire a new lock while the original daemon is still running. If the copy or close then fails, ReclaimStaleLock also removes the sidelined lock and leaves the original holder with no protocol-visible lock for later contenders. Stage a complete copy under a separate name and publish it with a no-replace primitive, and make failed restoration keep acquisition globally fail-closed until the live holder's lock is again represented at the lock path.

  • [P1] Honor the mailbox lock timeout after a failed reclaim attempt
    internal/swarm/mailbox.go:373
    ReclaimStaleLock deliberately returns (false, nil) when it loses the rename race or cannot reclaim a contended Windows file, but this caller discards the boolean and always continues. When the stale file remains present, the next iteration takes the same branch before the timeout and 2 ms backoff below, so mailbox operations can hot-spin past LockTimeout. Capture cleared and only continue immediately when it is true; otherwise fall through to the timeout and sleep path.

…box reclaims

restoreByCopy created the destination lock file empty before copying into
it, so a crash mid-copy could leave a live holder's lock permanently
unrepresented at the lock path. Stage the copy under a private name and
publish it with the platform's no-replace primitive instead.

swarm's mailbox lock discarded the reclaimed boolean and always retried
immediately, so a lost reclaim race (or Windows sharing-violation
contention) could hot-spin past the lock timeout instead of falling
through to the bounded wait like the other lock callers.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed 01bb58a for both P1s from the 5:15pm review.

Mailbox lock timeout: acquireLock now captures the cleared boolean from ReclaimStaleLock and only retries immediately when it is true. A lost race or a Windows sharing-violation contention falls through to the existing deadline check and 2ms sleep, matching the pattern already used in cron, oauth, and hooks.

Partial restore exposure: restoreByCopy no longer creates the destination with O_EXCL and writes into it in place. It now copies into a private staged file next to the sidelined lock, then publishes that staged file to the lock path using the same no-replace primitive the platform's primary restore uses (hard link on POSIX, MoveFileEx with no-replace on Windows). A crash between staging and publish leaves the lock path exactly as it was before the restore attempt, either still missing or still holding a competing holder's lock, never a truncated file. The no-overwrite guarantee is unchanged: if a new holder created the lock path in the meantime, publish fails with os.ErrExist and the caller's existing handling applies.

Added a staged-copy cleanup assertion to the existing restoreByCopy tests to cover the new intermediate file on both the success and losing-the-race paths.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep a live lock protected while it is being restored
    internal/lockutil/reclaim.go:39
    The no-overwrite restore still leaves lockPath absent between the rename-aside and the restore. A reclaimer can observe an old lock, a new holder can acquire it before the reclaimer's rename, and the reclaimer then moves that new live lock to reclaimed. Before RestoreLockFile publishes it back, another process can successfully O_EXCL-create lockPath; the restore returns os.ErrExist, and this branch deletes reclaimed and returns normally. The original holder and that third process then both continue through their critical sections. The POSIX copy fallback makes the same window longer and cannot publish on the hard-link-incapable filesystems it claims to support because it calls os.Link again. Do not treat a competing canonical path as a safe winner while the displaced live holder is still running; use a recovery protocol that prevents acquisitions for the entire restore transition, and add a regression that interleaves a third acquisition before restore.

…g rename

Restoring a lock that turned out live went through the no-replace path
(plus its copy fallback on some failures) first, which is slow enough
that a fourth process's unrelated O_EXCL create could win lockPath while
it was absent for the whole call. RestoreLockFile's no-replace contract
would then report os.ErrExist and the caller silently dropped the
sidelined lock, orphaning the still-running original holder.

restoreLiveLock now tries a single replacing rename straight back first,
falling back to the no-replace path only if that fails. It shrinks the
window from a whole restore call down to roughly one syscall; it does
not close it, since the fast path can silently overwrite a competing
lock rather than detect it. A fully race-free reclaim would need an
OS-level advisory lock checked non-destructively instead of by moving
the file, which is a larger change than this.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Confirmed, this is real: when the moved-aside lock turns out live, RestoreLockFile's no-replace-plus-copy-fallback path can take long enough that a fourth process's O_EXCL create wins lockPath while it's absent, and the current code treats that ErrExist as a benign lost race, silently dropping the original live holder's lock.

Pushed d8f2943. restoreLiveLock now tries a single replacing rename straight back first, before falling back to the no-replace path. That shrinks the window from a whole restore call (potentially including the copy fallback) down to roughly one syscall. I want to be direct about what this does and doesn't do: it does not make the race impossible, it makes it much less likely to be hit. The fast path can still silently overwrite a competing lock instead of detecting it, so a fourth process that legitimately wins the window can still run its critical section concurrently with the original holder. Closing this properly would need an OS-level advisory lock (flock/LockFileEx) held for a holder's whole critical section and checked non-destructively instead of by moving the file, across all five callers of this protocol. That's a real design change, not a fixup commit, so I've scoped this one to the pragmatic tightening rather than that redesign. Happy to open a follow-up for the advisory-lock approach if you'd rather have it fully closed.

Added a test pinning the new fast-path overwrite behavior (TestRestoreLiveLockFastPathOverwritesCompetingFile). No existing tests needed changes: the seam-based tests replace restoreLockFile wholesale, so they're unaffected by what restoreLiveLock does internally.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@kevincodex1 kevincodex1 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.

LGTM

@kevincodex1
kevincodex1 merged commit da41c3a into Gitlawb:main Jul 13, 2026
7 checks passed
@euxaristia
euxaristia deleted the fix/lock-reclaim-posix-overwrite branch July 17, 2026 21:05
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.

4 participants