Skip to main content

phoenix_cli/
dev.rs

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    /// Build browser and renderer bundles before each backend restart.
51    pub build_frontend: bool,
52    /// When true (default), watch backend and frontend sources and restart `cargo run` on change.
53    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    /// Run Rust and Vite together until Ctrl-C, or until Vite exits on its own.
125    /// When `watch_rust` is enabled, Rust source changes restart the backend;
126    /// compile/run failures wait for the next change instead of tearing down Vite.
127    ///
128    /// # Errors
129    ///
130    /// Returns an error when a child cannot start, signal registration fails,
131    /// or the Vite process exits on its own.
132    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    /// Run with a caller-provided shutdown future, primarily for integration
138    /// with another application lifecycle or deterministic tests.
139    ///
140    /// # Errors
141    ///
142    /// Returns a spawn, wait, shutdown, watch, or early-child-exit error.
143    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: &notify::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    // The scaffold uses this explicit lifecycle signal rather than APP_ENV to
406    // choose Vite's dev client. A packaged binary may still be started with a
407    // development config while it must serve its hashed release assets.
408    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}