Skip to main content

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}