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