Skip to main content

wavecraft_dev_server/reload/
rebuild.rs

1//! Hot-reload rebuild pipeline
2//!
3//! This module provides automatic rebuilding when Rust source files change.
4//! The pipeline watches for file changes, triggers a Cargo build, and updates
5//! the development server's parameter state without dropping WebSocket connections.
6//!
7//! CLI-specific functions (dylib discovery, subprocess param extraction, sidecar
8//! caching) are injected via [`RebuildCallbacks`] to keep this crate CLI-agnostic.
9
10use anyhow::{Context, Result};
11use console::style;
12use std::any::Any;
13use std::future::Future;
14use std::path::{Path, PathBuf};
15use std::pin::Pin;
16use std::process::Stdio;
17use std::sync::Arc;
18use tokio::io::AsyncReadExt;
19use tokio::process::Command;
20use tokio::sync::watch;
21use wavecraft_bridge::ParameterHost;
22use wavecraft_protocol::ParameterInfo;
23
24use crate::host::DevServerHost;
25use crate::reload::guard::BuildGuard;
26use crate::ws::WsServer;
27
28/// Callback type for loading parameters from a rebuilt dylib.
29///
30/// The closure receives the engine directory and must return the loaded
31/// parameters. This is injected by the CLI to handle dylib discovery,
32/// temp copying, and subprocess extraction.
33pub type ParamLoaderFn = Arc<
34    dyn Fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<Vec<ParameterInfo>>> + Send>>
35        + Send
36        + Sync,
37>;
38
39/// Callback type for writing parameter cache to a sidecar file.
40pub type SidecarWriterFn = Arc<dyn Fn(&Path, &[ParameterInfo]) -> Result<()> + Send + Sync>;
41
42/// Callbacks for CLI-specific operations.
43///
44/// The rebuild pipeline needs to perform operations that depend on CLI
45/// infrastructure (dylib discovery, subprocess parameter extraction,
46/// sidecar caching). These are injected as callbacks to keep the
47/// dev-server crate independent of CLI internals.
48pub struct RebuildCallbacks {
49    /// Engine package name for `cargo build --package` flag.
50    /// `None` means no `--package` flag (single-crate project).
51    pub package_name: Option<String>,
52    /// Optional sidecar cache writer (writes params to JSON file).
53    pub write_sidecar: Option<SidecarWriterFn>,
54    /// Loads parameters from the rebuilt dylib (async).
55    /// Receives the engine directory and returns parsed parameters.
56    pub param_loader: ParamLoaderFn,
57}
58
59/// Rebuild pipeline for hot-reload.
60///
61/// Coordinates Cargo builds, parameter reloading, and WebSocket notifications.
62pub struct RebuildPipeline {
63    guard: Arc<BuildGuard>,
64    engine_dir: PathBuf,
65    host: Arc<DevServerHost>,
66    ws_server: Arc<WsServer<Arc<DevServerHost>>>,
67    shutdown_rx: watch::Receiver<bool>,
68    callbacks: RebuildCallbacks,
69    #[cfg(feature = "audio")]
70    audio_reload_tx: Option<tokio::sync::mpsc::UnboundedSender<Vec<ParameterInfo>>>,
71}
72
73impl RebuildPipeline {
74    /// Create a new rebuild pipeline.
75    #[allow(clippy::too_many_arguments)]
76    pub fn new(
77        guard: Arc<BuildGuard>,
78        engine_dir: PathBuf,
79        host: Arc<DevServerHost>,
80        ws_server: Arc<WsServer<Arc<DevServerHost>>>,
81        shutdown_rx: watch::Receiver<bool>,
82        callbacks: RebuildCallbacks,
83        #[cfg(feature = "audio")] audio_reload_tx: Option<
84            tokio::sync::mpsc::UnboundedSender<Vec<ParameterInfo>>,
85        >,
86    ) -> Self {
87        Self {
88            guard,
89            engine_dir,
90            host,
91            ws_server,
92            shutdown_rx,
93            callbacks,
94            #[cfg(feature = "audio")]
95            audio_reload_tx,
96        }
97    }
98
99    /// Handle a file change event. Triggers rebuild if not already running.
100    pub async fn handle_change(&self) -> Result<()> {
101        if !self.guard.try_start() {
102            self.guard.mark_pending();
103            println!(
104                "  {} Build already in progress, queuing rebuild...",
105                style("→").dim()
106            );
107            return Ok(());
108        }
109
110        loop {
111            let result = self.do_build().await;
112
113            match result {
114                Ok((params, param_count_change)) => {
115                    let mut reload_ok = true;
116
117                    if let Some(ref writer) = self.callbacks.write_sidecar
118                        && let Err(e) = writer(&self.engine_dir, &params)
119                    {
120                        println!("  Warning: failed to update param cache: {}", e);
121                    }
122
123                    println!("  {} Updating parameter host...", style("→").dim());
124                    let replace_result =
125                        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
126                            self.host.replace_parameters(params.clone())
127                        }));
128
129                    match replace_result {
130                        Ok(Ok(())) => {
131                            println!(
132                                "  {} Updated {} parameters",
133                                style("→").dim(),
134                                params.len()
135                            );
136                        }
137                        Ok(Err(e)) => {
138                            reload_ok = false;
139                            println!(
140                                "  {} Failed to replace parameters: {:#}",
141                                style("✗").red(),
142                                e
143                            );
144                        }
145                        Err(panic_payload) => {
146                            reload_ok = false;
147                            println!(
148                                "  {} Panic while replacing parameters: {}",
149                                style("✗").red(),
150                                panic_message(panic_payload)
151                            );
152                        }
153                    }
154
155                    if reload_ok {
156                        println!("  {} Notifying UI clients...", style("→").dim());
157                        let broadcast_result = tokio::spawn({
158                            let ws_server = Arc::clone(&self.ws_server);
159                            async move { ws_server.broadcast_parameters_changed().await }
160                        })
161                        .await;
162
163                        match broadcast_result {
164                            Ok(Ok(())) => {
165                                println!("  {} UI notified", style("→").dim());
166                            }
167                            Ok(Err(e)) => {
168                                reload_ok = false;
169                                println!(
170                                    "  {} Failed to notify UI clients: {:#}",
171                                    style("✗").red(),
172                                    e
173                                );
174                            }
175                            Err(join_err) => {
176                                reload_ok = false;
177                                println!(
178                                    "  {} Panic while notifying UI clients: {}",
179                                    style("✗").red(),
180                                    join_err
181                                );
182                            }
183                        }
184                    }
185
186                    if reload_ok {
187                        let change_info = if param_count_change > 0 {
188                            format!(" (+{} new)", param_count_change)
189                        } else if param_count_change < 0 {
190                            format!(" ({} removed)", -param_count_change)
191                        } else {
192                            String::new()
193                        };
194
195                        println!(
196                            "  {} Hot-reload complete — {} parameters{}",
197                            style("✓").green(),
198                            params.len(),
199                            change_info
200                        );
201
202                        // Trigger audio reload if audio is enabled
203                        #[cfg(feature = "audio")]
204                        if let Some(ref tx) = self.audio_reload_tx {
205                            let _ = tx.send(params);
206                        }
207                    } else {
208                        println!(
209                            "  {} Hot-reload aborted — parameters not fully applied",
210                            style("✗").red()
211                        );
212                    }
213                }
214                Err(e) => {
215                    println!("  {} Build failed:\n{}", style("✗").red(), e);
216                    // Preserve old state, don't update parameters
217                }
218            }
219
220            if !self.guard.complete() {
221                break; // No pending rebuild
222            }
223            println!(
224                "  {} Pending changes detected, rebuilding...",
225                style("→").cyan()
226            );
227        }
228
229        Ok(())
230    }
231
232    /// Execute a Cargo build and load new parameters on success.
233    async fn do_build(&self) -> Result<(Vec<ParameterInfo>, i32)> {
234        if *self.shutdown_rx.borrow() {
235            anyhow::bail!("Build cancelled due to shutdown");
236        }
237
238        println!("  {} Rebuilding plugin...", style("🔄").cyan());
239        let start = std::time::Instant::now();
240
241        // Get old parameter count for change reporting
242        let old_count = self.host.get_all_parameters().len() as i32;
243
244        // Build command with optional --package flag
245        let mut cmd = Command::new("cargo");
246        cmd.args([
247            "build",
248            "--lib",
249            "--features",
250            "_param-discovery",
251            "--message-format=json",
252        ]);
253
254        if let Some(ref package_name) = self.callbacks.package_name {
255            cmd.args(["--package", package_name]);
256        }
257
258        let mut child = cmd
259            .current_dir(&self.engine_dir)
260            .stdout(Stdio::piped())
261            .stderr(Stdio::piped())
262            .spawn()
263            .context("Failed to spawn cargo build")?;
264
265        let stdout = child
266            .stdout
267            .take()
268            .context("Failed to capture cargo stdout")?;
269        let stderr = child
270            .stderr
271            .take()
272            .context("Failed to capture cargo stderr")?;
273
274        let stdout_handle = tokio::spawn(read_to_end(stdout));
275        let stderr_handle = tokio::spawn(read_to_end(stderr));
276
277        let mut shutdown_rx = self.shutdown_rx.clone();
278        let status = tokio::select! {
279            status = child.wait() => status.context("Failed to wait for cargo build")?,
280            _ = shutdown_rx.changed() => {
281                self.kill_build_process(&mut child).await?;
282                let _ = stdout_handle.await;
283                let _ = stderr_handle.await;
284                anyhow::bail!("Build cancelled due to shutdown");
285            }
286        };
287
288        let stdout = stdout_handle
289            .await
290            .context("Failed to join cargo stdout task")??;
291        let stderr = stderr_handle
292            .await
293            .context("Failed to join cargo stderr task")??;
294
295        let elapsed = start.elapsed();
296
297        if !status.success() {
298            // Parse JSON output for errors
299            let stderr = String::from_utf8_lossy(&stderr);
300            let stdout = String::from_utf8_lossy(&stdout);
301
302            // Try to extract compiler errors from JSON
303            let mut error_lines = Vec::new();
304            for line in stdout.lines().chain(stderr.lines()) {
305                if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
306                    && json["reason"] == "compiler-message"
307                    && let Some(message) = json["message"]["rendered"].as_str()
308                {
309                    error_lines.push(message.to_string());
310                }
311            }
312
313            if error_lines.is_empty() {
314                error_lines.push(stderr.to_string());
315            }
316
317            anyhow::bail!("{}", error_lines.join("\n"));
318        }
319
320        println!(
321            "  {} Build succeeded in {:.1}s",
322            style("✓").green(),
323            elapsed.as_secs_f64()
324        );
325
326        // Load parameters via the injected callback
327        let loader = Arc::clone(&self.callbacks.param_loader);
328        let engine_dir = self.engine_dir.clone();
329        let params = loader(engine_dir)
330            .await
331            .context("Failed to load parameters from rebuilt dylib")?;
332
333        let param_count_change = params.len() as i32 - old_count;
334
335        Ok((params, param_count_change))
336    }
337
338    async fn kill_build_process(&self, child: &mut tokio::process::Child) -> Result<()> {
339        #[cfg(unix)]
340        {
341            use nix::sys::signal::{kill, Signal};
342            use nix::unistd::Pid;
343
344            if let Some(pid) = child.id() {
345                let _ = kill(Pid::from_raw(-(pid as i32)), Signal::SIGTERM);
346            }
347        }
348
349        let _ = child.kill().await;
350        Ok(())
351    }
352}
353
354async fn read_to_end(mut reader: impl tokio::io::AsyncRead + Unpin) -> Result<Vec<u8>> {
355    let mut buffer = Vec::new();
356    reader
357        .read_to_end(&mut buffer)
358        .await
359        .context("Failed to read cargo output")?;
360    Ok(buffer)
361}
362
363fn panic_message(payload: Box<dyn Any + Send>) -> String {
364    if let Some(msg) = payload.downcast_ref::<String>() {
365        msg.clone()
366    } else if let Some(msg) = payload.downcast_ref::<&str>() {
367        msg.to_string()
368    } else {
369        "Unknown panic".to_string()
370    }
371}