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 client_build: CommandSpec,
49 pub renderer_build: CommandSpec,
50 pub build_frontend: bool,
52 pub watch_rust: bool,
54}
55
56impl Default for DevConfig {
57 fn default() -> Self {
58 Self {
59 working_directory: PathBuf::from("."),
60 rust: CommandSpec::new("cargo").args(["run", "--", "serve"]),
61 vite: CommandSpec::new("npm").args(["run", "dev", "--", "--strictPort"]),
62 client_build: CommandSpec::new("npm").args(["run", "build:client"]),
63 renderer_build: CommandSpec::new("npm").args(["run", "build:ssr"]),
64 build_frontend: true,
65 watch_rust: true,
66 }
67 }
68}
69
70impl DevConfig {
71 #[must_use]
72 pub fn working_directory(mut self, directory: impl Into<PathBuf>) -> Self {
73 self.working_directory = directory.into();
74 self
75 }
76
77 #[must_use]
78 pub fn rust(mut self, command: CommandSpec) -> Self {
79 self.rust = command;
80 self
81 }
82
83 #[must_use]
84 pub fn vite(mut self, command: CommandSpec) -> Self {
85 self.vite = command;
86 self
87 }
88
89 #[must_use]
90 pub fn client_build(mut self, command: CommandSpec) -> Self {
91 self.client_build = command;
92 self
93 }
94
95 #[must_use]
96 pub fn renderer_build(mut self, command: CommandSpec) -> Self {
97 self.renderer_build = command;
98 self
99 }
100
101 #[must_use]
102 pub const fn build_frontend(mut self, enabled: bool) -> Self {
103 self.build_frontend = enabled;
104 self
105 }
106
107 #[must_use]
108 pub const fn watch_rust(mut self, enabled: bool) -> Self {
109 self.watch_rust = enabled;
110 self
111 }
112}
113
114pub struct DevSupervisor {
115 config: DevConfig,
116}
117
118impl DevSupervisor {
119 #[must_use]
120 pub fn new(config: DevConfig) -> Self {
121 Self { config }
122 }
123
124 pub async fn run(self) -> Result<(), DevError> {
133 self.run_with_shutdown(async { tokio::signal::ctrl_c().await.map_err(DevError::Signal) })
134 .await
135 }
136
137 pub async fn run_with_shutdown<F>(self, shutdown: F) -> Result<(), DevError>
144 where
145 F: Future<Output = Result<(), DevError>> + Send,
146 {
147 let cwd = self.config.working_directory.clone();
148 let mut vite = spawn("Vite", &self.config.vite, &cwd)?;
149 let mut changes = if self.config.watch_rust {
150 Some(start_rust_watcher(&cwd)?)
151 } else {
152 None
153 };
154 tokio::pin!(shutdown);
155
156 let result = async {
157 loop {
158 self.build_frontend(&cwd).await?;
159 let mut rust = spawn("Rust", &self.config.rust, &cwd)?;
160 if self.config.watch_rust {
161 eprintln!(
162 "px dev: watching backend and frontend sources for rebuilds"
163 );
164 }
165
166 let event = tokio::select! {
167 result = rust.wait() => Event::Rust(result.map_err(DevError::Wait)?),
168 result = vite.wait() => Event::Vite(result.map_err(DevError::Wait)?),
169 result = &mut shutdown => Event::Shutdown(result),
170 changed = recv_change(&mut changes) => {
171 changed?;
172 Event::RustChanged
173 }
174 };
175
176 match event {
177 Event::Shutdown(result) => {
178 terminate(&mut rust).await?;
179 return result;
180 }
181 Event::Vite(status) => {
182 terminate(&mut rust).await?;
183 return Err(DevError::Exited {
184 process: "Vite",
185 status,
186 });
187 }
188 Event::RustChanged => {
189 eprintln!("px dev: source change detected — rebuilding client, renderer, and backend…");
190 terminate(&mut rust).await?;
191 drain_changes(&mut changes, Duration::from_millis(400)).await;
192 continue;
193 }
194 Event::Rust(status) => {
195 if !self.config.watch_rust {
196 return Err(DevError::Exited {
197 process: "Rust",
198 status,
199 });
200 }
201 eprintln!(
202 "px dev: Rust process exited ({status}); waiting for source changes…"
203 );
204 let wait = tokio::select! {
205 result = vite.wait() => WaitWhileDown::Vite(result.map_err(DevError::Wait)?),
206 result = &mut shutdown => WaitWhileDown::Shutdown(result),
207 changed = recv_change(&mut changes) => {
208 changed?;
209 WaitWhileDown::Changed
210 }
211 };
212 match wait {
213 WaitWhileDown::Shutdown(result) => return result,
214 WaitWhileDown::Vite(status) => {
215 return Err(DevError::Exited {
216 process: "Vite",
217 status,
218 });
219 }
220 WaitWhileDown::Changed => {
221 drain_changes(&mut changes, Duration::from_millis(400)).await;
222 continue;
223 }
224 }
225 }
226 }
227 }
228 }
229 .await;
230
231 terminate(&mut vite).await?;
232 result
233 }
234
235 async fn build_frontend(&self, cwd: &Path) -> Result<(), DevError> {
236 if !self.config.build_frontend {
237 return Ok(());
238 }
239 run_to_completion("Client build", &self.config.client_build, cwd).await?;
240 run_to_completion("Renderer build", &self.config.renderer_build, cwd).await
241 }
242}
243
244enum Event {
245 Rust(ExitStatus),
246 Vite(ExitStatus),
247 Shutdown(Result<(), DevError>),
248 RustChanged,
249}
250
251enum WaitWhileDown {
252 Vite(ExitStatus),
253 Shutdown(Result<(), DevError>),
254 Changed,
255}
256
257struct RustWatcher {
258 _watcher: RecommendedWatcher,
259 rx: UnboundedReceiver<()>,
260}
261
262fn start_rust_watcher(cwd: &Path) -> Result<RustWatcher, DevError> {
263 let (tx, rx) = unbounded_channel();
264 let (notify_tx, notify_rx) = mpsc::channel();
265 let mut watcher = notify::recommended_watcher(move |result| {
266 let _ = notify_tx.send(result);
267 })
268 .map_err(DevError::Watch)?;
269
270 for relative in ["app", "src", "routes", "config", "database", "views"] {
271 let path = cwd.join(relative);
272 if path.is_dir() {
273 watcher
274 .watch(&path, RecursiveMode::Recursive)
275 .map_err(DevError::Watch)?;
276 }
277 }
278 for relative in [
279 "Cargo.toml",
280 "package.json",
281 "package-lock.json",
282 "tsconfig.json",
283 "vite.config.ts",
284 "vite.ssr.config.ts",
285 ] {
286 let path = cwd.join(relative);
287 if path.is_file() {
288 watcher
289 .watch(&path, RecursiveMode::NonRecursive)
290 .map_err(DevError::Watch)?;
291 }
292 }
293
294 let cwd = cwd.to_path_buf();
295 std::thread::Builder::new()
296 .name("px-dev-rust-watch".into())
297 .spawn(move || watch_loop(cwd, notify_rx, tx))
298 .map_err(|source| DevError::Spawn {
299 process: "RustWatcher",
300 source,
301 })?;
302
303 Ok(RustWatcher {
304 _watcher: watcher,
305 rx,
306 })
307}
308
309fn watch_loop(
310 cwd: PathBuf,
311 notify_rx: mpsc::Receiver<Result<notify::Event, notify::Error>>,
312 tx: UnboundedSender<()>,
313) {
314 while let Ok(result) = notify_rx.recv() {
315 let Ok(event) = result else {
316 continue;
317 };
318 if is_relevant_event(&cwd, &event) {
319 let _ = tx.send(());
320 }
321 }
322}
323
324fn is_relevant_event(cwd: &Path, event: ¬ify::Event) -> bool {
325 match event.kind {
326 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) => {}
327 _ => return false,
328 }
329 event
330 .paths
331 .iter()
332 .any(|path| is_watched_rust_path(cwd, path))
333}
334
335fn is_watched_rust_path(cwd: &Path, path: &Path) -> bool {
336 let Ok(relative) = path.strip_prefix(cwd) else {
337 return false;
338 };
339 if relative
340 .parent()
341 .is_none_or(|parent| parent.as_os_str().is_empty())
342 {
343 return relative.file_name().is_some_and(is_watched_root_file);
344 }
345 let mut components = relative.components();
346 let Some(std::path::Component::Normal(first)) = components.next() else {
347 return path.file_name().is_some_and(is_watched_root_file);
348 };
349 let first = first.to_string_lossy();
350 matches!(
351 first.as_ref(),
352 "app" | "src" | "routes" | "config" | "database" | "views"
353 ) && !relative.components().any(
354 |component| matches!(component, std::path::Component::Normal(name) if name == "target"),
355 )
356}
357
358fn is_watched_root_file(name: &std::ffi::OsStr) -> bool {
359 matches!(
360 name.to_str(),
361 Some(
362 "Cargo.toml"
363 | "package.json"
364 | "package-lock.json"
365 | "tsconfig.json"
366 | "vite.config.ts"
367 | "vite.ssr.config.ts"
368 )
369 )
370}
371
372async fn recv_change(changes: &mut Option<RustWatcher>) -> Result<(), DevError> {
373 match changes.as_mut() {
374 Some(watcher) => watcher.rx.recv().await.ok_or(DevError::WatchClosed),
375 None => std::future::pending().await,
376 }
377}
378
379async fn drain_changes(changes: &mut Option<RustWatcher>, window: Duration) {
380 let Some(watcher) = changes.as_mut() else {
381 return;
382 };
383 let deadline = tokio::time::Instant::now() + window;
384 loop {
385 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
386 if remaining.is_zero() {
387 break;
388 }
389 match tokio::time::timeout(remaining, watcher.rx.recv()).await {
390 Ok(Some(())) => continue,
391 _ => break,
392 }
393 }
394}
395
396fn spawn(label: &'static str, spec: &CommandSpec, cwd: &Path) -> Result<Child, DevError> {
397 let mut command = Command::new(&spec.program);
398 command
399 .args(&spec.args)
400 .current_dir(cwd)
401 .stdin(Stdio::inherit())
402 .stdout(Stdio::inherit())
403 .stderr(Stdio::inherit())
404 .kill_on_drop(true);
405 if label == "Rust" {
409 command.env("PHOENIX_VITE_DEV", "1");
410 }
411 #[cfg(unix)]
412 command.process_group(0);
413 command.spawn().map_err(|source| DevError::Spawn {
414 process: label,
415 source,
416 })
417}
418
419async fn run_to_completion(
420 label: &'static str,
421 spec: &CommandSpec,
422 cwd: &Path,
423) -> Result<(), DevError> {
424 let mut child = spawn(label, spec, cwd)?;
425 let status = child.wait().await.map_err(DevError::Wait)?;
426 if status.success() {
427 Ok(())
428 } else {
429 Err(DevError::Exited {
430 process: label,
431 status,
432 })
433 }
434}
435
436#[cfg(unix)]
437async fn terminate(child: &mut Child) -> Result<(), DevError> {
438 use nix::{
439 sys::signal::{Signal, killpg},
440 unistd::Pid,
441 };
442
443 let Some(id) = child.id() else {
444 let _ = child.wait().await.map_err(DevError::Wait)?;
445 return Ok(());
446 };
447 let process_group = Pid::from_raw(i32::try_from(id).map_err(|_| DevError::InvalidProcessId)?);
448 if let Err(error) = killpg(process_group, Signal::SIGTERM)
449 && error != nix::errno::Errno::ESRCH
450 {
451 return Err(DevError::SignalProcess(error));
452 }
453 if tokio::time::timeout(Duration::from_secs(3), child.wait())
454 .await
455 .is_err()
456 {
457 if let Err(error) = killpg(process_group, Signal::SIGKILL)
458 && error != nix::errno::Errno::ESRCH
459 {
460 return Err(DevError::SignalProcess(error));
461 }
462 let _ = child.wait().await.map_err(DevError::Wait)?;
463 }
464 Ok(())
465}
466
467#[cfg(not(unix))]
468async fn terminate(child: &mut Child) -> Result<(), DevError> {
469 if child.id().is_some() {
470 child.start_kill().map_err(DevError::Shutdown)?;
471 }
472 let _ = child.wait().await.map_err(DevError::Wait)?;
473 Ok(())
474}
475
476#[derive(Debug, Error)]
477pub enum DevError {
478 #[error("failed to start the {process} development process: {source}")]
479 Spawn {
480 process: &'static str,
481 source: std::io::Error,
482 },
483 #[error("failed while waiting for a development process: {0}")]
484 Wait(std::io::Error),
485 #[error("failed to stop a development process: {0}")]
486 Shutdown(std::io::Error),
487 #[cfg(unix)]
488 #[error("failed to signal a development process group: {0}")]
489 SignalProcess(nix::errno::Errno),
490 #[error("a development process returned an invalid process id")]
491 InvalidProcessId,
492 #[error("failed to listen for Ctrl-C: {0}")]
493 Signal(std::io::Error),
494 #[error("failed to watch Rust sources for reload: {0}")]
495 Watch(#[from] notify::Error),
496 #[error("Rust file watcher stopped unexpectedly")]
497 WatchClosed,
498 #[error("the {process} development process exited early with {status}")]
499 Exited {
500 process: &'static str,
501 status: ExitStatus,
502 },
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use std::{fs, time::Duration};
509
510 fn shell(script: &str) -> CommandSpec {
511 CommandSpec::new("sh").args(["-c", script])
512 }
513
514 #[test]
515 fn default_dev_config_runs_serve_with_watch() {
516 let config = DevConfig::default();
517 assert_eq!(config.rust.program, PathBuf::from("cargo"));
518 assert!(config.watch_rust);
519 assert_eq!(
520 config.rust.args,
521 vec![
522 OsString::from("run"),
523 OsString::from("--"),
524 OsString::from("serve")
525 ]
526 );
527 }
528
529 #[test]
530 fn watched_paths_cover_app_and_react_sources() {
531 let cwd = PathBuf::from("/app");
532 assert!(is_watched_rust_path(
533 &cwd,
534 &cwd.join("app/controllers/home.rs")
535 ));
536 assert!(is_watched_rust_path(&cwd, &cwd.join("Cargo.toml")));
537 assert!(is_watched_rust_path(
538 &cwd,
539 &cwd.join("views/pages/home.tsx")
540 ));
541 assert!(is_watched_rust_path(&cwd, &cwd.join("vite.config.ts")));
542 assert!(is_watched_rust_path(&cwd, &cwd.join("package.json")));
543 assert!(!is_watched_rust_path(
544 &cwd,
545 &cwd.join("target/debug/my-app")
546 ));
547 }
548
549 #[tokio::test]
550 async fn shutdown_stops_and_reaps_both_processes() {
551 let supervisor = DevSupervisor::new(
552 DevConfig::default()
553 .build_frontend(false)
554 .watch_rust(false)
555 .rust(shell("sleep 10 & wait"))
556 .vite(shell("sleep 10 & wait")),
557 );
558 supervisor
559 .run_with_shutdown(async {
560 tokio::time::sleep(Duration::from_millis(50)).await;
561 Ok(())
562 })
563 .await
564 .unwrap();
565 }
566
567 #[tokio::test]
568 async fn one_failed_process_stops_the_other_without_watch() {
569 let supervisor = DevSupervisor::new(
570 DevConfig::default()
571 .build_frontend(false)
572 .watch_rust(false)
573 .rust(shell("exit 7"))
574 .vite(shell("sleep 10")),
575 );
576 let result = supervisor.run_with_shutdown(std::future::pending()).await;
577
578 assert!(matches!(
579 result,
580 Err(DevError::Exited {
581 process: "Rust",
582 status,
583 }) if status.code() == Some(7)
584 ));
585 }
586
587 #[tokio::test]
588 async fn rust_exit_with_watch_waits_for_shutdown_without_failing() {
589 let supervisor = DevSupervisor::new(
590 DevConfig::default()
591 .build_frontend(false)
592 .watch_rust(true)
593 .rust(shell("exit 1"))
594 .vite(shell("sleep 10 & wait")),
595 );
596 supervisor
597 .run_with_shutdown(async {
598 tokio::time::sleep(Duration::from_millis(80)).await;
599 Ok(())
600 })
601 .await
602 .unwrap();
603 }
604
605 #[tokio::test]
606 async fn frontend_builds_run_before_the_backend() {
607 let marker = std::env::temp_dir().join(format!(
608 "phoenix-dev-build-{}-{}.txt",
609 std::process::id(),
610 std::time::SystemTime::now()
611 .duration_since(std::time::UNIX_EPOCH)
612 .unwrap()
613 .as_nanos()
614 ));
615 let marker = marker.to_string_lossy().into_owned();
616 let supervisor = DevSupervisor::new(
617 DevConfig::default()
618 .watch_rust(false)
619 .client_build(shell(&format!("printf client > {marker}")))
620 .renderer_build(shell(&format!("printf renderer >> {marker}")))
621 .rust(shell("sleep 10"))
622 .vite(shell("sleep 10")),
623 );
624 supervisor
625 .run_with_shutdown(async {
626 tokio::time::sleep(Duration::from_millis(80)).await;
627 Ok(())
628 })
629 .await
630 .unwrap();
631 assert_eq!(fs::read_to_string(&marker).unwrap(), "clientrenderer");
632 fs::remove_file(marker).unwrap();
633 }
634}