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 memo_map::MemoMap;
10use tokio::sync::OnceCell;
11
12use crate::HostTargetType;
13use crate::progress::ProgressTracker;
14
15/// Build parameters for [`build_crate_memoized`].
16#[derive(PartialEq, Eq, Hash, Clone)]
17pub struct BuildParams {
18    /// The working directory for the build, where the `cargo build` command will be run. Crate root.
19    /// [`Self::new`] canonicalizes this path.
20    src: PathBuf,
21    /// The workspace root encompassing the build, which may be a parent of `src` in a multi-crate
22    /// workspace.
23    workspace_root: PathBuf,
24    /// `--bin` binary name parameter.
25    bin: Option<String>,
26    /// `--example` parameter.
27    example: Option<String>,
28    /// `--profile` parameter.
29    profile: Option<String>,
30    rustflags: Option<String>,
31    target_dir: Option<PathBuf>,
32    // Environment variables available during build
33    build_env: Vec<(String, String)>,
34    no_default_features: bool,
35    /// `--target <linux>` if cross-compiling for linux ([`HostTargetType::Linux`]).
36    target_type: HostTargetType,
37    /// If set, the build uses dynamic linking: `src` is a generated trybuild `dylib-examples`
38    /// crate whose examples link against the trybuild dylib with this cargo package name.
39    dylib_package: Option<String>,
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        dylib_package: Option<String>,
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            dylib_package,
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    /// Directories to put on the dynamic library search path when running the binary,
108    /// containing any necessary dylibs (empty for statically linked builds).
109    pub shared_library_paths: Vec<PathBuf>,
110}
111impl BuildOutput {
112    /// A unique ID for the binary, based its contents.
113    pub fn unique_id(&self) -> impl use<> + Display {
114        blake3::hash(&self.bin_data).to_hex()
115    }
116}
117
118/// Build memoization cache.
119static BUILDS: OnceLock<MemoMap<BuildParams, OnceCell<BuildOutput>>> = OnceLock::new();
120
121pub async fn build_crate_memoized(params: BuildParams) -> Result<&'static BuildOutput, BuildError> {
122    BUILDS
123        .get_or_init(MemoMap::new)
124        .get_or_insert(&params, Default::default)
125        .get_or_try_init(move || {
126            ProgressTracker::rich_leaf("build", move |set_msg| async move {
127                tokio::task::spawn_blocking(move || {
128                    let base_target_dir = params
129                        .target_dir
130                        .clone()
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, _prebuild_guard, _cargo_lock) = if params.dylib_package.is_some() {
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                        // In dylib mode `src` is the generated project's `dylib-examples` crate.
162                        let project_dir = params.src.parent().unwrap();
163                        let (prebuild_guard, cargo_lock) = hydro_concurrent_cargo::run_prebuild(
164                            &base_target_dir,
165                            project_dir.file_name().unwrap().to_str().unwrap(),
166                            &features,
167                            params.rustflags.as_deref().unwrap_or_default(),
168                            &staged_paths,
169                            |prebuild_target| {
170                                set_msg("building dependencies".to_owned());
171                                hydro_concurrent_cargo::set_dylib_lib_name(
172                                    project_dir,
173                                    params.rustflags.as_deref().unwrap_or_default(),
174                                );
175
176                                // Prebuild the dylib-examples lib (`src` in dylib mode), which
177                                // transitively builds the trybuild dylib *as a dependency*.
178                                // This matters: cargo passes `-C prefer-dynamic` to dylib
179                                // crates when they are built as dependencies (linking libstd
180                                // dynamically), but *not* when they are the primary build
181                                // target — and the two variants share a cargo fingerprint. If
182                                // the dylib were prebuilt as a primary target, the cached
183                                // variant would statically link libstd and the final example
184                                // builds would fail to link ("cannot satisfy dependencies so
185                                // `std` only shows up once").
186                                let mut lib_cmd = Command::new("cargo");
187                                lib_cmd.current_dir(&src);
188                                lib_cmd.args(["build", "--locked", "--lib"]);
189                                if let Some(profile) = profile.as_ref() {
190                                    lib_cmd.args(["--profile", profile]);
191                                }
192                                match target_type {
193                                    HostTargetType::Local => {}
194                                    HostTargetType::Linux(crate::LinuxCompileType::Glibc) => {
195                                        lib_cmd.args(["--target", "x86_64-unknown-linux-gnu"]);
196                                    }
197                                    HostTargetType::Linux(crate::LinuxCompileType::Musl) => {
198                                        lib_cmd.args(["--target", "x86_64-unknown-linux-musl"]);
199                                    }
200                                }
201                                if no_default_features {
202                                    lib_cmd.arg("--no-default-features");
203                                }
204                                if !features_for_closure.is_empty() {
205                                    lib_cmd.args(["--features", &features_for_closure.join(",")]);
206                                }
207                                for c in &config {
208                                    lib_cmd.args(["--config", c]);
209                                }
210                                lib_cmd.args(["--target-dir", prebuild_target.to_str().unwrap()]);
211                                if let Some(rustflags) = rustflags.as_ref() {
212                                    lib_cmd.env("RUSTFLAGS", rustflags);
213                                }
214                                for (k, v) in &build_env {
215                                    lib_cmd.env(k, v);
216                                }
217                                let lib_status = lib_cmd
218                                    .stdin(Stdio::null())
219                                    .status()
220                                    .unwrap();
221                                assert!(lib_status.success(), "dep prebuild failed")
222                            },
223                        );
224
225                        (per_job, Some(prebuild_guard), Some(cargo_lock))
226                    } else {
227                        (base_target_dir.clone(), None, None)
228                    };
229
230                    hydro_concurrent_cargo::log_build_event(&base_target_dir, "deploy: starting final build");
231
232                    // Populate per-job build/ dir. Hold guard for entire final build.
233                    let _job_build_guard = if params.dylib_package.is_some() {
234                        let shared_debug = base_target_dir.join("debug");
235                        Some(hydro_concurrent_cargo::populate_job_build_dir(&per_job_target_dir.join("debug"), &shared_debug))
236                    } else {
237                        None
238                    };
239
240                    let mut command = Command::new("cargo");
241                    command.args(["build", if params.dylib_package.is_some() { "--frozen" } else { "--locked" }]);
242
243                    if let Some(profile) = params.profile.as_ref() {
244                        command.args(["--profile", profile]);
245                    }
246
247                    if let Some(bin) = params.bin.as_ref() {
248                        command.args(["--bin", bin]);
249                    }
250
251                    if let Some(example) = params.example.as_ref() {
252                        command.args(["--example", example]);
253                    }
254
255                    match params.target_type {
256                        HostTargetType::Local => {}
257                        HostTargetType::Linux(crate::LinuxCompileType::Glibc) => {
258                            command.args(["--target", "x86_64-unknown-linux-gnu"]);
259                        }
260                        HostTargetType::Linux(crate::LinuxCompileType::Musl) => {
261                            command.args(["--target", "x86_64-unknown-linux-musl"]);
262                        }
263                    }
264
265                    if params.no_default_features {
266                        command.arg("--no-default-features");
267                    }
268
269                    if let Some(features) = params.features {
270                        command.args(["--features", &features.join(",")]);
271                    }
272
273                    for config in &params.config {
274                        command.args(["--config", config]);
275                    }
276
277                    command.arg("--message-format=json-diagnostic-rendered-ansi");
278                    command.args(["--target-dir", per_job_target_dir.to_str().unwrap()]);
279
280                    if let Some(rustflags) = params.rustflags.as_ref() {
281                        command.env("RUSTFLAGS", rustflags);
282                    }
283
284                    for (k, v) in params.build_env {
285                        command.env(k, v);
286                    }
287
288                    let mut spawned = command
289                        .current_dir(&params.src)
290                        .stdout(Stdio::piped())
291                        .stderr(Stdio::piped())
292                        .stdin(Stdio::null())
293                        .spawn()
294                        .unwrap();
295
296                    let reader = std::io::BufReader::new(spawned.stdout.take().unwrap());
297                    let stderr_reader = std::io::BufReader::new(spawned.stderr.take().unwrap());
298
299                    let stderr_worker = std::thread::spawn(move || {
300                        let mut stderr_lines = Vec::new();
301                        for line in stderr_reader.lines() {
302                            let Ok(line) = line else {
303                                break;
304                            };
305                            set_msg(line.clone());
306                            stderr_lines.push(line);
307                        }
308                        stderr_lines
309                    });
310
311                    let mut diagnostics = Vec::new();
312                    let mut text_lines = Vec::new();
313                    for message in cargo_metadata::Message::parse_stream(reader) {
314                        match message.unwrap() {
315                            cargo_metadata::Message::CompilerArtifact(artifact) => {
316                                let is_output = if params.example.is_some() {
317                                    artifact.target.kind.iter().any(|k| "example" == k)
318                                } else {
319                                    artifact.target.kind.iter().any(|k| "bin" == k)
320                                };
321
322                                if is_output {
323                                    let path = artifact.executable.unwrap();
324                                    let path_buf: PathBuf = path.clone().into();
325                                    let path = path.into_string();
326                                    let data = std::fs::read(path).unwrap();
327                                    let exit_status = spawned.wait().unwrap();
328
329                                    let stderr_lines = stderr_worker.join().unwrap();
330
331                                    // Check for unexpected recompilations (only in dylib mode with prebuild).
332                                    if params.dylib_package.is_some() {
333                                        for line in &stderr_lines {
334                                            if line.contains("Compiling") {
335                                                assert!(
336                                                    line.contains("dylib-examples")
337                                                        || line.contains(job_name),
338                                                    "unexpected recompilation in deploy final build: {line}\nfull stderr:\n{}",
339                                                    stderr_lines.join("\n")
340                                                );
341                                            }
342                                        }
343                                    }
344
345                                    assert!(exit_status.success(), "deploy final build failed:\n{}", stderr_lines.join("\n"));
346
347                                    return Ok(BuildOutput {
348                                        bin_data: data,
349                                        bin_path: path_buf,
350                                        shared_library_paths: if let Some(dylib_package) = &params.dylib_package {
351                                            hydro_concurrent_cargo::dylib_search_dirs(
352                                                dylib_package,
353                                                &per_job_target_dir.join("debug"),
354                                                &base_target_dir.join("debug"),
355                                            )
356                                        } else {
357                                            Vec::new()
358                                        },
359                                    });
360                                }
361                            }
362                            cargo_metadata::Message::CompilerMessage(mut msg) => {
363                                // Update the path displayed to enable clicking in IDE.
364                                // TODO(mingwei): deduplicate code with hydro_lang sim/graph.rs
365                                if let Some(rendered) = msg.message.rendered.as_mut() {
366                                    let file_names = msg
367                                        .message
368                                        .spans
369                                        .iter()
370                                        .map(|s| &s.file_name)
371                                        .collect::<std::collections::BTreeSet<_>>();
372                                    for file_name in file_names {
373                                        if Path::new(file_name).is_relative() {
374                                            *rendered = rendered.replace(
375                                                file_name,
376                                                &format!(
377                                                    "(full path) {}/{file_name}",
378                                                    params.workspace_root.display(),
379                                                ),
380                                            )
381                                        }
382                                    }
383                                }
384                                ProgressTracker::println(msg.message.to_string());
385                                diagnostics.push(msg.message);
386                            }
387                            cargo_metadata::Message::TextLine(line) => {
388                                ProgressTracker::println(&line);
389                                text_lines.push(line);
390                            }
391                            cargo_metadata::Message::BuildFinished(_) => {}
392                            cargo_metadata::Message::BuildScriptExecuted(_) => {}
393                            msg => panic!("Unexpected message type: {:?}", msg),
394                        }
395                    }
396
397                    let exit_status = spawned.wait().unwrap();
398                    if exit_status.success() {
399                        Err(BuildError::NoBinaryEmitted)
400                    } else {
401                        let stderr_lines = stderr_worker
402                            .join()
403                            .expect("Stderr worker unexpectedly panicked.");
404
405                        Err(BuildError::FailedToBuildCrate {
406                            exit_status,
407                            diagnostics,
408                            text_lines,
409                            stderr_lines,
410                        })
411                    }
412                })
413                .await
414                .map_err(|_| BuildError::TokioJoinError)?
415            })
416        })
417        .await
418}
419
420#[derive(Clone, Debug)]
421pub enum BuildError {
422    FailedToBuildCrate {
423        exit_status: ExitStatus,
424        diagnostics: Vec<Diagnostic>,
425        text_lines: Vec<String>,
426        stderr_lines: Vec<String>,
427    },
428    TokioJoinError,
429    NoBinaryEmitted,
430}
431
432impl Display for BuildError {
433    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434        match self {
435            Self::FailedToBuildCrate {
436                exit_status,
437                diagnostics,
438                text_lines,
439                stderr_lines,
440            } => {
441                writeln!(f, "Failed to build crate ({})", exit_status)?;
442                writeln!(f, "Diagnostics ({}):", diagnostics.len())?;
443                for diagnostic in diagnostics {
444                    write!(f, "{}", diagnostic)?;
445                }
446                writeln!(f, "Text output ({} lines):", text_lines.len())?;
447                for line in text_lines {
448                    writeln!(f, "{}", line)?;
449                }
450                writeln!(f, "Stderr output ({} lines):", stderr_lines.len())?;
451                for line in stderr_lines {
452                    writeln!(f, "{}", line)?;
453                }
454            }
455            Self::TokioJoinError => {
456                write!(f, "Failed to spawn tokio blocking task.")?;
457            }
458            Self::NoBinaryEmitted => {
459                write!(f, "`cargo build` succeeded but no binary was emitted.")?;
460            }
461        }
462        Ok(())
463    }
464}
465
466impl Error for BuildError {}