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/// Callback type for writing generated TypeScript parameter ID types.
43pub type TsTypesWriterFn = Arc<dyn Fn(&[ParameterInfo]) -> Result<()> + Send + Sync>;
44
45/// Callbacks for CLI-specific operations.
46///
47/// The rebuild pipeline needs to perform operations that depend on CLI
48/// infrastructure (dylib discovery, subprocess parameter extraction,
49/// sidecar caching). These are injected as callbacks to keep the
50/// dev-server crate independent of CLI internals.
51pub struct RebuildCallbacks {
52    /// Engine package name for `cargo build --package` flag.
53    /// `None` means no `--package` flag (single-crate project).
54    pub package_name: Option<String>,
55    /// Optional sidecar cache writer (writes params to JSON file).
56    pub write_sidecar: Option<SidecarWriterFn>,
57    /// Optional TypeScript types writer (writes generated parameter ID typings).
58    pub write_ts_types: Option<TsTypesWriterFn>,
59    /// Loads parameters from the rebuilt dylib (async).
60    /// Receives the engine directory and returns parsed parameters.
61    pub param_loader: ParamLoaderFn,
62}
63
64/// Rebuild pipeline for hot-reload.
65///
66/// Coordinates Cargo builds, parameter reloading, and WebSocket notifications.
67pub struct RebuildPipeline {
68    guard: Arc<BuildGuard>,
69    engine_dir: PathBuf,
70    host: Arc<DevServerHost>,
71    ws_server: Arc<WsServer<Arc<DevServerHost>>>,
72    shutdown_rx: watch::Receiver<bool>,
73    callbacks: RebuildCallbacks,
74    #[cfg(feature = "audio")]
75    audio_reload_tx: Option<tokio::sync::mpsc::UnboundedSender<Vec<ParameterInfo>>>,
76    /// Channel for canceling the current parameter extraction when superseded.
77    cancel_param_load_tx: watch::Sender<bool>,
78    cancel_param_load_rx: watch::Receiver<bool>,
79}
80
81impl RebuildPipeline {
82    /// Create a new rebuild pipeline.
83    #[allow(clippy::too_many_arguments)]
84    pub fn new(
85        guard: Arc<BuildGuard>,
86        engine_dir: PathBuf,
87        host: Arc<DevServerHost>,
88        ws_server: Arc<WsServer<Arc<DevServerHost>>>,
89        shutdown_rx: watch::Receiver<bool>,
90        callbacks: RebuildCallbacks,
91        #[cfg(feature = "audio")] audio_reload_tx: Option<
92            tokio::sync::mpsc::UnboundedSender<Vec<ParameterInfo>>,
93        >,
94    ) -> Self {
95        let (cancel_param_load_tx, cancel_param_load_rx) = watch::channel(false);
96        Self {
97            guard,
98            engine_dir,
99            host,
100            ws_server,
101            shutdown_rx,
102            callbacks,
103            #[cfg(feature = "audio")]
104            audio_reload_tx,
105            cancel_param_load_tx,
106            cancel_param_load_rx,
107        }
108    }
109
110    /// Handle a file change event. Triggers rebuild if not already running.
111    pub async fn handle_change(&self) -> Result<()> {
112        if !self.guard.try_start() {
113            self.guard.mark_pending();
114            // Cancel any ongoing parameter extraction - it will be superseded
115            let _ = self.cancel_param_load_tx.send(true);
116            println!(
117                "  {} Build already in progress, queuing rebuild...",
118                style("→").dim()
119            );
120            return Ok(());
121        }
122
123        // Reset cancellation flag at the start of a new build cycle
124        let _ = self.cancel_param_load_tx.send(false);
125
126        loop {
127            let result = self.do_build().await;
128
129            match result {
130                Ok((params, param_count_change)) => {
131                    let mut reload_ok = true;
132                    let mut ts_types_ok = true;
133
134                    if let Some(ref writer) = self.callbacks.write_sidecar
135                        && let Err(e) = writer(&self.engine_dir, &params)
136                    {
137                        println!("  Warning: failed to update param cache: {}", e);
138                    }
139
140                    if let Some(ref writer) = self.callbacks.write_ts_types
141                        && let Err(e) = writer(&params)
142                    {
143                        ts_types_ok = false;
144                        println!(
145                            "  {} Failed to regenerate TypeScript parameter types: {}",
146                            style("⚠").yellow(),
147                            e
148                        );
149                        println!(
150                            "  {} Save a Rust source file to retry, or restart the dev server",
151                            style("  ↳").dim(),
152                        );
153                    }
154
155                    println!("  {} Updating parameter host...", style("→").dim());
156                    let replace_result =
157                        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
158                            self.host.replace_parameters(params.clone())
159                        }));
160
161                    match replace_result {
162                        Ok(Ok(())) => {
163                            println!("  {} Updated {} parameters", style("→").dim(), params.len());
164                        }
165                        Ok(Err(e)) => {
166                            reload_ok = false;
167                            println!(
168                                "  {} Failed to replace parameters: {:#}",
169                                style("✗").red(),
170                                e
171                            );
172                        }
173                        Err(panic_payload) => {
174                            reload_ok = false;
175                            println!(
176                                "  {} Panic while replacing parameters: {}",
177                                style("✗").red(),
178                                panic_message(panic_payload)
179                            );
180                        }
181                    }
182
183                    if reload_ok {
184                        println!("  {} Notifying UI clients...", style("→").dim());
185                        let broadcast_result = tokio::spawn({
186                            let ws_server = Arc::clone(&self.ws_server);
187                            async move { ws_server.broadcast_parameters_changed().await }
188                        })
189                        .await;
190
191                        match broadcast_result {
192                            Ok(Ok(())) => {
193                                println!("  {} UI notified", style("→").dim());
194                            }
195                            Ok(Err(e)) => {
196                                reload_ok = false;
197                                println!(
198                                    "  {} Failed to notify UI clients: {:#}",
199                                    style("✗").red(),
200                                    e
201                                );
202                            }
203                            Err(join_err) => {
204                                reload_ok = false;
205                                println!(
206                                    "  {} Panic while notifying UI clients: {}",
207                                    style("✗").red(),
208                                    join_err
209                                );
210                            }
211                        }
212                    }
213
214                    if reload_ok {
215                        let change_info = if param_count_change > 0 {
216                            format!(" (+{} new)", param_count_change)
217                        } else if param_count_change < 0 {
218                            format!(" ({} removed)", -param_count_change)
219                        } else {
220                            String::new()
221                        };
222
223                        println!(
224                            "  {} Hot-reload complete — {} parameters{}{}",
225                            style("✓").green(),
226                            params.len(),
227                            change_info,
228                            if ts_types_ok {
229                                ""
230                            } else {
231                                " (⚠ TypeScript types stale)"
232                            }
233                        );
234
235                        // Trigger audio reload if audio is enabled
236                        #[cfg(feature = "audio")]
237                        if let Some(ref tx) = self.audio_reload_tx {
238                            let _ = tx.send(params);
239                        }
240                    } else {
241                        println!(
242                            "  {} Hot-reload aborted — parameters not fully applied",
243                            style("✗").red()
244                        );
245                    }
246                }
247                Err(e) => {
248                    println!("  {} Build failed:\n{}", style("✗").red(), e);
249                    // Preserve old state, don't update parameters
250                }
251            }
252
253            if !self.guard.complete() {
254                break; // No pending rebuild
255            }
256            println!(
257                "  {} Pending changes detected, rebuilding...",
258                style("→").cyan()
259            );
260        }
261
262        Ok(())
263    }
264
265    /// Execute a Cargo build and load new parameters on success.
266    async fn do_build(&self) -> Result<(Vec<ParameterInfo>, i32)> {
267        if *self.shutdown_rx.borrow() {
268            anyhow::bail!("Build cancelled due to shutdown");
269        }
270
271        println!("  {} Rebuilding plugin...", style("🔄").cyan());
272        let start = std::time::Instant::now();
273
274        // Get old parameter count for change reporting
275        let old_count = self.host.get_all_parameters().len() as i32;
276
277        // Build command with optional --package flag
278        let mut cmd = Command::new("cargo");
279        cmd.args([
280            "build",
281            "--lib",
282            "--features",
283            "_param-discovery",
284            "--message-format=json",
285        ]);
286
287        if let Some(ref package_name) = self.callbacks.package_name {
288            cmd.args(["--package", package_name]);
289        }
290
291        let mut child = cmd
292            .current_dir(&self.engine_dir)
293            .stdout(Stdio::piped())
294            .stderr(Stdio::piped())
295            .spawn()
296            .context("Failed to spawn cargo build")?;
297
298        let stdout = child
299            .stdout
300            .take()
301            .context("Failed to capture cargo stdout")?;
302        let stderr = child
303            .stderr
304            .take()
305            .context("Failed to capture cargo stderr")?;
306
307        let stdout_handle = tokio::spawn(read_to_end(stdout));
308        let stderr_handle = tokio::spawn(read_to_end(stderr));
309
310        let mut shutdown_rx = self.shutdown_rx.clone();
311        let status = tokio::select! {
312            status = child.wait() => status.context("Failed to wait for cargo build")?,
313            _ = shutdown_rx.changed() => {
314                self.kill_build_process(&mut child).await?;
315                let _ = stdout_handle.await;
316                let _ = stderr_handle.await;
317                anyhow::bail!("Build cancelled due to shutdown");
318            }
319        };
320
321        let stdout = stdout_handle
322            .await
323            .context("Failed to join cargo stdout task")??;
324        let stderr = stderr_handle
325            .await
326            .context("Failed to join cargo stderr task")??;
327
328        let elapsed = start.elapsed();
329
330        if !status.success() {
331            // Parse JSON output for errors
332            let stderr = String::from_utf8_lossy(&stderr);
333            let stdout = String::from_utf8_lossy(&stdout);
334
335            // Try to extract compiler errors from JSON
336            let mut error_lines = Vec::new();
337            for line in stdout.lines().chain(stderr.lines()) {
338                if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
339                    && json["reason"] == "compiler-message"
340                    && let Some(message) = json["message"]["rendered"].as_str()
341                {
342                    error_lines.push(message.to_string());
343                }
344            }
345
346            if error_lines.is_empty() {
347                error_lines.push(stderr.to_string());
348            }
349
350            anyhow::bail!("{}", error_lines.join("\n"));
351        }
352
353        println!(
354            "  {} Build succeeded in {:.1}s",
355            style("✓").green(),
356            elapsed.as_secs_f64()
357        );
358
359        // Load parameters via the injected callback, but race against cancellation
360        let loader = Arc::clone(&self.callbacks.param_loader);
361        let engine_dir = self.engine_dir.clone();
362        let mut cancel_rx = self.cancel_param_load_rx.clone();
363
364        let params = tokio::select! {
365            result = loader(engine_dir) => {
366                result.context("Failed to load parameters from rebuilt dylib")?
367            }
368            _ = cancel_rx.changed() => {
369                if *cancel_rx.borrow_and_update() {
370                    println!(
371                        "  {} Parameter extraction cancelled — superseded by newer change",
372                        style("⚠").yellow()
373                    );
374                    anyhow::bail!("Parameter extraction cancelled due to newer file change");
375                }
376                // If cancellation was reset to false, continue waiting
377                loader(self.engine_dir.clone())
378                    .await
379                    .context("Failed to load parameters from rebuilt dylib")?
380            }
381        };
382
383        let param_count_change = params.len() as i32 - old_count;
384
385        Ok((params, param_count_change))
386    }
387
388    async fn kill_build_process(&self, child: &mut tokio::process::Child) -> Result<()> {
389        #[cfg(unix)]
390        {
391            use nix::sys::signal::{Signal, kill};
392            use nix::unistd::Pid;
393
394            if let Some(pid) = child.id() {
395                let _ = kill(Pid::from_raw(-(pid as i32)), Signal::SIGTERM);
396            }
397        }
398
399        let _ = child.kill().await;
400        Ok(())
401    }
402}
403
404async fn read_to_end(mut reader: impl tokio::io::AsyncRead + Unpin) -> Result<Vec<u8>> {
405    let mut buffer = Vec::new();
406    reader
407        .read_to_end(&mut buffer)
408        .await
409        .context("Failed to read cargo output")?;
410    Ok(buffer)
411}
412
413fn panic_message(payload: Box<dyn Any + Send>) -> String {
414    if let Some(msg) = payload.downcast_ref::<String>() {
415        msg.clone()
416    } else if let Some(msg) = payload.downcast_ref::<&str>() {
417        msg.to_string()
418    } else {
419        "Unknown panic".to_string()
420    }
421}