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                        let (prebuild_guard, cargo_lock) = hydro_concurrent_cargo::run_prebuild(
161                            &base_target_dir,
162                            params.src.parent().unwrap().file_name().unwrap().to_str().unwrap(),
163                            &features,
164                            &staged_paths,
165                            |prebuild_target| {
166                                set_msg("building dependencies".to_owned());
167
168                                // Prebuild the dylib-examples lib (`src` in dylib mode), which
169                                // transitively builds the trybuild dylib *as a dependency*.
170                                // This matters: cargo passes `-C prefer-dynamic` to dylib
171                                // crates when they are built as dependencies (linking libstd
172                                // dynamically), but *not* when they are the primary build
173                                // target — and the two variants share a cargo fingerprint. If
174                                // the dylib were prebuilt as a primary target, the cached
175                                // variant would statically link libstd and the final example
176                                // builds would fail to link ("cannot satisfy dependencies so
177                                // `std` only shows up once").
178                                let mut lib_cmd = Command::new("cargo");
179                                lib_cmd.current_dir(&src);
180                                lib_cmd.args(["build", "--locked", "--lib"]);
181                                if let Some(profile) = profile.as_ref() {
182                                    lib_cmd.args(["--profile", profile]);
183                                }
184                                match target_type {
185                                    HostTargetType::Local => {}
186                                    HostTargetType::Linux(crate::LinuxCompileType::Glibc) => {
187                                        lib_cmd.args(["--target", "x86_64-unknown-linux-gnu"]);
188                                    }
189                                    HostTargetType::Linux(crate::LinuxCompileType::Musl) => {
190                                        lib_cmd.args(["--target", "x86_64-unknown-linux-musl"]);
191                                    }
192                                }
193                                if no_default_features {
194                                    lib_cmd.arg("--no-default-features");
195                                }
196                                if !features_for_closure.is_empty() {
197                                    lib_cmd.args(["--features", &features_for_closure.join(",")]);
198                                }
199                                for c in &config {
200                                    lib_cmd.args(["--config", c]);
201                                }
202                                lib_cmd.args(["--target-dir", prebuild_target.to_str().unwrap()]);
203                                if let Some(rustflags) = rustflags.as_ref() {
204                                    lib_cmd.env("RUSTFLAGS", rustflags);
205                                }
206                                for (k, v) in &build_env {
207                                    lib_cmd.env(k, v);
208                                }
209                                let lib_status = lib_cmd
210                                    .stdin(Stdio::null())
211                                    .status()
212                                    .unwrap();
213                                if !lib_status.success() {
214                                    panic!("dep prebuild failed");
215                                }
216                            },
217                        );
218
219                        (per_job, Some(prebuild_guard), Some(cargo_lock))
220                    } else {
221                        (base_target_dir.clone(), None, None)
222                    };
223
224                    hydro_concurrent_cargo::log_build_event(&base_target_dir, "deploy: starting final build");
225
226                    // Populate per-job build/ dir. Hold guard for entire final build.
227                    let _job_build_guard = if params.is_dylib {
228                        let shared_debug = base_target_dir.join("debug");
229                        Some(hydro_concurrent_cargo::populate_job_build_dir(&per_job_target_dir.join("debug"), &shared_debug))
230                    } else {
231                        None
232                    };
233
234                    let mut command = Command::new("cargo");
235                    command.args(["build", if params.is_dylib { "--frozen" } else { "--locked" }]);
236
237                    if let Some(profile) = params.profile.as_ref() {
238                        command.args(["--profile", profile]);
239                    }
240
241                    if let Some(bin) = params.bin.as_ref() {
242                        command.args(["--bin", bin]);
243                    }
244
245                    if let Some(example) = params.example.as_ref() {
246                        command.args(["--example", example]);
247                    }
248
249                    match params.target_type {
250                        HostTargetType::Local => {}
251                        HostTargetType::Linux(crate::LinuxCompileType::Glibc) => {
252                            command.args(["--target", "x86_64-unknown-linux-gnu"]);
253                        }
254                        HostTargetType::Linux(crate::LinuxCompileType::Musl) => {
255                            command.args(["--target", "x86_64-unknown-linux-musl"]);
256                        }
257                    }
258
259                    if params.no_default_features {
260                        command.arg("--no-default-features");
261                    }
262
263                    if let Some(features) = params.features {
264                        command.args(["--features", &features.join(",")]);
265                    }
266
267                    for config in &params.config {
268                        command.args(["--config", config]);
269                    }
270
271                    command.arg("--message-format=json-diagnostic-rendered-ansi");
272                    command.args(["--target-dir", per_job_target_dir.to_str().unwrap()]);
273
274                    if let Some(rustflags) = params.rustflags.as_ref() {
275                        command.env("RUSTFLAGS", rustflags);
276                    }
277
278                    for (k, v) in params.build_env {
279                        command.env(k, v);
280                    }
281
282                    let mut spawned = command
283                        .current_dir(&params.src)
284                        .stdout(Stdio::piped())
285                        .stderr(Stdio::piped())
286                        .stdin(Stdio::null())
287                        .spawn()
288                        .unwrap();
289
290                    let reader = std::io::BufReader::new(spawned.stdout.take().unwrap());
291                    let stderr_reader = std::io::BufReader::new(spawned.stderr.take().unwrap());
292
293                    let stderr_worker = std::thread::spawn(move || {
294                        let mut stderr_lines = Vec::new();
295                        for line in stderr_reader.lines() {
296                            let Ok(line) = line else {
297                                break;
298                            };
299                            set_msg(line.clone());
300                            stderr_lines.push(line);
301                        }
302                        stderr_lines
303                    });
304
305                    let mut diagnostics = Vec::new();
306                    let mut text_lines = Vec::new();
307                    for message in cargo_metadata::Message::parse_stream(reader) {
308                        match message.unwrap() {
309                            cargo_metadata::Message::CompilerArtifact(artifact) => {
310                                let is_output = if params.example.is_some() {
311                                    artifact.target.kind.iter().any(|k| "example" == k)
312                                } else {
313                                    artifact.target.kind.iter().any(|k| "bin" == k)
314                                };
315
316                                if is_output {
317                                    let path = artifact.executable.unwrap();
318                                    let path_buf: PathBuf = path.clone().into();
319                                    let path = path.into_string();
320                                    let data = std::fs::read(path).unwrap();
321                                    let exit_status = spawned.wait().unwrap();
322
323                                    let stderr_lines = stderr_worker.join().unwrap();
324
325                                    // Check for unexpected recompilations (only in dylib mode with prebuild).
326                                    if params.is_dylib {
327                                        for line in &stderr_lines {
328                                            if line.contains("Compiling") && !line.contains("dylib-examples") && !line.contains(job_name) {
329                                                panic!(
330                                                    "unexpected recompilation in deploy final build: {line}\nfull stderr:\n{}",
331                                                    stderr_lines.join("\n")
332                                                );
333                                            }
334                                        }
335                                    }
336
337                                    assert!(exit_status.success(), "deploy final build failed:\n{}", stderr_lines.join("\n"));
338
339                                    return Ok(BuildOutput {
340                                        bin_data: data,
341                                        bin_path: path_buf,
342                                        shared_library_path: if params.is_dylib {
343                                            Some(per_job_target_dir.join("debug").join("deps"))
344                                        } else {
345                                            None
346                                        },
347                                    });
348                                }
349                            }
350                            cargo_metadata::Message::CompilerMessage(mut msg) => {
351                                // Update the path displayed to enable clicking in IDE.
352                                // TODO(mingwei): deduplicate code with hydro_lang sim/graph.rs
353                                if let Some(rendered) = msg.message.rendered.as_mut() {
354                                    let file_names = msg
355                                        .message
356                                        .spans
357                                        .iter()
358                                        .map(|s| &s.file_name)
359                                        .collect::<std::collections::BTreeSet<_>>();
360                                    for file_name in file_names {
361                                        if Path::new(file_name).is_relative() {
362                                            *rendered = rendered.replace(
363                                                file_name,
364                                                &format!(
365                                                    "(full path) {}/{file_name}",
366                                                    params.workspace_root.display(),
367                                                ),
368                                            )
369                                        }
370                                    }
371                                }
372                                ProgressTracker::println(msg.message.to_string());
373                                diagnostics.push(msg.message);
374                            }
375                            cargo_metadata::Message::TextLine(line) => {
376                                ProgressTracker::println(&line);
377                                text_lines.push(line);
378                            }
379                            cargo_metadata::Message::BuildFinished(_) => {}
380                            cargo_metadata::Message::BuildScriptExecuted(_) => {}
381                            msg => panic!("Unexpected message type: {:?}", msg),
382                        }
383                    }
384
385                    let exit_status = spawned.wait().unwrap();
386                    if exit_status.success() {
387                        Err(BuildError::NoBinaryEmitted)
388                    } else {
389                        let stderr_lines = stderr_worker
390                            .join()
391                            .expect("Stderr worker unexpectedly panicked.");
392
393                        Err(BuildError::FailedToBuildCrate {
394                            exit_status,
395                            diagnostics,
396                            text_lines,
397                            stderr_lines,
398                        })
399                    }
400                })
401                .await
402                .map_err(|_| BuildError::TokioJoinError)?
403            })
404        })
405        .await
406}
407
408#[derive(Clone, Debug)]
409pub enum BuildError {
410    FailedToBuildCrate {
411        exit_status: ExitStatus,
412        diagnostics: Vec<Diagnostic>,
413        text_lines: Vec<String>,
414        stderr_lines: Vec<String>,
415    },
416    TokioJoinError,
417    NoBinaryEmitted,
418}
419
420impl Display for BuildError {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        match self {
423            Self::FailedToBuildCrate {
424                exit_status,
425                diagnostics,
426                text_lines,
427                stderr_lines,
428            } => {
429                writeln!(f, "Failed to build crate ({})", exit_status)?;
430                writeln!(f, "Diagnostics ({}):", diagnostics.len())?;
431                for diagnostic in diagnostics {
432                    write!(f, "{}", diagnostic)?;
433                }
434                writeln!(f, "Text output ({} lines):", text_lines.len())?;
435                for line in text_lines {
436                    writeln!(f, "{}", line)?;
437                }
438                writeln!(f, "Stderr output ({} lines):", stderr_lines.len())?;
439                for line in stderr_lines {
440                    writeln!(f, "{}", line)?;
441                }
442            }
443            Self::TokioJoinError => {
444                write!(f, "Failed to spawn tokio blocking task.")?;
445            }
446            Self::NoBinaryEmitted => {
447                write!(f, "`cargo build` succeeded but no binary was emitted.")?;
448            }
449        }
450        Ok(())
451    }
452}
453
454impl Error for BuildError {}