Skip to main content

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(&params, 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(&params.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 &params.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(&params.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 {}