flint_lean/state.rs
1//! Durable emptyDir bookkeeping (plan §2.1 "restart matrix").
2//!
3//! Everything the barrier needs to survive a CONTAINER restart lives
4//! here as small JSON files under `<root>/.flint-sync/`, each written
5//! temp+rename. The baseline is the exception (lever D): a snapshot
6//! written temp+rename and a journal that each save appends to in place,
7//! held parsed in memory for the life of the process.
8//!
9//! A POD replacement gets a fresh emptyDir and therefore a fresh
10//! identity — that asymmetry is deliberate (the plan's P4: the
11//! incarnation id is emptyDir-scoped, so only the same pod's restarted
12//! container may self-supersede the lease).
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20use super::tracked::{TrackedMap, TrackedSet};
21use super::{LeanError, LeanResult};
22
23/// One published path as this syncer last knew it: the handle it
24/// integrated (the citation repair re-cites it, the merge recognises it
25/// as this tree's own) and the ETag that attests its bytes.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct BaselineEntry {
28 pub etag: String,
29 /// The HANDLE this tree integrated at the path — its own upload, the
30 /// entry it consumed, the citation it checked out (design 2026-09-19,
31 /// R1-R2). The merge counts a document citing it as citing THIS tree's
32 /// version, not a foreign one. `None` only on a baseline written
33 /// before handles.
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub key: Option<String>,
36 pub generation: u64,
37 pub size: u64,
38 pub mtime_unix: i64,
39 /// Sub-second mtime, when this entry was written by a syncer that
40 /// stamps it; `None` compares as "unknown" (`scan::stat_changed`).
41 #[serde(default)]
42 pub mtime_nanos: Option<u32>,
43 /// The mode (`scan::PERM_BITS` of it) the document cites for the
44 /// version this tree integrated — its own upload's, or the one it
45 /// materialised, which is the tree's whenever the write could apply
46 /// it. A `chmod` moves neither size nor mtime, so the scan compares
47 /// the tree's mode with this (`scan::stat_changed`); and it publishes
48 /// the same bytes under the same etag, so a consume owes an entry
49 /// whose cited mode is not this one (`cites_mode`). A mode the tree
50 /// cannot hold (a bit the kernel refuses an unprivileged owner)
51 /// scans as a change and publishes what the tree does hold, once.
52 /// `None` compares as "unknown", as above.
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub mode: Option<u32>,
55 /// CRC-64/NVME (wire form) of the object bytes this syncer
56 /// integrated at this path — computed from the bytes it wrote or
57 /// uploaded, never from a HEAD: content convergence compares it, and
58 /// it is what lets the manifest carry a CRC for an object whose
59 /// backend returns none (Ozone). Every writer sets it; `None` only on
60 /// a baseline from before it existed.
61 pub crc64_b64: Option<String>,
62 /// The stat above was taken within `scan::RACY_WINDOW_NANOS` of the
63 /// file's later stamp (`scan::later`), so it cannot vouch for the bytes
64 /// on its own: the next scan hashes the file before calling it clean
65 /// (`scan::confirm_racy`).
66 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
67 pub racy: bool,
68 /// The file's ctime and inode as recorded with the stat above
69 /// (`scan::ChangeStamp`): one that moved while size, mtime and mode did
70 /// not makes the entry unsure, and its bytes decide. Taken after the
71 /// last thing the recording write did to the file (its rename), or the
72 /// first scan re-hashes it. `None` compares as unknown.
73 #[serde(rename = "cs", default, skip_serializing_if = "Option::is_none")]
74 pub change: Option<super::scan::ChangeStamp>,
75}
76
77impl BaselineEntry {
78 /// Whether `cited`, a document entry's mode, is the one this entry
79 /// integrated. With the etag, what a consume asks before it skips an
80 /// entry: a `chmod` publishes the same bytes, so the same etag.
81 pub fn cites_mode(&self, cited: u32) -> bool {
82 self.mode.is_none_or(|m| (m ^ cited) & super::scan::PERM_BITS == 0)
83 }
84}
85
86/// The persisted baseline snapshot: what this syncer believes the
87/// bucket holds AND has integrated locally — and, since P1-lite
88/// (2026-09-25), the three-way MERGE BASE too. A separate merge base
89/// existed because a tree could adopt a UI write the document did not
90/// cite yet; under P2 every UI edit commits first, so no tree holds a
91/// version the document never cited, and the split had nothing left to
92/// keep apart (the sandbox pair `LeanCoreP1`/`LeanCoreP2R`).
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94pub struct Baseline {
95 /// Manifest seq at our last install/checkout.
96 pub seq: u64,
97 /// Manifest document ETag we expect at the next CAS.
98 pub manifest_etag: Option<String>,
99 pub entries: TrackedMap<BaselineEntry>,
100 /// THE CHEAP PATH'S RECORD (scan trigger). The pointer etag of the
101 /// document the last full consume derived against, with nothing left
102 /// but the agent's own work; `None` = derive at the next consume (a
103 /// failed fetch, a sync, a baseline from before this field). Only the
104 /// consume writes it — and the commit, when its CAS replaced exactly
105 /// this document, so the new one is it plus this tree's own changes.
106 #[serde(default)]
107 pub derived_etag: Option<String>,
108 /// The paths that derive left untaken because the agent is working on
109 /// them (dirty against the baseline where the document differs). The
110 /// cheap path holds only while every one is still dirty: an agent that
111 /// backs out makes the document's version owed.
112 #[serde(default)]
113 pub skipped: TrackedSet,
114 /// Paths present at the PREVIOUS scan (the two-consecutive-scans
115 /// deletion rule: absence must survive two scans).
116 pub prev_scan: TrackedSet,
117 /// This snapshot's id, minted at each whole write: the journal
118 /// (`baseline.log`) applies to the snapshot its first line names
119 /// (lever D). An id rather than a checksum, so a snapshot edited in
120 /// place keeps its journal, and one written whole anew does not.
121 /// LAST, so the fields before it keep the order the e2e plant relies
122 /// on (`run-verbs.sh`).
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub(crate) journal: Option<String>,
125}
126
127/// A rescope in flight (scoped-read design §4.3). `target` is the scope
128/// the workspace is moving TO; `None` means the whole tree.
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130pub struct ScopeIntent {
131 pub target: Option<Vec<String>>,
132 /// The paths this rescope is removing from the held set, recorded
133 /// BEFORE the first mutation.
134 ///
135 /// Load-bearing, and the mutation check that found it: replay
136 /// cannot RE-DERIVE this set. Once a path is out of
137 /// `baseline.entries` it is indistinguishable from a file the agent
138 /// just created — same scan row, same absence from the baseline —
139 /// and the two have opposite correct answers: unlink the leftover,
140 /// KEEP the agent's new file. Deriving the drop set from the
141 /// baseline made a crash between the uncite and the unlink leave
142 /// six uncited files on disk forever; deriving it from the tree
143 /// would delete the agent's work instead.
144 #[serde(default)]
145 pub drop: Vec<String>,
146 /// What the uncite dropped, per path, recorded BEFORE the uncite (and
147 /// kept across a replay, which can no longer read it off the
148 /// baseline). The unlink takes a file only while its bytes are still
149 /// these: the agent may write a dropped path between the uncite and
150 /// the unlink — a crash leaves that window open until the next
151 /// barrier — and once uncited, its write and the leftover read alike
152 /// (the model's `LeanP1UnlinkBlind`, 2026-10-02).
153 #[serde(default)]
154 pub held: BTreeMap<String, BaselineEntry>,
155}
156
157/// The pod-incarnation identity + lease bookkeeping ({last_token,
158/// quiet_polls} persist so container restarts RESUME the takeover
159/// observation instead of resetting it — plan §2.1).
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct Incarnation {
162 pub holder_id: String,
163 pub epoch: u64,
164 pub last_token: Option<String>,
165 pub quiet_polls: u32,
166}
167
168/// What the conditional-write probes saw this store do
169/// (`conformance.rs`). Cached because the probes cost ~10 requests and
170/// a store does not change its mind between two barriers — but keyed by
171/// `identity`, because a workspace re-pointed at a different endpoint or
172/// bucket is a different store and must be asked again.
173///
174/// `probe_version` is what makes the cache safe to extend: a build that
175/// asks a NEW question must not read an old verdict as the answer to
176/// it, so a bump invalidates every cached verdict rather than inheriting
177/// a silent pass.
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179pub struct StoreConformance {
180 pub identity: String,
181 pub probe_version: u32,
182 /// Whether the probe got an ANSWER at all. False means it could not
183 /// write its own object — a credential, a policy, the network — and
184 /// the two flags below say nothing. Never cached false: an outage
185 /// must not become this workspace's standing verdict.
186 pub answered: bool,
187 /// `If-None-Match:*` and `If-Match` on PUT — the manifest CAS, the
188 /// upload gate, the lease cell. Nothing works without it.
189 pub conditional_put: bool,
190 /// `If-Match` on DELETE — the file collector, and only it.
191 pub conditional_delete: bool,
192 /// What the failing probe said, for the operator message.
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub detail: Option<String>,
195 pub at_unix: u64,
196}
197
198/// The intent journal: the flush id of the barrier in flight, and the H10
199/// carrier. (Its `keys`, `recent_uuids` and `declared_deletes` served the
200/// slot's AdoptOwn and the cell's declared removals; nothing read them
201/// once both were gone — M6 of the 2026-09-24 simplification analysis.)
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203pub struct IntentJournal {
204 pub flush_uuid: String,
205 /// What the installs that CARRIED the standing publish declaration
206 /// published, journalled in the write that follows each CAS (review
207 /// 2026-09-18, H10). The ack reads the barrier that honors the
208 /// declaration, and that is not always the one that carried it: a
209 /// restart after step 7, an ack write that failed, or an honor that
210 /// failed before the floor's cadence barrier published the
211 /// declaration, each left the ack to a later barrier — and when a
212 /// peer had deleted a declared path in between, that barrier was a
213 /// pull of the peer's document, answered `ok` while the tree still
214 /// held the declared bytes. Carried forward across barriers; matched
215 /// on the pending record's `id`.
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub carrier: Option<Carrier>,
218}
219
220/// See `IntentJournal::carrier`.
221#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
222pub struct Carrier {
223 /// `PendingSentinel::id` of the declaration these installs carried.
224 pub pending_id: String,
225 /// The dirty paths they published — uploads and deletes.
226 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
227 pub paths: BTreeSet<String>,
228 /// Which of `paths` were DELETES: a document that does not cite one
229 /// of those is the declaration honoured, not dropped.
230 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
231 pub deletes: BTreeSet<String>,
232 /// Carried uploads the document the LAST barrier ended at does not
233 /// cite with the bytes this writer published: a peer deleted them after
234 /// this writer published them, or replaced them (L-137: deleted, then
235 /// renamed another file onto the path). The ack's document is that
236 /// one, so it says so (H10). Recomputed at every barrier's end from the
237 /// document in hand; no request.
238 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
239 pub uncited: BTreeSet<String>,
240 /// The bytes each carried upload published: the `crc64_b64` of the
241 /// entry its CAS wrote. What `uncited` compares the document against.
242 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
243 pub published: BTreeMap<String, String>,
244 /// Declared paths they did not carry (parked, deferred, or a delete
245 /// the merge outranked) — the ack's `report.dropped` survives them.
246 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
247 pub dropped: BTreeSet<String>,
248}
249
250/// One surfaced conflict: both versions stay recoverable (local bytes in
251/// the tree, foreign bytes preserved at `preserved_key` or still current
252/// under the data key). Never a silent winner.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct ConflictRecord {
255 pub path: String,
256 pub foreign_etag: String,
257 /// Where the foreign bytes were preserved (server-side copy), when
258 /// the local version is about to overwrite them.
259 pub preserved_key: Option<String>,
260 pub kind: String, // "consume-dirty" | "upload-412-parked" | "sync-dirty" | "gc-skip"
261 pub at_unix: u64,
262}
263
264pub struct SyncerState {
265 dir: PathBuf,
266 /// The state-directory occupancy lock (flock, held for the process
267 /// lifetime). Self-recognition of the lease via the persisted
268 /// incarnation id is only sound because the PREVIOUS process is
269 /// gone — and this lock is what makes that true. Without it, a
270 /// second flint-sync on the same workspace self-recognizes,
271 /// deposes a LIVE sibling, and both write the tree concurrently
272 /// (observed on the 0b rig: a diagnostic re-run raced a live 1M
273 /// checkout into tmp-rename ENOENT collisions). The hub has the
274 /// identical gate (`state_backend::is_single_occupant`).
275 _lock: std::fs::File,
276 /// The baseline, parsed once per process (lever D).
277 baseline: std::sync::Mutex<BaselineStore>,
278}
279
280const MARKER: &str = "checkout-complete";
281/// Written by the SIGTERM drain only after it published: the node
282/// plugin's evidence that a worker that is now gone actually drained
283/// (audit 2026-09-03, finding 3). Absent ⇒ the tree is preserved.
284pub const DRAINED: &str = "drained.json";
285const LOCK: &str = "lock";
286const BASELINE: &str = "baseline.json";
287/// The admitted set of a SCOPED checkout. Deliberately its own
288/// document rather than a field on `Baseline`: the baseline is
289/// rewritten by every barrier, every sync and every consume, and a
290/// scope that rides along is one `Baseline::default()` away from
291/// silently widening a workspace to the whole tree. This file is
292/// written once, by checkout, and thereafter only read. Absent means
293/// UNSCOPED — the only encoding of "no restriction", since an empty
294/// admitted set is refused before it can be written.
295const SCOPE: &str = "scope.json";
296
297/// A rescope IN FLIGHT: the target scope, written before the first
298/// mutation and removed only after the last (scoped-read design §4.3).
299///
300/// Its own document and not a field on `scope.json`, because the two
301/// answer different questions — `scope.json` is what this workspace
302/// holds, this is what it is on its way to holding — and a crash must
303/// leave both answers readable.
304///
305/// `null` is a LEGAL target (the whole tree), so absence of the file
306/// means "no rescope in flight", never "unscoped". That is why the
307/// document wraps the target in a struct instead of being a bare
308/// `Option<Vec<String>>`: a bare `null` on disk and a missing file
309/// would deserialize to the same thing.
310const SCOPE_INTENT: &str = "scope-intent.json";
311const INCARNATION: &str = "incarnation.json";
312const INTENT: &str = "intent.json";
313/// When this writer last swept for untracked uploads (`untracked.rs`).
314const UNTRACKED_SWEEP: &str = "untracked-sweep";
315const ORPHAN_SWEEP: &str = "orphan-sweep";
316/// When this writer's commit section last ran the collection pass
317/// (`LeanConfig::collect_every_secs`, latency plan lever C).
318const COLLECT_AT: &str = "collect-at";
319/// The retire logs this writer has read (`untracked.rs`): immutable, so each
320/// is fetched once.
321const RETIRE_CACHE: &str = "retire-cache.json";
322/// The cached conditional-write verdict for this store (`conformance.rs`).
323const CONFORMANCE: &str = "conformance.json";
324const CONFLICTS: &str = "conflicts.jsonl";
325/// The rotated generation. `load_conflicts` reads it FIRST, so the
326/// sequence a reader sees is unbroken across a rotation — which matters
327/// because `honor_sync` takes the count before a sync and `skip`s it
328/// after, and a rotation that SHORTENED the list would make it skip past
329/// the very records the sync just produced.
330const CONFLICTS_PREV: &str = "conflicts.1.jsonl";
331/// How many records were dropped when a SECOND rotation overwrote the
332/// first. Truncation that nobody can count is truncation that reads as
333/// "there were no more".
334const CONFLICTS_DROPPED: &str = "conflicts.dropped";
335/// Rotate past this. The file is append-only and `load_conflicts` parses
336/// it WHOLE — twice per sync honor, again per status read and per
337/// scrape (U23) — so an unbounded file is an unbounded parse on a hot
338/// path, and a standing condition (a UI autosaving over a path the agent
339/// holds dirty) appends on every barrier. Two generations bound the
340/// resident set at ~2 MiB, which is thousands of records.
341const CONFLICTS_MAX_BYTES: u64 = 1 << 20;
342
343fn write_atomic(path: &Path, bytes: &[u8]) -> LeanResult<()> {
344 // The state dir lives in the same app-writable emptyDir.
345 super::safefs::check_parent(path)?;
346 let tmp = path.with_extension("tmp");
347 super::safefs::write_via_tmp(path, &tmp, bytes, None).map(|_| ())
348}
349
350/// The journal beside `baseline.json`: what each save changed since the
351/// snapshot it names (lever D).
352const BASELINE_LOG: &str = "baseline.log";
353
354/// A journal longer than this share of its snapshot is folded into a new
355/// snapshot at the next save: a reload never replays more than a quarter
356/// of the document, and a full write is paid once per that many bytes of
357/// change rather than at every save.
358const COMPACT_SHARE: u64 = 4;
359/// ...and never for a journal shorter than this.
360const COMPACT_MIN_BYTES: u64 = 256 * 1024;
361
362/// The journal's first line: the snapshot it applies to.
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364struct JournalHead {
365 snapshot: String,
366}
367
368/// One save: its scalars, and the members it changed.
369#[derive(Debug, Default, Serialize, Deserialize)]
370struct JournalRecord {
371 seq: u64,
372 #[serde(default)]
373 manifest_etag: Option<String>,
374 #[serde(default)]
375 derived_etag: Option<String>,
376 #[serde(default, skip_serializing_if = "Vec::is_empty")]
377 put: Vec<(String, BaselineEntry)>,
378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
379 del: Vec<String>,
380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
381 skipped_add: Vec<String>,
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
383 skipped_del: Vec<String>,
384 #[serde(default, skip_serializing_if = "Vec::is_empty")]
385 scan_add: Vec<String>,
386 #[serde(default, skip_serializing_if = "Vec::is_empty")]
387 scan_del: Vec<String>,
388}
389
390impl JournalRecord {
391 /// What `b` changed since its lineage, draining what it recorded.
392 fn drain(b: &mut Baseline) -> JournalRecord {
393 let mut r = JournalRecord {
394 seq: b.seq,
395 manifest_etag: b.manifest_etag.clone(),
396 derived_etag: b.derived_etag.clone(),
397 ..Default::default()
398 };
399 for k in b.entries.take_touched() {
400 match b.entries.get(&k) {
401 Some(e) => r.put.push((k, e.clone())),
402 None => r.del.push(k),
403 }
404 }
405 for k in b.skipped.take_touched() {
406 match b.skipped.contains(&k) {
407 true => r.skipped_add.push(k),
408 false => r.skipped_del.push(k),
409 }
410 }
411 for k in b.prev_scan.take_touched() {
412 match b.prev_scan.contains(&k) {
413 true => r.scan_add.push(k),
414 false => r.scan_del.push(k),
415 }
416 }
417 r
418 }
419
420 fn changes_members(&self) -> bool {
421 !(self.put.is_empty()
422 && self.del.is_empty()
423 && self.skipped_add.is_empty()
424 && self.skipped_del.is_empty()
425 && self.scan_add.is_empty()
426 && self.scan_del.is_empty())
427 }
428
429 fn apply(self, b: &mut Baseline) {
430 b.seq = self.seq;
431 b.manifest_etag = self.manifest_etag;
432 b.derived_etag = self.derived_etag;
433 for (k, e) in self.put {
434 b.entries.apply_put(k, e);
435 }
436 for k in &self.del {
437 b.entries.apply_del(k);
438 }
439 for k in self.skipped_add {
440 b.skipped.apply_add(k);
441 }
442 for k in &self.skipped_del {
443 b.skipped.apply_del(k);
444 }
445 for k in self.scan_add {
446 b.prev_scan.apply_add(k);
447 }
448 for k in &self.scan_del {
449 b.prev_scan.apply_del(k);
450 }
451 }
452}
453
454/// `<crc64 hex> <json>\n`. serde_json never writes a raw newline, so a
455/// line is one record, and a line without its newline is a torn one.
456fn journal_line(json: &[u8]) -> Vec<u8> {
457 let mut l = format!("{:016x} ", flint_store::crc64_nvme(json)).into_bytes();
458 l.extend_from_slice(json);
459 l.push(b'\n');
460 l
461}
462
463/// The JSON of one verified line, or `None` (torn, or not this format).
464fn journal_payload(line: &[u8]) -> Option<&[u8]> {
465 if line.len() < 18 || line[16] != b' ' { return None }
466 let crc = u64::from_str_radix(std::str::from_utf8(&line[..16]).ok()?, 16).ok()?;
467 let json = &line[17..];
468 (flint_store::crc64_nvme(json) == crc).then_some(json)
469}
470
471/// The scalars a save writes every time: a save that changed none of
472/// them and no member writes nothing.
473#[derive(Debug, Clone, Default, PartialEq, Eq)]
474struct Scalars {
475 seq: u64,
476 manifest_etag: Option<String>,
477 derived_etag: Option<String>,
478}
479
480impl Scalars {
481 fn of(b: &Baseline) -> Scalars {
482 Scalars { seq: b.seq, manifest_etag: b.manifest_etag.clone(), derived_etag: b.derived_etag.clone() }
483 }
484}
485
486/// The baseline as the disk holds it, parsed once per process. The state
487/// directory's flock makes this process its only writer, so nothing but
488/// this process's own saves can move the disk out from under it.
489#[derive(Default)]
490struct BaselineStore {
491 /// The disk's state, unless a caller holds it (`take_baseline`).
492 cache: Option<Baseline>,
493 /// The lineage the disk holds; 0 = not loaded yet.
494 lineage: u64,
495 next_lineage: u64,
496 /// The snapshot's length on disk; `None` = no snapshot.
497 snapshot: Option<u64>,
498 /// Verified bytes of `baseline.log`, head included; 0 = no journal
499 /// that belongs to the snapshot.
500 log_len: u64,
501 scalars: Scalars,
502 /// Whole-snapshot parses this process made (`tests_baseline`).
503 parses: u64,
504 #[cfg(test)]
505 compact_min_bytes: Option<u64>,
506}
507
508impl BaselineStore {
509 fn next(&mut self) -> u64 {
510 self.next_lineage += 1;
511 self.next_lineage
512 }
513}
514
515fn stamp(b: &mut Baseline, lineage: u64) {
516 b.entries.stamp(lineage);
517 b.skipped.stamp(lineage);
518 b.prev_scan.stamp(lineage);
519}
520
521impl SyncerState {
522 fn baseline_store(&self) -> std::sync::MutexGuard<'_, BaselineStore> {
523 // A panic while holding it leaves the cache as it was: the disk
524 // is the truth either way.
525 self.baseline.lock().unwrap_or_else(|p| p.into_inner())
526 }
527
528 /// Parse the disk's baseline into the store if no copy is there.
529 fn fill(&self, st: &mut BaselineStore) -> LeanResult<()> {
530 if st.cache.is_some() {
531 return Ok(());
532 }
533 let disk = read_baseline_files(&self.dir, true)?;
534 st.parses += disk.snapshot_len.is_some() as u64;
535 let mut b = disk.baseline;
536 let lineage = st.next();
537 stamp(&mut b, lineage);
538 st.lineage = lineage;
539 st.snapshot = disk.snapshot_len;
540 st.log_len = disk.log_len;
541 st.scalars = Scalars::of(&b);
542 st.cache = Some(b);
543 Ok(())
544 }
545
546 /// Write `b` to disk: what it changed, as one journal line, when it
547 /// is the disk's own lineage and the journal has room; else the whole
548 /// document, as before D, with a fresh journal. On success `b` is the
549 /// disk's state and of its new lineage.
550 fn persist(&self, st: &mut BaselineStore, b: &mut Baseline) -> LeanResult<()> {
551 let ours = st.lineage != 0
552 && b.entries.lineage() == st.lineage
553 && b.skipped.lineage() == st.lineage
554 && b.prev_scan.lineage() == st.lineage;
555 let room = match st.snapshot {
556 Some(len) if st.log_len > 0 => {
557 #[cfg(test)]
558 let min = st.compact_min_bytes.unwrap_or(COMPACT_MIN_BYTES);
559 #[cfg(not(test))]
560 let min = COMPACT_MIN_BYTES;
561 st.log_len < (len / COMPACT_SHARE).max(min)
562 }
563 _ => false,
564 };
565 // A save that changed much of the tree (a checkout, a rescope) is a
566 // new snapshot, not a journal line as long as one.
567 let changed = b.entries.touched_len() + b.skipped.touched_len() + b.prev_scan.touched_len();
568 let small = changed <= (b.entries.len() / 8).max(4096);
569 if ours && room && small {
570 let rec = JournalRecord::drain(b);
571 if !rec.changes_members() && Scalars::of(b) == st.scalars {
572 return Ok(());
573 }
574 let json = serde_json::to_vec(&rec).map_err(|e| LeanError::State(format!("baseline journal: {e}")))?;
575 let line = journal_line(&json);
576 let path = self.dir.join(BASELINE_LOG);
577 super::safefs::check_parent(&path)?;
578 // Written at the verified length, not appended: a write that
579 // failed half-way left bytes past it, and this one replaces
580 // them rather than following them.
581 use std::io::{Seek, SeekFrom, Write};
582 let mut f = fs::OpenOptions::new().write(true).open(&path)?;
583 f.seek(SeekFrom::Start(st.log_len))?;
584 f.write_all(&line)?;
585 let len = st.log_len + line.len() as u64;
586 f.set_len(len)?;
587 f.sync_data()?;
588 st.log_len = len;
589 } else {
590 // Written whole: what it recorded is in the document.
591 b.entries.take_touched();
592 b.skipped.take_touched();
593 b.prev_scan.take_touched();
594 let id = uuid::Uuid::new_v4().to_string();
595 b.journal = Some(id.clone());
596 // COMPACT JSON: the snapshot is the O(files) document.
597 let bytes = serde_json::to_vec(&*b).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
598 write_atomic(&self.dir.join(BASELINE), &bytes)?;
599 // The snapshot first, then its journal: a death between the two
600 // leaves the old journal naming the old snapshot's id, which the
601 // next load ignores.
602 let json = serde_json::to_vec(&JournalHead { snapshot: id })
603 .map_err(|e| LeanError::State(format!("baseline journal: {e}")))?;
604 let line = journal_line(&json);
605 write_atomic(&self.dir.join(BASELINE_LOG), &line)?;
606 // Held as the bytes just written parse, not as the map that was
607 // written: a map a checkout built by inserting in arrival order is
608 // scattered across the heap, and every later walk of it, and of
609 // the documents allocated around it, pays for that (measured at
610 // 200k files: `merge` 225-235 -> 167-168 ms and `retire_set`
611 // 181-184 -> 100-104 ms per publish). A parse allocates in key
612 // order. A whole write is rare (a checkout, a compaction, a large
613 // change): one parse each. The written map is freed first, so
614 // the two are never held at once.
615 *b = Baseline::default();
616 *b = serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
617 st.snapshot = Some(bytes.len() as u64);
618 st.log_len = line.len() as u64;
619 }
620 // Every copy made before this save is now stale: a later save of
621 // one writes it whole, as every save did before D.
622 let lineage = st.next();
623 stamp(b, lineage);
624 st.lineage = lineage;
625 st.scalars = Scalars::of(b);
626 Ok(())
627 }
628
629 /// A copy of the baseline. O(entries): for reads off the hot path and
630 /// the battery. The hot paths use `take_baseline` / `with_baseline`.
631 pub fn load_baseline(&self) -> LeanResult<Baseline> {
632 let mut st = self.baseline_store();
633 self.fill(&mut st)?;
634 Ok(st.cache.clone().expect("filled above"))
635 }
636
637 /// Read the baseline in place.
638 pub fn with_baseline<R>(&self, f: impl FnOnce(&Baseline) -> R) -> LeanResult<R> {
639 let mut st = self.baseline_store();
640 self.fill(&mut st)?;
641 Ok(f(st.cache.as_ref().expect("filled above")))
642 }
643
644 /// The baseline itself, to change and hand back with
645 /// `commit_baseline`. Until then the store holds none: a caller that
646 /// never hands it back costs the next load one parse, and changes
647 /// nothing on disk.
648 pub fn take_baseline(&self) -> LeanResult<Baseline> {
649 let mut st = self.baseline_store();
650 self.fill(&mut st)?;
651 Ok(st.cache.take().expect("filled above"))
652 }
653
654 /// Persist a baseline taken with `take_baseline` and keep it. A
655 /// baseline that changed nothing writes nothing.
656 pub fn commit_baseline(&self, mut b: Baseline) -> LeanResult<()> {
657 let mut st = self.baseline_store();
658 if let Err(e) = self.persist(&mut st, &mut b) {
659 // What the disk holds is no longer known here (a snapshot
660 // renamed before its journal failed, say): forget it, and the
661 // next load reads the disk.
662 st.cache = None;
663 st.lineage = 0;
664 st.snapshot = None;
665 st.log_len = 0;
666 return Err(e);
667 }
668 st.cache = Some(b);
669 Ok(())
670 }
671
672 /// Persist a copy, keeping one (O(entries)).
673 pub fn save_baseline(&self, b: &Baseline) -> LeanResult<()> {
674 self.commit_baseline(b.clone())
675 }
676
677 /// Whole-snapshot parses this process has made: one at the first
678 /// load, and then one after a save failed or a taken baseline was not
679 /// handed back.
680 pub fn baseline_parses(&self) -> u64 {
681 self.baseline_store().parses
682 }
683
684 /// Drop the held baseline, as a new process would start without one.
685 #[cfg(test)]
686 pub(crate) fn forget_baseline(&self) {
687 let mut st = self.baseline_store();
688 st.cache = None;
689 st.lineage = 0;
690 }
691
692 #[cfg(test)]
693 pub(crate) fn set_baseline_compact_min(&self, bytes: u64) {
694 self.baseline_store().compact_min_bytes = Some(bytes);
695 }
696}
697
698/// The baseline as the disk holds it: the snapshot, and the journal lines
699/// that belong to it.
700struct DiskBaseline {
701 baseline: Baseline,
702 /// The snapshot's length; `None` = there is none.
703 snapshot_len: Option<u64>,
704 /// Verified bytes of the journal; 0 = none that belongs.
705 log_len: u64,
706}
707
708/// Read `dir`'s baseline. `owner` (the process holding the state
709/// directory's flock) also cuts a torn last line off, so its next save
710/// follows the last good one. Any other reader leaves the file alone: the
711/// line it found torn may be the owner's write in progress.
712fn read_baseline_files(dir: &Path, owner: bool) -> LeanResult<DiskBaseline> {
713 // NotFound alone means absent: `exists()` answered false for EACCES
714 // too, and an unreadable baseline read as an empty one is the
715 // "unreadable means deleted" class (`load_scope`).
716 match fs::read(dir.join(BASELINE)) {
717 Ok(bytes) => {
718 let b = serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
719 replay_onto(dir, b, bytes.len() as u64, owner)
720 }
721 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
722 Ok(DiskBaseline { baseline: Baseline::default(), snapshot_len: None, log_len: 0 })
723 }
724 Err(e) => Err(LeanError::State(format!("read {BASELINE}: {e}"))),
725 }
726}
727
728fn replay_onto(dir: &Path, mut b: Baseline, snapshot_len: u64, owner: bool) -> LeanResult<DiskBaseline> {
729 let none = |b| Ok(DiskBaseline { baseline: b, snapshot_len: Some(snapshot_len), log_len: 0 });
730 let Some(id) = b.journal.clone() else { return none(b) };
731 let path = dir.join(BASELINE_LOG);
732 let bytes = match fs::read(&path) {
733 Ok(bytes) => bytes,
734 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return none(b),
735 Err(e) => return Err(LeanError::State(format!("read {BASELINE_LOG}: {e}"))),
736 };
737 let mut lines = bytes.split_inclusive(|c| *c == b'\n');
738 let Some(first) = lines.next().filter(|l| l.ends_with(b"\n")) else { return none(b) };
739 let belongs = journal_payload(&first[..first.len() - 1])
740 .and_then(|j| serde_json::from_slice::<JournalHead>(j).ok())
741 .is_some_and(|h| h.snapshot == id);
742 if !belongs {
743 // Written for another snapshot: a whole write that landed its new
744 // snapshot and died before its fresh journal. Everything in this
745 // one is in that snapshot. The next save replaces it.
746 return none(b);
747 }
748 let mut good = first.len() as u64;
749 for line in lines {
750 let rec = line
751 .strip_suffix(b"\n")
752 .and_then(journal_payload)
753 .and_then(|j| serde_json::from_slice::<JournalRecord>(j).ok());
754 match rec {
755 Some(r) => {
756 r.apply(&mut b);
757 good += line.len() as u64;
758 }
759 None => break,
760 }
761 }
762 if owner && good < bytes.len() as u64 {
763 let f = fs::OpenOptions::new().write(true).open(&path)?;
764 f.set_len(good)?;
765 f.sync_all()?;
766 }
767 Ok(DiskBaseline { baseline: b, snapshot_len: Some(snapshot_len), log_len: good })
768}
769
770/// The baseline of a state directory this process does not hold: what
771/// its owner's next load would read, never cutting anything. For
772/// `status`, which runs beside a live syncer.
773pub fn read_baseline_at(dir: &Path) -> LeanResult<Baseline> {
774 Ok(read_baseline_files(dir, false)?.baseline)
775}
776
777impl SyncerState {
778 pub fn open(dir: PathBuf) -> LeanResult<SyncerState> {
779 fs::create_dir_all(&dir)?;
780 let lock = fs::OpenOptions::new()
781 .create(true)
782 .truncate(false)
783 .write(true)
784 .open(dir.join(LOCK))?;
785 #[cfg(unix)]
786 {
787 use std::os::fd::AsRawFd;
788 // SAFETY: flock on an owned, open fd.
789 let rc = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
790 if rc != 0 {
791 return Err(LeanError::State(format!(
792 "another flint-sync already holds this workspace ({}): \
793 refusing to run two syncers over one tree",
794 dir.display()
795 )));
796 }
797 }
798 Ok(SyncerState { dir, _lock: lock, baseline: Default::default() })
799 }
800
801 pub fn marker_present(&self) -> bool {
802 self.dir.join(MARKER).exists()
803 }
804
805 /// Flush the filesystem holding the tree (the state dir lives in
806 /// it) so everything materialised so far is on stable storage.
807 pub fn sync_tree(&self) -> LeanResult<()> {
808 super::safefs::sync_tree(&self.dir)
809 }
810
811 /// The drain's attestation. `seq` is the manifest the drain left
812 /// installed, when it knows it.
813 pub fn write_drained(&self, seq: Option<u64>, acks: usize) -> LeanResult<()> {
814 let doc = serde_json::json!({
815 "unix": super::now_unix(),
816 "seq": seq,
817 "acks": acks,
818 });
819 write_atomic(&self.dir.join(DRAINED), doc.to_string().as_bytes())
820 }
821
822 /// A fresh incarnation owes its own attestation; a stale one from
823 /// an earlier life of this tree must not vouch for it.
824 pub fn clear_drained(&self) -> LeanResult<()> {
825 match fs::remove_file(self.dir.join(DRAINED)) {
826 Ok(()) => Ok(()),
827 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
828 Err(e) => Err(LeanError::State(format!("clear {DRAINED}: {e}"))),
829 }
830 }
831
832 pub fn drained_path(&self) -> PathBuf {
833 self.dir.join(DRAINED)
834 }
835
836 /// Written LAST at checkout: the agent-start gate.
837 pub fn write_marker(&self) -> LeanResult<()> {
838 write_atomic(&self.dir.join(MARKER), b"ok\n")
839 }
840
841 /// What this workspace was admitted to hold, or `None` for an
842 /// unscoped (whole-manifest) checkout.
843 ///
844 /// `fs::read` directly, with NO `exists()` pre-check: `exists()`
845 /// answers false for EACCES and EIO alike, and an unreadable scope
846 /// answered as "unscoped" is the same class of bug as an unreadable
847 /// path answered as "deleted" (`barrier.rs`, 14b3637c). Only
848 /// NotFound means absent.
849 pub fn load_scope(&self) -> LeanResult<Option<Vec<String>>> {
850 let p = self.dir.join(SCOPE);
851 let bytes = match fs::read(&p) {
852 Ok(b) => b,
853 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
854 Err(e) => {
855 return Err(LeanError::State(format!(
856 "cannot read the checkout scope at {}: {e} — refusing to \
857 guess what this workspace is holding",
858 p.display()
859 )))
860 }
861 };
862 serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("scope: {e}")))
863 }
864
865 /// `Some` writes the admitted set; `None` REMOVES the document.
866 /// Removing matters: a scoped checkout that crashed before its
867 /// marker leaves a scope behind, and the whole-tree checkout that
868 /// replaces it must not inherit a claim it did not make.
869 pub fn save_scope(&self, scope: Option<&[String]>) -> LeanResult<()> {
870 let p = self.dir.join(SCOPE);
871 match scope {
872 Some(entries) => {
873 let bytes = serde_json::to_vec(entries)
874 .map_err(|e| LeanError::State(format!("scope: {e}")))?;
875 write_atomic(&p, &bytes)
876 }
877 None => match fs::remove_file(&p) {
878 Ok(()) => Ok(()),
879 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
880 Err(e) => Err(LeanError::State(format!("clear {SCOPE}: {e}"))),
881 },
882 }
883 }
884
885 /// Read a rescope in flight. `None` = none in flight.
886 pub fn load_scope_intent(&self) -> LeanResult<Option<ScopeIntent>> {
887 let p = self.dir.join(SCOPE_INTENT);
888 let bytes = match fs::read(&p) {
889 Ok(b) => b,
890 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
891 Err(e) => {
892 return Err(LeanError::State(format!(
893 "cannot read the rescope intent at {}: {e} — refusing to run a barrier \
894 over a half-applied scope",
895 p.display()
896 )))
897 }
898 };
899 serde_json::from_slice(&bytes)
900 .map(Some)
901 .map_err(|e| LeanError::State(format!("scope intent: {e}")))
902 }
903
904 pub fn save_scope_intent(&self, intent: &ScopeIntent) -> LeanResult<()> {
905 let bytes = serde_json::to_vec(intent)
906 .map_err(|e| LeanError::State(format!("scope intent: {e}")))?;
907 write_atomic(&self.dir.join(SCOPE_INTENT), &bytes)
908 }
909
910 /// Cleared LAST, after the new scope is durable. Clearing it first
911 /// would turn a crash into a workspace whose scope says one thing
912 /// and whose held set says another, with nothing left to replay.
913 pub fn clear_scope_intent(&self) -> LeanResult<()> {
914 match fs::remove_file(self.dir.join(SCOPE_INTENT)) {
915 Ok(()) => Ok(()),
916 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
917 Err(e) => Err(LeanError::State(format!("clear {SCOPE_INTENT}: {e}"))),
918 }
919 }
920
921 pub fn load_incarnation(&self) -> LeanResult<Option<Incarnation>> {
922 let p = self.dir.join(INCARNATION);
923 if !p.exists() {
924 return Ok(None);
925 }
926 let bytes = fs::read(&p)?;
927 serde_json::from_slice(&bytes)
928 .map(Some)
929 .map_err(|e| LeanError::State(format!("incarnation: {e}")))
930 }
931
932 pub fn save_incarnation(&self, i: &Incarnation) -> LeanResult<()> {
933 let bytes = serde_json::to_vec_pretty(i)
934 .map_err(|e| LeanError::State(format!("incarnation: {e}")))?;
935 write_atomic(&self.dir.join(INCARNATION), &bytes)
936 }
937
938 /// Unix seconds of this writer's last untracked-upload sweep; 0 when it
939 /// has never swept.
940 pub fn load_untracked_sweep_at(&self) -> LeanResult<u64> {
941 match fs::read_to_string(self.dir.join(UNTRACKED_SWEEP)) {
942 Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
943 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
944 Err(e) => Err(e.into()),
945 }
946 }
947
948 pub fn save_untracked_sweep_at(&self, at: u64) -> LeanResult<()> {
949 write_atomic(&self.dir.join(UNTRACKED_SWEEP), at.to_string().as_bytes())
950 }
951
952 /// The orphan sweep's clock (`untracked.rs`, `sweep_orphans_if_due`):
953 /// 0 = never swept, and the first commit starts it.
954 pub fn load_orphan_sweep_at(&self) -> LeanResult<u64> {
955 match fs::read_to_string(self.dir.join(ORPHAN_SWEEP)) {
956 Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
957 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
958 Err(e) => Err(e.into()),
959 }
960 }
961
962 pub fn save_orphan_sweep_at(&self, at: u64) -> LeanResult<()> {
963 write_atomic(&self.dir.join(ORPHAN_SWEEP), at.to_string().as_bytes())
964 }
965
966 /// The last collection pass, unix seconds; 0 = never (or unreadable,
967 /// which runs the pass: the safe direction is collecting).
968 pub fn load_collect_at(&self) -> u64 {
969 fs::read_to_string(self.dir.join(COLLECT_AT)).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0)
970 }
971
972 pub fn save_collect_at(&self, at: u64) -> LeanResult<()> {
973 write_atomic(&self.dir.join(COLLECT_AT), at.to_string().as_bytes())
974 }
975
976 /// The retire logs read so far, by key. A cache: unreadable reads as
977 /// empty (the logs are fetched again), never as an error.
978 pub fn load_retire_cache(&self) -> BTreeMap<String, super::manifest::RetireLog> {
979 fs::read(self.dir.join(RETIRE_CACHE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
980 }
981
982 pub fn save_retire_cache(&self, c: &BTreeMap<String, super::manifest::RetireLog>) -> LeanResult<()> {
983 let bytes = serde_json::to_vec(c).map_err(|e| LeanError::State(format!("retire cache: {e}")))?;
984 write_atomic(&self.dir.join(RETIRE_CACHE), &bytes)
985 }
986
987 /// A verdict that cannot be parsed reads as ABSENT, never as a pass:
988 /// the probe runs again. The expensive outcome of a corrupt cache is
989 /// ten requests; the cheap one would be running the collector on a
990 /// store nobody asked.
991 pub fn load_conformance(&self) -> LeanResult<Option<StoreConformance>> {
992 match fs::read(self.dir.join(CONFORMANCE)) {
993 Ok(b) => Ok(serde_json::from_slice(&b).ok()),
994 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
995 Err(e) => Err(e.into()),
996 }
997 }
998
999 pub fn save_conformance(&self, c: &StoreConformance) -> LeanResult<()> {
1000 let bytes = serde_json::to_vec_pretty(c)
1001 .map_err(|e| LeanError::State(format!("conformance: {e}")))?;
1002 write_atomic(&self.dir.join(CONFORMANCE), &bytes)
1003 }
1004
1005 pub fn load_intent(&self) -> LeanResult<IntentJournal> {
1006 let p = self.dir.join(INTENT);
1007 if !p.exists() {
1008 return Ok(IntentJournal::default());
1009 }
1010 let bytes = fs::read(&p)?;
1011 serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("intent: {e}")))
1012 }
1013
1014 pub fn save_intent(&self, j: &IntentJournal) -> LeanResult<()> {
1015 let bytes =
1016 serde_json::to_vec_pretty(j).map_err(|e| LeanError::State(format!("intent: {e}")))?;
1017 write_atomic(&self.dir.join(INTENT), &bytes)
1018 }
1019
1020 /// The barrier is over: no flush in flight. The carrier stays.
1021 pub fn clear_intent_keys(&self) -> LeanResult<()> {
1022 let mut j = self.load_intent()?;
1023 j.flush_uuid = String::new();
1024 self.save_intent(&j)
1025 }
1026
1027 pub fn append_conflict(&self, c: &ConflictRecord) -> LeanResult<()> {
1028 use std::io::Write;
1029 let line =
1030 serde_json::to_string(c).map_err(|e| LeanError::State(format!("conflict: {e}")))?;
1031 let live = self.dir.join(CONFLICTS);
1032 let mut f = fs::OpenOptions::new().create(true).append(true).open(&live)?;
1033 writeln!(f, "{line}")?;
1034 // Size off the handle we just wrote through — no extra stat of
1035 // the path, and no line count, which would make every append
1036 // O(records).
1037 let len = f.metadata()?.len();
1038 drop(f);
1039 if len > CONFLICTS_MAX_BYTES {
1040 self.rotate_conflicts()?;
1041 }
1042 Ok(())
1043 }
1044
1045 /// Rotate live -> `.1`, counting whatever the previous `.1` held so
1046 /// the loss is a NUMBER rather than a silence. Best effort: a
1047 /// rotation that fails leaves a large file, which is a performance
1048 /// problem; losing the record we just appended would be a
1049 /// correctness one.
1050 fn rotate_conflicts(&self) -> LeanResult<()> {
1051 let live = self.dir.join(CONFLICTS);
1052 let prev = self.dir.join(CONFLICTS_PREV);
1053 // Only read the outgoing generation at ROTATION time — rare by
1054 // construction, since it takes CONFLICTS_MAX_BYTES to get here.
1055 if let Ok(text) = fs::read_to_string(&prev) {
1056 let dropped = text.lines().filter(|l| !l.trim().is_empty()).count() as u64;
1057 let total = self.conflicts_dropped()? + dropped;
1058 write_atomic(&self.dir.join(CONFLICTS_DROPPED), total.to_string().as_bytes())?;
1059 }
1060 fs::rename(&live, &prev)?;
1061 Ok(())
1062 }
1063
1064 /// Records lost to rotation. A caller rendering conflicts owes the
1065 /// user this number: a truncated list that does not say it was
1066 /// truncated reads as a complete one.
1067 pub fn conflicts_dropped(&self) -> LeanResult<u64> {
1068 match fs::read_to_string(self.dir.join(CONFLICTS_DROPPED)) {
1069 Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
1070 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
1071 Err(e) => Err(LeanError::State(format!("{CONFLICTS_DROPPED}: {e}"))),
1072 }
1073 }
1074
1075 /// Every record ever appended: the ones rotation dropped plus the
1076 /// ones held. Unlike the held count it never goes down, so a reader
1077 /// that takes it before an operation knows exactly how many records
1078 /// the operation produced, across any rotation (`conflicts_since`).
1079 pub fn conflicts_appended(&self) -> LeanResult<u64> {
1080 Ok(self.conflicts_dropped()? + self.load_conflicts()?.len() as u64)
1081 }
1082
1083 /// The rotated generation first, then the live one: oldest to
1084 /// newest, unbroken across a single rotation.
1085 pub fn load_conflicts(&self) -> LeanResult<Vec<ConflictRecord>> {
1086 let mut out = vec![];
1087 for name in [CONFLICTS_PREV, CONFLICTS] {
1088 let p = self.dir.join(name);
1089 // `fs::read_to_string` directly: `exists()` answers false
1090 // for EACCES and EIO alike, and a conflict log that reads as
1091 // EMPTY because it is unreadable is the same shape of bug as
1092 // an unreadable path reading as deleted.
1093 let text = match fs::read_to_string(&p) {
1094 Ok(t) => t,
1095 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
1096 Err(e) => {
1097 return Err(LeanError::State(format!(
1098 "cannot read {name}: {e} — refusing to report an unreadable \
1099 conflict log as an empty one"
1100 )))
1101 }
1102 };
1103 for line in text.lines() {
1104 if line.trim().is_empty() {
1105 continue;
1106 }
1107 out.push(
1108 serde_json::from_str(line)
1109 .map_err(|e| LeanError::State(format!("conflict line: {e}")))?,
1110 );
1111 }
1112 }
1113 Ok(out)
1114 }
1115}