use std::hash::Hasher as _;
use std::io::{BufRead as _, BufReader, Read as _};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use super::child::{ChildGuard, inherited};
use super::endpoints::{self, Endpoints};
use super::service::BackendHandle;
use super::{DevError, project};
const BOOT_TIMEOUT: Duration = Duration::from_secs(60);
const STDERR_TAIL: usize = 40;
const STAGE_DIR: &str = ".arc-dev";
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct Stages {
pub(crate) check: Option<Duration>,
pub(crate) codegen_link: Option<Duration>,
pub(crate) cargo: Duration,
pub(crate) swap: Option<Duration>,
pub(crate) spawn: Option<Duration>,
pub(crate) boot: Option<Duration>,
pub(crate) unchanged: bool,
pub(crate) typegen: Option<Duration>,
pub(crate) total: Duration,
}
impl std::fmt::Display for Stages {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "cargo {:.2}s", self.cargo.as_secs_f32())?;
let inner: Vec<String> = [("check", self.check), ("codegen+link", self.codegen_link)]
.into_iter()
.filter_map(|(label, value)| {
value.map(|value| format!("{label} {:.2}s", value.as_secs_f32()))
})
.collect();
if !inner.is_empty() {
write!(formatter, " ({})", inner.join(", "))?;
}
if self.unchanged {
write!(formatter, " unchanged")?;
}
for (label, value) in [
("swap", self.swap),
("spawn", self.spawn),
("boot", self.boot),
("typegen", self.typegen),
] {
let Some(value) = value else { continue };
write!(formatter, " {label} {:.2}s", value.as_secs_f32())?;
}
write!(formatter, " total {:.2}s", self.total.as_secs_f32())
}
}
pub(crate) enum Build {
Succeeded {
executable: PathBuf,
check: Option<Duration>,
codegen_link: Option<Duration>,
},
Failed { diagnostics: String },
Cancelled,
}
#[derive(Clone, Default)]
pub(crate) struct Cancel {
inner: Arc<Mutex<CancelState>>,
}
#[derive(Default)]
struct CancelState {
requested: bool,
child: Option<std::process::Child>,
}
impl Cancel {
pub(crate) fn request(&self) {
let mut state = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
state.requested = true;
if let Some(child) = state.child.as_mut() {
drop(child.kill());
}
}
pub(crate) fn requested(&self) -> bool {
self.inner
.lock()
.unwrap_or_else(PoisonError::into_inner)
.requested
}
fn adopt(&self, child: std::process::Child) -> bool {
let mut state = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
state.child = Some(child);
if state.requested {
if let Some(child) = state.child.as_mut() {
drop(child.kill());
}
return false;
}
true
}
fn reclaim(&self) -> Option<std::process::Child> {
self.inner
.lock()
.unwrap_or_else(PoisonError::into_inner)
.child
.take()
}
}
fn run_cargo_build(root: &Path, cancel: &Cancel) -> Result<Build, DevError> {
let started = Instant::now();
let mut child = Command::new("cargo")
.args(["build", "--features", "dev", "--message-format", "json"])
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|source| DevError::Cargo { source })?;
let stdout = child.stdout.take().ok_or_else(|| DevError::Cargo {
source: std::io::Error::other("cargo produced no message stream"),
})?;
let stderr = child.stderr.take().ok_or_else(|| DevError::Cargo {
source: std::io::Error::other("cargo produced no error stream"),
})?;
let live = cancel.adopt(child);
let tail = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&tail);
let pump = std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
eprintln!("{line}");
let mut lines = sink.lock().unwrap_or_else(PoisonError::into_inner);
if lines.len() == STDERR_TAIL {
lines.remove(0);
}
lines.push(line);
}
});
let mut stream = MessageStream::default();
for line in BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
stream.absorb(&line);
}
let status = match cancel.reclaim() {
Some(mut child) => child.wait().map_err(|source| DevError::Cargo { source })?,
None => {
drop(pump.join());
return Ok(Build::Cancelled);
}
};
drop(pump.join());
if !live || cancel.requested() {
return Ok(Build::Cancelled);
}
let tail = tail
.lock()
.unwrap_or_else(PoisonError::into_inner)
.join("\n");
Ok(stream.finish(started, status, &tail))
}
#[derive(Default)]
struct MessageStream {
diagnostics: String,
executable: Option<PathBuf>,
last_metadata: Option<Instant>,
linked: Option<Instant>,
}
impl MessageStream {
fn absorb(&mut self, line: &str) {
let Ok(message) = serde_json::from_str::<serde_json::Value>(line) else {
return;
};
match message.get("reason").and_then(serde_json::Value::as_str) {
Some("compiler-message") => {
let Some(rendered) = message
.pointer("/message/rendered")
.and_then(serde_json::Value::as_str)
else {
return;
};
eprint!("{rendered}");
self.diagnostics.push_str(rendered);
}
Some("compiler-artifact") => {
let fresh = message.get("fresh").and_then(serde_json::Value::as_bool) == Some(true);
match message
.get("executable")
.and_then(serde_json::Value::as_str)
{
Some(path) => {
self.executable = Some(PathBuf::from(path));
if !fresh {
self.linked = Some(Instant::now());
}
}
None => {
if !fresh {
self.last_metadata = Some(Instant::now());
}
}
}
}
_ => {}
}
}
}
impl MessageStream {
fn finish(self, started: Instant, status: std::process::ExitStatus, stderr: &str) -> Build {
let Self {
mut diagnostics,
executable,
last_metadata,
linked,
} = self;
if !status.success() {
if diagnostics.trim().is_empty() {
diagnostics.push_str(if stderr.trim().is_empty() {
"cargo failed without saying why. Look at the terminal \
running `arc dev` for the reason."
} else {
stderr
});
}
return Build::Failed { diagnostics };
}
let Some(executable) = executable else {
return Build::Failed {
diagnostics: String::from(
"cargo succeeded but produced no executable. \
`arc dev` needs a binary target -- check that \
src/main.rs exists and that the package is not \
library-only.",
),
};
};
let check = last_metadata.map(|at| at.duration_since(started));
let codegen_link = match (last_metadata, linked) {
(Some(metadata), Some(linked)) => linked.checked_duration_since(metadata),
(None, Some(linked)) => Some(linked.duration_since(started)),
_ => None,
};
Build::Succeeded {
executable,
check,
codegen_link,
}
}
}
struct Restart {
swap: Duration,
spawn: Duration,
boot: Duration,
}
fn staged_path(executable: &Path, generation: u32) -> PathBuf {
let directory = executable
.parent()
.unwrap_or_else(|| Path::new("."))
.join(STAGE_DIR);
let stem = executable
.file_stem()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("app");
let mut name = format!("{stem}-{}-{generation}", std::process::id());
if let Some(extension) = executable.extension().and_then(std::ffi::OsStr::to_str) {
name.push('.');
name.push_str(extension);
}
directory.join(name)
}
fn stage(executable: &Path, generation: u32) -> Result<Option<PathBuf>, std::io::Error> {
if !cfg!(windows) {
return Ok(None);
}
let destination = staged_path(executable, generation);
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
sweep(parent);
}
std::fs::copy(executable, &destination)?;
Ok(Some(destination))
}
fn digest(path: &Path) -> Result<u64, std::io::Error> {
let mut file = std::fs::File::open(path)?;
let mut hasher = std::hash::DefaultHasher::new();
let mut buffer = vec![0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
return Ok(hasher.finish());
}
hasher.write(&buffer[..read]);
}
}
fn sweep(directory: &Path) {
let marker = format!("-{}-", std::process::id());
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.contains(&marker) {
drop(std::fs::remove_file(entry.path()));
}
}
}
pub(crate) struct Backend {
root: PathBuf,
endpoints: Endpoints,
sentinel: PathBuf,
handle: BackendHandle,
child: Option<ChildGuard>,
staged: Option<PathBuf>,
generation: u32,
built: Option<PathBuf>,
built_digest: Option<u64>,
}
impl Backend {
pub(crate) fn new(
root: PathBuf,
endpoints: Endpoints,
sentinel: PathBuf,
handle: BackendHandle,
) -> Self {
Self {
root,
endpoints,
sentinel,
handle,
child: None,
staged: None,
generation: 0,
built: None,
built_digest: None,
}
}
pub(crate) async fn reload(&mut self, cancel: &Cancel) -> Result<Reload, DevError> {
let started = Instant::now();
self.handle.mark_building();
let root = self.root.clone();
let handle = cancel.clone();
let building = Instant::now();
let build = tokio::task::spawn_blocking(move || run_cargo_build(&root, &handle))
.await
.map_err(|error| DevError::Cargo {
source: std::io::Error::other(error.to_string()),
})??;
let cargo = building.elapsed();
let (executable, check, codegen_link) = match build {
Build::Succeeded {
executable,
check,
codegen_link,
} => (executable, check, codegen_link),
Build::Failed { diagnostics } => {
self.handle.mark_failed(diagnostics);
return Ok(Reload::Done(Stages {
cargo,
total: started.elapsed(),
..Stages::default()
}));
}
Build::Cancelled => return Ok(Reload::Cancelled),
};
let fingerprint = digest(&executable).ok();
self.built = Some(executable.clone());
if self.child.is_some() && fingerprint.is_some() && fingerprint == self.built_digest {
self.handle.mark_ready();
return Ok(Reload::Done(Stages {
check,
codegen_link,
cargo,
unchanged: true,
total: started.elapsed(),
..Stages::default()
}));
}
let Restart { swap, spawn, boot } = self.restart(&executable).await?;
self.built_digest = fingerprint;
self.handle.mark_ready();
let typegen = self.regenerate().await;
if let Err(error) = project::touch_sentinel(&self.sentinel) {
eprintln!("warning: could not signal the browser to reload: {error}");
}
Ok(Reload::Done(Stages {
check,
codegen_link,
cargo,
swap: Some(swap),
spawn: Some(spawn),
boot: Some(boot),
typegen,
total: started.elapsed(),
unchanged: false,
}))
}
pub(crate) async fn restart_only(&mut self, cancel: &Cancel) -> Result<Reload, DevError> {
let Some(executable) = self.built.clone() else {
return self.reload(cancel).await;
};
let started = Instant::now();
self.handle.mark_building();
let Restart { swap, spawn, boot } = self.restart(&executable).await?;
self.handle.mark_ready();
let typegen = self.regenerate().await;
if let Err(error) = project::touch_sentinel(&self.sentinel) {
eprintln!("warning: could not signal the browser to reload: {error}");
}
Ok(Reload::Done(Stages {
swap: Some(swap),
spawn: Some(spawn),
boot: Some(boot),
typegen,
total: started.elapsed(),
..Stages::default()
}))
}
}
pub(crate) enum Reload {
Done(Stages),
Cancelled,
}
impl Backend {
async fn regenerate(&self) -> Option<Duration> {
let started = Instant::now();
match super::codegen::regenerate(&self.root, &self.endpoints.app).await {
Ok(written) if written.is_empty() => Some(started.elapsed()),
Ok(written) => {
for path in written {
println!(" typegen {}", path.display());
}
Some(started.elapsed())
}
Err(error) => {
eprintln!(" typegen failed: {error}");
None
}
}
}
async fn restart(&mut self, executable: &Path) -> Result<Restart, DevError> {
let swapping = Instant::now();
self.generation = self.generation.wrapping_add(1);
let staged =
stage(executable, self.generation).map_err(|source| DevError::Stage { source })?;
if let Some(mut previous) = self.child.take() {
tokio::task::spawn_blocking(move || previous.stop())
.await
.map_err(|error| DevError::Spawn {
program: String::from("the previous application process"),
source: std::io::Error::other(error.to_string()),
})?;
}
self.discard_staged();
self.staged = staged;
let program = self
.staged
.as_deref()
.unwrap_or(executable)
.display()
.to_string();
let mut command = inherited(&program);
command
.current_dir(&self.root)
.env(crate::config::APP_IPC_ENV, &self.endpoints.app)
.env(crate::config::VITE_IPC_ENV, &self.endpoints.vite);
let swap = swapping.elapsed();
let spawning = Instant::now();
let mut child = ChildGuard::spawn("application", &mut command)
.map_err(|source| DevError::Spawn { program, source })?;
let spawn = spawning.elapsed();
let waited = endpoints::wait_until_listening(&self.endpoints.app, BOOT_TIMEOUT, || {
child
.exited()
.map(|status| format!("the application exited with {status}"))
})
.await;
match waited {
Ok(boot) => {
self.child = Some(child);
Ok(Restart { swap, spawn, boot })
}
Err(error) => {
Err(DevError::Wait {
source: Box::new(error),
})
}
}
}
pub(crate) fn stop(&mut self) {
if let Some(mut child) = self.child.take() {
child.stop();
}
self.discard_staged();
}
fn discard_staged(&mut self) {
if let Some(path) = self.staged.take() {
drop(std::fs::remove_file(path));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(super) fn artifact(executable: Option<&str>, fresh: bool) -> String {
let executable = match executable {
Some(path) => format!("\"{path}\""),
None => String::from("null"),
};
format!(r#"{{"reason":"compiler-artifact","fresh":{fresh},"executable":{executable}}}"#)
}
pub(super) fn exit_status(success: bool) -> std::process::ExitStatus {
let mut command = if cfg!(windows) {
let mut command = Command::new("cmd");
command.args(["/C", if success { "exit 0" } else { "exit 1" }]);
command
} else {
let mut command = Command::new("sh");
command.args(["-c", if success { "exit 0" } else { "exit 1" }]);
command
};
command
.status()
.expect("a shell should be available to produce an exit status")
}
#[test]
fn a_build_that_produced_no_binary_is_a_failure_even_when_cargo_is_happy() {
let mut stream = MessageStream::default();
stream.absorb(&artifact(None, false));
match stream.finish(Instant::now(), exit_status(true), "") {
Build::Failed { diagnostics } => {
assert!(
diagnostics.contains("no executable"),
"the page should say what is missing, got: {diagnostics}"
);
}
Build::Succeeded { .. } => panic!("no binary was produced, so nothing can be run"),
Build::Cancelled => unreachable!("a parsed build was never asked to stop"),
}
}
#[test]
fn a_failure_with_nothing_on_stdout_still_says_something_useful() {
match MessageStream::default().finish(Instant::now(), exit_status(false), "") {
Build::Failed { diagnostics } => assert!(
diagnostics.contains("terminal"),
"an empty failure should point at the terminal, got: {diagnostics}"
),
Build::Succeeded { .. } => panic!("a non-zero exit is never a success"),
Build::Cancelled => unreachable!("a parsed build was never asked to stop"),
}
}
}
#[cfg(test)]
mod parsing_tests {
use super::tests::{artifact, exit_status};
use super::*;
#[test]
fn a_cached_build_is_not_counted_as_time_spent_checking() {
let mut stream = MessageStream::default();
stream.absorb(r#"{"reason":"compiler-artifact","fresh":true,"executable":null}"#);
stream.absorb(r#"{"reason":"compiler-artifact","fresh":false,"executable":"app"}"#);
match stream.finish(Instant::now(), exit_status(true), "") {
Build::Succeeded { check, .. } => assert!(
check.is_none(),
"nothing but the binary was rebuilt, so there is no check stage to report"
),
Build::Failed { .. } => panic!("the build produced a binary"),
Build::Cancelled => unreachable!("a parsed build was never asked to stop"),
}
}
#[test]
fn a_diagnostic_is_kept_verbatim_for_the_browser() {
let mut stream = MessageStream::default();
stream.absorb(
r#"{"reason":"compiler-message","message":{"rendered":"error[E0308]: mismatched types\n"}}"#,
);
match stream.finish(Instant::now(), exit_status(false), "") {
Build::Failed { diagnostics } => assert!(
diagnostics.contains("E0308"),
"the browser needs the compiler's own words, got: {diagnostics}"
),
Build::Succeeded { .. } => panic!("a non-zero exit is never a success"),
Build::Cancelled => unreachable!("a parsed build was never asked to stop"),
}
}
#[test]
fn a_line_that_is_not_json_is_ignored_rather_than_fatal() {
let mut stream = MessageStream::default();
stream.absorb(" Compiling arcature v0.1.0");
stream.absorb(r#"{"reason":"build-finished","success":true}"#);
stream.absorb(&artifact(Some("target/debug/app"), false));
match stream.finish(Instant::now(), exit_status(true), "") {
Build::Succeeded { executable, .. } => {
assert_eq!(executable, PathBuf::from("target/debug/app"));
}
Build::Failed { .. } => panic!("cargo succeeded and named a binary"),
Build::Cancelled => unreachable!("a parsed build was never asked to stop"),
}
}
#[test]
fn a_build_cargo_had_nothing_to_do_for_still_names_the_binary_to_run() {
let mut stream = MessageStream::default();
stream.absorb(&artifact(Some("target/debug/app"), true));
match stream.finish(Instant::now(), exit_status(true), "") {
Build::Succeeded {
executable,
check,
codegen_link,
} => {
assert_eq!(executable, PathBuf::from("target/debug/app"));
assert_eq!(check, None);
assert_eq!(codegen_link, None);
}
Build::Failed { diagnostics } => {
panic!("a fully cached build is a success: {diagnostics}")
}
Build::Cancelled => unreachable!("a parsed build was never asked to stop"),
}
}
#[test]
fn stages_that_were_never_observed_are_left_out_of_the_line() {
let printed = Stages {
cargo: Duration::from_millis(1_400),
swap: Some(Duration::from_millis(200)),
total: Duration::from_millis(1_950),
..Stages::default()
}
.to_string();
assert_eq!(printed, "cargo 1.40s swap 0.20s total 1.95s");
}
#[test]
fn the_parts_of_the_line_add_up_to_the_total_it_reports() {
let printed = Stages {
check: Some(Duration::from_millis(2_730)),
codegen_link: Some(Duration::from_millis(5_060)),
cargo: Duration::from_millis(7_850),
swap: Some(Duration::from_millis(500)),
spawn: Some(Duration::from_millis(5_000)),
boot: Some(Duration::from_millis(40)),
typegen: Some(Duration::from_millis(20)),
total: Duration::from_millis(13_430),
unchanged: false,
}
.to_string();
assert_eq!(
printed,
concat!(
"cargo 7.85s (check 2.73s, codegen+link 5.06s)",
" swap 0.50s spawn 5.00s boot 0.04s typegen 0.02s total 13.43s",
)
);
}
#[test]
fn a_build_that_changed_nothing_says_so_instead_of_showing_an_empty_line() {
let printed = Stages {
cargo: Duration::from_millis(6_900),
unchanged: true,
total: Duration::from_millis(6_920),
..Stages::default()
}
.to_string();
assert_eq!(printed, "cargo 6.90s unchanged total 6.92s");
}
#[test]
fn two_identical_files_have_the_same_digest_and_a_changed_one_does_not() {
let directory = std::env::temp_dir().join(format!("arc-digest-{}", std::process::id()));
std::fs::create_dir_all(&directory).expect("a temp directory");
let (one, two, three) = (
directory.join("one"),
directory.join("two"),
directory.join("three"),
);
let body = vec![7_u8; 200 * 1024];
std::fs::write(&one, &body).expect("write");
std::fs::write(&two, &body).expect("write");
let mut changed = body.clone();
changed[199 * 1024] = 8;
std::fs::write(&three, &changed).expect("write");
assert_eq!(digest(&one).expect("read"), digest(&two).expect("read"));
assert_ne!(digest(&one).expect("read"), digest(&three).expect("read"));
drop(std::fs::remove_dir_all(&directory));
}
#[test]
fn a_digest_of_something_unreadable_is_an_error_not_a_number() {
assert!(digest(Path::new("no-such-file-anywhere")).is_err());
}
#[test]
fn a_cancel_asked_for_before_the_compiler_exists_still_stops_it() {
let cancel = Cancel::default();
assert!(!cancel.requested());
cancel.request();
assert!(cancel.requested());
let mut command = if cfg!(windows) {
let mut command = Command::new("cmd");
command.args(["/C", "exit 0"]);
command
} else {
let mut command = Command::new("sh");
command.args(["-c", "exit 0"]);
command
};
command.stdout(Stdio::null()).stderr(Stdio::null());
let child = command.spawn().expect("spawn");
assert!(
!cancel.adopt(child),
"adopting into an already-cancelled handle must refuse"
);
drop(cancel.reclaim().map(|mut child| child.wait()));
}
#[test]
fn cargo_s_own_words_are_what_the_browser_is_shown() {
let stderr = "error: failed to remove file `target/debug/demo.exe`";
match MessageStream::default().finish(Instant::now(), exit_status(false), stderr) {
Build::Failed { diagnostics } => assert!(
diagnostics.contains("failed to remove file"),
"the reason cargo gave should reach the page, got: {diagnostics}"
),
Build::Succeeded { .. } => panic!("a non-zero exit is never a success"),
Build::Cancelled => unreachable!("a parsed build was never asked to stop"),
}
}
#[test]
fn a_compiler_diagnostic_is_preferred_over_the_stderr_tail() {
let mut stream = MessageStream::default();
stream.absorb(
r#"{"reason":"compiler-message","message":{"rendered":"error[E0425]: cannot find value"}}"#,
);
match stream.finish(
Instant::now(),
exit_status(false),
" Compiling demo v0.1.0",
) {
Build::Failed { diagnostics } => {
assert!(diagnostics.contains("E0425"), "got: {diagnostics}");
assert!(
!diagnostics.contains("Compiling"),
"progress noise does not belong under a real diagnostic, got: {diagnostics}"
);
}
Build::Succeeded { .. } => panic!("a non-zero exit is never a success"),
Build::Cancelled => unreachable!("a parsed build was never asked to stop"),
}
}
#[test]
fn each_staged_copy_gets_a_name_of_its_own() {
let executable = Path::new("target/debug/demo.exe");
let first = staged_path(executable, 1);
let second = staged_path(executable, 2);
assert_ne!(
first, second,
"a copy is made while the previous one may still be running"
);
assert_eq!(
first.extension().and_then(std::ffi::OsStr::to_str),
Some("exe"),
"Windows will not execute a file that is not named like one"
);
assert_eq!(first.parent(), Some(Path::new("target/debug/.arc-dev")));
}
#[test]
fn a_copy_is_named_after_the_supervisor_that_made_it() {
let staged = staged_path(Path::new("target/debug/demo.exe"), 1);
let name = staged
.file_name()
.and_then(std::ffi::OsStr::to_str)
.expect("the copy is named");
assert!(
name.contains(&format!("-{}-", std::process::id())),
"the name should carry this process id, got: {name}"
);
}
#[test]
fn the_sweep_keeps_this_run_s_copies_and_only_those() {
let directory = std::env::temp_dir().join("arcature-sweep-test");
drop(std::fs::remove_dir_all(&directory));
std::fs::create_dir_all(&directory).expect("a temp directory");
let mine = staged_path(&directory.join("demo.exe"), 3);
let mine = directory.join(mine.file_name().expect("a file name"));
let theirs = directory.join("demo-999999-1.exe");
std::fs::write(&mine, b"mine").expect("write");
std::fs::write(&theirs, b"theirs").expect("write");
sweep(&directory);
assert!(mine.exists(), "the running child's own copy must survive");
assert!(!theirs.exists(), "another run's leftovers are litter");
drop(std::fs::remove_dir_all(&directory));
}
#[test]
fn a_binary_without_an_extension_stays_without_one() {
let staged = staged_path(Path::new("target/debug/demo"), 7);
assert_eq!(
staged,
PathBuf::from(format!(
"target/debug/.arc-dev/demo-{}-7",
std::process::id()
))
);
}
}