1use 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
21pub enum LoopInput {
24 Fs(notify::Result<notify::Event>),
27 BuildCompleted {
29 generation: BuildGeneration,
31 result: Result<(), BuildError>,
33 },
34 NodeFailed {
37 detail: String,
39 },
40 Interrupted,
42}
43
44pub trait SessionEffects {
48 fn start_build(&mut self, generation: BuildGeneration);
51 fn discard_stale(&mut self, generation: BuildGeneration);
54 fn push_candidate(&mut self, generation: BuildGeneration) -> Result<(), String>;
60 fn report_build_failure(&mut self, generation: BuildGeneration, error: &BuildError);
63 fn resubscribe(&mut self) -> Result<(), String>;
70}
71
72#[derive(Debug, PartialEq, Eq)]
74pub enum SessionExit {
75 NodeFailed {
78 detail: String,
80 },
81 WatchRootGone {
84 path: PathBuf,
86 },
87 ResubscribeFailed {
90 detail: String,
92 },
93 Interrupted,
95 InputsClosed,
97}
98
99#[derive(Default)]
104pub struct TimerChoke {
105 armed_until: Option<Instant>,
106 #[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
125pub struct Session<E: SessionEffects> {
127 set: WatchedSet,
128 coalescer: Coalescer,
129 choke: TimerChoke,
130 effects: E,
131}
132
133impl<E: SessionEffects> Session<E> {
134 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 pub fn effects(&self) -> &E {
146 &self.effects
147 }
148
149 #[cfg(test)]
151 pub fn choke(&self) -> &TimerChoke {
152 &self.choke
153 }
154
155 pub fn run(&mut self, inputs: &Receiver<LoopInput>) -> SessionExit {
158 loop {
159 let input = match self.choke.armed_until {
160 None => match inputs.recv() {
163 Ok(input) => input,
164 Err(_) => return SessionExit::InputsClosed,
165 },
166 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 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 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: ¬ify::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 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 #[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 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 #[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 #[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 #[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 #[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 #[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}