1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Ref compare-and-swap + optimistic rebuild-on-conflict (GWS.3).
//!
//! `cas_ref` is the serialization primitive: it advances a branch ref from an
//! expected old value to a new commit **under git's ref lock**, mirroring
//! `git update-ref <new> <old>`. It is atomic and **cross-process** (the lock is
//! a lockfile in `.git`), which is the property in-process mutexes cannot give.
//!
//! `commit_with_retry` is the optimistic loop: build a commit on the current
//! tip, CAS the ref; if a concurrent writer advanced it first, re-read the tip,
//! rebuild on the new tip, and retry. The caller's builder re-runs its per-file
//! preconditions (GWS.4) on each rebuild, so a conflicting change to one of the
//! changeset's own paths surfaces as an abort rather than a silent overwrite.
use crate::error::{Error, Result};
use crate::repo::VaultRepo;
use git2::Oid;
use tracing::instrument;
/// How many times `commit_with_retry` rebuilds before giving up. Contention is
/// rare; this only guards against pathological live-lock.
const DEFAULT_MAX_RETRIES: u32 = 8;
impl VaultRepo {
/// Atomically advance `refname` from `expected_old` to `new`, under git's
/// ref lock (mirrors `update-ref <new> <old>`).
///
/// `expected_old == None` means the ref must **not** yet exist (the
/// initial-commit case). On any mismatch returns [`Error::CasConflict`] with
/// **nothing applied** — the ref is untouched.
#[instrument(
skip(self),
fields(refname = %refname, expected = ?expected_old, new = %new),
name = "git_cas_ref"
)]
pub fn cas_ref(&self, refname: &str, expected_old: Option<Oid>, new: Oid) -> Result<()> {
let repo = self.git();
let mut tx = repo.transaction()?;
tx.lock_ref(refname)?;
// Read the current value *under the lock* — this is the CAS comparison.
// tlx.9: discriminate "ref absent" (NotFound) from a real read error.
// `.ok()` would flatten an I/O/corruption error into `None`, which on
// the initial-commit path (expected_old == None) could be misread as
// "ref doesn't exist yet" and let a blind advance through.
//
// hq8: the non-NotFound arm here is irreducible-defensive — `lock_ref`
// above already validated + read the ref, so a corrupt ref aborts at
// the lock, never reaching this read. The same guard in
// `commit_with_retry_n` (which has NO prior lock) IS reachable and is
// killed by `corrupt_ref_surfaces_error_instead_of_silent_absent`.
let current = match repo.refname_to_id(refname) {
Ok(oid) => Some(oid),
Err(e) if e.code() == git2::ErrorCode::NotFound => None,
Err(e) => return Err(Error::Git(e)),
};
if current != expected_old {
// Dropping `tx` here releases the lock without committing.
return Err(Error::CasConflict {
refname: refname.to_string(),
expected: expected_old,
found: current,
});
}
tx.set_target(refname, new, None, "turbovault-git: cas advance")?;
tx.commit()?;
Ok(())
}
/// Advance `refname` with optimistic retry (default retry budget).
/// See [`Self::commit_with_retry_n`].
pub fn commit_with_retry<F>(&self, refname: &str, build: F) -> Result<Option<Oid>>
where
F: FnMut(Option<Oid>) -> Result<Option<Oid>>,
{
self.commit_with_retry_n(refname, DEFAULT_MAX_RETRIES, build)
}
/// Advance `refname` with optimistic retry. `build` is called with the
/// current tip (the parent to build on, `None` if the branch is unborn) and
/// returns `Some(commit)` to CAS onto that tip, or `None` to signal a
/// **no-op** — there is nothing to commit (e.g. the resulting tree is
/// identical to the base), so the ref is left untouched and the method
/// returns `Ok(None)`. If the CAS loses to a concurrent advance, the tip is
/// re-read and `build` is called again on the new tip, up to `max_retries`
/// rebuilds.
///
/// The builder owns conflict policy: on a rebuild it re-validates its
/// per-file preconditions against the new tip (GWS.4) and may itself return
/// an error to abort (the reconsideration domino) instead of rebuilding.
#[instrument(
skip(self, build),
fields(refname = %refname, max_retries),
name = "git_commit_with_retry"
)]
pub fn commit_with_retry_n<F>(
&self,
refname: &str,
max_retries: u32,
mut build: F,
) -> Result<Option<Oid>>
where
F: FnMut(Option<Oid>) -> Result<Option<Oid>>,
{
for _ in 0..=max_retries {
// tlx.9: same NotFound-vs-real-error discrimination as cas_ref — an
// unborn ref is `None`, but a real read error must surface, not
// masquerade as "branch has no commits yet".
let tip = match self.git().refname_to_id(refname) {
Ok(oid) => Some(oid),
Err(e) if e.code() == git2::ErrorCode::NotFound => None,
Err(e) => return Err(Error::Git(e)),
};
// `None` from the builder = no-op (e.g. an identity tree): nothing
// to commit, so skip the CAS and leave the ref where it is.
let new = match build(tip)? {
Some(oid) => oid,
None => return Ok(None),
};
match self.cas_ref(refname, tip, new) {
Ok(()) => return Ok(Some(new)),
// Lost the race: the ref moved between our read and the lock.
// Re-read the tip and rebuild on it.
Err(Error::CasConflict { .. }) => continue,
Err(e) => return Err(e),
}
}
Err(Error::Other(format!(
"ref CAS exhausted {max_retries} retries on {refname} (excessive contention)"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plumbing::TreeChange;
use git2::Repository;
use std::cell::Cell;
use tempfile::TempDir;
const MAIN: &str = "refs/heads/main";
fn open_unborn() -> (TempDir, VaultRepo) {
let tmp = TempDir::new().unwrap();
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head("main");
Repository::init_opts(tmp.path(), &opts).unwrap();
let vr = VaultRepo::open(tmp.path()).unwrap();
(tmp, vr)
}
fn upsert(path: &str, content: &str) -> TreeChange {
TreeChange::Upsert {
path: path.to_string(),
content: content.as_bytes().to_vec(),
}
}
/// Build a commit on `parent` (or initial if None) carrying one upsert.
fn build_on(vr: &VaultRepo, parent: Option<Oid>, path: &str, content: &str) -> Oid {
let base = parent.map(|p| vr.git().find_commit(p).unwrap().tree_id());
let tree = vr.build_tree(base, &[upsert(path, content)]).unwrap();
let parents: Vec<Oid> = parent.into_iter().collect();
vr.commit_tree(tree, &parents, "c").unwrap()
}
#[test]
fn cas_ref_initial_then_advance() {
let (_tmp, vr) = open_unborn();
let c0 = build_on(&vr, None, "a.md", "a");
vr.cas_ref(MAIN, None, c0)
.expect("initial CAS (None -> c0)");
assert_eq!(vr.head_oid(), Some(c0));
let c1 = build_on(&vr, Some(c0), "b.md", "b");
vr.cas_ref(MAIN, Some(c0), c1).expect("advance c0 -> c1");
assert_eq!(vr.head_oid(), Some(c1));
}
/// hq8 (tlx.9 follow-up): real fault injection — corrupt a throwaway `.git`
/// loose ref so `refname_to_id` fails with a NON-NotFound error, and assert
/// `cas_ref` / `commit_with_retry` SURFACE it instead of swallowing to
/// `None` (a blind "ref absent"). This is what `.ok()` used to do; it kills
/// the NotFound-match-guard mutation survivors without mocking the git2 API.
#[test]
fn corrupt_ref_surfaces_error_instead_of_silent_absent() {
let (tmp, vr) = open_unborn();
let c0 = build_on(&vr, None, "a.md", "a");
vr.cas_ref(MAIN, None, c0).unwrap();
drop(vr); // release the handle before corrupting the ref on disk
// Malformed oid in the loose ref → libgit2 parse error (not NotFound).
std::fs::write(tmp.path().join(".git/refs/heads/main"), "not-a-valid-oid\n").unwrap();
let vr = VaultRepo::open(tmp.path()).unwrap();
// Precondition: the corruption really yields a non-NotFound error — else
// the guard would legitimately map it to None and this proves nothing.
let code = vr.git().refname_to_id(MAIN).unwrap_err().code();
assert_ne!(
code,
git2::ErrorCode::NotFound,
"corruption must produce a non-NotFound error; got {code:?}"
);
// commit_with_retry resolves the tip via refname_to_id first, so a
// non-NotFound error must abort, not be treated as an unborn branch.
let res = vr.commit_with_retry_n(MAIN, 0, |_tip| Ok(None));
assert!(
res.is_err(),
"commit_with_retry must surface the ref-read error, not swallow to None"
);
// cas_ref's read-under-lock must do the same.
let some = Oid::from_str("0000000000000000000000000000000000000001").unwrap();
assert!(
vr.cas_ref(MAIN, None, some).is_err(),
"cas_ref must surface the ref-read error, not blind-write a 'new' ref"
);
}
#[test]
fn cas_ref_rejects_stale_and_leaves_ref() {
let (_tmp, vr) = open_unborn();
let c0 = build_on(&vr, None, "a.md", "a");
vr.cas_ref(MAIN, None, c0).unwrap();
let bogus = Oid::from_str("0000000000000000000000000000000000000001").unwrap();
let c1 = build_on(&vr, Some(c0), "b.md", "b");
match vr.cas_ref(MAIN, Some(bogus), c1) {
Err(Error::CasConflict { found, .. }) => assert_eq!(found, Some(c0)),
other => panic!("expected CasConflict, got {other:?}"),
}
assert_eq!(vr.head_oid(), Some(c0), "ref unchanged on reject");
}
#[test]
fn cas_ref_initial_rejects_when_ref_exists() {
let (_tmp, vr) = open_unborn();
let c0 = build_on(&vr, None, "a.md", "a");
vr.cas_ref(MAIN, None, c0).unwrap();
// expected_old = None means "must not exist", but it does now.
let c1 = build_on(&vr, Some(c0), "b.md", "b");
assert!(matches!(
vr.cas_ref(MAIN, None, c1),
Err(Error::CasConflict { .. })
));
}
/// turbovault-a0l (PERF-1 safety guard): a REUSED `VaultRepo` handle must
/// still observe a ref advance made by a DIFFERENT handle (another process)
/// under `lock_ref`. If libgit2's refdb served a stale cached tip, handle A
/// would clobber B's commit — a cross-process lost update, the exact failure
/// the substrate exists to prevent. This is the pivotal correctness question
/// for caching the repo handle (PERF-1): if it fails, caching is unsafe.
#[test]
fn reused_handle_detects_external_ref_advance_no_lost_update() {
let (tmp, vr_a) = open_unborn();
// A makes the initial commit (populates A's refdb with c0).
let c0 = build_on(&vr_a, None, "a.md", "v1");
vr_a.cas_ref(MAIN, None, c0).unwrap();
assert_eq!(vr_a.head_oid(), Some(c0));
// B = a SEPARATE handle (mimics another process) advances main.
let vr_b = VaultRepo::open(tmp.path()).unwrap();
let c1 = build_on(&vr_b, Some(c0), "b.md", "from-B");
vr_b.cas_ref(MAIN, Some(c0), c1).unwrap();
// A, REUSING its handle, advances main. commit_with_retry reads the tip
// and CAS-locks; correct behavior is to see c1 and commit on top of it,
// never clobber it from a stale c0.
let got = vr_a
.commit_with_retry(MAIN, |tip| Ok(Some(build_on(&vr_a, tip, "c.md", "from-A"))))
.unwrap()
.expect("a commit was produced");
let parent = vr_a.git().find_commit(got).unwrap().parent_id(0).unwrap();
assert_eq!(
parent, c1,
"reused handle committed atop B's external advance (saw the ref change; no lost update)"
);
assert!(
vr_a.git().find_commit(c1).is_ok(),
"B's commit is still reachable, not clobbered"
);
}
#[test]
fn commit_with_retry_no_contention() {
let (_tmp, vr) = open_unborn();
let c0 = build_on(&vr, None, "a.md", "a");
vr.cas_ref(MAIN, None, c0).unwrap();
let got = vr
.commit_with_retry(MAIN, |tip| Ok(Some(build_on(&vr, tip, "b.md", "b"))))
.unwrap()
.expect("a commit was produced");
assert_eq!(vr.head_oid(), Some(got));
}
#[test]
fn commit_with_retry_rebuilds_on_conflict() {
let (_tmp, vr) = open_unborn();
let c0 = build_on(&vr, None, "a.md", "a");
vr.cas_ref(MAIN, None, c0).unwrap();
let calls = Cell::new(0u32);
let got = vr
.commit_with_retry(MAIN, |tip| {
calls.set(calls.get() + 1);
let tip = tip.unwrap();
// On the FIRST attempt only, a concurrent writer advances the ref
// behind our back so our CAS must lose and rebuild.
if calls.get() == 1 {
let concurrent = build_on(&vr, Some(tip), "concurrent.md", "x");
vr.cas_ref(MAIN, Some(tip), concurrent).unwrap();
}
Ok(Some(build_on(&vr, Some(tip), "mine.md", "m")))
})
.unwrap()
.expect("a commit was produced");
assert_eq!(calls.get(), 2, "exactly one rebuild after the conflict");
assert_eq!(vr.head_oid(), Some(got));
// We rebuilt on the concurrent tip, so the final tree carries BOTH files.
let head_tree = vr.git().find_commit(got).unwrap().tree_id();
assert!(
vr.blob_oid_at(head_tree, "concurrent.md")
.unwrap()
.is_some()
);
assert!(vr.blob_oid_at(head_tree, "mine.md").unwrap().is_some());
}
/// turbovault-uag: relentless contention exhausts the retry budget and
/// surfaces a loud error (the live-lock guard) rather than spinning forever
/// or silently giving up. Every attempt loses the CAS because a concurrent
/// writer advances the ref first.
#[test]
fn commit_with_retry_exhausts_under_relentless_contention() {
let (_tmp, vr) = open_unborn();
let c0 = build_on(&vr, None, "a.md", "a");
vr.cas_ref(MAIN, None, c0).unwrap();
let calls = Cell::new(0u32);
let err = vr
.commit_with_retry_n(MAIN, 2, |tip| {
calls.set(calls.get() + 1);
let tip = tip.unwrap();
// Advance the ref behind our back BEFORE our CAS, every attempt.
let concurrent = build_on(&vr, Some(tip), &format!("c{}.md", calls.get()), "x");
vr.cas_ref(MAIN, Some(tip), concurrent).unwrap();
Ok(Some(build_on(&vr, Some(tip), "mine.md", "m")))
})
.unwrap_err();
// max_retries=2 -> the loop runs 0..=2 = 3 attempts, all lose.
assert_eq!(
calls.get(),
3,
"builder runs max_retries+1 times then gives up"
);
assert!(
err.to_string().contains("exhausted") && err.to_string().contains("contention"),
"loud exhaustion error: {err}"
);
}
/// turbovault-xw4: real-thread contention. N threads each open their OWN
/// VaultRepo (sharing the CommitLocks registry) and commit a DISTINCT file
/// concurrently. Every commit must land — the per-worktree commit lock +
/// update-ref CAS serialize them with NO lost update. The whole "no lost
/// update under contention" thesis was previously asserted only via
/// sequential simulated races; this drives genuine threads.
#[test]
fn parallel_commit_changeset_lands_every_commit() {
let (tmp, vr0) = open_unborn();
vr0.commit_changeset(&crate::Changeset::new("seed").create("seed.md", "0"))
.unwrap();
let path = tmp.path().to_path_buf();
let locks = vr0.commit_locks();
drop(vr0);
let n = 8u32;
let handles: Vec<_> = (0..n)
.map(|i| {
let p = path.clone();
let l = std::sync::Arc::clone(&locks);
std::thread::spawn(move || {
let vr = crate::VaultRepo::open_with_locks(&p, l).unwrap();
vr.commit_changeset(
&crate::Changeset::new("c").create(format!("f{i}.md"), "x"),
)
.unwrap();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
// Every file + the seed is in HEAD's tree — nothing lost to a race.
let vr = crate::VaultRepo::open_with_locks(&path, locks).unwrap();
let tree = vr
.git()
.find_commit(vr.head_oid().unwrap())
.unwrap()
.tree_id();
assert!(vr.blob_oid_at(tree, "seed.md").unwrap().is_some());
for i in 0..n {
assert!(
vr.blob_oid_at(tree, &format!("f{i}.md")).unwrap().is_some(),
"f{i}.md must have landed"
);
}
}
}