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 type TsTypesWriterFn = Arc<dyn Fn(&[ParameterInfo]) -> Result<()> + Send + Sync>;
44
45pub struct RebuildCallbacks {
52 pub package_name: Option<String>,
55 pub write_sidecar: Option<SidecarWriterFn>,
57 pub write_ts_types: Option<TsTypesWriterFn>,
59 pub param_loader: ParamLoaderFn,
62}
63
64pub 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 cancel_param_load_tx: watch::Sender<bool>,
78 cancel_param_load_rx: watch::Receiver<bool>,
79}
80
81impl RebuildPipeline {
82 #[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 pub async fn handle_change(&self) -> Result<()> {
112 if !self.guard.try_start() {
113 self.guard.mark_pending();
114 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 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, ¶ms)
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(¶ms)
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 #[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 }
251 }
252
253 if !self.guard.complete() {
254 break; }
256 println!(
257 " {} Pending changes detected, rebuilding...",
258 style("→").cyan()
259 );
260 }
261
262 Ok(())
263 }
264
265 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 let old_count = self.host.get_all_parameters().len() as i32;
276
277 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 let stderr = String::from_utf8_lossy(&stderr);
333 let stdout = String::from_utf8_lossy(&stdout);
334
335 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 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 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}