Skip to main content

balise_cli/dev/
mod.rs

1//! The dev supervisor, the same on the machine (`--native`) and in the container
2//! (`--inside`): it watches the files, rebuilds, restarts the app, and writes one stream of
3//! logs. While a build runs, or fails, the last version that built keeps answering.
4
5mod app;
6mod build;
7pub mod docker;
8mod signal;
9mod watch;
10
11use std::io::Write;
12use std::path::Path;
13use std::time::{Duration, Instant};
14
15use balise_core::error_codes::{DEV0001, DEV0002};
16use balise_core::event::{Event, Level};
17
18use crate::{Failure, project};
19use app::App;
20use build::Built;
21use watch::Watcher;
22
23pub use signal::install as catch_signals;
24
25/// How often the files are read.
26const POLL: Duration = Duration::from_millis(300);
27
28/// A change is complete once the files stop changing for this long: an editor saving several
29/// files is one change.
30const SETTLE: Duration = Duration::from_millis(150);
31
32/// An app that stops sooner than this after starting would stop again: `balise dev` waits
33/// for a change instead of starting it again.
34const STARTUP: Duration = Duration::from_secs(2);
35
36/// How much longer than `shutdown.timeout` the app is given to stop before it is killed.
37pub const GRACE_MARGIN: Duration = Duration::from_secs(2);
38
39/// Runs the supervisor of the project at `root` until `stop` says so.
40pub fn supervise(
41    root: &Path,
42    out: &mut dyn Write,
43    color: bool,
44    stop: &dyn Fn() -> bool,
45) -> Result<(), Failure> {
46    let name = project::package_name(root).unwrap_or_else(|| "app".to_string());
47    let target = project::target_dir(root);
48    let binary = target
49        .join("debug")
50        .join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
51    let runs = target.join("balise-dev");
52    // The app gets the time its own shutdown takes, and a little more. The app inherits
53    // this process's variables: the configuration is read with them.
54    let variables: Vec<(String, String)> = std::env::vars().collect();
55    let config = project::config(root, &variables);
56    let grace = config.shutdown_timeout + GRACE_MARGIN;
57    // In a container, the port is published on the machine as it is.
58    let ready = format!(
59        "Ready: http://localhost:{}{}",
60        config.port,
61        config.api_base()
62    );
63    balise_core::journal::open(root);
64    let say = |out: &mut dyn Write, event: Event| {
65        let _ = writeln!(out, "{}", event.render_text(color));
66        let _ = out.flush();
67        balise_core::journal::event(&event);
68    };
69
70    let mut watcher = Watcher::new(root);
71    let mut app: Option<App> = None;
72    let mut generation = 0;
73    // Whether the app started and does not listen yet.
74    let mut starting = false;
75    let mut rebuild = Some(format!("Building {name}"));
76    loop {
77        if stop() {
78            if let Some(app) = app.take() {
79                app.stop(grace);
80            }
81            return Ok(());
82        }
83        if let Some(reason) = rebuild.take() {
84            say(out, Event::new(Level::Info, reason));
85            match build::build(root)? {
86                Built::Ok { seconds, warnings } => {
87                    let warnings = warnings.map(|w| format!(", {w}")).unwrap_or_default();
88                    let restart = if app.is_some() {
89                        "; restarting the app"
90                    } else {
91                        ""
92                    };
93                    say(
94                        out,
95                        Event::new(
96                            Level::Info,
97                            format!("Built in {seconds:.1} s{warnings}{restart}"),
98                        ),
99                    );
100                    if let Some(old) = app.take() {
101                        old.stop(grace);
102                    }
103                    generation += 1;
104                    app = Some(start(&binary, root, &runs, generation)?);
105                    starting = true;
106                }
107                Built::Failed { errors, lines } => {
108                    for line in lines {
109                        let _ = writeln!(out, "  {line}");
110                    }
111                    say(
112                        out,
113                        Event::coded(Level::Warn, DEV0002, &[("errors", &errors)]),
114                    );
115                }
116            }
117        }
118        std::thread::sleep(POLL);
119        if starting && app.is_some() && listens(config.port) {
120            starting = false;
121            say(out, Event::new(Level::Info, ready.as_str()));
122        }
123
124        let mut changes = watcher.changes();
125        if !changes.is_empty() {
126            let since = Instant::now();
127            while since.elapsed() < Duration::from_secs(2) {
128                std::thread::sleep(SETTLE);
129                let more = watcher.changes();
130                if more.is_empty() {
131                    break;
132                }
133                changes.extend(more);
134            }
135            changes.sort();
136            changes.dedup();
137            let what = match changes.as_slice() {
138                [one] => format!("{} changed", one.display()),
139                many => format!("{} files changed", many.len()),
140            };
141            rebuild = Some(format!("Rebuilding: {what}"));
142        }
143
144        if let Some(running) = app.as_mut()
145            && let Some(status) = running.exited()
146        {
147            let uptime = running.uptime();
148            app = None;
149            say(
150                out,
151                Event::coded(Level::Error, DEV0001, &[("status", &app::describe(status))]),
152            );
153            if uptime > STARTUP && rebuild.is_none() {
154                say(out, Event::new(Level::Info, "Starting the app again"));
155                generation += 1;
156                app = Some(start(&binary, root, &runs, generation)?);
157                starting = true;
158            }
159        }
160    }
161}
162
163/// Whether the app accepts connections: a connection alone, which the app does not log.
164fn listens(port: u16) -> bool {
165    let address = (std::net::Ipv4Addr::LOCALHOST, port).into();
166    std::net::TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok()
167}
168
169fn start(binary: &Path, root: &Path, runs: &Path, generation: u64) -> Result<App, Failure> {
170    App::start(binary, root, runs, generation).map_err(|error| {
171        Failure::problem(
172            balise_core::error_codes::DEV0003,
173            &[(
174                "cause",
175                &format!("cannot start {}: {error}", binary.display()),
176            )],
177            &[],
178        )
179    })
180}
181
182/// Whether Ctrl-C or `SIGTERM` asked the supervisor to stop.
183pub fn stop_requested() -> bool {
184    signal::requested()
185}