wavecraft_dev_server/reload/
rebuild.rs1use 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
28pub type ParamLoaderFn = Arc<
34 dyn Fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<Vec<ParameterInfo>>> + Send>>
35 + Send
36 + Sync,
37>;
38
39pub type SidecarWriterFn = Arc<dyn Fn(&Path, &[ParameterInfo]) -> Result<()> + Send + Sync>;
41
42pub struct RebuildCallbacks {
49 pub package_name: Option<String>,
52 pub write_sidecar: Option<SidecarWriterFn>,
54 pub param_loader: ParamLoaderFn,
57}
58
59pub 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 #[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 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, ¶ms)
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 #[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 }
218 }
219
220 if !self.guard.complete() {
221 break; }
223 println!(
224 " {} Pending changes detected, rebuilding...",
225 style("→").cyan()
226 );
227 }
228
229 Ok(())
230 }
231
232 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 let old_count = self.host.get_all_parameters().len() as i32;
243
244 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 let stderr = String::from_utf8_lossy(&stderr);
300 let stdout = String::from_utf8_lossy(&stdout);
301
302 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 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}