majordomus_cli/live.rs
1//! The repository as a long-lived process must see it: current, not as it was when the
2//! process started.
3//!
4//! # The defect this exists to end
5//!
6//! [`crate::app::App::load`] builds one [`Index`](crate::index::Index) and one
7//! [`Context`] and hands them out as `Arc`s. For a command that answers a question and
8//! exits, that is exactly right: the picture cannot go stale inside a process that lives
9//! for 40 ms. The shared server lives for hours, and it held the same two values for its
10//! whole life. A session would commit, ask `GET /api/v1/repository` for the head it had
11//! just written, and be told the head the server had read when it started — the same
12//! answer from MCP, from the Cockpit and from every capability that reads the index, with
13//! nothing in any of them saying the picture was old. Measured on `origin/master` at
14//! `faba8fdef`: head `A` before a commit, head `A` after it, a rule committed while the
15//! server ran absent from `objects`, and a working tree reported `clean` while it was
16//! dirty. Only restarting the process fixed it.
17//!
18//! # What is watched, and why it is these files
19//!
20//! Git announces every move of the repository by writing a small, fixed set of files:
21//! `HEAD`, the staging index, `packed-refs`, the reflog, and the in-progress markers a
22//! merge or a rebase leaves. A commit, a checkout, a reset, a merge, a rebase and a branch
23//! switch each write at least one of them. So the question "has the repository moved?"
24//! is [`Stamp`]: the size and modification time of those files, hashed — one `stat` per
25//! watched file, taken on the request's own thread before it is answered, which
26//! `the_stamp_is_cheap_enough_to_take_on_every_request` holds to well under the
27//! millisecond that would make it matter. No thread, no interval, no watcher to leak, and
28//! nothing over the network.
29//!
30//! The limit, named rather than hidden: an edit to a tracked file that is never staged
31//! changes no git control file, so this does not see it. Seeing it would mean a `stat` per
32//! declared object on every request — a number that grows with the layer, against an HTTP
33//! p50 the benchmark baselines record in the hundreds of microseconds — which is the
34//! per-request regression the performance doctrine refuses. What a long-lived session
35//! actually loses to the frozen picture is the commit it just made, and a commit always
36//! moves git. `git add` moves it too: the staging index is watched.
37//!
38//! One kind of unstaged edit is seen anyway: the ones this process makes. A capability whose
39//! effect is a repository mutation — `plan.transition` stamping an issue — writes a tracked
40//! file and moves no git control file, and a Cockpit page read right after the move showed
41//! the status from before it. The executor counts every such call that succeeds, whichever
42//! transport or execution made it, and outlives a reload; a generation records the count it
43//! was built after, and a count that has moved is a repository that has moved.
44//!
45//! # The obligation that comes with watching: this process must not write them
46//!
47//! `git status` and `git diff` refresh the staging index as a side effect, writing back
48//! stat data that has gone stale or is *racily* close to the index's own modification
49//! time. So a served page that asked git about the working tree moved the stamp itself,
50//! and the *next* request paid a whole [`crate::app::App::load`] for a repository that had
51//! not moved — an observer disturbing what it observes, and charging the following caller
52//! for it. Measured over the Cockpit page sweep against a committed fixture:
53//! `repository_scans` 1 → 2 → 4 across `/cockpit/continuity` and `/cockpit/health`, with
54//! `.git/index`'s modification time moving at exactly those two points and its size never
55//! changing. Every read this crate makes therefore goes through
56//! [`crate::git::read_only`], which is that one rule made into a constructor;
57//! `a_page_costs_no_rebuild_of_anything_canonical` holds it, page by page.
58//!
59//! # What a new generation costs, and who pays it
60//!
61//! Rebuilding is a whole [`crate::app::App::load`]: discovery, every declared file read
62//! and validated, the registry, the Why catalogue, the product model, the web topology.
63//! It is paid once per repository move, by the one request that noticed, and never
64//! concurrently — a second request arriving mid-rebuild waits for it and is answered from
65//! the generation it produced, because it has seen the move too and the previous
66//! generation is the stale answer it was asking about. Everything a generation carries is a projection of the
67//! index and is therefore rebuilt with it. What is *not* a projection of the index is
68//! carried across: the peer board, the executions this process is running and the
69//! capability executor, because a reload is not a restart and an attached peer must not
70//! lose its place on the board because somebody committed.
71//!
72//! ```
73//! use std::sync::Arc;
74//! use majordomus_cli::live::Live;
75//!
76//! // A pinned view never asks the filesystem anything: what a one-shot command,
77//! // a benchmark and a test want, and what they cost.
78//! # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
79//! let live = Live::pinned(ctx);
80//! assert_eq!(live.generation(), 0, "a pinned view has one generation, forever");
81//! let a = live.current();
82//! let b = live.current();
83//! assert!(Arc::ptr_eq(&a, &b), "and hands out the same context every time");
84//! # }
85//! ```
86
87use std::path::{Path, PathBuf};
88use std::sync::atomic::{AtomicU64, Ordering};
89use std::sync::{Arc, Mutex, RwLock};
90use std::time::{Duration, Instant};
91
92use crate::capability::Context;
93use crate::cli::RepoArgs;
94
95/// The git control files whose size and modification time say the repository has moved.
96///
97/// Relative to the *per-worktree* git directory (`git rev-parse --git-dir`): a linked
98/// worktree has its own `HEAD`, its own staging index and its own reflog, and watching the
99/// primary checkout's instead would make every worktree's server blind to its own commits.
100pub const WORKTREE_FILES: [&str; 6] = [
101 "HEAD",
102 "index",
103 "ORIG_HEAD",
104 "MERGE_HEAD",
105 "logs/HEAD",
106 "rebase-merge",
107];
108
109/// The git control files shared by every worktree of one repository
110/// (`git rev-parse --git-common-dir`): where a ref that is not this worktree's moves.
111///
112/// `refs` is the directory itself, which changes when a ref file is created, renamed or
113/// removed — the shape a ref update takes, since git writes a lock file and renames it
114/// over the target.
115pub const COMMON_FILES: [&str; 2] = ["packed-refs", "refs"];
116
117/// How long a stamp whose load failed is answered from the previous generation before the
118/// load is tried again. A failure is not retried on every request — a layer that will not
119/// load costs a whole load each time — and it is not given up on either: the change it
120/// failed on is still the repository's, so it is read once the interval has passed, or at
121/// once when git moves again.
122pub const RETRY_AFTER: Duration = Duration::from_secs(5);
123
124/// The salt the stamp is taken under, so that it can never collide with another
125/// fingerprint computed over the same files for another purpose.
126pub const SALT: &str = "live/repository-generation";
127
128/// A picture of the git control files: what the repository looked like when a generation
129/// was built.
130///
131/// Two stamps that are equal mean git has not been asked to move anything. They are
132/// compared, never interpreted: nothing reads a head or a branch out of one.
133///
134/// ```
135/// use majordomus_cli::live::Stamp;
136/// use std::process::Command;
137///
138/// let dir = tempfile::tempdir().expect("a temporary directory");
139/// let git = |args: &[&str]| {
140/// Command::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git")
141/// };
142/// git(&["init", "-q"]);
143/// git(&["config", "user.email", "t@example.com"]);
144/// git(&["config", "user.name", "t"]);
145/// std::fs::write(dir.path().join("a"), "a").expect("a file");
146/// git(&["add", "-A"]);
147/// git(&["commit", "-qm", "one"]);
148///
149/// let watch = Stamp::watching(dir.path()).expect("a work tree is watchable");
150/// let before = watch.take();
151/// assert_eq!(before, watch.take(), "nothing moved, nothing changed");
152///
153/// std::fs::write(dir.path().join("b"), "b").expect("a second file");
154/// git(&["add", "-A"]);
155/// git(&["commit", "-qm", "two"]);
156/// assert_ne!(before, watch.take(), "a commit moved the repository");
157/// ```
158#[derive(Debug, Clone)]
159pub struct Stamp {
160 root: PathBuf,
161 paths: Vec<String>,
162}
163
164impl Stamp {
165 /// The control files of the checkout at `root`, resolved once.
166 ///
167 /// `git rev-parse` is run exactly here — one subprocess for the life of the process —
168 /// because the two directories it names do not move while a checkout exists, and a
169 /// subprocess on the request path is the cost this whole module exists to avoid.
170 /// `None` when the directory is not a work tree, which is a repository of the layer
171 /// that is simply not version controlled: it has no moves to follow.
172 ///
173 /// ```
174 /// use majordomus_cli::live::Stamp;
175 ///
176 /// // a directory git knows nothing about has nothing to watch, and that is not a failure
177 /// let plain = tempfile::tempdir().expect("a temporary directory");
178 /// assert!(Stamp::watching(plain.path()).is_none());
179 /// ```
180 pub fn watching(root: &Path) -> Option<Stamp> {
181 let git_dir = resolve(root, "--git-dir")?;
182 let common = resolve(root, "--git-common-dir").unwrap_or_else(|| git_dir.clone());
183 let mut paths: Vec<String> = Vec::with_capacity(WORKTREE_FILES.len() + COMMON_FILES.len());
184 for name in WORKTREE_FILES {
185 paths.push(git_dir.join(name).to_string_lossy().into_owned());
186 }
187 for name in COMMON_FILES {
188 paths.push(common.join(name).to_string_lossy().into_owned());
189 }
190 Some(Stamp {
191 root: root.to_path_buf(),
192 paths,
193 })
194 }
195
196 /// The files this stamp is taken over, absolute. The hash sorts them, so this order
197 /// is the order they were collected in and carries no meaning of its own.
198 pub fn paths(&self) -> &[String] {
199 &self.paths
200 }
201
202 /// Take the stamp: one `stat` per watched file, hashed.
203 ///
204 /// A file that is not there contributes its absence, which is itself a move worth
205 /// noticing — `MERGE_HEAD` appearing and disappearing is how a merge begins and ends.
206 /// The work is [`crate::environment::cache::fingerprint_of`], which is the
207 /// fingerprint-over-files this crate already had; a second implementation of it would
208 /// be a second thing to keep correct.
209 ///
210 /// ```
211 /// use majordomus_cli::live::Stamp;
212 /// use std::process::Command;
213 ///
214 /// let dir = tempfile::tempdir().expect("a temporary directory");
215 /// Command::new("git").arg("-C").arg(dir.path()).args(["init", "-q"]).output().expect("git");
216 /// let watch = Stamp::watching(dir.path()).expect("a work tree");
217 /// // taking it twice over a repository nobody touched gives one value
218 /// assert_eq!(watch.take(), watch.take());
219 /// ```
220 pub fn take(&self) -> String {
221 crate::environment::cache::fingerprint_of(&self.root, &self.paths, &[SALT])
222 }
223}
224
225fn resolve(root: &Path, flag: &str) -> Option<PathBuf> {
226 let out = crate::git::read_only(root)
227 .args(["rev-parse", flag])
228 .output()
229 .ok()?;
230 if !out.status.success() {
231 return None;
232 }
233 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
234 if text.is_empty() {
235 return None;
236 }
237 let path = PathBuf::from(text);
238 Some(if path.is_absolute() {
239 path
240 } else {
241 root.join(path)
242 })
243}
244
245/// One generation of the repository: the context built from it and the stamp it was built
246/// at.
247struct Generation {
248 ctx: Arc<Context>,
249 stamp: String,
250 number: u64,
251 /// The executor's write count this generation was built after. A write to a tracked
252 /// file moves no git control file; a write this process made moves this.
253 writes: u64,
254}
255
256impl Generation {
257 /// Is this generation still the repository as it is: git has not moved it, and this
258 /// process has not written it since?
259 fn current(&self, taken: &str) -> bool {
260 self.stamp == taken && self.writes == self.ctx.executor.writes()
261 }
262}
263
264/// The repository a process reads, kept current.
265///
266/// Two forms, and the difference is whether anything is watched at all:
267///
268/// - [`Live::pinned`] — one context, forever, at no cost. A command that answers and
269/// exits, a benchmark, a test: the picture cannot go stale inside them, and making them
270/// pay a `stat` per call to prove it would be a regression bought with nothing.
271/// - [`Live::watching`] — the shared server's form. Every [`Live::current`] takes a
272/// [`Stamp`] first and rebuilds when it differs from the one the generation it holds was
273/// built at.
274///
275/// ```
276/// # use std::sync::Arc;
277/// # use majordomus_cli::live::Live;
278/// # use majordomus_cli::capability::Context;
279/// # fn example(ctx: Arc<Context>, args: majordomus_cli::cli::RepoArgs) {
280/// // the shared server follows the repository; a one-shot command does not
281/// let followed = Live::watching(args, Arc::clone(&ctx));
282/// let pinned = Live::pinned(ctx);
283/// assert_eq!(pinned.generation(), followed.generation(), "both start at the first");
284/// # }
285/// ```
286pub struct Live {
287 watch: Option<Watch>,
288 state: RwLock<Generation>,
289 /// Held by the one thread rebuilding. A request that finds it taken waits: it has
290 /// seen the repository move, so the generation that exists is not an answer to it.
291 rebuilding: Mutex<()>,
292}
293
294struct Watch {
295 args: RepoArgs,
296 stamp: Stamp,
297 /// The clock a failed load's retry interval is measured on: the process's own, or a
298 /// test's, so that the interval is asserted rather than slept through.
299 clock: Arc<dyn Fn() -> Instant + Send + Sync>,
300 /// The stamp and the write count the last load failed at, and when. The generation keeps
301 /// the stamp and the count it was built at, so the change stays unconsumed and is read
302 /// again.
303 failed: Mutex<Option<(String, u64, Instant)>>,
304 /// How many loads were attempted: what a test counts to hold a failing load to one
305 /// attempt per interval.
306 loads: AtomicU64,
307}
308
309impl std::fmt::Debug for Live {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 let state = read(&self.state);
312 f.debug_struct("Live")
313 .field("watching", &self.watch.is_some())
314 .field("generation", &state.number)
315 .finish()
316 }
317}
318
319impl Live {
320 /// One context that never changes: no stamp is taken and nothing is rebuilt.
321 ///
322 /// ```
323 /// # use std::sync::Arc;
324 /// # use majordomus_cli::live::Live;
325 /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
326 /// let live = Live::pinned(Arc::clone(&ctx));
327 /// assert!(Arc::ptr_eq(&live.current(), &ctx), "the very context it was given");
328 /// # }
329 /// ```
330 pub fn pinned(ctx: Arc<Context>) -> Live {
331 let writes = ctx.executor.writes();
332 Live {
333 watch: None,
334 state: RwLock::new(Generation {
335 ctx,
336 stamp: String::new(),
337 number: 0,
338 writes,
339 }),
340 rebuilding: Mutex::new(()),
341 }
342 }
343
344 /// A context that follows the repository, reloaded with `args` when git moves it.
345 ///
346 /// `args` is how the process was pointed at the repository in the first place, kept so
347 /// that a reload discovers the same root, the same distribution and the same discovery
348 /// mode the first load did. Falls back to [`Live::pinned`] when the root is not a work
349 /// tree: there is nothing to follow, and pretending otherwise would take a stamp over
350 /// a handful of absent files on every request forever.
351 ///
352 /// ```
353 /// # use std::sync::Arc;
354 /// # use majordomus_cli::live::Live;
355 /// # fn example(ctx: Arc<majordomus_cli::capability::Context>, args: majordomus_cli::cli::RepoArgs) {
356 /// let live = Live::watching(args, ctx);
357 /// // the first reading is the one it was handed; a commit makes the next one the second
358 /// assert_eq!(live.view().generation, 0);
359 /// # }
360 /// ```
361 pub fn watching(args: RepoArgs, ctx: Arc<Context>) -> Live {
362 Live::watching_on(args, ctx, Arc::new(Instant::now))
363 }
364
365 /// [`Live::watching`], measuring the retry interval of a failed load on `clock`.
366 fn watching_on(
367 args: RepoArgs,
368 ctx: Arc<Context>,
369 clock: Arc<dyn Fn() -> Instant + Send + Sync>,
370 ) -> Live {
371 let root = PathBuf::from(&ctx.index.repository.root);
372 // The root is pinned to the one already discovered rather than left to be
373 // rediscovered from `--repo`, which is `None` for a process started in the
374 // repository: a server is detached from the terminal that started it, and a
375 // reload that began from the current directory would rediscover whatever
376 // directory the process happens to be in — a different repository, or none.
377 let args = RepoArgs {
378 repo: Some(root.clone()),
379 ..args
380 };
381 let Some(stamp) = Stamp::watching(&root) else {
382 tracing::info!(
383 root = %root.display(),
384 "the repository is not a git work tree: this process serves the layer as it reads it now and has no moves to follow"
385 );
386 return Live::pinned(ctx);
387 };
388 let taken = stamp.take();
389 tracing::info!(
390 watched = stamp.paths().len(),
391 "following the repository: every request compares {} git control file(s) and reloads the layer when they move",
392 stamp.paths().len()
393 );
394 let writes = ctx.executor.writes();
395 Live {
396 watch: Some(Watch {
397 args,
398 stamp,
399 clock,
400 failed: Mutex::new(None),
401 loads: AtomicU64::new(0),
402 }),
403 state: RwLock::new(Generation {
404 ctx,
405 stamp: taken,
406 number: 0,
407 writes,
408 }),
409 rebuilding: Mutex::new(()),
410 }
411 }
412
413 /// Which generation is current, without taking a stamp.
414 ///
415 /// Only for a caller that already holds a [`View`] and wants to say which one it is; a
416 /// caller that is about to *use* the context must take [`Live::view`], because reading
417 /// the number and then the context is two reads with a reload possible between them —
418 /// the bug this whole module exists to end, one layer in. A projection memoised under
419 /// the number read before the reload is the previous generation's, handed out as the
420 /// current one.
421 ///
422 /// ```
423 /// # use std::sync::Arc;
424 /// # use majordomus_cli::live::Live;
425 /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
426 /// let live = Live::pinned(ctx);
427 /// assert_eq!(live.generation(), live.view().generation);
428 /// # }
429 /// ```
430 pub fn generation(&self) -> u64 {
431 read(&self.state).number
432 }
433
434 /// The context as it is now: the one that was loaded, or a newly built one when the
435 /// repository has moved under this process.
436 ///
437 /// ```
438 /// # use std::sync::Arc;
439 /// # use majordomus_cli::live::Live;
440 /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
441 /// let live = Live::pinned(ctx);
442 /// assert!(Arc::ptr_eq(&live.current(), &live.view().ctx));
443 /// # }
444 /// ```
445 pub fn current(&self) -> Arc<Context> {
446 self.view().ctx
447 }
448
449 /// The current generation and the context of it, as one reading.
450 ///
451 /// One call, so that a caller keying a memo on the number and filling it from the
452 /// context cannot key the new context under the old number.
453 ///
454 /// ```
455 /// # use std::sync::Arc;
456 /// # use majordomus_cli::live::{Live, Memo};
457 /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
458 /// let live = Live::pinned(ctx);
459 /// let memo: Memo<usize> = Memo::default();
460 /// // the shape every projection uses: one reading, keyed and filled from itself
461 /// let view = live.view();
462 /// let objects = memo.get_or_init(view.generation, || view.ctx.index.objects.len());
463 /// assert_eq!(*objects, view.ctx.index.objects.len());
464 /// # }
465 /// ```
466 pub fn view(&self) -> View {
467 let Some(watch) = self.watch.as_ref() else {
468 let state = read(&self.state);
469 return View {
470 generation: state.number,
471 ctx: Arc::clone(&state.ctx),
472 };
473 };
474 let taken = watch.stamp.take();
475 {
476 let state = read(&self.state);
477 if state.current(&taken) || watch.waiting(&taken, state.ctx.executor.writes()) {
478 return View {
479 generation: state.number,
480 ctx: Arc::clone(&state.ctx),
481 };
482 }
483 }
484 self.reload(watch, taken)
485 }
486
487 /// The generation there is, at once, and whether the repository has moved past it: what
488 /// a page a person is looking at asks for (ADR 0142).
489 ///
490 /// [`Live::view`] waits for a reload, because an answer must be of the repository the
491 /// caller saw. A page need not be: it is drawn from the generation that exists, says that
492 /// the repository changed, and is read again once the new generation has loaded. So
493 /// when the repository has moved, this starts the reload on a thread of its own, unless
494 /// one is already running, and returns the existing generation with `behind` set.
495 /// Nothing about [`Live::view`] changes for any other caller.
496 ///
497 /// ```
498 /// use majordomus_cli::live::Live;
499 /// use majordomus_cli::synthetic::SyntheticRepository;
500 /// use std::sync::Arc;
501 ///
502 /// let repo = SyntheticRepository::small().unwrap();
503 /// let live = Arc::new(Live::pinned(repo.context().unwrap()));
504 /// // a pinned view follows nothing, so the generation there is is the one to show and
505 /// // the repository has never moved past it
506 /// let (view, behind) = live.view_without_waiting();
507 /// assert!(!behind);
508 /// assert_eq!(view.generation, live.view().generation);
509 /// ```
510 pub fn view_without_waiting(self: &Arc<Self>) -> (View, bool) {
511 let Some(watch) = self.watch.as_ref() else {
512 return (self.view(), false);
513 };
514 let taken = watch.stamp.take();
515 let existing = {
516 let state = read(&self.state);
517 let view = View {
518 generation: state.number,
519 ctx: Arc::clone(&state.ctx),
520 };
521 if state.current(&taken) {
522 return (view, false);
523 }
524 view
525 };
526 // one reload at a time: a caller that finds one running leaves it to finish
527 if let Ok(guard) = self.rebuilding.try_lock() {
528 drop(guard);
529 let live = Arc::clone(self);
530 let started = std::thread::Builder::new()
531 .name("mj-reload".into())
532 .spawn(move || {
533 live.view();
534 });
535 if let Err(refused) = started {
536 tracing::warn!("no thread to reload the repository behind a page: {refused}");
537 return (self.view(), false);
538 }
539 }
540 (existing, true)
541 }
542
543 fn reload(&self, watch: &Watch, taken: String) -> View {
544 // A caller that arrives mid-rebuild has already seen the stamp move: the generation
545 // that exists is known to be stale, and answering from it is the frozen picture
546 // again for as long as a load takes. The server's own tick takes a view twice a
547 // second, so it is usually the one rebuilding, and a session that commits and asks
548 // at once was handed the commit before its own. It waits instead; the check below
549 // then finds the new generation, or rebuilds when the rebuilder's stamp was older
550 // than this caller's.
551 let _one_at_a_time = self
552 .rebuilding
553 .lock()
554 .unwrap_or_else(std::sync::PoisonError::into_inner);
555 let previous = {
556 let state = read(&self.state);
557 // Asked again, now that the rebuild this caller waited for is over. It finds
558 // the new generation when that rebuild read the stamp this caller saw — or it
559 // finds that the rebuild failed at that very stamp, and then it is answered
560 // from the previous generation like every caller inside the retry interval. A
561 // waiter that loaded here instead would try the failing load once per waiter:
562 // every request that queued behind one failure would repeat it, which is the
563 // cost the interval exists to bound.
564 if state.current(&taken) || watch.waiting(&taken, state.ctx.executor.writes()) {
565 return View {
566 generation: state.number,
567 ctx: Arc::clone(&state.ctx),
568 };
569 }
570 Arc::clone(&state.ctx)
571 };
572 // read before the load: a write that lands while the layer is being read is one the
573 // new generation may not carry, and it must leave the next reading stale
574 let writes = previous.executor.writes();
575 watch.loads.fetch_add(1, Ordering::SeqCst);
576 let built = crate::app::App::load(&watch.args);
577 let mut state = write(&self.state);
578 match built {
579 Ok(app) => {
580 *lock(&watch.failed) = None;
581 state.writes = writes;
582 state.ctx = Arc::new(app.context.continuing(&previous));
583 state.number += 1;
584 // the declaration may have changed with the repository: a key withdrawn from
585 // it loses its links now, not at the next restart (I2134)
586 warn_of_ended_links(&crate::capability::builtin::mesh::retrust(&state.ctx));
587 state.stamp = taken;
588 tracing::info!(
589 generation = state.number,
590 objects = state.ctx.index.objects.len(),
591 head = %head_of(&state.ctx),
592 "the repository moved; this process reads generation {} of it",
593 state.number
594 );
595 }
596 Err(e) => {
597 // A repository caught mid-rebase, a manifest being edited, a checkout half
598 // written: the last generation that loaded is a better answer than an
599 // error. The generation keeps the stamp it was built at, so the change is
600 // not consumed by a load that failed to read it; the failure is recorded
601 // instead, and holds the retry off for RETRY_AFTER — never once per request,
602 // never given up on (Watch::waiting).
603 *lock(&watch.failed) = Some((taken, writes, (watch.clock)()));
604 tracing::warn!(
605 error = %e,
606 "the repository moved but the layer would not load; serving generation {} and reading it again in {}s, or at once when it moves again",
607 state.number,
608 RETRY_AFTER.as_secs()
609 );
610 }
611 }
612 View {
613 generation: state.number,
614 ctx: Arc::clone(&state.ctx),
615 }
616 }
617}
618
619/// One reading of the repository: which generation, and the context of it.
620///
621/// The pair is taken together because a memo keyed on the number and filled from the
622/// context must not mix two readings: taking the number first, then the context, lets a
623/// reload happen between them and files the new context under the old number, where the
624/// next request finds the *previous* generation and calls it current. That is the original
625/// defect in miniature, and it is why there is no way to ask for the two separately on the
626/// path that uses them.
627///
628/// ```
629/// # use std::sync::Arc;
630/// # use majordomus_cli::live::Live;
631/// # use majordomus_cli::live::View;
632/// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
633/// let view: View = Live::pinned(ctx).view();
634/// assert_eq!(view.generation, 0);
635/// assert!(view.ctx.registry.len() > 0, "the context of that generation, not another");
636/// # }
637/// ```
638#[derive(Clone)]
639pub struct View {
640 /// Which generation of the repository this is.
641 pub generation: u64,
642 /// The context built from it.
643 pub ctx: Arc<Context>,
644}
645
646impl std::fmt::Debug for View {
647 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648 f.debug_struct("View")
649 .field("generation", &self.generation)
650 .field("objects", &self.ctx.index.objects.len())
651 .finish()
652 }
653}
654
655/// What a projection is built over: something that can hand out the current context.
656///
657/// One trait so that the shared server passes the [`Live`] it watches with and every other
658/// caller — one-shot commands, benchmarks, the crate's own tests — passes the
659/// [`Arc<Context>`] it already has and gets a pinned view without saying so.
660///
661/// ```
662/// # use std::sync::Arc;
663/// # use majordomus_cli::live::{IntoLive, Live};
664/// # fn example(ctx: Arc<majordomus_cli::capability::Context>, live: Arc<Live>) {
665/// // a projection takes either, and neither caller has to say which it holds
666/// fn projection(over: impl IntoLive) -> u64 { over.into_live().generation() }
667/// assert_eq!(projection(ctx), 0);
668/// assert_eq!(projection(live), 0);
669/// # }
670/// ```
671pub trait IntoLive {
672 /// The live view this value stands for.
673 ///
674 /// ```
675 /// # use std::sync::Arc;
676 /// # use majordomus_cli::live::IntoLive;
677 /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
678 /// // a plain context becomes a view that follows nothing and costs nothing
679 /// assert_eq!(ctx.into_live().generation(), 0);
680 /// # }
681 /// ```
682 fn into_live(self) -> Arc<Live>;
683}
684
685impl IntoLive for Arc<Live> {
686 fn into_live(self) -> Arc<Live> {
687 self
688 }
689}
690
691impl IntoLive for Arc<Context> {
692 fn into_live(self) -> Arc<Live> {
693 Arc::new(Live::pinned(self))
694 }
695}
696
697/// A value derived from one generation of the repository, kept until that generation ends.
698///
699/// The OpenAPI document, the MCP tool and resource listings and the resolved web surfaces
700/// are all expensive functions of the index and the registry, and all of them used to be
701/// `OnceLock`s — right while the index was immutable for the life of a process, and a
702/// second frozen picture the moment it was not. A memo answers from the generation it was
703/// built for and rebuilds for the next one.
704///
705/// ```
706/// use majordomus_cli::live::Memo;
707///
708/// let memo: Memo<String> = Memo::default();
709/// let a = memo.get_or_init(0, || "built once".to_string());
710/// let b = memo.get_or_init(0, || panic!("the same generation must not rebuild"));
711/// assert_eq!(*a, *b);
712/// let c = memo.get_or_init(1, || "built again".to_string());
713/// assert_eq!(*c, "built again", "a new generation is a new value");
714/// ```
715#[derive(Debug)]
716pub struct Memo<T> {
717 cell: Mutex<Option<(u64, Arc<T>)>>,
718}
719
720impl<T> Default for Memo<T> {
721 fn default() -> Self {
722 Memo {
723 cell: Mutex::new(None),
724 }
725 }
726}
727
728impl<T> Memo<T> {
729 /// The value for `generation`, building it once when this memo does not hold it.
730 ///
731 /// The lock is held across `build`, so two threads that arrive together on a new
732 /// generation do the work once rather than twice.
733 ///
734 /// ```
735 /// use majordomus_cli::live::Memo;
736 ///
737 /// let memo: Memo<Vec<u8>> = Memo::default();
738 /// let first = memo.get_or_init(3, || vec![1, 2, 3]);
739 /// let again = memo.get_or_init(3, || unreachable!("the same generation is not rebuilt"));
740 /// assert!(std::sync::Arc::ptr_eq(&first, &again));
741 /// assert_eq!(*memo.get_or_init(4, Vec::new), Vec::<u8>::new(), "a new generation rebuilds");
742 /// ```
743 pub fn get_or_init(&self, generation: u64, build: impl FnOnce() -> T) -> Arc<T> {
744 let mut cell = self.cell.lock().unwrap_or_else(|e| e.into_inner());
745 if let Some((held, value)) = cell.as_ref() {
746 if *held == generation {
747 return Arc::clone(value);
748 }
749 }
750 let value = Arc::new(build());
751 *cell = Some((generation, Arc::clone(&value)));
752 value
753 }
754}
755
756/// The head the index was built at, for the log line that announces a generation.
757fn head_of(ctx: &Context) -> String {
758 match &ctx.index.repository.git {
759 crate::git::GitState::Available(info) => info.head.clone().unwrap_or_else(|| "-".into()),
760 crate::git::GitState::Unavailable { .. } => "-".into(),
761 }
762}
763
764/// Say which mesh links a reload ended because the declaration no longer trusts their keys
765/// (I2134); `true` when there was one to name. A reload that ended none says nothing.
766fn warn_of_ended_links(ended: &[String]) -> bool {
767 if ended.is_empty() {
768 return false;
769 }
770 tracing::warn!(
771 peers = ?ended,
772 "mesh links ended: their keys are no longer trusted by the declaration"
773 );
774 true
775}
776
777impl Watch {
778 /// Whether `taken` and `writes` are the stamp and the write count the last load failed
779 /// at, inside the retry interval: the previous generation answers, and nothing is loaded.
780 /// A stamp or a count that moved since, or an interval that has passed, is read again.
781 fn waiting(&self, taken: &str, writes: u64) -> bool {
782 lock(&self.failed)
783 .as_ref()
784 .is_some_and(|(stamp, failed_at, at)| {
785 stamp == taken && *failed_at == writes && (self.clock)() < *at + RETRY_AFTER
786 })
787 }
788}
789
790fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
791 mutex
792 .lock()
793 .unwrap_or_else(std::sync::PoisonError::into_inner)
794}
795
796fn read<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
797 lock.read().unwrap_or_else(|e| e.into_inner())
798}
799
800fn write<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
801 lock.write().unwrap_or_else(|e| e.into_inner())
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use std::process::Command;
808 use std::time::Instant;
809
810 fn repo() -> tempfile::TempDir {
811 let dir = tempfile::tempdir().expect("a temporary directory");
812 let git = |args: &[&str]| {
813 Command::new("git")
814 .arg("-C")
815 .arg(dir.path())
816 .args(args)
817 .output()
818 .expect("git")
819 };
820 git(&["init", "-q"]);
821 git(&["config", "user.email", "t@example.com"]);
822 git(&["config", "user.name", "t"]);
823 std::fs::write(dir.path().join("a"), "a").expect("a file");
824 git(&["add", "-A"]);
825 git(&["commit", "-qm", "one"]);
826 dir
827 }
828
829 /// A synthetic layer in a git work tree, loaded once, with the arguments that reload it
830 /// and a clock the test moves by hand.
831 struct Layer {
832 repo: crate::synthetic::SyntheticRepository,
833 live: Live,
834 now: Arc<Mutex<Instant>>,
835 }
836
837 impl Layer {
838 fn new() -> Layer {
839 let repo = crate::synthetic::SyntheticRepository::small().expect("a synthetic layer");
840 let layer = |args: &[&str]| {
841 Command::new("git")
842 .arg("-C")
843 .arg(repo.root())
844 .args(args)
845 .output()
846 .expect("git")
847 };
848 layer(&["init", "-q"]);
849 layer(&["config", "user.email", "t@example.com"]);
850 layer(&["config", "user.name", "t"]);
851 layer(&["add", "-A"]);
852 layer(&["commit", "-qm", "layer"]);
853 let args = RepoArgs {
854 repo: Some(repo.root().to_path_buf()),
855 share: Some(crate::synthetic::crate_share()),
856 discovery: crate::cli::DiscoveryMode::Filesystem,
857 ..Default::default()
858 };
859 let ctx = crate::app::App::load(&args)
860 .expect("the layer loads")
861 .context;
862 let now = Arc::new(Mutex::new(Instant::now()));
863 let clock = {
864 let now = Arc::clone(&now);
865 Arc::new(move || *lock(&now))
866 };
867 Layer {
868 live: Live::watching_on(args, ctx, clock),
869 repo,
870 now,
871 }
872 }
873
874 fn git(&self, args: &[&str]) {
875 let out = Command::new("git")
876 .arg("-C")
877 .arg(self.repo.root())
878 .args(args)
879 .output()
880 .expect("git");
881 assert!(out.status.success(), "git {args:?}: {out:?}");
882 }
883
884 fn manifest(&self) -> PathBuf {
885 self.repo.root().join(".ai/manifest.yaml")
886 }
887
888 /// The manifest gains a key nothing declares, so the layer will not load, and the
889 /// change is committed: git moves.
890 fn break_and_commit(&self) -> String {
891 let manifest = std::fs::read_to_string(self.manifest()).expect("a manifest");
892 std::fs::write(
893 self.manifest(),
894 format!("{manifest}a_key_nothing_declares: 1\n"),
895 )
896 .expect("the manifest is written");
897 self.git(&["commit", "-qam", "a manifest that will not load"]);
898 manifest
899 }
900
901 fn loads(&self) -> u64 {
902 self.live
903 .watch
904 .as_ref()
905 .expect("a watching view")
906 .loads
907 .load(Ordering::SeqCst)
908 }
909
910 fn advance(&self, by: Duration) {
911 let mut now = lock(&self.now);
912 *now += by;
913 }
914 }
915
916 #[test]
917 fn a_failed_load_consumes_no_change_and_is_read_again_after_the_interval() {
918 let l = Layer::new();
919 let good = l.break_and_commit();
920 assert_eq!(l.live.view().generation, 0, "the layer will not load");
921 assert_eq!(l.loads(), 1);
922 // the layer is mended in the work tree alone: no git control file moves, so only the
923 // retry can find it
924 std::fs::write(l.manifest(), good).expect("the manifest is written");
925 assert_eq!(
926 l.live.view().generation,
927 0,
928 "inside the interval, nothing is loaded"
929 );
930 assert_eq!(
931 l.loads(),
932 1,
933 "a failed load is not retried on every request"
934 );
935 l.advance(RETRY_AFTER);
936 assert_eq!(
937 l.live.view().generation,
938 1,
939 "the change the failed load did not read is read once the interval has passed"
940 );
941 assert_eq!(l.loads(), 2);
942 assert_eq!(l.live.view().generation, 1, "and is then current");
943 assert_eq!(l.loads(), 2);
944 }
945
946 #[test]
947 fn a_failure_is_read_again_at_once_when_the_repository_moves_again() {
948 let l = Layer::new();
949 let good = l.break_and_commit();
950 assert_eq!(l.live.view().generation, 0);
951 std::fs::write(l.manifest(), good).expect("the manifest is written");
952 l.git(&["commit", "-qam", "the manifest mended"]);
953 assert_eq!(
954 l.live.view().generation,
955 1,
956 "a new move is read at once, whatever the interval"
957 );
958 assert_eq!(l.loads(), 2);
959 }
960
961 #[test]
962 fn a_write_whose_reload_fails_is_read_again_after_the_interval() {
963 let l = Layer::new();
964 // the layer is broken in the work tree and this process writes: no git control file
965 // moves, so only the write count says the repository moved
966 let good = std::fs::read_to_string(l.manifest()).expect("a manifest");
967 std::fs::write(l.manifest(), format!("{good}a_key_nothing_declares: 1\n"))
968 .expect("the manifest is written");
969 l.live.current().executor.count_write();
970 assert_eq!(l.live.view().generation, 0, "the layer will not load");
971 assert_eq!(l.loads(), 1);
972 std::fs::write(l.manifest(), &good).expect("the manifest is written");
973 assert_eq!(
974 l.live.view().generation,
975 0,
976 "inside the interval, nothing is loaded"
977 );
978 assert_eq!(l.loads(), 1);
979 l.advance(RETRY_AFTER);
980 assert_eq!(
981 l.live.view().generation,
982 1,
983 "the write the failed load did not read is read once the interval has passed"
984 );
985 assert_eq!(l.loads(), 2);
986 }
987
988 #[test]
989 fn a_new_write_after_a_failed_load_is_read_at_once() {
990 let l = Layer::new();
991 let good = l.break_and_commit();
992 assert_eq!(l.live.view().generation, 0);
993 std::fs::write(l.manifest(), good).expect("the manifest is written");
994 l.live.current().executor.count_write();
995 assert_eq!(
996 l.live.view().generation,
997 1,
998 "a write is a move, read at once whatever the interval"
999 );
1000 assert_eq!(l.loads(), 2);
1001 }
1002
1003 #[test]
1004 fn a_load_that_keeps_failing_is_tried_once_per_interval() {
1005 let l = Layer::new();
1006 l.break_and_commit();
1007 for _ in 0..5 {
1008 assert_eq!(l.live.view().generation, 0);
1009 }
1010 assert_eq!(l.loads(), 1);
1011 l.advance(RETRY_AFTER - Duration::from_millis(1));
1012 assert_eq!(l.live.view().generation, 0);
1013 assert_eq!(l.loads(), 1, "not a moment before the interval");
1014 l.advance(Duration::from_millis(1));
1015 for _ in 0..3 {
1016 assert_eq!(l.live.view().generation, 0);
1017 }
1018 assert_eq!(l.loads(), 2, "once when it has passed, and not again");
1019 }
1020
1021 /// A caller that queued behind a rebuild which then failed is answered from the
1022 /// previous generation and loads nothing: the failure it waited for is the failure at
1023 /// the stamp it saw, and that is retried once per interval, not once per waiter.
1024 #[test]
1025 fn a_caller_that_waited_for_a_rebuild_that_failed_does_not_repeat_it() {
1026 let l = Layer::new();
1027 l.break_and_commit();
1028 let watch = l.live.watch.as_ref().expect("a watching view");
1029 // the rebuild somebody else is running
1030 let rebuilding = l.live.rebuilding.lock().expect("the rebuild");
1031 let (said, heard) = std::sync::mpsc::channel();
1032 std::thread::scope(|scope| {
1033 scope.spawn(|| {
1034 let _ = said.send(l.live.view().generation);
1035 });
1036 assert!(
1037 heard
1038 .recv_timeout(std::time::Duration::from_millis(300))
1039 .is_err(),
1040 "a caller that saw the move was answered while the rebuild was still running"
1041 );
1042 // that rebuild ends as a failed load does: the stamp and the write count it
1043 // failed at are recorded, and the generation is left where it was
1044 *lock(&watch.failed) = Some((
1045 watch.stamp.take(),
1046 read(&l.live.state).ctx.executor.writes(),
1047 (watch.clock)(),
1048 ));
1049 drop(rebuilding);
1050 let generation = heard
1051 .recv_timeout(std::time::Duration::from_secs(60))
1052 .expect("the caller is answered once the rebuild is over");
1053 assert_eq!(generation, 0, "from the generation that loaded");
1054 });
1055 assert_eq!(
1056 l.loads(),
1057 0,
1058 "the waiter repeated the load it had just waited to see fail"
1059 );
1060 // and the change is still read: once the interval has passed, by whoever asks
1061 l.advance(RETRY_AFTER);
1062 assert_eq!(l.live.view().generation, 0, "the layer still will not load");
1063 assert_eq!(l.loads(), 1, "tried once when the interval has passed");
1064 }
1065
1066 /// A synthetic layer in a git work tree with one commit, and a `Live` watching it.
1067 fn followed() -> (crate::synthetic::SyntheticRepository, Live) {
1068 let fixture = crate::synthetic::SyntheticRepository::small().expect("a layer");
1069 let git = |args: &[&str]| {
1070 Command::new("git")
1071 .arg("-C")
1072 .arg(fixture.root())
1073 .args(args)
1074 .output()
1075 .expect("git")
1076 };
1077 git(&["init", "-q"]);
1078 git(&["config", "user.email", "t@example.com"]);
1079 git(&["config", "user.name", "t"]);
1080 git(&["add", "-A"]);
1081 git(&["commit", "-qm", "one"]);
1082 let args = RepoArgs {
1083 repo: Some(fixture.root().to_path_buf()),
1084 discovery: crate::cli::DiscoveryMode::Filesystem,
1085 share: Some(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share")),
1086 ..Default::default()
1087 };
1088 let app = crate::app::App::load(&args).expect("the layer loads");
1089 let live = Live::watching(args, Arc::clone(&app.context));
1090 (fixture, live)
1091 }
1092
1093 fn head_in(root: &Path) -> String {
1094 let out = Command::new("git")
1095 .arg("-C")
1096 .arg(root)
1097 .args(["rev-parse", "HEAD"])
1098 .output()
1099 .expect("git");
1100 String::from_utf8_lossy(&out.stdout).trim().to_string()
1101 }
1102
1103 #[test]
1104 fn a_request_that_arrives_during_a_rebuild_waits_for_it() {
1105 let (fixture, live) = followed();
1106 let before = head_in(fixture.root());
1107 assert_eq!(head_of(&live.view().ctx), before, "it starts on HEAD");
1108
1109 // the rebuild somebody else is running: the server's tick, in the process this
1110 // was found in
1111 let rebuilding = live.rebuilding.lock().expect("the rebuild");
1112 Command::new("git")
1113 .arg("-C")
1114 .arg(fixture.root())
1115 .args(["commit", "-q", "--allow-empty", "-m", "two"])
1116 .output()
1117 .expect("git");
1118 let after = head_in(fixture.root());
1119 assert_ne!(before, after, "the commit moved HEAD");
1120
1121 let (said, heard) = std::sync::mpsc::channel();
1122 std::thread::scope(|scope| {
1123 scope.spawn(|| {
1124 let _ = said.send(head_of(&live.view().ctx));
1125 });
1126 assert!(
1127 heard
1128 .recv_timeout(std::time::Duration::from_millis(300))
1129 .is_err(),
1130 "a caller that saw the move was answered while the rebuild was still running"
1131 );
1132 drop(rebuilding);
1133 let answered = heard
1134 .recv_timeout(std::time::Duration::from_secs(60))
1135 .expect("the caller is answered once the rebuild is over");
1136 assert_eq!(answered, after, "and with the commit it asked about");
1137 });
1138 }
1139
1140 /// A page does not wait for a rebuild: it is handed the generation there is, told the
1141 /// repository has moved past it, and the rebuild runs behind it; once it is over, the
1142 /// next page is current. A caller of `view` still waits, as before.
1143 #[test]
1144 fn a_page_is_handed_the_generation_there_is_and_the_rebuild_runs_behind_it() {
1145 let (fixture, live) = followed();
1146 let live = Arc::new(live);
1147 let before = head_in(fixture.root());
1148 let (view, behind) = live.view_without_waiting();
1149 assert!(!behind, "nothing has moved");
1150 assert_eq!(head_of(&view.ctx), before);
1151
1152 // a rebuild already running: the page is not held by it
1153 let rebuilding = live.rebuilding.lock().expect("the rebuild");
1154 Command::new("git")
1155 .arg("-C")
1156 .arg(fixture.root())
1157 .args(["commit", "-q", "--allow-empty", "-m", "two"])
1158 .output()
1159 .expect("git");
1160 let after = head_in(fixture.root());
1161 let started = std::time::Instant::now();
1162 let (view, behind) = live.view_without_waiting();
1163 assert!(
1164 started.elapsed() < std::time::Duration::from_secs(1),
1165 "the page waited for the rebuild: {:?}",
1166 started.elapsed()
1167 );
1168 assert!(
1169 behind,
1170 "the page is told the repository moved past what it shows"
1171 );
1172 assert_eq!(head_of(&view.ctx), before, "as it was");
1173 drop(rebuilding);
1174
1175 // no rebuild running: the page starts one behind itself, and the generation catches up
1176 let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
1177 loop {
1178 let (view, behind) = live.view_without_waiting();
1179 if !behind {
1180 assert_eq!(
1181 head_of(&view.ctx),
1182 after,
1183 "current once the rebuild is over"
1184 );
1185 break;
1186 }
1187 assert!(
1188 std::time::Instant::now() < until,
1189 "the rebuild never caught up"
1190 );
1191 std::thread::sleep(std::time::Duration::from_millis(20));
1192 }
1193 // a pinned view has nothing to follow, and is never behind
1194 let pinned = Arc::new(Live::pinned(Arc::clone(&live.view().ctx)));
1195 assert!(!pinned.view_without_waiting().1);
1196 }
1197
1198 #[test]
1199 fn a_directory_that_is_not_a_work_tree_has_nothing_to_watch() {
1200 let plain = tempfile::tempdir().expect("a temporary directory");
1201 assert!(
1202 Stamp::watching(plain.path()).is_none(),
1203 "there are no moves to follow where there is no git"
1204 );
1205 }
1206
1207 #[test]
1208 fn a_commit_moves_the_stamp_and_a_read_does_not() {
1209 let dir = repo();
1210 let watch = Stamp::watching(dir.path()).expect("a work tree");
1211 let before = watch.take();
1212 // reading the repository is not moving it
1213 let _ = std::fs::read_to_string(dir.path().join("a"));
1214 assert_eq!(before, watch.take());
1215 Command::new("git")
1216 .arg("-C")
1217 .arg(dir.path())
1218 .args(["commit", "-q", "--allow-empty", "-m", "two"])
1219 .output()
1220 .expect("git");
1221 assert_ne!(before, watch.take(), "a commit is a move");
1222 }
1223
1224 #[test]
1225 fn staging_a_file_moves_the_stamp() {
1226 let dir = repo();
1227 let watch = Stamp::watching(dir.path()).expect("a work tree");
1228 let before = watch.take();
1229 std::fs::write(dir.path().join("b"), "b").expect("a file");
1230 Command::new("git")
1231 .arg("-C")
1232 .arg(dir.path())
1233 .args(["add", "-A"])
1234 .output()
1235 .expect("git");
1236 assert_ne!(before, watch.take(), "the staging index is watched too");
1237 }
1238
1239 #[test]
1240 fn the_stamp_is_cheap_enough_to_take_on_every_request() {
1241 // The whole design rests on this: the check runs on the request's own thread,
1242 // before the request is answered, and the HTTP p50 this repository records is a
1243 // few hundred microseconds. A stamp that cost a millisecond would be the
1244 // regression the performance doctrine refuses, and the file set would have to
1245 // shrink. The bound is generous — this runs on loaded CI machines — and it still
1246 // fails long before the cost could matter.
1247 let dir = repo();
1248 let watch = Stamp::watching(dir.path()).expect("a work tree");
1249 let _warm = watch.take();
1250 let rounds = 200;
1251 let started = Instant::now();
1252 for _ in 0..rounds {
1253 let _ = watch.take();
1254 }
1255 let each = started.elapsed() / rounds;
1256 assert!(
1257 each < std::time::Duration::from_millis(2),
1258 "a stamp took {each:?}, which is too much to take on every request"
1259 );
1260 }
1261
1262 #[test]
1263 fn the_paths_are_the_worktree_files_then_the_common_ones() {
1264 let dir = repo();
1265 let watch = Stamp::watching(dir.path()).expect("a work tree");
1266 assert_eq!(
1267 watch.paths().len(),
1268 WORKTREE_FILES.len() + COMMON_FILES.len()
1269 );
1270 assert!(
1271 watch.paths().iter().all(|p| p.starts_with('/')),
1272 "absolute, so that a linked worktree's git directory outside the root is read"
1273 );
1274 }
1275
1276 #[test]
1277 fn a_memo_of_one_generation_builds_once() {
1278 let memo: Memo<u64> = Memo::default();
1279 let mut built = 0u64;
1280 for _ in 0..5 {
1281 memo.get_or_init(7, || {
1282 built += 1;
1283 7
1284 });
1285 }
1286 assert_eq!(built, 1);
1287 }
1288
1289 #[test]
1290 fn a_reload_names_the_links_it_ended_and_is_silent_when_it_ended_none() {
1291 assert!(!warn_of_ended_links(&[]));
1292 assert!(warn_of_ended_links(&["node-a".to_string()]));
1293 }
1294}