frame_cli/dev/debounce.rs
1//! The R2 burst-coalescing state machine: relevant filesystem events open
2//! an edit burst; one resettable semantic quiet deadline defines the
3//! burst; expiry starts exactly one build of the latest content snapshot;
4//! edits during a build make the completing candidate stale (last write
5//! wins) and schedule exactly one follow-up build.
6//!
7//! The machine is PURE: it owns no thread, no channel, and no timer. Time
8//! enters only as `now` arguments (the injected clock R6's determinism
9//! observations require), and every effect leaves as a [`Directive`] the
10//! caller executes. The ONE place a real timer may be armed is the
11//! caller's handling of [`Directive::ArmQuietDeadline`] — the named
12//! debounce choke point the Ruling B tripwire admits — and expiry starts
13//! already-known dirty work; it never inspects the filesystem to discover
14//! whether work exists.
15
16use std::time::{Duration, Instant};
17
18/// What the caller must do after feeding the machine one input.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Directive {
21 /// Arm (or re-arm) THE quiet deadline to fire at this instant. This is
22 /// the single timer-admission choke point of the whole dev loop.
23 ArmQuietDeadline(Instant),
24 /// Start one build of the latest content snapshot, labeled with this
25 /// build generation.
26 StartBuild(BuildGeneration),
27 /// The build that just completed is stale (newer edits exist); discard
28 /// its candidate without activation.
29 DiscardStale(BuildGeneration),
30 /// The build that just completed is the latest content; hand its
31 /// candidate to activation.
32 PromoteCandidate(BuildGeneration),
33 /// Nothing to do.
34 None,
35}
36
37/// Monotonic label for one build of one content snapshot. Stale build
38/// completions are recognized by generation, never by timing.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub struct BuildGeneration(pub u64);
41
42/// Where the loop is between builds and bursts.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum State {
45 /// No dirty work, no build in flight. The idle state arms nothing —
46 /// zero-edit idle admits zero timer work.
47 Idle,
48 /// An edit burst is open; THE quiet deadline is armed for `deadline`.
49 Burst { deadline: Instant },
50 /// A build is in flight and no edit has arrived since it started.
51 Building { generation: BuildGeneration },
52 /// A build is in flight and edits arrived after its snapshot was
53 /// taken: the burst deadline coalesces the follow-up. `ripe` records
54 /// that the follow-up's quiet deadline already expired while the
55 /// build still ran — the follow-up starts at completion, not before
56 /// (one build at a time).
57 BuildingDirty {
58 generation: BuildGeneration,
59 deadline: Instant,
60 ripe: bool,
61 },
62}
63
64/// The coalescing machine. See the module doc for the laws it encodes.
65#[derive(Debug)]
66pub struct Coalescer {
67 quiet: Duration,
68 state: State,
69 next_generation: u64,
70}
71
72impl Coalescer {
73 /// A machine with the given semantic quiet window (the named CLI
74 /// parameter; 100 ms is the declared, visible default at the CLI).
75 #[must_use]
76 pub fn new(quiet: Duration) -> Self {
77 Self {
78 quiet,
79 state: State::Idle,
80 next_generation: 1,
81 }
82 }
83
84 /// One relevant filesystem event at `now`: opens a burst or resets THE
85 /// deadline of the open one; during a build it marks the newer
86 /// generation dirty.
87 pub fn relevant_event(&mut self, now: Instant) -> Directive {
88 let deadline = now + self.quiet;
89 match self.state {
90 State::Idle | State::Burst { .. } => {
91 self.state = State::Burst { deadline };
92 Directive::ArmQuietDeadline(deadline)
93 }
94 State::Building { generation } | State::BuildingDirty { generation, .. } => {
95 self.state = State::BuildingDirty {
96 generation,
97 deadline,
98 ripe: false,
99 };
100 Directive::ArmQuietDeadline(deadline)
101 }
102 }
103 }
104
105 /// THE quiet deadline fired at `now`. A stale firing (the deadline was
106 /// re-armed after this firing was scheduled) is recognized by time and
107 /// ignored; a ripe firing starts the build the burst already made
108 /// dirty — expiry never discovers work, it starts known work.
109 pub fn quiet_deadline_elapsed(&mut self, now: Instant) -> Directive {
110 match self.state {
111 State::Idle | State::Building { .. } => Directive::None,
112 State::Burst { deadline } => {
113 if now < deadline {
114 // A firing from a deadline that has since been pushed
115 // out by a newer event: benign, the re-armed deadline
116 // is still pending.
117 return Directive::None;
118 }
119 let generation = self.mint_generation();
120 self.state = State::Building { generation };
121 Directive::StartBuild(generation)
122 }
123 State::BuildingDirty {
124 generation,
125 deadline,
126 ripe,
127 } => {
128 if now < deadline {
129 return Directive::None;
130 }
131 // The follow-up burst went quiet while the build still
132 // runs: record ripeness; the follow-up starts at
133 // completion (one build at a time).
134 let _ = ripe;
135 self.state = State::BuildingDirty {
136 generation,
137 deadline,
138 ripe: true,
139 };
140 Directive::None
141 }
142 }
143 }
144
145 /// The in-flight build for `generation` completed (successfully or
146 /// not — candidate handling is the caller's; this machine only decides
147 /// staleness and follow-up scheduling).
148 pub fn build_completed(&mut self, completed: BuildGeneration, now: Instant) -> [Directive; 2] {
149 match self.state {
150 State::Building { generation } if generation == completed => {
151 self.state = State::Idle;
152 [Directive::PromoteCandidate(completed), Directive::None]
153 }
154 State::BuildingDirty {
155 generation,
156 deadline,
157 ripe,
158 } if generation == completed => {
159 // Last write wins: edits arrived after this build's
160 // snapshot, so its candidate is stale regardless of its
161 // own verdict.
162 if ripe || now >= deadline {
163 let follow_up = self.mint_generation();
164 self.state = State::Building {
165 generation: follow_up,
166 };
167 [
168 Directive::DiscardStale(completed),
169 Directive::StartBuild(follow_up),
170 ]
171 } else {
172 self.state = State::Burst { deadline };
173 // The deadline for the follow-up burst is already
174 // armed (events arm it as they arrive); nothing to
175 // re-arm here.
176 [Directive::DiscardStale(completed), Directive::None]
177 }
178 }
179 // A completion for a generation this machine no longer tracks
180 // (never minted here, or superseded by a state the caller
181 // drove differently) cannot activate anything.
182 State::Idle
183 | State::Burst { .. }
184 | State::Building { .. }
185 | State::BuildingDirty { .. } => [Directive::DiscardStale(completed), Directive::None],
186 }
187 }
188
189 /// The watcher reported loss (overflow/desynchronization): the backend
190 /// no longer promises completeness, so the caller takes ONE full
191 /// content snapshot and rebuilds it — immediately, not debounced; the
192 /// loss already coalesced everything it swallowed. During a build the
193 /// snapshot rebuild becomes the (ripe) follow-up.
194 pub fn watcher_desynchronized(&mut self, now: Instant) -> Directive {
195 match self.state {
196 State::Idle | State::Burst { .. } => {
197 let generation = self.mint_generation();
198 self.state = State::Building { generation };
199 Directive::StartBuild(generation)
200 }
201 State::Building { generation } | State::BuildingDirty { generation, .. } => {
202 self.state = State::BuildingDirty {
203 generation,
204 deadline: now,
205 ripe: true,
206 };
207 Directive::None
208 }
209 }
210 }
211
212 fn mint_generation(&mut self) -> BuildGeneration {
213 let generation = BuildGeneration(self.next_generation);
214 self.next_generation += 1;
215 generation
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::{BuildGeneration, Coalescer, Directive};
222 use std::time::{Duration, Instant};
223
224 const QUIET: Duration = Duration::from_millis(100);
225
226 fn machine() -> (Coalescer, Instant) {
227 (Coalescer::new(QUIET), Instant::now())
228 }
229
230 /// One event → one armed deadline → one build. The baseline law.
231 #[test]
232 fn one_event_one_deadline_one_build() {
233 let (mut m, t0) = machine();
234 assert_eq!(
235 m.relevant_event(t0),
236 Directive::ArmQuietDeadline(t0 + QUIET)
237 );
238 assert_eq!(
239 m.quiet_deadline_elapsed(t0 + QUIET),
240 Directive::StartBuild(BuildGeneration(1))
241 );
242 assert_eq!(
243 m.build_completed(BuildGeneration(1), t0 + QUIET * 2),
244 [
245 Directive::PromoteCandidate(BuildGeneration(1)),
246 Directive::None
247 ]
248 );
249 }
250
251 /// A rapid burst re-arms THE one deadline; a stale firing from a
252 /// superseded deadline is ignored by time; exactly one build follows.
253 #[test]
254 fn rapid_burst_coalesces_to_one_build() {
255 let (mut m, t0) = machine();
256 for i in 0..10 {
257 let at = t0 + Duration::from_millis(i * 10);
258 assert_eq!(
259 m.relevant_event(at),
260 Directive::ArmQuietDeadline(at + QUIET),
261 "every event re-arms the same single deadline"
262 );
263 }
264 let last_event = t0 + Duration::from_millis(90);
265 // The firing scheduled by the FIRST event is stale by the time it
266 // would run: the deadline moved.
267 assert_eq!(m.quiet_deadline_elapsed(t0 + QUIET), Directive::None);
268 // The true quiet point starts exactly one build.
269 assert_eq!(
270 m.quiet_deadline_elapsed(last_event + QUIET),
271 Directive::StartBuild(BuildGeneration(1))
272 );
273 // And nothing further is armed or started.
274 assert_eq!(
275 m.build_completed(BuildGeneration(1), last_event + QUIET * 2),
276 [
277 Directive::PromoteCandidate(BuildGeneration(1)),
278 Directive::None
279 ]
280 );
281 }
282
283 /// Edits during a build: the completing candidate is stale even
284 /// though its build succeeded, and exactly one follow-up build of the
285 /// final content starts at completion (the follow-up's own quiet
286 /// deadline having expired mid-build).
287 #[test]
288 fn edit_during_build_discards_stale_and_rebuilds_once() {
289 let (mut m, t0) = machine();
290 m.relevant_event(t0);
291 assert_eq!(
292 m.quiet_deadline_elapsed(t0 + QUIET),
293 Directive::StartBuild(BuildGeneration(1))
294 );
295 // Edit lands while generation 1 builds.
296 let edit = t0 + QUIET + Duration::from_millis(20);
297 assert_eq!(
298 m.relevant_event(edit),
299 Directive::ArmQuietDeadline(edit + QUIET)
300 );
301 // Its quiet deadline expires while the build still runs: no
302 // second concurrent build starts.
303 assert_eq!(m.quiet_deadline_elapsed(edit + QUIET), Directive::None);
304 // Completion of the stale build discards it and starts exactly
305 // one follow-up.
306 assert_eq!(
307 m.build_completed(BuildGeneration(1), edit + QUIET * 2),
308 [
309 Directive::DiscardStale(BuildGeneration(1)),
310 Directive::StartBuild(BuildGeneration(2))
311 ]
312 );
313 assert_eq!(
314 m.build_completed(BuildGeneration(2), edit + QUIET * 3),
315 [
316 Directive::PromoteCandidate(BuildGeneration(2)),
317 Directive::None
318 ]
319 );
320 }
321
322 /// Edit-during-build whose burst is still OPEN at completion: the
323 /// stale candidate is discarded and the follow-up waits for the
324 /// already-armed quiet deadline — no build of a still-moving tree.
325 #[test]
326 fn open_burst_at_completion_waits_for_quiet() {
327 let (mut m, t0) = machine();
328 m.relevant_event(t0);
329 m.quiet_deadline_elapsed(t0 + QUIET);
330 let edit = t0 + QUIET + Duration::from_millis(20);
331 m.relevant_event(edit);
332 // Build completes 10ms after the edit — inside the edit's quiet
333 // window.
334 let completion = edit + Duration::from_millis(10);
335 assert_eq!(
336 m.build_completed(BuildGeneration(1), completion),
337 [Directive::DiscardStale(BuildGeneration(1)), Directive::None]
338 );
339 // The burst goes quiet → exactly one follow-up build.
340 assert_eq!(
341 m.quiet_deadline_elapsed(edit + QUIET),
342 Directive::StartBuild(BuildGeneration(2))
343 );
344 }
345
346 /// Zero-edit idle admits zero timer work: no event, no directive ever
347 /// asks the caller to arm anything. (The live counter witness rides
348 /// the choke point in the loop; this is the machine's half.)
349 #[test]
350 fn idle_arms_nothing() {
351 let (mut m, t0) = machine();
352 assert_eq!(m.quiet_deadline_elapsed(t0 + QUIET), Directive::None);
353 assert_eq!(m.quiet_deadline_elapsed(t0 + QUIET * 100), Directive::None);
354 }
355
356 /// Determinism (R8/constraint 8): the same event sequence and build
357 /// outcomes produce the same generations and directives, run twice.
358 #[test]
359 fn same_sequence_same_generations() {
360 let script = |m: &mut Coalescer, t0: Instant| {
361 let mut log = vec![
362 m.relevant_event(t0),
363 m.quiet_deadline_elapsed(t0 + QUIET),
364 m.relevant_event(t0 + QUIET + Duration::from_millis(5)),
365 m.quiet_deadline_elapsed(t0 + QUIET * 2 + Duration::from_millis(5)),
366 ];
367 for d in m.build_completed(BuildGeneration(1), t0 + QUIET * 3) {
368 log.push(d);
369 }
370 for d in m.build_completed(BuildGeneration(2), t0 + QUIET * 4) {
371 log.push(d);
372 }
373 log
374 };
375 let t0 = Instant::now();
376 let (mut a, mut b) = (Coalescer::new(QUIET), Coalescer::new(QUIET));
377 assert_eq!(script(&mut a, t0), script(&mut b, t0));
378 }
379
380 /// Watcher loss forces one immediate full-snapshot rebuild — the loss
381 /// already coalesced whatever it swallowed; waiting on a quiet
382 /// deadline would wait on events the backend just admitted losing.
383 #[test]
384 fn desync_rebuilds_immediately_when_not_building() {
385 let (mut m, t0) = machine();
386 assert_eq!(
387 m.watcher_desynchronized(t0),
388 Directive::StartBuild(BuildGeneration(1))
389 );
390 }
391
392 /// Watcher loss during a build marks the follow-up ripe: the running
393 /// build's candidate is stale (its snapshot predates the loss) and
394 /// the snapshot rebuild starts at completion.
395 #[test]
396 fn desync_during_build_rebuilds_at_completion() {
397 let (mut m, t0) = machine();
398 m.relevant_event(t0);
399 m.quiet_deadline_elapsed(t0 + QUIET);
400 assert_eq!(
401 m.watcher_desynchronized(t0 + QUIET + Duration::from_millis(1)),
402 Directive::None
403 );
404 assert_eq!(
405 m.build_completed(BuildGeneration(1), t0 + QUIET * 2),
406 [
407 Directive::DiscardStale(BuildGeneration(1)),
408 Directive::StartBuild(BuildGeneration(2))
409 ]
410 );
411 }
412
413 /// A completion for a generation the machine is not tracking never
414 /// promotes: stale completions are recognized by generation.
415 #[test]
416 fn unknown_generation_completion_never_promotes() {
417 let (mut m, t0) = machine();
418 assert_eq!(
419 m.build_completed(BuildGeneration(7), t0),
420 [Directive::DiscardStale(BuildGeneration(7)), Directive::None]
421 );
422 }
423}