1use std::{
2 ffi::OsString,
3 future::Future,
4 path::{Path, PathBuf},
5 process::{ExitStatus, Stdio},
6 sync::mpsc,
7 time::Duration,
8};
9
10use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
11use thiserror::Error;
12use tokio::process::{Child, Command};
13use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct CommandSpec {
17 pub program: PathBuf,
18 pub args: Vec<OsString>,
19}
20
21impl CommandSpec {
22 #[must_use]
23 pub fn new(program: impl Into<PathBuf>) -> Self {
24 Self {
25 program: program.into(),
26 args: Vec::new(),
27 }
28 }
29
30 #[must_use]
31 pub fn arg(mut self, argument: impl Into<OsString>) -> Self {
32 self.args.push(argument.into());
33 self
34 }
35
36 #[must_use]
37 pub fn args(mut self, arguments: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
38 self.args.extend(arguments.into_iter().map(Into::into));
39 self
40 }
41}
42
43#[derive(Clone, Debug)]
44pub struct DevConfig {
45 pub working_directory: PathBuf,
46 pub rust: CommandSpec,
47 pub vite: CommandSpec,
48 pub watch_rust: bool,
50}
51
52impl Default for DevConfig {
53 fn default() -> Self {
54 Self {
55 working_directory: PathBuf::from("."),
56 rust: CommandSpec::new("cargo").args(["run", "--", "serve"]),
57 vite: CommandSpec::new("npm").args(["run", "dev", "--", "--strictPort"]),
58 watch_rust: true,
59 }
60 }
61}
62
63impl DevConfig {
64 #[must_use]
65 pub fn working_directory(mut self, directory: impl Into<PathBuf>) -> Self {
66 self.working_directory = directory.into();
67 self
68 }
69
70 #[must_use]
71 pub fn rust(mut self, command: CommandSpec) -> Self {
72 self.rust = command;
73 self
74 }
75
76 #[must_use]
77 pub fn vite(mut self, command: CommandSpec) -> Self {
78 self.vite = command;
79 self
80 }
81
82 #[must_use]
83 pub const fn watch_rust(mut self, enabled: bool) -> Self {
84 self.watch_rust = enabled;
85 self
86 }
87}
88
89pub struct DevSupervisor {
90 config: DevConfig,
91}
92
93impl DevSupervisor {
94 #[must_use]
95 pub fn new(config: DevConfig) -> Self {
96 Self { config }
97 }
98
99 pub async fn run(self) -> Result<(), DevError> {
108 self.run_with_shutdown(async { tokio::signal::ctrl_c().await.map_err(DevError::Signal) })
109 .await
110 }
111
112 pub async fn run_with_shutdown<F>(self, shutdown: F) -> Result<(), DevError>
119 where
120 F: Future<Output = Result<(), DevError>> + Send,
121 {
122 let cwd = self.config.working_directory.clone();
123 let mut vite = spawn("Vite", &self.config.vite, &cwd)?;
124 let mut changes = if self.config.watch_rust {
125 Some(start_rust_watcher(&cwd)?)
126 } else {
127 None
128 };
129 tokio::pin!(shutdown);
130
131 let result = async {
132 loop {
133 let mut rust = spawn("Rust", &self.config.rust, &cwd)?;
134 if self.config.watch_rust {
135 eprintln!(
136 "px dev: watching Rust (app/, src/, routes/, config/, database/, Cargo.toml)"
137 );
138 }
139
140 let event = tokio::select! {
141 result = rust.wait() => Event::Rust(result.map_err(DevError::Wait)?),
142 result = vite.wait() => Event::Vite(result.map_err(DevError::Wait)?),
143 result = &mut shutdown => Event::Shutdown(result),
144 changed = recv_change(&mut changes) => {
145 changed?;
146 Event::RustChanged
147 }
148 };
149
150 match event {
151 Event::Shutdown(result) => {
152 terminate(&mut rust).await?;
153 return result;
154 }
155 Event::Vite(status) => {
156 terminate(&mut rust).await?;
157 return Err(DevError::Exited {
158 process: "Vite",
159 status,
160 });
161 }
162 Event::RustChanged => {
163 eprintln!("px dev: Rust source changed — rebuilding…");
164 terminate(&mut rust).await?;
165 drain_changes(&mut changes, Duration::from_millis(400)).await;
166 continue;
167 }
168 Event::Rust(status) => {
169 if !self.config.watch_rust {
170 return Err(DevError::Exited {
171 process: "Rust",
172 status,
173 });
174 }
175 eprintln!(
176 "px dev: Rust process exited ({status}); waiting for source changes…"
177 );
178 let wait = tokio::select! {
179 result = vite.wait() => WaitWhileDown::Vite(result.map_err(DevError::Wait)?),
180 result = &mut shutdown => WaitWhileDown::Shutdown(result),
181 changed = recv_change(&mut changes) => {
182 changed?;
183 WaitWhileDown::Changed
184 }
185 };
186 match wait {
187 WaitWhileDown::Shutdown(result) => return result,
188 WaitWhileDown::Vite(status) => {
189 return Err(DevError::Exited {
190 process: "Vite",
191 status,
192 });
193 }
194 WaitWhileDown::Changed => {
195 drain_changes(&mut changes, Duration::from_millis(400)).await;
196 continue;
197 }
198 }
199 }
200 }
201 }
202 }
203 .await;
204
205 terminate(&mut vite).await?;
206 result
207 }
208}
209
210enum Event {
211 Rust(ExitStatus),
212 Vite(ExitStatus),
213 Shutdown(Result<(), DevError>),
214 RustChanged,
215}
216
217enum WaitWhileDown {
218 Vite(ExitStatus),
219 Shutdown(Result<(), DevError>),
220 Changed,
221}
222
223struct RustWatcher {
224 _watcher: RecommendedWatcher,
225 rx: UnboundedReceiver<()>,
226}
227
228fn start_rust_watcher(cwd: &Path) -> Result<RustWatcher, DevError> {
229 let (tx, rx) = unbounded_channel();
230 let (notify_tx, notify_rx) = mpsc::channel();
231 let mut watcher = notify::recommended_watcher(move |result| {
232 let _ = notify_tx.send(result);
233 })
234 .map_err(DevError::Watch)?;
235
236 for relative in ["app", "src", "routes", "config", "database"] {
237 let path = cwd.join(relative);
238 if path.is_dir() {
239 watcher
240 .watch(&path, RecursiveMode::Recursive)
241 .map_err(DevError::Watch)?;
242 }
243 }
244 let cargo_toml = cwd.join("Cargo.toml");
245 if cargo_toml.is_file() {
246 watcher
247 .watch(&cargo_toml, RecursiveMode::NonRecursive)
248 .map_err(DevError::Watch)?;
249 }
250
251 let cwd = cwd.to_path_buf();
252 std::thread::Builder::new()
253 .name("px-dev-rust-watch".into())
254 .spawn(move || watch_loop(cwd, notify_rx, tx))
255 .map_err(|source| DevError::Spawn {
256 process: "RustWatcher",
257 source,
258 })?;
259
260 Ok(RustWatcher {
261 _watcher: watcher,
262 rx,
263 })
264}
265
266fn watch_loop(
267 cwd: PathBuf,
268 notify_rx: mpsc::Receiver<Result<notify::Event, notify::Error>>,
269 tx: UnboundedSender<()>,
270) {
271 while let Ok(result) = notify_rx.recv() {
272 let Ok(event) = result else {
273 continue;
274 };
275 if is_relevant_event(&cwd, &event) {
276 let _ = tx.send(());
277 }
278 }
279}
280
281fn is_relevant_event(cwd: &Path, event: ¬ify::Event) -> bool {
282 match event.kind {
283 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) => {}
284 _ => return false,
285 }
286 event.paths.iter().any(|path| is_watched_rust_path(cwd, path))
287}
288
289fn is_watched_rust_path(cwd: &Path, path: &Path) -> bool {
290 let Ok(relative) = path.strip_prefix(cwd) else {
291 return false;
292 };
293 let mut components = relative.components();
294 let Some(std::path::Component::Normal(first)) = components.next() else {
295 return path.file_name().is_some_and(|name| name == "Cargo.toml");
296 };
297 let first = first.to_string_lossy();
298 matches!(
299 first.as_ref(),
300 "app" | "src" | "routes" | "config" | "database" | "Cargo.toml"
301 ) && !relative
302 .components()
303 .any(|component| matches!(component, std::path::Component::Normal(name) if name == "target"))
304}
305
306async fn recv_change(changes: &mut Option<RustWatcher>) -> Result<(), DevError> {
307 match changes.as_mut() {
308 Some(watcher) => watcher.rx.recv().await.ok_or(DevError::WatchClosed),
309 None => std::future::pending().await,
310 }
311}
312
313async fn drain_changes(changes: &mut Option<RustWatcher>, window: Duration) {
314 let Some(watcher) = changes.as_mut() else {
315 return;
316 };
317 let deadline = tokio::time::Instant::now() + window;
318 loop {
319 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
320 if remaining.is_zero() {
321 break;
322 }
323 match tokio::time::timeout(remaining, watcher.rx.recv()).await {
324 Ok(Some(())) => continue,
325 _ => break,
326 }
327 }
328}
329
330fn spawn(label: &'static str, spec: &CommandSpec, cwd: &Path) -> Result<Child, DevError> {
331 let mut command = Command::new(&spec.program);
332 command
333 .args(&spec.args)
334 .current_dir(cwd)
335 .stdin(Stdio::inherit())
336 .stdout(Stdio::inherit())
337 .stderr(Stdio::inherit())
338 .kill_on_drop(true);
339 #[cfg(unix)]
340 command.process_group(0);
341 command.spawn().map_err(|source| DevError::Spawn {
342 process: label,
343 source,
344 })
345}
346
347#[cfg(unix)]
348async fn terminate(child: &mut Child) -> Result<(), DevError> {
349 use nix::{
350 sys::signal::{Signal, killpg},
351 unistd::Pid,
352 };
353
354 let Some(id) = child.id() else {
355 let _ = child.wait().await.map_err(DevError::Wait)?;
356 return Ok(());
357 };
358 let process_group = Pid::from_raw(i32::try_from(id).map_err(|_| DevError::InvalidProcessId)?);
359 if let Err(error) = killpg(process_group, Signal::SIGTERM)
360 && error != nix::errno::Errno::ESRCH
361 {
362 return Err(DevError::SignalProcess(error));
363 }
364 if tokio::time::timeout(Duration::from_secs(3), child.wait())
365 .await
366 .is_err()
367 {
368 if let Err(error) = killpg(process_group, Signal::SIGKILL)
369 && error != nix::errno::Errno::ESRCH
370 {
371 return Err(DevError::SignalProcess(error));
372 }
373 let _ = child.wait().await.map_err(DevError::Wait)?;
374 }
375 Ok(())
376}
377
378#[cfg(not(unix))]
379async fn terminate(child: &mut Child) -> Result<(), DevError> {
380 if child.id().is_some() {
381 child.start_kill().map_err(DevError::Shutdown)?;
382 }
383 let _ = child.wait().await.map_err(DevError::Wait)?;
384 Ok(())
385}
386
387#[derive(Debug, Error)]
388pub enum DevError {
389 #[error("failed to start the {process} development process: {source}")]
390 Spawn {
391 process: &'static str,
392 source: std::io::Error,
393 },
394 #[error("failed while waiting for a development process: {0}")]
395 Wait(std::io::Error),
396 #[error("failed to stop a development process: {0}")]
397 Shutdown(std::io::Error),
398 #[cfg(unix)]
399 #[error("failed to signal a development process group: {0}")]
400 SignalProcess(nix::errno::Errno),
401 #[error("a development process returned an invalid process id")]
402 InvalidProcessId,
403 #[error("failed to listen for Ctrl-C: {0}")]
404 Signal(std::io::Error),
405 #[error("failed to watch Rust sources for reload: {0}")]
406 Watch(#[from] notify::Error),
407 #[error("Rust file watcher stopped unexpectedly")]
408 WatchClosed,
409 #[error("the {process} development process exited early with {status}")]
410 Exited {
411 process: &'static str,
412 status: ExitStatus,
413 },
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use std::time::Duration;
420
421 fn shell(script: &str) -> CommandSpec {
422 CommandSpec::new("sh").args(["-c", script])
423 }
424
425 #[test]
426 fn default_dev_config_runs_serve_with_watch() {
427 let config = DevConfig::default();
428 assert_eq!(config.rust.program, PathBuf::from("cargo"));
429 assert!(config.watch_rust);
430 assert_eq!(
431 config.rust.args,
432 vec![
433 OsString::from("run"),
434 OsString::from("--"),
435 OsString::from("serve")
436 ]
437 );
438 }
439
440 #[test]
441 fn watched_paths_cover_app_sources_not_views() {
442 let cwd = PathBuf::from("/app");
443 assert!(is_watched_rust_path(
444 &cwd,
445 &cwd.join("app/controllers/home.rs")
446 ));
447 assert!(is_watched_rust_path(&cwd, &cwd.join("Cargo.toml")));
448 assert!(!is_watched_rust_path(
449 &cwd,
450 &cwd.join("views/pages/home.tsx")
451 ));
452 assert!(!is_watched_rust_path(
453 &cwd,
454 &cwd.join("target/debug/my-app")
455 ));
456 }
457
458 #[tokio::test]
459 async fn shutdown_stops_and_reaps_both_processes() {
460 let supervisor = DevSupervisor::new(
461 DevConfig::default()
462 .watch_rust(false)
463 .rust(shell("sleep 10 & wait"))
464 .vite(shell("sleep 10 & wait")),
465 );
466 supervisor
467 .run_with_shutdown(async {
468 tokio::time::sleep(Duration::from_millis(50)).await;
469 Ok(())
470 })
471 .await
472 .unwrap();
473 }
474
475 #[tokio::test]
476 async fn one_failed_process_stops_the_other_without_watch() {
477 let supervisor = DevSupervisor::new(
478 DevConfig::default()
479 .watch_rust(false)
480 .rust(shell("exit 7"))
481 .vite(shell("sleep 10")),
482 );
483 let result = supervisor.run_with_shutdown(std::future::pending()).await;
484
485 assert!(matches!(
486 result,
487 Err(DevError::Exited {
488 process: "Rust",
489 status,
490 }) if status.code() == Some(7)
491 ));
492 }
493
494 #[tokio::test]
495 async fn rust_exit_with_watch_waits_for_shutdown_without_failing() {
496 let supervisor = DevSupervisor::new(
497 DevConfig::default()
498 .watch_rust(true)
499 .rust(shell("exit 1"))
500 .vite(shell("sleep 10 & wait")),
501 );
502 supervisor
503 .run_with_shutdown(async {
504 tokio::time::sleep(Duration::from_millis(80)).await;
505 Ok(())
506 })
507 .await
508 .unwrap();
509 }
510}