Skip to main content

frame_cli/dev/
session.rs

1//! The dev session loop: watcher events → classification → coalescing →
2//! builds → candidate pushes, around ONE wait.
3//!
4//! Ruling B, structurally: when no quiet deadline is armed the loop waits
5//! in a BLOCKING `recv()` — zero-edit idle admits zero timer work. The
6//! only bounded wait in the loop is the semantic edit-quiet deadline,
7//! armed exclusively at the [`TimerChoke`] — the named debounce choke
8//! point — in response to an observed relevant event, and its expiry
9//! starts already-known dirty work through the coalescer; nothing here
10//! inspects the filesystem to discover whether work exists. The
11//! `cfg(test)` counter on the choke is R6's zero-edit idle authority.
12
13use std::path::PathBuf;
14use std::sync::mpsc::{Receiver, RecvTimeoutError};
15use std::time::{Duration, Instant};
16
17use crate::dev::build::BuildError;
18use crate::dev::debounce::{BuildGeneration, Coalescer, Directive};
19use crate::dev::watch::{Classification, WatchedSet};
20
21/// Everything that can wake the session loop. The watcher callback and
22/// the build worker send these; the loop is the only receiver.
23pub enum LoopInput {
24    /// One watcher event (or the backend's error, which classifies as
25    /// desynchronization).
26    Fs(notify::Result<notify::Event>),
27    /// A candidate build finished (successfully or not).
28    BuildCompleted {
29        /// The build generation that completed.
30        generation: BuildGeneration,
31        /// The build verdict; failures carry verbatim diagnostics.
32        result: Result<(), BuildError>,
33    },
34    /// The node child exited or its connection died (S3): the loop stops
35    /// watching and reports NODE FAILED.
36    NodeFailed {
37        /// Exit/tombstone/connection evidence, rendered.
38        detail: String,
39    },
40    /// Ctrl-C / SIGTERM: the operator asked for the ordered teardown.
41    Interrupted,
42}
43
44/// The effects the loop commands; the real implementation snapshots,
45/// builds, and pushes over S1 — tests record and script. Every method is
46/// commanded by exactly one [`Directive`] (or the halt paths).
47pub trait SessionEffects {
48    /// Launch one build of the CURRENT content at this generation; its
49    /// completion arrives as [`LoopInput::BuildCompleted`].
50    fn start_build(&mut self, generation: BuildGeneration);
51    /// A completed build was superseded before activation; drop its
52    /// candidate.
53    fn discard_stale(&mut self, generation: BuildGeneration);
54    /// Push the completed candidate through S1 and report the outcome.
55    ///
56    /// # Errors
57    ///
58    /// The node connection is gone (the S3 fate), rendered.
59    fn push_candidate(&mut self, generation: BuildGeneration) -> Result<(), String>;
60    /// A build failed: report `RELOAD FAILED — RUNNING LAST GOOD` with
61    /// the verbatim diagnostics.
62    fn report_build_failure(&mut self, generation: BuildGeneration, error: &BuildError);
63    /// The watcher lost sync: re-establish the subscription after the
64    /// forced snapshot rebuild the coalescer already scheduled.
65    ///
66    /// # Errors
67    ///
68    /// Re-subscription failed: watching halts loudly, node on last good.
69    fn resubscribe(&mut self) -> Result<(), String>;
70}
71
72/// Why the session ended. Every variant is terminal and loud.
73#[derive(Debug, PartialEq, Eq)]
74pub enum SessionExit {
75    /// The node failed (child exit / connection death): typed report,
76    /// watcher stopped, nonzero exit — never auto-restart.
77    NodeFailed {
78        /// Exit/tombstone/connection evidence.
79        detail: String,
80    },
81    /// A watched root disappeared (deleted or moved out): watching cannot
82    /// continue; the path and recovery are named.
83    WatchRootGone {
84        /// The path that disappeared.
85        path: PathBuf,
86    },
87    /// Watcher re-subscription after a desynchronization failed: watching
88    /// halts loudly while the node serves last good.
89    ResubscribeFailed {
90        /// The typed failure, rendered.
91        detail: String,
92    },
93    /// Ctrl-C / SIGTERM: ordered teardown, exit zero.
94    Interrupted,
95    /// Every input sender is gone (teardown).
96    InputsClosed,
97}
98
99/// THE timer-admission choke point: the only place the loop converts an
100/// [`Directive::ArmQuietDeadline`] into a real bounded wait. The counter
101/// is the R6 zero-edit-idle authority: established RUNNING CURRENT, held
102/// idle, it reads zero.
103#[derive(Default)]
104pub struct TimerChoke {
105    armed_until: Option<Instant>,
106    /// How many times a deadline was ever armed (test observability).
107    #[cfg(test)]
108    pub armed_count: usize,
109}
110
111impl TimerChoke {
112    fn arm(&mut self, deadline: Instant) {
113        self.armed_until = Some(deadline);
114        #[cfg(test)]
115        {
116            self.armed_count += 1;
117        }
118    }
119
120    fn disarm(&mut self) {
121        self.armed_until = None;
122    }
123}
124
125/// One dev session's loop state.
126pub struct Session<E: SessionEffects> {
127    set: WatchedSet,
128    coalescer: Coalescer,
129    choke: TimerChoke,
130    effects: E,
131}
132
133impl<E: SessionEffects> Session<E> {
134    /// A session over the given watched set and quiet window.
135    pub fn new(set: WatchedSet, quiet: Duration, effects: E) -> Self {
136        Self {
137            set,
138            coalescer: Coalescer::new(quiet),
139            choke: TimerChoke::default(),
140            effects,
141        }
142    }
143
144    /// The effects, for post-run inspection in tests.
145    pub fn effects(&self) -> &E {
146        &self.effects
147    }
148
149    /// The choke, for the zero-edit-idle witness.
150    #[cfg(test)]
151    pub fn choke(&self) -> &TimerChoke {
152        &self.choke
153    }
154
155    /// Runs the loop until a terminal exit. See the module doc for the
156    /// wait discipline.
157    pub fn run(&mut self, inputs: &Receiver<LoopInput>) -> SessionExit {
158        loop {
159            let input = match self.choke.armed_until {
160                // Idle or building with nothing armed: BLOCK. No timer
161                // exists here.
162                None => match inputs.recv() {
163                    Ok(input) => input,
164                    Err(_) => return SessionExit::InputsClosed,
165                },
166                // THE semantic quiet deadline, and nothing else.
167                Some(deadline) => {
168                    let now = Instant::now();
169                    if now >= deadline {
170                        self.choke.disarm();
171                        let directive = self.coalescer.quiet_deadline_elapsed(now);
172                        self.execute(directive);
173                        continue;
174                    }
175                    match inputs.recv_timeout(deadline - now) {
176                        Ok(input) => input,
177                        Err(RecvTimeoutError::Timeout) => {
178                            self.choke.disarm();
179                            let directive = self.coalescer.quiet_deadline_elapsed(Instant::now());
180                            self.execute(directive);
181                            continue;
182                        }
183                        Err(RecvTimeoutError::Disconnected) => return SessionExit::InputsClosed,
184                    }
185                }
186            };
187            if let Some(exit) = self.handle(input) {
188                return exit;
189            }
190        }
191    }
192
193    fn handle(&mut self, input: LoopInput) -> Option<SessionExit> {
194        match input {
195            LoopInput::Fs(Ok(event)) => self.handle_fs(&event),
196            // A backend ERROR is loss: the backend no longer promises
197            // completeness — same recovery as the rescan flag.
198            LoopInput::Fs(Err(_error)) => self.handle_desync(),
199            LoopInput::BuildCompleted { generation, result } => {
200                if let Err(error) = &result {
201                    self.effects.report_build_failure(generation, error);
202                }
203                let succeeded = result.is_ok();
204                let [first, second] = self.coalescer.build_completed(generation, Instant::now());
205                for directive in [first, second] {
206                    // A failed build's candidate must never be pushed:
207                    // Promote becomes the failure report already made.
208                    if matches!(directive, Directive::PromoteCandidate(_)) && !succeeded {
209                        continue;
210                    }
211                    if let Some(exit) = self.execute_terminal(directive) {
212                        return Some(exit);
213                    }
214                }
215                None
216            }
217            LoopInput::NodeFailed { detail } => Some(SessionExit::NodeFailed { detail }),
218            LoopInput::Interrupted => Some(SessionExit::Interrupted),
219        }
220    }
221
222    fn handle_fs(&mut self, event: &notify::Event) -> Option<SessionExit> {
223        match self.set.classify(event) {
224            Classification::Irrelevant => None,
225            Classification::Dirtying => {
226                let directive = self.coalescer.relevant_event(Instant::now());
227                self.execute(directive);
228                None
229            }
230            Classification::RootAffected(path) => {
231                // The ONE event-driven existence check: an atomic editor
232                // replace leaves the path present (dirtying); a true
233                // disappearance is the typed halted state.
234                if path.exists() {
235                    let directive = self.coalescer.relevant_event(Instant::now());
236                    self.execute(directive);
237                    None
238                } else {
239                    Some(SessionExit::WatchRootGone { path })
240                }
241            }
242            Classification::Desynchronized => self.handle_desync(),
243        }
244    }
245
246    fn handle_desync(&mut self) -> Option<SessionExit> {
247        let directive = self.coalescer.watcher_desynchronized(Instant::now());
248        self.execute(directive);
249        match self.effects.resubscribe() {
250            Ok(()) => None,
251            Err(detail) => Some(SessionExit::ResubscribeFailed { detail }),
252        }
253    }
254
255    fn execute(&mut self, directive: Directive) {
256        let _ = self.execute_terminal(directive);
257    }
258
259    fn execute_terminal(&mut self, directive: Directive) -> Option<SessionExit> {
260        match directive {
261            Directive::None => None,
262            Directive::ArmQuietDeadline(deadline) => {
263                self.choke.arm(deadline);
264                None
265            }
266            Directive::StartBuild(generation) => {
267                self.effects.start_build(generation);
268                None
269            }
270            Directive::DiscardStale(generation) => {
271                self.effects.discard_stale(generation);
272                None
273            }
274            Directive::PromoteCandidate(generation) => {
275                match self.effects.push_candidate(generation) {
276                    Ok(()) => None,
277                    Err(detail) => Some(SessionExit::NodeFailed { detail }),
278                }
279            }
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    #![allow(clippy::expect_used)]
287
288    use super::{LoopInput, Session, SessionEffects, SessionExit};
289    use crate::dev::build::BuildError;
290    use crate::dev::debounce::BuildGeneration;
291    use crate::dev::watch::WatchedSet;
292    use notify::EventKind;
293    use notify::event::{CreateKind, Event};
294    use std::path::{Path, PathBuf};
295    use std::sync::mpsc;
296    use std::time::Duration;
297
298    const QUIET: Duration = Duration::from_millis(20);
299
300    #[derive(Default)]
301    struct Recording {
302        started: Vec<u64>,
303        discarded: Vec<u64>,
304        pushed: Vec<u64>,
305        failures: Vec<u64>,
306        resubscribes: usize,
307    }
308
309    impl SessionEffects for Recording {
310        fn start_build(&mut self, generation: BuildGeneration) {
311            self.started.push(generation.0);
312        }
313        fn discard_stale(&mut self, generation: BuildGeneration) {
314            self.discarded.push(generation.0);
315        }
316        fn push_candidate(&mut self, generation: BuildGeneration) -> Result<(), String> {
317            self.pushed.push(generation.0);
318            Ok(())
319        }
320        fn report_build_failure(&mut self, generation: BuildGeneration, _error: &BuildError) {
321            self.failures.push(generation.0);
322        }
323        fn resubscribe(&mut self) -> Result<(), String> {
324            self.resubscribes += 1;
325            Ok(())
326        }
327    }
328
329    fn session() -> Session<Recording> {
330        Session::new(
331            WatchedSet::of_project(Path::new("/proj")),
332            QUIET,
333            Recording::default(),
334        )
335    }
336
337    fn dirtying_event() -> LoopInput {
338        LoopInput::Fs(Ok(Event::new(EventKind::Create(CreateKind::File))
339            .add_path(PathBuf::from("/proj/component/src/app.gleam"))))
340    }
341
342    fn build_done(generation: u64) -> LoopInput {
343        LoopInput::BuildCompleted {
344            generation: BuildGeneration(generation),
345            result: Ok(()),
346        }
347    }
348
349    /// One edit: the deadline arms once, one build starts on its expiry,
350    /// the clean completion is pushed. End to end through the real wait.
351    #[test]
352    fn one_edit_one_build_one_push() {
353        let (sender, receiver) = mpsc::channel();
354        let mut session = session();
355        sender.send(dirtying_event()).expect("send");
356        std::thread::spawn(move || {
357            // Give the quiet deadline room to expire, then complete the
358            // build the expiry started, then hang up (loop exits).
359            std::thread::sleep(Duration::from_millis(60));
360            let _ = sender.send(build_done(1));
361        });
362        let exit = session.run(&receiver);
363        assert_eq!(exit, SessionExit::InputsClosed);
364        assert_eq!(session.effects().started, vec![1]);
365        assert_eq!(session.effects().pushed, vec![1]);
366        assert_eq!(session.choke().armed_count, 1, "one edit, one arm");
367    }
368
369    /// Zero-edit idle: inputs close without any event — the choke was
370    /// never armed. (The loop sat in a BLOCKING recv; the counter is the
371    /// authority that no timer work was admitted.)
372    #[test]
373    fn zero_edit_idle_arms_zero_timers() {
374        let (sender, receiver) = mpsc::channel();
375        let mut session = session();
376        drop(sender);
377        let exit = session.run(&receiver);
378        assert_eq!(exit, SessionExit::InputsClosed);
379        assert_eq!(
380            session.choke().armed_count,
381            0,
382            "idle admits ZERO timer work"
383        );
384        assert!(session.effects().started.is_empty());
385    }
386
387    /// A failed build reports diagnostics and is NEVER pushed; the next
388    /// clean generation follows the ordinary path (R4's recovery shape).
389    #[test]
390    fn failed_build_reports_and_never_pushes() {
391        let (sender, receiver) = mpsc::channel();
392        let mut session = session();
393        sender.send(dirtying_event()).expect("send");
394        std::thread::spawn(move || {
395            std::thread::sleep(Duration::from_millis(60));
396            let _ = sender.send(LoopInput::BuildCompleted {
397                generation: BuildGeneration(1),
398                result: Err(BuildError::MissingBeam {
399                    path: PathBuf::from("/x"),
400                }),
401            });
402        });
403        let exit = session.run(&receiver);
404        assert_eq!(exit, SessionExit::InputsClosed);
405        assert_eq!(session.effects().failures, vec![1]);
406        assert!(session.effects().pushed.is_empty(), "failures never push");
407    }
408
409    /// A deleted watch root is the typed halted exit naming the path; an
410    /// atomic replace (path still present) is ordinary dirtying. Uses a
411    /// real scratch path for the existence check.
412    #[test]
413    fn missing_root_halts_and_present_root_dirties() {
414        let (sender, receiver) = mpsc::channel();
415        let mut session = session();
416        let gone = PathBuf::from("/proj/component/src");
417        sender
418            .send(LoopInput::Fs(Ok(Event::new(EventKind::Remove(
419                notify::event::RemoveKind::Folder,
420            ))
421            .add_path(gone.clone()))))
422            .expect("send");
423        let exit = session.run(&receiver);
424        assert_eq!(exit, SessionExit::WatchRootGone { path: gone });
425    }
426
427    /// Node failure is terminal, typed, and never restarts anything.
428    #[test]
429    fn node_failure_is_terminal() {
430        let (sender, receiver) = mpsc::channel();
431        let mut session = session();
432        sender
433            .send(LoopInput::NodeFailed {
434                detail: "child exited: signal 9".to_owned(),
435            })
436            .expect("send");
437        let exit = session.run(&receiver);
438        assert_eq!(
439            exit,
440            SessionExit::NodeFailed {
441                detail: "child exited: signal 9".to_owned()
442            }
443        );
444        assert!(session.effects().started.is_empty());
445    }
446
447    /// Watcher loss forces the immediate snapshot rebuild and one
448    /// re-subscription; a failing re-subscription halts loudly.
449    #[test]
450    fn desync_rebuilds_and_resubscribes() {
451        let (sender, receiver) = mpsc::channel();
452        let mut session = session();
453        sender
454            .send(LoopInput::Fs(Ok(
455                Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan)
456            )))
457            .expect("send");
458        drop(sender);
459        let exit = session.run(&receiver);
460        assert_eq!(exit, SessionExit::InputsClosed);
461        assert_eq!(session.effects().started, vec![1], "forced snapshot build");
462        assert_eq!(session.effects().resubscribes, 1);
463    }
464}