use std::path::PathBuf;
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::time::{Duration, Instant};
use crate::dev::build::BuildError;
use crate::dev::debounce::{BuildGeneration, Coalescer, Directive};
use crate::dev::watch::{Classification, WatchedSet};
pub enum LoopInput {
Fs(notify::Result<notify::Event>),
BuildCompleted {
generation: BuildGeneration,
result: Result<(), BuildError>,
},
NodeFailed {
detail: String,
},
Interrupted,
}
pub trait SessionEffects {
fn start_build(&mut self, generation: BuildGeneration);
fn discard_stale(&mut self, generation: BuildGeneration);
fn push_candidate(&mut self, generation: BuildGeneration) -> Result<(), String>;
fn report_build_failure(&mut self, generation: BuildGeneration, error: &BuildError);
fn resubscribe(&mut self) -> Result<(), String>;
}
#[derive(Debug, PartialEq, Eq)]
pub enum SessionExit {
NodeFailed {
detail: String,
},
WatchRootGone {
path: PathBuf,
},
ResubscribeFailed {
detail: String,
},
Interrupted,
InputsClosed,
}
#[derive(Default)]
pub struct TimerChoke {
armed_until: Option<Instant>,
#[cfg(test)]
pub armed_count: usize,
}
impl TimerChoke {
fn arm(&mut self, deadline: Instant) {
self.armed_until = Some(deadline);
#[cfg(test)]
{
self.armed_count += 1;
}
}
fn disarm(&mut self) {
self.armed_until = None;
}
}
pub struct Session<E: SessionEffects> {
set: WatchedSet,
coalescer: Coalescer,
choke: TimerChoke,
effects: E,
}
impl<E: SessionEffects> Session<E> {
pub fn new(set: WatchedSet, quiet: Duration, effects: E) -> Self {
Self {
set,
coalescer: Coalescer::new(quiet),
choke: TimerChoke::default(),
effects,
}
}
pub fn effects(&self) -> &E {
&self.effects
}
#[cfg(test)]
pub fn choke(&self) -> &TimerChoke {
&self.choke
}
pub fn run(&mut self, inputs: &Receiver<LoopInput>) -> SessionExit {
loop {
let input = match self.choke.armed_until {
None => match inputs.recv() {
Ok(input) => input,
Err(_) => return SessionExit::InputsClosed,
},
Some(deadline) => {
let now = Instant::now();
if now >= deadline {
self.choke.disarm();
let directive = self.coalescer.quiet_deadline_elapsed(now);
self.execute(directive);
continue;
}
match inputs.recv_timeout(deadline - now) {
Ok(input) => input,
Err(RecvTimeoutError::Timeout) => {
self.choke.disarm();
let directive = self.coalescer.quiet_deadline_elapsed(Instant::now());
self.execute(directive);
continue;
}
Err(RecvTimeoutError::Disconnected) => return SessionExit::InputsClosed,
}
}
};
if let Some(exit) = self.handle(input) {
return exit;
}
}
}
fn handle(&mut self, input: LoopInput) -> Option<SessionExit> {
match input {
LoopInput::Fs(Ok(event)) => self.handle_fs(&event),
LoopInput::Fs(Err(_error)) => self.handle_desync(),
LoopInput::BuildCompleted { generation, result } => {
if let Err(error) = &result {
self.effects.report_build_failure(generation, error);
}
let succeeded = result.is_ok();
let [first, second] = self.coalescer.build_completed(generation, Instant::now());
for directive in [first, second] {
if matches!(directive, Directive::PromoteCandidate(_)) && !succeeded {
continue;
}
if let Some(exit) = self.execute_terminal(directive) {
return Some(exit);
}
}
None
}
LoopInput::NodeFailed { detail } => Some(SessionExit::NodeFailed { detail }),
LoopInput::Interrupted => Some(SessionExit::Interrupted),
}
}
fn handle_fs(&mut self, event: ¬ify::Event) -> Option<SessionExit> {
match self.set.classify(event) {
Classification::Irrelevant => None,
Classification::Dirtying => {
let directive = self.coalescer.relevant_event(Instant::now());
self.execute(directive);
None
}
Classification::RootAffected(path) => {
if path.exists() {
let directive = self.coalescer.relevant_event(Instant::now());
self.execute(directive);
None
} else {
Some(SessionExit::WatchRootGone { path })
}
}
Classification::Desynchronized => self.handle_desync(),
}
}
fn handle_desync(&mut self) -> Option<SessionExit> {
let directive = self.coalescer.watcher_desynchronized(Instant::now());
self.execute(directive);
match self.effects.resubscribe() {
Ok(()) => None,
Err(detail) => Some(SessionExit::ResubscribeFailed { detail }),
}
}
fn execute(&mut self, directive: Directive) {
let _ = self.execute_terminal(directive);
}
fn execute_terminal(&mut self, directive: Directive) -> Option<SessionExit> {
match directive {
Directive::None => None,
Directive::ArmQuietDeadline(deadline) => {
self.choke.arm(deadline);
None
}
Directive::StartBuild(generation) => {
self.effects.start_build(generation);
None
}
Directive::DiscardStale(generation) => {
self.effects.discard_stale(generation);
None
}
Directive::PromoteCandidate(generation) => {
match self.effects.push_candidate(generation) {
Ok(()) => None,
Err(detail) => Some(SessionExit::NodeFailed { detail }),
}
}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{LoopInput, Session, SessionEffects, SessionExit};
use crate::dev::build::BuildError;
use crate::dev::debounce::BuildGeneration;
use crate::dev::watch::WatchedSet;
use notify::EventKind;
use notify::event::{CreateKind, Event};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
const QUIET: Duration = Duration::from_millis(20);
#[derive(Default)]
struct Recording {
started: Vec<u64>,
discarded: Vec<u64>,
pushed: Vec<u64>,
failures: Vec<u64>,
resubscribes: usize,
}
impl SessionEffects for Recording {
fn start_build(&mut self, generation: BuildGeneration) {
self.started.push(generation.0);
}
fn discard_stale(&mut self, generation: BuildGeneration) {
self.discarded.push(generation.0);
}
fn push_candidate(&mut self, generation: BuildGeneration) -> Result<(), String> {
self.pushed.push(generation.0);
Ok(())
}
fn report_build_failure(&mut self, generation: BuildGeneration, _error: &BuildError) {
self.failures.push(generation.0);
}
fn resubscribe(&mut self) -> Result<(), String> {
self.resubscribes += 1;
Ok(())
}
}
fn session() -> Session<Recording> {
Session::new(
WatchedSet::of_project(Path::new("/proj")),
QUIET,
Recording::default(),
)
}
fn dirtying_event() -> LoopInput {
LoopInput::Fs(Ok(Event::new(EventKind::Create(CreateKind::File))
.add_path(PathBuf::from("/proj/component/src/app.gleam"))))
}
fn build_done(generation: u64) -> LoopInput {
LoopInput::BuildCompleted {
generation: BuildGeneration(generation),
result: Ok(()),
}
}
#[test]
fn one_edit_one_build_one_push() {
let (sender, receiver) = mpsc::channel();
let mut session = session();
sender.send(dirtying_event()).expect("send");
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(60));
let _ = sender.send(build_done(1));
});
let exit = session.run(&receiver);
assert_eq!(exit, SessionExit::InputsClosed);
assert_eq!(session.effects().started, vec![1]);
assert_eq!(session.effects().pushed, vec![1]);
assert_eq!(session.choke().armed_count, 1, "one edit, one arm");
}
#[test]
fn zero_edit_idle_arms_zero_timers() {
let (sender, receiver) = mpsc::channel();
let mut session = session();
drop(sender);
let exit = session.run(&receiver);
assert_eq!(exit, SessionExit::InputsClosed);
assert_eq!(
session.choke().armed_count,
0,
"idle admits ZERO timer work"
);
assert!(session.effects().started.is_empty());
}
#[test]
fn failed_build_reports_and_never_pushes() {
let (sender, receiver) = mpsc::channel();
let mut session = session();
sender.send(dirtying_event()).expect("send");
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(60));
let _ = sender.send(LoopInput::BuildCompleted {
generation: BuildGeneration(1),
result: Err(BuildError::MissingBeam {
path: PathBuf::from("/x"),
}),
});
});
let exit = session.run(&receiver);
assert_eq!(exit, SessionExit::InputsClosed);
assert_eq!(session.effects().failures, vec![1]);
assert!(session.effects().pushed.is_empty(), "failures never push");
}
#[test]
fn missing_root_halts_and_present_root_dirties() {
let (sender, receiver) = mpsc::channel();
let mut session = session();
let gone = PathBuf::from("/proj/component/src");
sender
.send(LoopInput::Fs(Ok(Event::new(EventKind::Remove(
notify::event::RemoveKind::Folder,
))
.add_path(gone.clone()))))
.expect("send");
let exit = session.run(&receiver);
assert_eq!(exit, SessionExit::WatchRootGone { path: gone });
}
#[test]
fn node_failure_is_terminal() {
let (sender, receiver) = mpsc::channel();
let mut session = session();
sender
.send(LoopInput::NodeFailed {
detail: "child exited: signal 9".to_owned(),
})
.expect("send");
let exit = session.run(&receiver);
assert_eq!(
exit,
SessionExit::NodeFailed {
detail: "child exited: signal 9".to_owned()
}
);
assert!(session.effects().started.is_empty());
}
#[test]
fn desync_rebuilds_and_resubscribes() {
let (sender, receiver) = mpsc::channel();
let mut session = session();
sender
.send(LoopInput::Fs(Ok(
Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan)
)))
.expect("send");
drop(sender);
let exit = session.run(&receiver);
assert_eq!(exit, SessionExit::InputsClosed);
assert_eq!(session.effects().started, vec![1], "forced snapshot build");
assert_eq!(session.effects().resubscribes, 1);
}
}