craken_cli/hot_reload.rs
1use std::{path::Path, time::Duration};
2
3use anyhow::Result;
4use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
5use tokio::sync::mpsc;
6
7/// Run the development server with automatic hot-reload.
8///
9/// # Behaviour
10///
11/// 1. Spawns `cargo run -- serve --addr <addr>` as a child process.
12/// 2. Watches `./src` for `Create`, `Modify`, or `Remove` events via
13/// the [`notify`] crate.
14/// 3. On change: kills the running child, waits for it to exit, drains
15/// any burst of additional events (300 ms debounce), and re-spawns.
16/// 4. If the child exits on its own (e.g. compile error), the loop waits
17/// for the next file-change event before attempting to restart, avoiding
18/// a tight CPU-burning restart loop.
19///
20/// # Limitations
21///
22/// Because Rust is AOT-compiled, "hot reload" here means recompilation and
23/// process restart — not in-process code swapping. Use `RUST_LOG=info` for
24/// structured log output during development.
25pub async fn run_dev(addr: &str) -> Result<()> {
26 tracing::info!("Craken dev server — hot-reload enabled");
27 tracing::info!("Addr: {addr}");
28
29 // Bridge notify's sync callback into an async tokio channel.
30 let (tx, mut rx) = mpsc::channel::<()>(64);
31
32 let tx_for_watcher = tx.clone();
33 let mut watcher = RecommendedWatcher::new(
34 move |result: notify::Result<notify::Event>| {
35 if let Ok(event) = result {
36 // Only react to meaningful filesystem mutations.
37 if matches!(
38 event.kind,
39 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
40 ) {
41 // `blocking_send` is safe here: notify callbacks run on a
42 // plain OS thread, outside the tokio runtime.
43 let _ = tx_for_watcher.blocking_send(());
44 }
45 }
46 },
47 // Poll every 500 ms as a fallback on platforms where inotify is
48 // unavailable or has limitations (e.g. network filesystems).
49 Config::default().with_poll_interval(Duration::from_millis(500)),
50 )?;
51
52 let src = Path::new("src");
53 if src.exists() {
54 watcher.watch(src, RecursiveMode::Recursive)?;
55 tracing::info!("Watching: src/");
56 } else {
57 tracing::warn!("src/ not found — file watching disabled");
58 }
59
60 loop {
61 tracing::info!("Spawning: cargo run -- serve {addr}");
62
63 let mut child = tokio::process::Command::new("cargo")
64 .args(["run", "--", "serve", addr])
65 .spawn()
66 .map_err(|e| anyhow::anyhow!("Failed to spawn cargo: {e}"))?;
67
68 tokio::select! {
69 // ── Branch 1: child process exited on its own ──────────────────
70 status = child.wait() => {
71 match status {
72 Ok(s) if s.success() => tracing::info!("Server exited cleanly"),
73 Ok(s) => tracing::warn!("Server exited: {s}"),
74 Err(e) => tracing::error!("Server wait error: {e}"),
75 }
76 // Wait for a file change before re-spawning to avoid hammering
77 // the filesystem with rebuild attempts on a compile error.
78 tracing::info!("Waiting for file change…");
79 rx.recv().await;
80 // Debounce burst events.
81 while rx.try_recv().is_ok() {}
82 tokio::time::sleep(Duration::from_millis(300)).await;
83 }
84
85 // ── Branch 2: file change detected ────────────────────────────
86 Some(()) = rx.recv() => {
87 tracing::info!("File change detected — restarting server");
88 let _ = child.kill().await;
89 let _ = child.wait().await;
90 // Drain any burst of change events before re-spawning.
91 while rx.try_recv().is_ok() {}
92 tokio::time::sleep(Duration::from_millis(300)).await;
93 }
94 }
95 }
96}