Repository navigation
Conversation
`acquire` over an expired lock deleted the ref and wrote the new lock as a
parentless root commit. Once origin had that lock ref, every later acquire of
the same resource - by any actor, including the same actor after its TTL -
produced history unrelated to origin's, and the next `sync --push` failed:
cannot push non-fastforwardable reference ... NotFastForward (-11)
`release` deleted only the local ref, so the remote copy outlived it and the
next pull brought the released lock back.
- `acquire` writes the replacement lock as a child of the expired or released
one, and swaps the ref with a compare-and-swap on the tip it inspected
instead of delete-then-create. A concurrent acquirer in the same repository
now loses cleanly rather than racing through the gap between the two.
- `release` writes a tombstone (expiry=0, as docs/locking.md always described)
on top of the lock instead of deleting the ref, so the release has something
to propagate. `read_lock` and `list_locks` treat a tombstone as no lock.
- `gc` keeps tombstones while the repository has a remote: they are how a
release reaches it, and `sync` removes them once it has.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…veness `push` sent every `refs/grite/*` ref in one libgit2 push. libgit2 checks fast-forwards client-side before sending anything, so one diverged lock ref failed the whole push with `NotFastForward` - including a WAL that would have fast-forwarded - and returned it as a hard error. `pull` used a non-forced fetch, which leaves a diverged ref where it is, so nothing ever reconciled it. The same two facts meant `push_with_rebase` never rebased anything: the push error escaped through `?` before the rebase branch, and the rebase relied on a pull that does not move a diverged WAL. Two clones that both wrote events could not sync at all. Both directions now download the remote's grite objects without moving any ref, compare tips, and decide per ref: - WAL and snapshots fast-forward, in their own push. A diverged WAL is rebased explicitly onto the remote head (local-only events re-appended, as before) and pushed again. - Lock refs go in a second push and are planned by `lock_sync::plan`, shared by pull and push, from git ancestry plus lock liveness. A diverged local lock is re-parented onto the remote tip only when the remote lock is expired or has the same owner; expired and released locks are deleted on both sides; a lock owned by an actor outside this repository that the remote no longer has is dropped, never pushed back. - Every remote change is a compare-and-swap on the tip that was inspected: each push re-reads the advertisement on the connection it pushes over and skips a ref that moved, and receive-pack rejects one that moves after that. A live lock held by another actor is therefore never replaced or deleted. It is reported as a lock conflict (`lock_conflicts` in the sync output) and replaces the local lock. It never fails the WAL push. Repositories already in the broken state - unrelated, expired lock refs on both sides - heal through a normal sync with no manual ref deletion. The daemon runs sync through the same `SyncManager`, so it gets the fix too; lock commands never route through the daemon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Eleven tests driving the real binary against a bare origin and two clones with distinct actors: re-acquire after expiry by the same actor (in-process and through the daemon) and by another actor, with and without a pull first; a live lock of another actor still conflicting while the WAL is pushed; release and gc propagating to origin and to other clones; pull reconciling a diverged expired lock; self-heal of unrelated expired lock refs on both sides; a live legacy lock never replaced by healing; a diverged WAL rebased; and a linked worktree's unpushed lock surviving another actor's sync. All ten that exist before the sibling-worktree case fail against 05d9d3e. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Re-acquiring a lock whose ref already exists on origin broke
grite sync:the push failed with
NotFastForward (-11)and, because it was all-or-nothing,took the WAL down with it. Released and collected locks never left origin, so
the next pull brought them back, and nothing ever reconciled a diverged lock
ref. This PR keeps lock-ref history linear, pushes locks apart from the WAL,
and reconciles them by git ancestry plus lock liveness. A live lock held by
another actor keeps its mutual-exclusion meaning across clones.
Independent of #28 and #29; based directly on
main(33afee0).Root cause
acquireover an expired lockdeleted the ref and wrote a parentless commit. Once origin had the ref, any
later acquire of that resource — any actor, including the same one after
TTL — produced history unrelated to origin's.
refs/grite/*. libgit2 checks fast-forwardsclient-side before sending anything, so one diverged lock ref failed the
whole push — including a WAL that would have fast-forwarded — and it
surfaced as a hard
Err, not a per-ref rejection.it is.
survived and came back on the next pull.
The same facts (2) and (3) meant
push_with_rebasenever rebased: thepush error escaped through
?before the rebase branch, and the rebase reliedon a pull that does not move a diverged WAL. Two clones that both wrote events
could not sync at all, whatever their locks were doing. It is fixed here
because the new push path makes it a few lines, and leaving it would mean "the
WAL is never blocked" was only true for one writer.
Reproduction (before,
0.5.3)Fix
Lock history stays linear (
lock_manager.rs).acquirewrites a replacement as a child of the expired or released lock,swapping the ref with a compare-and-swap. Previously it deleted then
created, which also left a gap that a concurrent acquirer could slip into.
releasewrites a tombstone (expires_unix_ms = 0, exactly whatdocs/locking.mdalready specified) on top of the lock instead of deletingit, so there is something to propagate.
read_lock/list_lockstreat atombstone as no lock.
gckeeps tombstones while a remote exists.Sync decides per ref (
sync.rs, newlock_sync.rs). Pull and push bothdownload the remote's grite objects without moving refs, then compare tips:
rebased explicitly onto the remote head (local-only events re-appended, as
the original code intended) and pushed again.
pull and push:
[local, remote]), push fast-forwardlock_conflictsMutual exclusion. Every remote change is a compare-and-swap on the tip
that was inspected.
push_refsre-reads the advertisement on the connectionit pushes over and skips a ref that moved. libgit2 sends that advertised oid
as the old value, so receive-pack rejects a ref that moves after that. A remote
lock that is live and owned by another actor is therefore never replaced or
deleted, unless the local lock was written on top of it. That only happens if
acquire/renew/releasesaw it as expired or as its own. A raced lock isre-planned once on the retry.
"Actors of this repository" (from
<commondir>/grite/actors) matters becauselinked worktrees share refs: a sibling worktree's unpushed lock must not be
mistaken for a stale copy of a released remote lock.
Reporting.
PullResult/PushResultgainlock_conflicts. It appears insyncJSON for the CLI and the daemon, plus aLock conflict: …line in humanoutput. Lock conflicts never make
push_successfalse.Daemon path. The daemon's
Synchandler uses the sameSyncManager, soit is fixed by construction (covered by a test). Lock commands never route
through the daemon.
Changed files
crates/libgrite-git/src/lock_manager.rscrates/libgrite-git/src/lock_sync.rscrates/libgrite-git/src/sync.rspush_refs, explicit WAL rebasecrates/libgrite-git/src/lib.rsLockConflictcrates/grite/src/commands/sync.rs,crates/grite-daemon/src/worker.rslock_conflictsin outputdocs/locking.md,docs/cli-json.mdcrates/grite/tests/lock_sync.rsSyncManager::push/syncsignatures are unchanged.Validation
cargo test --all→ 174 passed, 0 failed on this branch (3 new unit +11 new integration tests).
cargo fmt --all -- --checkandcargo clippy --all-targets --all-features -- -D warningsclean. No leakeddaemons or sockets after a full run.
main(33afee0)reacquire_after_expiry_pushes_cleanlyreacquire_after_expiry_pushes_cleanly_through_the_daemonanother_actor_takes_over_an_expired_locklive_lock_of_another_actor_still_conflictsrelease_propagates_to_origingc_and_expiry_propagate_to_originpull_reconciles_a_diverged_expired_lockdiverged_expired_locks_self_heal_on_syncself_heal_never_replaces_a_live_legacy_lockdiverged_wal_is_rebased_on_syncsibling_worktree_lock_survives_a_sync_from_another_actor10 of the 11 fail against
main(verified by running this test file on 33afee0).Manual repro (after), built binary, scratch bare origin + clones
Extended run (
GRITE_DAEMON_SOCKETon a private socket for the daemon step):Compatibility
lock.json. A tombstoneis an ordinary expired lock, and a re-parented lock is just a two-parent
commit.
old clients' non-forced fetch follows it. They read tombstones as expired
locks. Two limits stay as before: old clients do not prune refs the remote
deleted, and their own acquire over an expired lock still writes a root
commit. Their push of such a ref can therefore still fail as before — or,
now that new clients delete stale remote refs, simply recreate an expired
ref, which the next new-client sync deletes again.
grite syncfrom a newclient. No manual ref deletion is needed.
second push, so a full
syncmakes four connections instead of two.Notes for reviewers
an actor of this repository. For a repository's own actors, "unpushed" and
"released elsewhere" cannot be told apart without remote-tracking state. In
that case the lock is kept and pushed back, the conservative side, until it
expires. This only happens when the same actor id is used from two clones.
receive-pack's old-oid check; the compare-and-swap on ssh/https relies on it.
🤖 Generated with Claude Code