use std::collections::BTreeMap;
use std::fs;
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, SystemTime};
use crate::project::ProjectConfig;
use super::{
CancellableOutcome, ChildGuard, ProcessError, ProcessSpec, cleanup_vite_ipc, ipc_path,
run_cancellable, spawn, start_vite_ipc, wait_ipc_ready,
};
pub(crate) fn supervise(project: &ProjectConfig) -> Result<(), ProcessError> {
let stopping = install_shutdown_listener()?;
if matches!(
build_backend(project, &stopping)?,
CancellableOutcome::Cancelled
) {
return Ok(());
}
refresh_manifest(project);
let ipc = ipc_path();
let (mut vite_child, harness_path) = start_vite_ipc(project, &ipc)?;
wait_ipc_ready(&ipc, &mut vite_child, &stopping)?;
let mut backend = Some(start_backend(project, &ipc)?);
if let Some(child) = backend.as_mut() {
wait_ready("backend", project.backend_port, child, &stopping)?;
}
println!("ready http://127.0.0.1:{}", project.backend_port);
let mut snapshot = rust_snapshot(project.root(), &project.backend_src_dir)?;
let result = loop {
if stopping.load(Ordering::SeqCst) {
break Ok(());
}
if let Some(status) = vite_child.try_wait()? {
if stopping.load(Ordering::SeqCst) {
break Ok(());
}
break Err(ProcessError::Failed {
program: "node (Vite)".to_owned(),
directory: project.frontend_root(),
status,
});
}
if let Some(child) = backend.as_mut()
&& let Some(status) = child.try_wait()?
{
if stopping.load(Ordering::SeqCst) {
break Ok(());
}
break Err(ProcessError::Failed {
program: project.backend_binary.clone(),
directory: project.root().to_path_buf(),
status,
});
}
thread::sleep(Duration::from_millis(250));
let next = rust_snapshot(project.root(), &project.backend_src_dir)?;
if next != snapshot {
snapshot = next;
if let Some(child) = backend.as_mut() {
child.terminate()?;
}
backend = None;
println!("backend rebuilding");
match build_backend(project, &stopping) {
Ok(CancellableOutcome::Completed) => {}
Ok(CancellableOutcome::Cancelled) => break Ok(()),
Err(error) => {
eprintln!("backend build failed: {error}");
continue;
}
}
refresh_manifest(project);
let mut child = start_backend(project, &ipc)?;
wait_ready("backend", project.backend_port, &mut child, &stopping)?;
backend = Some(child);
println!("ready http://127.0.0.1:{}", project.backend_port);
}
};
let backend_stop = match backend.as_mut() {
Some(child) => child.terminate(),
None => Ok(()),
};
let vite_stop = vite_child.terminate();
cleanup_vite_ipc(&ipc, &harness_path);
result.and(backend_stop).and(vite_stop)
}
fn refresh_manifest(project: &ProjectConfig) {
let spec = ProcessSpec::new("cargo", project.root()).args([
"run",
"--quiet",
"--package",
&project.backend_package,
"--bin",
"arcature-metadata",
]);
if let Err(error) = super::run_quiet(&spec) {
eprintln!(
"warn could not refresh the UAG manifest and route helpers ({error}); the \
@arcature/client Vite plugin may report a stale or missing manifest. Add \
`arcature-build` as a build-dependency and an `arcature-metadata` bin to enable \
cross-stack route/page types (AP2.1-4)."
);
}
}
fn install_shutdown_listener() -> Result<Arc<AtomicBool>, ProcessError> {
let stopping = Arc::new(AtomicBool::new(false));
let signal = Arc::clone(&stopping);
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build();
match runtime {
Ok(runtime) => {
if sender.send(Ok(())).is_ok() && runtime.block_on(wait_for_shutdown()).is_ok() {
signal.store(true, Ordering::SeqCst);
}
}
Err(error) => {
let _ = sender.send(Err(error));
}
}
});
receiver
.recv()
.map_err(|error| ProcessError::Signal(std::io::Error::other(error)))?
.map_err(ProcessError::Signal)?;
Ok(stopping)
}
#[cfg(unix)]
async fn wait_for_shutdown() -> std::io::Result<()> {
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
tokio::select! { result = tokio::signal::ctrl_c() => result, _ = terminate.recv() => Ok(()) }
}
#[cfg(windows)]
async fn wait_for_shutdown() -> std::io::Result<()> {
let mut control_break = tokio::signal::windows::ctrl_break()?;
tokio::select! {
result = tokio::signal::ctrl_c() => result,
_ = control_break.recv() => Ok(()),
}
}
#[cfg(not(any(unix, windows)))]
async fn wait_for_shutdown() -> std::io::Result<()> {
tokio::signal::ctrl_c().await
}
fn build_backend(
project: &ProjectConfig,
stopping: &AtomicBool,
) -> Result<CancellableOutcome, ProcessError> {
run_cancellable(
&ProcessSpec::new("cargo", project.root())
.args(["build", "--package", &project.backend_package])
.new_process_group(true),
stopping,
)
}
fn start_backend(project: &ProjectConfig, ipc: &Path) -> Result<ChildGuard, ProcessError> {
let extension = if cfg!(windows) { ".exe" } else { "" };
let binary = project
.root()
.join("target")
.join("debug")
.join(format!("{}{extension}", project.backend_binary));
spawn(
&ProcessSpec::new(binary.into_os_string(), project.root())
.env("ARCATURE_VITE_IPC", ipc.display().to_string())
.env("ARCATURE_DEV", "1")
.env("ARCATURE_BACKEND_PORT", project.backend_port.to_string())
.new_process_group(true),
)
.map(|child| ChildGuard::new(child, "backend"))
}
fn wait_ready(
service: &'static str,
port: u16,
child: &mut ChildGuard,
stopping: &AtomicBool,
) -> Result<(), ProcessError> {
let address = SocketAddr::from(([127, 0, 0, 1], port));
for _ in 0..100 {
if stopping.load(Ordering::SeqCst) {
return Ok(());
}
if TcpStream::connect_timeout(&address, Duration::from_millis(100)).is_ok() {
return Ok(());
}
if let Some(status) = child.try_wait()? {
if stopping.load(Ordering::SeqCst) {
return Ok(());
}
return Err(ProcessError::Failed {
program: service.to_owned(),
directory: PathBuf::from("."),
status,
});
}
thread::sleep(Duration::from_millis(100));
}
Err(ProcessError::Readiness {
service,
address: address.to_string(),
})
}
fn rust_snapshot(
root: &Path,
backend_src_dir: &Path,
) -> Result<BTreeMap<PathBuf, (SystemTime, u64)>, ProcessError> {
let mut snapshot = BTreeMap::new();
collect(&root.join(backend_src_dir), &mut snapshot)?;
for name in ["Cargo.toml", "Cargo.lock", "build.rs"] {
let path = root.join(name);
if path.is_file() {
insert_metadata(&path, &mut snapshot)?;
}
}
Ok(snapshot)
}
fn collect(
path: &Path,
snapshot: &mut BTreeMap<PathBuf, (SystemTime, u64)>,
) -> Result<(), ProcessError> {
let entries = match fs::read_dir(path) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
return Err(ProcessError::Inspect {
path: path.to_path_buf(),
source,
});
}
};
for entry in entries {
let entry = entry.map_err(|source| ProcessError::Inspect {
path: path.to_path_buf(),
source,
})?;
let child = entry.path();
let kind = entry.file_type().map_err(|source| ProcessError::Inspect {
path: child.clone(),
source,
})?;
if kind.is_dir() {
collect(&child, snapshot)?;
} else if kind.is_file() && child.extension().is_some_and(|extension| extension == "rs") {
insert_metadata(&child, snapshot)?;
}
}
Ok(())
}
fn insert_metadata(
path: &Path,
snapshot: &mut BTreeMap<PathBuf, (SystemTime, u64)>,
) -> Result<(), ProcessError> {
let metadata = fs::metadata(path).map_err(|source| ProcessError::Inspect {
path: path.to_path_buf(),
source,
})?;
let modified = metadata
.modified()
.map_err(|source| ProcessError::Inspect {
path: path.to_path_buf(),
source,
})?;
snapshot.insert(path.to_path_buf(), (modified, metadata.len()));
Ok(())
}