hydro_deploy/rust_crate/build.rs
1use std::error::Error;
2use std::fmt::Display;
3use std::io::BufRead;
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitStatus, Stdio};
6use std::sync::OnceLock;
7
8use cargo_metadata::diagnostic::Diagnostic;
9use hydro_concurrent_cargo::final_rustc;
10use memo_map::MemoMap;
11use tokio::sync::OnceCell;
12
13use crate::HostTargetType;
14use crate::progress::ProgressTracker;
15
16/// Build parameters for [`build_crate_memoized`].
17#[derive(PartialEq, Eq, Hash, Clone)]
18pub struct BuildParams {
19 /// The working directory for the build, where the `cargo build` command will be run. Crate root.
20 /// [`Self::new`] canonicalizes this path.
21 src: PathBuf,
22 /// The workspace root encompassing the build, which may be a parent of `src` in a multi-crate
23 /// workspace.
24 workspace_root: PathBuf,
25 /// `--bin` binary name parameter.
26 bin: Option<String>,
27 /// `--example` parameter.
28 example: Option<String>,
29 /// `--profile` parameter.
30 profile: Option<String>,
31 rustflags: Option<String>,
32 target_dir: Option<PathBuf>,
33 // Environment variables available during build
34 build_env: Vec<(String, String)>,
35 no_default_features: bool,
36 /// `--target <linux>` if cross-compiling for linux ([`HostTargetType::Linux`]).
37 target_type: HostTargetType,
38 /// True is the build should use dynamic linking.
39 is_dylib: bool,
40 /// `--features` flags, will be comma-delimited.
41 features: Option<Vec<String>>,
42 /// `--config` flag
43 config: Vec<String>,
44}
45impl BuildParams {
46 /// Creates a new `BuildParams` and canonicalizes the `src` path.
47 #[expect(clippy::too_many_arguments, reason = "internal code")]
48 pub fn new(
49 src: impl AsRef<Path>,
50 workspace_root: impl AsRef<Path>,
51 bin: Option<String>,
52 example: Option<String>,
53 profile: Option<String>,
54 rustflags: Option<String>,
55 target_dir: Option<PathBuf>,
56 build_env: Vec<(String, String)>,
57 no_default_features: bool,
58 target_type: HostTargetType,
59 is_dylib: bool,
60 features: Option<Vec<String>>,
61 config: Vec<String>,
62 ) -> Self {
63 // `fs::canonicalize` prepends windows paths with the `r"\\?\"`
64 // https://stackoverflow.com/questions/21194530/what-does-mean-when-prepended-to-a-file-path
65 // However, this breaks the `include!(concat!(env!("OUT_DIR"), "/my/forward/slash/path.rs"))`
66 // Rust codegen pattern on windows. To help mitigate this happening in third party crates, we
67 // instead use `dunce::canonicalize` which is the same as `fs::canonicalize` but avoids the
68 // `\\?\` prefix when possible.
69 let src = dunce::canonicalize(src.as_ref()).unwrap_or_else(|e| {
70 panic!(
71 "Failed to canonicalize path `{}` for build: {e}.",
72 src.as_ref().display(),
73 )
74 });
75
76 let workspace_root = dunce::canonicalize(workspace_root.as_ref()).unwrap_or_else(|e| {
77 panic!(
78 "Failed to canonicalize path `{}` for build: {e}.",
79 workspace_root.as_ref().display(),
80 )
81 });
82
83 BuildParams {
84 src,
85 workspace_root,
86 bin,
87 example,
88 profile,
89 rustflags,
90 target_dir,
91 build_env,
92 no_default_features,
93 target_type,
94 is_dylib,
95 features,
96 config,
97 }
98 }
99}
100
101/// Information about a built crate. See [`build_crate_memoized`].
102pub struct BuildOutput {
103 /// The binary contents as a byte array.
104 pub bin_data: Vec<u8>,
105 /// The path to the binary file. [`Self::bin_data`] has a copy of the content.
106 pub bin_path: PathBuf,
107 /// Shared library path, containing any necessary dylibs.
108 pub shared_library_path: Option<PathBuf>,
109}
110impl BuildOutput {
111 /// A unique ID for the binary, based its contents.
112 pub fn unique_id(&self) -> impl use<> + Display {
113 blake3::hash(&self.bin_data).to_hex()
114 }
115}
116
117/// Build memoization cache.
118static BUILDS: OnceLock<MemoMap<BuildParams, OnceCell<BuildOutput>>> = OnceLock::new();
119
120pub async fn build_crate_memoized(params: BuildParams) -> Result<&'static BuildOutput, BuildError> {
121 BUILDS
122 .get_or_init(MemoMap::new)
123 .get_or_insert(¶ms, Default::default)
124 .get_or_try_init(move || {
125 ProgressTracker::rich_leaf("build", move |set_msg| async move {
126 tokio::task::spawn_blocking(move || {
127 let base_target_dir = params
128 .target_dir
129 .as_ref()
130 .cloned()
131 .unwrap_or_else(|| params.src.join("target"));
132 let job_name = params
133 .bin
134 .as_deref()
135 .or(params.example.as_deref())
136 .unwrap_or("default");
137
138 // Only use prebuild + per-job target dirs for dylib mode.
139 // Without dylib, build directly into the base target dir.
140 let (per_job_target_dir, mut prebuild_guard, _cargo_lock) = if params.is_dylib {
141 let shared_debug = base_target_dir.join("debug");
142 let jobs_dir = base_target_dir.join("jobs");
143 let per_job = hydro_concurrent_cargo::setup_job_dir(&jobs_dir, job_name, &shared_debug);
144
145 let features = params.features.clone().unwrap_or_default();
146 let staged_paths = vec![
147 params.src.join("src").join("__staged.rs"),
148 params.src.join("Cargo.lock"),
149 std::env::current_exe().unwrap(),
150 ];
151
152 let src = params.src.clone();
153 let profile = params.profile.clone();
154 let target_type = params.target_type;
155 let no_default_features = params.no_default_features;
156 let features_for_closure = features.clone();
157 let config = params.config.clone();
158 let rustflags = params.rustflags.clone();
159 let build_env = params.build_env.clone();
160
161 let (prebuild_guard, cargo_lock) = hydro_concurrent_cargo::run_prebuild(
162 &base_target_dir,
163 params.src.parent().unwrap().file_name().unwrap().to_str().unwrap(),
164 &features,
165 &staged_paths,
166 |prebuild_target| {
167 set_msg("building dependencies".to_owned());
168
169 // Prebuild the dylib-examples lib (`src` in dylib mode), which
170 // transitively builds the trybuild dylib *as a dependency*.
171 // This matters: cargo passes `-C prefer-dynamic` to dylib
172 // crates when they are built as dependencies (linking libstd
173 // dynamically), but *not* when they are the primary build
174 // target — and the two variants share a cargo fingerprint. If
175 // the dylib were prebuilt as a primary target, the cached
176 // variant would statically link libstd and the final example
177 // builds would fail to link ("cannot satisfy dependencies so
178 // `std` only shows up once").
179 let mut lib_cmd = Command::new("cargo");
180 lib_cmd.current_dir(&src);
181 lib_cmd.args(["build", "--locked", "--lib"]);
182 if let Some(profile) = profile.as_ref() {
183 lib_cmd.args(["--profile", profile]);
184 }
185 match target_type {
186 HostTargetType::Local => {}
187 HostTargetType::Linux(crate::LinuxCompileType::Glibc) => {
188 lib_cmd.args(["--target", "x86_64-unknown-linux-gnu"]);
189 }
190 HostTargetType::Linux(crate::LinuxCompileType::Musl) => {
191 lib_cmd.args(["--target", "x86_64-unknown-linux-musl"]);
192 }
193 }
194 if no_default_features {
195 lib_cmd.arg("--no-default-features");
196 }
197 if !features_for_closure.is_empty() {
198 lib_cmd.args(["--features", &features_for_closure.join(",")]);
199 }
200 for c in &config {
201 lib_cmd.args(["--config", c]);
202 }
203 lib_cmd.args(["--target-dir", prebuild_target.to_str().unwrap()]);
204 if let Some(rustflags) = rustflags.as_ref() {
205 lib_cmd.env("RUSTFLAGS", rustflags);
206 }
207 for (k, v) in &build_env {
208 lib_cmd.env(k, v);
209 }
210 let lib_status = lib_cmd
211 .stdin(Stdio::null())
212 .status()
213 .unwrap();
214 if !lib_status.success() {
215 panic!("dep prebuild failed");
216 }
217 },
218 );
219
220 (per_job, Some(prebuild_guard), Some(cargo_lock))
221 } else {
222 (base_target_dir.clone(), None, None)
223 };
224
225 hydro_concurrent_cargo::log_build_event(&base_target_dir, "deploy: starting final build");
226
227 // Fast path: replay the rustc invocation captured from a previous cargo
228 // final build against this same prebuild, skipping cargo entirely (see
229 // `hydro_concurrent_cargo::final_rustc`). Only for local dylib example
230 // builds with default profile/rustflags — the same conditions under
231 // which deploy_graph enables dynamic linking for trybuild crates.
232 let use_final_rustc_template = params.is_dylib
233 && params.example.is_some()
234 && params.profile.is_none()
235 && params.rustflags.is_none()
236 && matches!(params.target_type, HostTargetType::Local);
237
238 let (prebuild_stamp, template_path) = if use_final_rustc_template {
239 let guard = prebuild_guard.as_mut().unwrap();
240 (
241 Some(guard.read_stamp()),
242 Some(final_rustc::template_path(guard.lock_path())),
243 )
244 } else {
245 (None, None)
246 };
247
248 let example_crate_name = params.example.as_deref().map(|e| e.replace('-', "_"));
249 let example_source_path = params.example.as_deref().map(|example| {
250 format!(
251 "{}/examples/{}.rs",
252 params
253 .src
254 .strip_prefix(¶ms.workspace_root)
255 .ok()
256 .and_then(|p| p.to_str())
257 .unwrap_or_default(),
258 example
259 )
260 .trim_start_matches('/')
261 .to_owned()
262 });
263
264 if let (Some(stamp), Some(template_path)) = (&prebuild_stamp, &template_path)
265 && let Some(template) = final_rustc::load(template_path, stamp)
266 {
267 // Serialize concurrent builds of the same job without populating the
268 // per-job cargo target dir.
269 let jobs_dir = base_target_dir.join("jobs");
270 let _job_guard = hydro_concurrent_cargo::lock_job_dir(&jobs_dir, job_name);
271
272 // Unique symbol metadata per example (multiple built examples may be
273 // loaded/run side by side).
274 let metadata = format!("{}", blake3::hash(job_name.as_bytes()).to_hex())
275 .chars()
276 .take(16)
277 .collect::<String>();
278
279 let mut envs: Vec<(String, std::ffi::OsString)> = params
280 .build_env
281 .iter()
282 .map(|(k, v)| (k.clone(), std::ffi::OsString::from(v)))
283 .collect();
284 // Override the cargo-set vars inherited from the *test binary's* build
285 // so compile-time env lookups in the example see its own crate.
286 envs.push((
287 "CARGO_MANIFEST_DIR".to_owned(),
288 params.src.clone().into_os_string(),
289 ));
290 envs.push((
291 "CARGO_CRATE_NAME".to_owned(),
292 std::ffi::OsString::from(example_crate_name.as_deref().unwrap()),
293 ));
294
295 let replayed = final_rustc::replay(
296 &template,
297 final_rustc::ReplayParams {
298 crate_name: example_crate_name.as_deref().unwrap(),
299 source_path: example_source_path.as_deref().unwrap(),
300 out_dir: &jobs_dir.join(job_name).join("out"),
301 metadata: &metadata,
302 envs,
303 },
304 );
305
306 match replayed {
307 Ok(artifact) => {
308 let data = std::fs::read(&artifact).unwrap();
309 return Ok(BuildOutput {
310 bin_data: data,
311 bin_path: artifact,
312 shared_library_path: Some(
313 base_target_dir.join("debug").join("deps"),
314 ),
315 });
316 }
317 Err(message) => {
318 hydro_concurrent_cargo::log_build_event(
319 &base_target_dir,
320 &format!("deploy: final rustc replay failed, falling back to cargo: {message}"),
321 );
322 let _ = std::fs::remove_file(template_path);
323 }
324 }
325 }
326
327 // Populate per-job build/ dir. Hold guard for entire final build.
328 let _job_build_guard = if params.is_dylib {
329 let shared_debug = base_target_dir.join("debug");
330 Some(hydro_concurrent_cargo::populate_job_build_dir(&per_job_target_dir.join("debug"), &shared_debug))
331 } else {
332 None
333 };
334
335 let mut command = Command::new("cargo");
336 command.args(["build", if params.is_dylib { "--frozen" } else { "--locked" }]);
337 // Verbose output includes the exact rustc invocation, which we capture as
338 // a template so subsequent builds can replay it without cargo.
339 command.arg("-v");
340
341 if let Some(profile) = params.profile.as_ref() {
342 command.args(["--profile", profile]);
343 }
344
345 if let Some(bin) = params.bin.as_ref() {
346 command.args(["--bin", bin]);
347 }
348
349 if let Some(example) = params.example.as_ref() {
350 command.args(["--example", example]);
351 }
352
353 match params.target_type {
354 HostTargetType::Local => {}
355 HostTargetType::Linux(crate::LinuxCompileType::Glibc) => {
356 command.args(["--target", "x86_64-unknown-linux-gnu"]);
357 }
358 HostTargetType::Linux(crate::LinuxCompileType::Musl) => {
359 command.args(["--target", "x86_64-unknown-linux-musl"]);
360 }
361 }
362
363 if params.no_default_features {
364 command.arg("--no-default-features");
365 }
366
367 if let Some(features) = params.features {
368 command.args(["--features", &features.join(",")]);
369 }
370
371 for config in ¶ms.config {
372 command.args(["--config", config]);
373 }
374
375 command.arg("--message-format=json-diagnostic-rendered-ansi");
376 command.args(["--target-dir", per_job_target_dir.to_str().unwrap()]);
377
378 if let Some(rustflags) = params.rustflags.as_ref() {
379 command.env("RUSTFLAGS", rustflags);
380 }
381
382 for (k, v) in params.build_env {
383 command.env(k, v);
384 }
385
386 let mut spawned = command
387 .current_dir(¶ms.src)
388 .stdout(Stdio::piped())
389 .stderr(Stdio::piped())
390 .stdin(Stdio::null())
391 .spawn()
392 .unwrap();
393
394 let reader = std::io::BufReader::new(spawned.stdout.take().unwrap());
395 let stderr_reader = std::io::BufReader::new(spawned.stderr.take().unwrap());
396
397 let stderr_worker = std::thread::spawn(move || {
398 let mut stderr_lines = Vec::new();
399 for line in stderr_reader.lines() {
400 let Ok(line) = line else {
401 break;
402 };
403 set_msg(line.clone());
404 stderr_lines.push(line);
405 }
406 stderr_lines
407 });
408
409 let mut diagnostics = Vec::new();
410 let mut text_lines = Vec::new();
411 for message in cargo_metadata::Message::parse_stream(reader) {
412 match message.unwrap() {
413 cargo_metadata::Message::CompilerArtifact(artifact) => {
414 let is_output = if params.example.is_some() {
415 artifact.target.kind.iter().any(|k| "example" == k)
416 } else {
417 artifact.target.kind.iter().any(|k| "bin" == k)
418 };
419
420 if is_output {
421 let path = artifact.executable.unwrap();
422 let path_buf: PathBuf = path.clone().into();
423 let path = path.into_string();
424 let data = std::fs::read(path).unwrap();
425 let exit_status = spawned.wait().unwrap();
426
427 let stderr_lines = stderr_worker.join().unwrap();
428
429 // Check for unexpected recompilations (only in dylib mode with prebuild).
430 if params.is_dylib {
431 for line in &stderr_lines {
432 if line.contains("Compiling") && !line.contains("dylib-examples") && !line.contains(job_name) {
433 panic!(
434 "unexpected recompilation in deploy final build: {line}\nfull stderr:\n{}",
435 stderr_lines.join("\n")
436 );
437 }
438 }
439 }
440
441 assert!(exit_status.success(), "deploy final build failed:\n{}", stderr_lines.join("\n"));
442
443 // Capture the rustc invocation cargo just used as a replay
444 // template for subsequent final builds against this prebuild.
445 if let (Some(stamp), Some(template_path)) =
446 (&prebuild_stamp, &template_path)
447 && let Some(argv) = final_rustc::extract_rustc_command(
448 &stderr_lines.join("\n"),
449 example_crate_name.as_deref().unwrap(),
450 )
451 && let Some(argv) = final_rustc::build_template(
452 argv,
453 example_crate_name.as_deref().unwrap(),
454 params.example.as_deref().unwrap(),
455 &per_job_target_dir,
456 &base_target_dir,
457 )
458 {
459 let _ = final_rustc::store(
460 template_path,
461 &final_rustc::RustcTemplate {
462 stamp: stamp.clone(),
463 cwd: params.workspace_root.clone(),
464 argv,
465 },
466 );
467 }
468
469 return Ok(BuildOutput {
470 bin_data: data,
471 bin_path: path_buf,
472 shared_library_path: if params.is_dylib {
473 Some(per_job_target_dir.join("debug").join("deps"))
474 } else {
475 None
476 },
477 });
478 }
479 }
480 cargo_metadata::Message::CompilerMessage(mut msg) => {
481 // Update the path displayed to enable clicking in IDE.
482 // TODO(mingwei): deduplicate code with hydro_lang sim/graph.rs
483 if let Some(rendered) = msg.message.rendered.as_mut() {
484 let file_names = msg
485 .message
486 .spans
487 .iter()
488 .map(|s| &s.file_name)
489 .collect::<std::collections::BTreeSet<_>>();
490 for file_name in file_names {
491 if Path::new(file_name).is_relative() {
492 *rendered = rendered.replace(
493 file_name,
494 &format!(
495 "(full path) {}/{file_name}",
496 params.workspace_root.display(),
497 ),
498 )
499 }
500 }
501 }
502 ProgressTracker::println(msg.message.to_string());
503 diagnostics.push(msg.message);
504 }
505 cargo_metadata::Message::TextLine(line) => {
506 ProgressTracker::println(&line);
507 text_lines.push(line);
508 }
509 cargo_metadata::Message::BuildFinished(_) => {}
510 cargo_metadata::Message::BuildScriptExecuted(_) => {}
511 msg => panic!("Unexpected message type: {:?}", msg),
512 }
513 }
514
515 let exit_status = spawned.wait().unwrap();
516 if exit_status.success() {
517 Err(BuildError::NoBinaryEmitted)
518 } else {
519 let stderr_lines = stderr_worker
520 .join()
521 .expect("Stderr worker unexpectedly panicked.");
522
523 Err(BuildError::FailedToBuildCrate {
524 exit_status,
525 diagnostics,
526 text_lines,
527 stderr_lines,
528 })
529 }
530 })
531 .await
532 .map_err(|_| BuildError::TokioJoinError)?
533 })
534 })
535 .await
536}
537
538#[derive(Clone, Debug)]
539pub enum BuildError {
540 FailedToBuildCrate {
541 exit_status: ExitStatus,
542 diagnostics: Vec<Diagnostic>,
543 text_lines: Vec<String>,
544 stderr_lines: Vec<String>,
545 },
546 TokioJoinError,
547 NoBinaryEmitted,
548}
549
550impl Display for BuildError {
551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552 match self {
553 Self::FailedToBuildCrate {
554 exit_status,
555 diagnostics,
556 text_lines,
557 stderr_lines,
558 } => {
559 writeln!(f, "Failed to build crate ({})", exit_status)?;
560 writeln!(f, "Diagnostics ({}):", diagnostics.len())?;
561 for diagnostic in diagnostics {
562 write!(f, "{}", diagnostic)?;
563 }
564 writeln!(f, "Text output ({} lines):", text_lines.len())?;
565 for line in text_lines {
566 writeln!(f, "{}", line)?;
567 }
568 writeln!(f, "Stderr output ({} lines):", stderr_lines.len())?;
569 for line in stderr_lines {
570 writeln!(f, "{}", line)?;
571 }
572 }
573 Self::TokioJoinError => {
574 write!(f, "Failed to spawn tokio blocking task.")?;
575 }
576 Self::NoBinaryEmitted => {
577 write!(f, "`cargo build` succeeded but no binary was emitted.")?;
578 }
579 }
580 Ok(())
581 }
582}
583
584impl Error for BuildError {}