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    /// True is the build should use dynamic linking.
38    is_dylib: bool,
39    /// `--features` flags, will be comma-delimited.
40    features: Option<Vec<String>>,
41    /// `--config` flag
42    config: Vec<String>,
43}
44impl BuildParams {
45    /// Creates a new `BuildParams` and canonicalizes the `src` path.
46    #[expect(clippy::too_many_arguments, reason = "internal code")]
47    pub fn new(
48        src: impl AsRef<Path>,
49        workspace_root: impl AsRef<Path>,
50        bin: Option<String>,
51        example: Option<String>,
52        profile: Option<String>,
53        rustflags: Option<String>,
54        target_dir: Option<PathBuf>,
55        build_env: Vec<(String, String)>,
56        no_default_features: bool,
57        target_type: HostTargetType,
58        is_dylib: bool,
59        features: Option<Vec<String>>,
60        config: Vec<String>,
61    ) -> Self {
62        // `fs::canonicalize` prepends windows paths with the `r"\\?\"`
63        // https://stackoverflow.com/questions/21194530/what-does-mean-when-prepended-to-a-file-path
64        // However, this breaks the `include!(concat!(env!("OUT_DIR"), "/my/forward/slash/path.rs"))`
65        // Rust codegen pattern on windows. To help mitigate this happening in third party crates, we
66        // instead use `dunce::canonicalize` which is the same as `fs::canonicalize` but avoids the
67        // `\\?\` prefix when possible.
68        let src = dunce::canonicalize(src.as_ref()).unwrap_or_else(|e| {
69            panic!(
70                "Failed to canonicalize path `{}` for build: {e}.",
71                src.as_ref().display(),
72            )
73        });
74
75        let workspace_root = dunce::canonicalize(workspace_root.as_ref()).unwrap_or_else(|e| {
76            panic!(
77                "Failed to canonicalize path `{}` for build: {e}.",
78                workspace_root.as_ref().display(),
79            )
80        });
81
82        BuildParams {
83            src,
84            workspace_root,
85            bin,
86            example,
87            profile,
88            rustflags,
89            target_dir,
90            build_env,
91            no_default_features,
92            target_type,
93            is_dylib,
94            features,
95            config,
96        }
97    }
98}
99
100/// Information about a built crate. See [`build_crate_memoized`].
101pub struct BuildOutput {
102    /// The binary contents as a byte array.
103    pub bin_data: Vec<u8>,
104    /// The path to the binary file. [`Self::bin_data`] has a copy of the content.
105    pub bin_path: PathBuf,
106    /// Shared library path, containing any necessary dylibs.
107    pub shared_library_path: Option<PathBuf>,
108}
109impl BuildOutput {
110    /// A unique ID for the binary, based its contents.
111    pub fn unique_id(&self) -> impl use<> + Display {
112        blake3::hash(&self.bin_data).to_hex()
113    }
114}
115
116/// Build memoization cache.
117static BUILDS: OnceLock<MemoMap<BuildParams, OnceCell<BuildOutput>>> = OnceLock::new();
118
119pub async fn build_crate_memoized(params: BuildParams) -> Result<&'static BuildOutput, BuildError> {
120    BUILDS
121        .get_or_init(MemoMap::new)
122        .get_or_insert(&params, Default::default)
123        .get_or_try_init(move || {
124            ProgressTracker::rich_leaf("build", move |set_msg| async move {
125                tokio::task::spawn_blocking(move || {
126                    let base_target_dir = params
127                        .target_dir
128                        .as_ref()
129                        .cloned()
130                        .unwrap_or_else(|| params.src.join("target"));
131                    let job_name = params
132                        .bin
133                        .as_deref()
134                        .or(params.example.as_deref())
135                        .unwrap_or("default");
136
137                    // Only use prebuild + per-job target dirs for dylib mode.
138                    // Without dylib, build directly into the base target dir.
139                    let (per_job_target_dir, _prebuild_guard, _cargo_lock) = if params.is_dylib {
140                        let shared_debug = base_target_dir.join("debug");
141                        let jobs_dir = base_target_dir.join("jobs");
142                        let per_job = hydro_concurrent_cargo::setup_job_dir(&jobs_dir, job_name, &shared_debug);
143
144                        let features = params.features.clone().unwrap_or_default();
145                        let staged_paths = vec![
146                            params.src.join("src").join("__staged.rs"),
147                            params.src.join("Cargo.lock"),
148                            std::env::current_exe().unwrap(),
149                        ];
150
151                        let src = params.src.clone();
152                        let profile = params.profile.clone();
153                        let target_type = params.target_type;
154                        let no_default_features = params.no_default_features;
155                        let features_for_closure = features.clone();
156                        let config = params.config.clone();
157                        let rustflags = params.rustflags.clone();
158                        let build_env = params.build_env.clone();
159
160                        // In dylib mode `src` is the generated project's `dylib-examples` crate.
161                        let project_dir = params.src.parent().unwrap();
162                        let (prebuild_guard, cargo_lock) = hydro_concurrent_cargo::run_prebuild(
163                            &base_target_dir,
164                            project_dir.file_name().unwrap().to_str().unwrap(),
165                            &features,
166                            params.rustflags.as_deref().unwrap_or_default(),
167                            &staged_paths,
168                            |prebuild_target| {
169                                set_msg("building dependencies".to_owned());
170                                hydro_concurrent_cargo::set_dylib_lib_name(
171                                    project_dir,
172                                    params.rustflags.as_deref().unwrap_or_default(),
173                                );
174
175                                // Prebuild the dylib-examples lib (`src` in dylib mode), which
176                                // transitively builds the trybuild dylib *as a dependency*.
177                                // This matters: cargo passes `-C prefer-dynamic` to dylib
178                                // crates when they are built as dependencies (linking libstd
179                                // dynamically), but *not* when they are the primary build
180                                // target — and the two variants share a cargo fingerprint. If
181                                // the dylib were prebuilt as a primary target, the cached
182                                // variant would statically link libstd and the final example
183                                // builds would fail to link ("cannot satisfy dependencies so
184                                // `std` only shows up once").
185                                let mut lib_cmd = Command::new("cargo");
186                                lib_cmd.current_dir(&src);
187                                lib_cmd.args(["build", "--locked", "--lib"]);
188                                if let Some(profile) = profile.as_ref() {
189                                    lib_cmd.args(["--profile", profile]);
190                                }
191                                match target_type {
192                                    HostTargetType::Local => {}
193                                    HostTargetType::Linux(crate::LinuxCompileType::Glibc) => {
194                                        lib_cmd.args(["--target", "x86_64-unknown-linux-gnu"]);
195                                    }
196                                    HostTargetType::Linux(crate::LinuxCompileType::Musl) => {
197                                        lib_cmd.args(["--target", "x86_64-unknown-linux-musl"]);
198                                    }
199                                }
200                                if no_default_features {
201                                    lib_cmd.arg("--no-default-features");
202                                }
203                                if !features_for_closure.is_empty() {
204                                    lib_cmd.args(["--features", &features_for_closure.join(",")]);
205                                }
206                                for c in &config {
207                                    lib_cmd.args(["--config", c]);
208                                }
209                                lib_cmd.args(["--target-dir", prebuild_target.to_str().unwrap()]);
210                                if let Some(rustflags) = rustflags.as_ref() {
211                                    lib_cmd.env("RUSTFLAGS", rustflags);
212                                }
213                                for (k, v) in &build_env {
214                                    lib_cmd.env(k, v);
215                                }
216                                let lib_status = lib_cmd
217                                    .stdin(Stdio::null())
218                                    .status()
219                                    .unwrap();
220                                if !lib_status.success() {
221                                    panic!("dep prebuild failed");
222                                }
223                            },
224                        );
225
226                        (per_job, Some(prebuild_guard), Some(cargo_lock))
227                    } else {
228                        (base_target_dir.clone(), None, None)
229                    };
230
231                    hydro_concurrent_cargo::log_build_event(&base_target_dir, "deploy: starting final build");
232
233                    // Populate per-job build/ dir. Hold guard for entire final build.
234                    let _job_build_guard = if params.is_dylib {
235                        let shared_debug = base_target_dir.join("debug");
236                        Some(hydro_concurrent_cargo::populate_job_build_dir(&per_job_target_dir.join("debug"), &shared_debug))
237                    } else {
238                        None
239                    };
240
241                    let mut command = Command::new("cargo");
242                    command.args(["build", if params.is_dylib { "--frozen" } else { "--locked" }]);
243
244                    if let Some(profile) = params.profile.as_ref() {
245                        command.args(["--profile", profile]);
246                    }
247
248                    if let Some(bin) = params.bin.as_ref() {
249                        command.args(["--bin", bin]);
250                    }
251
252                    if let Some(example) = params.example.as_ref() {
253                        command.args(["--example", example]);
254                    }
255
256                    match params.target_type {
257                        HostTargetType::Local => {}
258                        HostTargetType::Linux(crate::LinuxCompileType::Glibc) => {
259                            command.args(["--target", "x86_64-unknown-linux-gnu"]);
260                        }
261                        HostTargetType::Linux(crate::LinuxCompileType::Musl) => {
262                            command.args(["--target", "x86_64-unknown-linux-musl"]);
263                        }
264                    }
265
266                    if params.no_default_features {
267                        command.arg("--no-default-features");
268                    }
269
270                    if let Some(features) = params.features {
271                        command.args(["--features", &features.join(",")]);
272                    }
273
274                    for config in &params.config {
275                        command.args(["--config", config]);
276                    }
277
278                    command.arg("--message-format=json-diagnostic-rendered-ansi");
279                    command.args(["--target-dir", per_job_target_dir.to_str().unwrap()]);
280
281                    if let Some(rustflags) = params.rustflags.as_ref() {
282                        command.env("RUSTFLAGS", rustflags);
283                    }
284
285                    for (k, v) in params.build_env {
286                        command.env(k, v);
287                    }
288
289                    let mut spawned = command
290                        .current_dir(&params.src)
291                        .stdout(Stdio::piped())
292                        .stderr(Stdio::piped())
293                        .stdin(Stdio::null())
294                        .spawn()
295                        .unwrap();
296
297                    let reader = std::io::BufReader::new(spawned.stdout.take().unwrap());
298                    let stderr_reader = std::io::BufReader::new(spawned.stderr.take().unwrap());
299
300                    let stderr_worker = std::thread::spawn(move || {
301                        let mut stderr_lines = Vec::new();
302                        for line in stderr_reader.lines() {
303                            let Ok(line) = line else {
304                                break;
305                            };
306                            set_msg(line.clone());
307                            stderr_lines.push(line);
308                        }
309                        stderr_lines
310                    });
311
312                    let mut diagnostics = Vec::new();
313                    let mut text_lines = Vec::new();
314                    for message in cargo_metadata::Message::parse_stream(reader) {
315                        match message.unwrap() {
316                            cargo_metadata::Message::CompilerArtifact(artifact) => {
317                                let is_output = if params.example.is_some() {
318                                    artifact.target.kind.iter().any(|k| "example" == k)
319                                } else {
320                                    artifact.target.kind.iter().any(|k| "bin" == k)
321                                };
322
323                                if is_output {
324                                    let path = artifact.executable.unwrap();
325                                    let path_buf: PathBuf = path.clone().into();
326                                    let path = path.into_string();
327                                    let data = std::fs::read(path).unwrap();
328                                    let exit_status = spawned.wait().unwrap();
329
330                                    let stderr_lines = stderr_worker.join().unwrap();
331
332                                    // Check for unexpected recompilations (only in dylib mode with prebuild).
333                                    if params.is_dylib {
334                                        for line in &stderr_lines {
335                                            if line.contains("Compiling") && !line.contains("dylib-examples") && !line.contains(job_name) {
336                                                panic!(
337                                                    "unexpected recompilation in deploy final build: {line}\nfull stderr:\n{}",
338                                                    stderr_lines.join("\n")
339                                                );
340                                            }
341                                        }
342                                    }
343
344                                    assert!(exit_status.success(), "deploy final build failed:\n{}", stderr_lines.join("\n"));
345
346                                    return Ok(BuildOutput {
347                                        bin_data: data,
348                                        bin_path: path_buf,
349                                        shared_library_path: if params.is_dylib {
350                                            Some(per_job_target_dir.join("debug").join("deps"))
351                                        } else {
352                                            None
353                                        },
354                                    });
355                                }
356                            }
357                            cargo_metadata::Message::CompilerMessage(mut msg) => {
358                                // Update the path displayed to enable clicking in IDE.
359                                // TODO(mingwei): deduplicate code with hydro_lang sim/graph.rs
360                                if let Some(rendered) = msg.message.rendered.as_mut() {
361                                    let file_names = msg
362                                        .message
363                                        .spans
364                                        .iter()
365                                        .map(|s| &s.file_name)
366                                        .collect::<std::collections::BTreeSet<_>>();
367                                    for file_name in file_names {
368                                        if Path::new(file_name).is_relative() {
369                                            *rendered = rendered.replace(
370                                                file_name,
371                                                &format!(
372                                                    "(full path) {}/{file_name}",
373                                                    params.workspace_root.display(),
374                                                ),
375                                            )
376                                        }
377                                    }
378                                }
379                                ProgressTracker::println(msg.message.to_string());
380                                diagnostics.push(msg.message);
381                            }
382                            cargo_metadata::Message::TextLine(line) => {
383                                ProgressTracker::println(&line);
384                                text_lines.push(line);
385                            }
386                            cargo_metadata::Message::BuildFinished(_) => {}
387                            cargo_metadata::Message::BuildScriptExecuted(_) => {}
388                            msg => panic!("Unexpected message type: {:?}", msg),
389                        }
390                    }
391
392                    let exit_status = spawned.wait().unwrap();
393                    if exit_status.success() {
394                        Err(BuildError::NoBinaryEmitted)
395                    } else {
396                        let stderr_lines = stderr_worker
397                            .join()
398                            .expect("Stderr worker unexpectedly panicked.");
399
400                        Err(BuildError::FailedToBuildCrate {
401                            exit_status,
402                            diagnostics,
403                            text_lines,
404                            stderr_lines,
405                        })
406                    }
407                })
408                .await
409                .map_err(|_| BuildError::TokioJoinError)?
410            })
411        })
412        .await
413}
414
415#[derive(Clone, Debug)]
416pub enum BuildError {
417    FailedToBuildCrate {
418        exit_status: ExitStatus,
419        diagnostics: Vec<Diagnostic>,
420        text_lines: Vec<String>,
421        stderr_lines: Vec<String>,
422    },
423    TokioJoinError,
424    NoBinaryEmitted,
425}
426
427impl Display for BuildError {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        match self {
430            Self::FailedToBuildCrate {
431                exit_status,
432                diagnostics,
433                text_lines,
434                stderr_lines,
435            } => {
436                writeln!(f, "Failed to build crate ({})", exit_status)?;
437                writeln!(f, "Diagnostics ({}):", diagnostics.len())?;
438                for diagnostic in diagnostics {
439                    write!(f, "{}", diagnostic)?;
440                }
441                writeln!(f, "Text output ({} lines):", text_lines.len())?;
442                for line in text_lines {
443                    writeln!(f, "{}", line)?;
444                }
445                writeln!(f, "Stderr output ({} lines):", stderr_lines.len())?;
446                for line in stderr_lines {
447                    writeln!(f, "{}", line)?;
448                }
449            }
450            Self::TokioJoinError => {
451                write!(f, "Failed to spawn tokio blocking task.")?;
452            }
453            Self::NoBinaryEmitted => {
454                write!(f, "`cargo build` succeeded but no binary was emitted.")?;
455            }
456        }
457        Ok(())
458    }
459}
460
461impl Error for BuildError {}