Skip to main content

hydro_lang/compile/trybuild/
generate.rs

1use std::fs::{self, File};
2use std::io::{Read, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4
5#[cfg(any(feature = "deploy", feature = "maelstrom"))]
6use dfir_lang::diagnostic::Diagnostics;
7#[cfg(any(feature = "deploy", feature = "maelstrom"))]
8use dfir_lang::graph::{AsCodeOptions, DfirGraph};
9use sha2::{Digest, Sha256};
10#[cfg(any(feature = "deploy", feature = "maelstrom"))]
11use stageleft::internal::quote;
12use trybuild_internals_api::cargo::{self, Metadata};
13use trybuild_internals_api::env::Update;
14use trybuild_internals_api::normalize::Normalization;
15use trybuild_internals_api::run::{PathDependency, Project};
16use trybuild_internals_api::{Runner, dependencies, features, path};
17
18pub const HYDRO_RUNTIME_FEATURES: &[&str] = &[
19    "deploy_integration",
20    "runtime_measure",
21    "docker_runtime",
22    "ecs_runtime",
23    "maelstrom_runtime",
24    "sim_runtime",
25];
26
27#[cfg(any(feature = "deploy", feature = "maelstrom"))]
28/// Whether to use dynamic linking for the generated binary.
29/// - `Static`: Place in base crate examples (for remote/containerized deploys)
30/// - `Dynamic`: Place in dylib crate examples (for sim and localhost deploys)
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum LinkingMode {
33    // `Static` is only constructed by the deploy backends; Maelstrom-only builds
34    // always use `Dynamic`.
35    #[cfg_attr(
36        not(feature = "deploy"),
37        expect(
38            dead_code,
39            reason = "only constructed by the deploy backends; Maelstrom-only builds use Dynamic"
40        )
41    )]
42    Static,
43    #[cfg(any(feature = "deploy", feature = "maelstrom"))]
44    Dynamic,
45}
46
47#[cfg(any(feature = "deploy", feature = "maelstrom"))]
48/// The deployment mode for code generation.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum DeployMode {
51    #[cfg(feature = "deploy")]
52    /// Standard `HydroDeploy`
53    HydroDeploy,
54    #[cfg(any(feature = "docker_deploy", feature = "ecs_deploy"))]
55    /// Containerized deployment (Docker/ECS)
56    Containerized,
57    #[cfg(feature = "maelstrom")]
58    /// Maelstrom deployment with stdin/stdout JSON protocol
59    Maelstrom,
60}
61
62pub(crate) static IS_TEST: std::sync::atomic::AtomicBool =
63    std::sync::atomic::AtomicBool::new(false);
64
65pub(crate) static CONCURRENT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
66
67/// Enables "test mode" for Hydro, which makes it possible to compile Hydro programs written
68/// inside a `#[cfg(test)]` module. This should be enabled in a global [`ctor`] hook.
69///
70/// # Example
71/// ```ignore
72/// #[cfg(test)]
73/// mod test_init {
74///    #[ctor::ctor]
75///    fn init() {
76///        hydro_lang::compile::init_test();
77///    }
78/// }
79/// ```
80pub fn init_test() {
81    IS_TEST.store(true, std::sync::atomic::Ordering::Relaxed);
82}
83
84#[cfg(any(feature = "deploy", feature = "maelstrom"))]
85fn clean_bin_name_prefix(bin_name_prefix: &str) -> String {
86    bin_name_prefix
87        .replace("::", "__")
88        .replace([' ', ',', '<', '{', '}'], "_")
89        .replace(['>', '(', ')'], "")
90}
91
92#[derive(Debug, Clone)]
93pub struct TrybuildConfig {
94    pub project_dir: PathBuf,
95    pub target_dir: PathBuf,
96    /// The cargo package name of the generated project's `dylib` crate, which the dynamically
97    /// linked examples (sim and localhost deploys) load at runtime.
98    pub dylib_package_name: String,
99    pub features: Option<Vec<String>>,
100    #[cfg(any(feature = "deploy", feature = "maelstrom"))]
101    // Only the deploy backends read this field; Maelstrom-only builds derive the
102    // linking behavior directly.
103    #[cfg_attr(
104        not(feature = "deploy"),
105        expect(dead_code, reason = "only read by the deploy backends")
106    )]
107    /// Which crate within the workspace to use for examples.
108    /// - `Static`: base crate (for remote/containerized deploys)
109    /// - `Dynamic`: dylib-examples crate (for sim and localhost deploys)
110    pub linking_mode: LinkingMode,
111}
112
113#[cfg(any(feature = "deploy", feature = "maelstrom"))]
114pub fn create_graph_trybuild(
115    graph: DfirGraph,
116    extra_stmts: &[syn::Stmt],
117    sidecars: &[syn::Expr],
118    as_code_options: &AsCodeOptions,
119    bin_name_prefix: Option<&str>,
120    deploy_mode: DeployMode,
121    linking_mode: LinkingMode,
122) -> (String, TrybuildConfig) {
123    let source_dir = cargo::manifest_dir().unwrap();
124    let source_manifest = dependencies::get_manifest(&source_dir).unwrap();
125    let crate_name = source_manifest.package.name.replace('-', "_");
126
127    let is_test = IS_TEST.load(std::sync::atomic::Ordering::Relaxed);
128
129    let generated_code = {
130        let _span = tracing::debug_span!(target: "hydro_build", "graph_codegen").entered();
131        compile_graph_trybuild(
132            graph,
133            extra_stmts,
134            sidecars,
135            as_code_options,
136            &crate_name,
137            deploy_mode,
138        )
139    };
140
141    let source = {
142        let _span = tracing::debug_span!(target: "hydro_build", "unparse_source").entered();
143        prettyplease::unparse(&generated_code)
144    };
145
146    let hash = format!("{:X}", Sha256::digest(&source))
147        .chars()
148        .take(8)
149        .collect::<String>();
150
151    let bin_name = if let Some(bin_name_prefix) = bin_name_prefix {
152        format!("{}_{}", clean_bin_name_prefix(bin_name_prefix), hash)
153    } else {
154        hash
155    };
156
157    let (project_dir, target_dir, dylib_package_name, mut cur_bin_enabled_features) =
158        create_trybuild().unwrap();
159
160    // Determine which crate's examples folder to use based on linking mode
161    let examples_dir = match linking_mode {
162        LinkingMode::Static => path!(project_dir / "examples"),
163        #[cfg(any(feature = "deploy", feature = "maelstrom"))]
164        LinkingMode::Dynamic => path!(project_dir / "dylib-examples" / "examples"),
165    };
166
167    // TODO(shadaj): garbage collect this directory occasionally
168    fs::create_dir_all(&examples_dir).unwrap();
169
170    let out_path = path!(examples_dir / format!("{bin_name}.rs"));
171    {
172        let _span =
173            tracing::debug_span!(target: "hydro_build", "write_generated_sources").entered();
174        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
175        write_atomic(source.as_ref(), &out_path).unwrap();
176    }
177
178    if is_test {
179        write_staged_source_cached(source_dir.as_ref(), &crate_name, &project_dir);
180    }
181
182    if is_test {
183        if cur_bin_enabled_features.is_none() {
184            cur_bin_enabled_features = Some(vec![]);
185        }
186
187        cur_bin_enabled_features
188            .as_mut()
189            .unwrap()
190            .push("hydro___test".to_owned());
191    }
192
193    (
194        bin_name,
195        TrybuildConfig {
196            project_dir,
197            target_dir,
198            dylib_package_name,
199            features: cur_bin_enabled_features,
200            #[cfg(any(feature = "deploy", feature = "maelstrom"))]
201            linking_mode,
202        },
203    )
204}
205
206#[cfg(any(feature = "deploy", feature = "maelstrom"))]
207pub fn compile_graph_trybuild(
208    partitioned_graph: DfirGraph,
209    extra_stmts: &[syn::Stmt],
210    sidecars: &[syn::Expr],
211    as_code_options: &AsCodeOptions,
212    crate_name: &str,
213    deploy_mode: DeployMode,
214) -> syn::File {
215    use crate::staging_util::get_this_crate;
216
217    let mut diagnostics = Diagnostics::new();
218    let dfir_expr: syn::Expr = syn::parse2(
219        partitioned_graph
220            .as_code_with_options(
221                &quote! { __root_dfir_rs },
222                as_code_options,
223                quote!(),
224                &mut diagnostics,
225            )
226            .expect("DFIR code generation failed with diagnostics."),
227    )
228    .unwrap();
229
230    let orig_crate_name = quote::format_ident!("{}", crate_name);
231    let trybuild_crate_name_ident = quote::format_ident!("{}_hydro_trybuild", crate_name);
232    let root = get_this_crate();
233    let tokio_main_ident = format!("{}::runtime_support::tokio", root);
234    let dfir_ident = quote::format_ident!("{}", crate::compile::DFIR_IDENT);
235
236    let source_ast: syn::File = match deploy_mode {
237        #[cfg(any(feature = "docker_deploy", feature = "ecs_deploy"))]
238        DeployMode::Containerized => {
239            syn::parse_quote! {
240                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
241                use #trybuild_crate_name_ident::__root as #orig_crate_name;
242                use #orig_crate_name::*;
243                use #orig_crate_name::__staged::__deps::*;
244                use #root::prelude::*;
245                use #root::runtime_support::dfir_rs as __root_dfir_rs;
246                pub use #orig_crate_name::__staged;
247
248                #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
249                async fn main() {
250                    #root::telemetry::initialize_tracing();
251
252                    #( #extra_stmts )*
253
254                    let mut #dfir_ident = #dfir_expr;
255
256                    let local_set = #root::runtime_support::tokio::task::LocalSet::new();
257                    #(
258                        let _ = local_set.spawn_local( #sidecars ); // Uses #dfir_ident
259                    )*
260
261                    let _ = local_set.run_until(#dfir_ident.run()).await;
262                }
263            }
264        }
265        #[cfg(feature = "deploy")]
266        DeployMode::HydroDeploy => {
267            syn::parse_quote! {
268                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
269                use #trybuild_crate_name_ident::__root as #orig_crate_name;
270                use #orig_crate_name::*;
271                use #orig_crate_name::__staged::__deps::*;
272                use #root::prelude::*;
273                use #root::runtime_support::dfir_rs as __root_dfir_rs;
274                pub use #orig_crate_name::__staged;
275
276                #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
277                async fn main() {
278                    let __hydro_lang_trybuild_cli_owned: #root::runtime_support::hydro_deploy_integration::DeployPorts<#root::__staged::deploy::deploy_runtime::HydroMeta> = #root::runtime_support::launch::init_no_ack_start().await;
279                    let __hydro_lang_trybuild_cli = &__hydro_lang_trybuild_cli_owned;
280
281                    #( #extra_stmts )*
282
283                    let mut #dfir_ident = #dfir_expr;
284                    println!("ack start");
285
286                    // TODO(mingwei): initialize `tracing` at this point in execution.
287                    // After "ack start" is when we can print whatever we want.
288
289                    let local_set = #root::runtime_support::tokio::task::LocalSet::new();
290                    #(
291                        let _ = local_set.spawn_local( #sidecars ); // Uses #dfir_ident
292                    )*
293
294                    let _ = local_set.run_until(#root::runtime_support::launch::run_stdin_commands(
295                        async move {
296                            #dfir_ident.run().await
297                        }
298                    )).await;
299                }
300            }
301        }
302        #[cfg(feature = "maelstrom")]
303        DeployMode::Maelstrom => {
304            // The `MetricsState` typestate of the generated `Dfir`, revealed in the
305            // `impl TickClosure` signature so sidecars can call `.metrics()` when tracking is enabled.
306            let dfir_metrics_ty = crate::compile::dfir_metrics_state_type(as_code_options, &root);
307            syn::parse_quote! {
308                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
309                use #trybuild_crate_name_ident::__root as #orig_crate_name;
310                use #orig_crate_name::*;
311                use #orig_crate_name::__staged::__deps::*;
312                use #root::prelude::*;
313                use #root::runtime_support::dfir_rs as __root_dfir_rs;
314                pub use #orig_crate_name::__staged;
315
316                #[allow(unused)]
317                fn __hydro_runtime<'a>(
318                    __hydro_lang_maelstrom_meta: &'a #root::__staged::deploy::maelstrom::deploy_runtime_maelstrom::MaelstromMeta
319                )
320                    -> #root::runtime_support::dfir_rs::scheduled::context::Dfir<impl #root::runtime_support::dfir_rs::scheduled::context::TickClosure<Metrics = #dfir_metrics_ty> + 'a>
321                {
322                    #( #extra_stmts )*
323
324                    #dfir_expr
325                }
326
327                #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
328                async fn main() {
329                    #root::telemetry::initialize_tracing();
330
331                    // Initialize Maelstrom protocol - read init message and send init_ok
332                    let __hydro_lang_maelstrom_meta = #root::__staged::deploy::maelstrom::deploy_runtime_maelstrom::maelstrom_init();
333
334                    let mut #dfir_ident = __hydro_runtime(&__hydro_lang_maelstrom_meta);
335
336                    __hydro_lang_maelstrom_meta.start_receiving(); // start receiving messages after initializing subscribers
337
338                    let local_set = #root::runtime_support::tokio::task::LocalSet::new();
339                    #(
340                        let _ = local_set.spawn_local( #sidecars ); // Uses #dfir_ident
341                    )*
342
343                    let _ = local_set.run_until(#dfir_ident.run()).await;
344                }
345            }
346        }
347    };
348    source_ast
349}
350
351/// Configuration for [`compile_trybuild_example`], the shared concurrent-build
352/// entrypoint used by both the simulator and the Maelstrom deployment target.
353#[cfg(any(feature = "sim", feature = "maelstrom"))]
354pub struct ExampleBuildConfig<'a> {
355    /// The trybuild project + target directories and enabled features.
356    pub trybuild: TrybuildConfig,
357    /// The generated example base name (a content hash). Used as the per-job
358    /// directory name and, when [`Self::set_trybuild_lib_name`] is set, as the
359    /// value of the `TRYBUILD_LIB_NAME` environment variable.
360    pub bin_name: String,
361    /// A runtime feature to enable in addition to [`TrybuildConfig::features`]
362    /// (e.g. `hydro___feature_sim_runtime` or `hydro___feature_maelstrom_runtime`).
363    pub runtime_feature: &'a str,
364    /// The cargo `--example` target to build. For the simulator this is the
365    /// fixed `sim-dylib` wrapper; for Maelstrom it is the generated `bin_name`.
366    pub example_name: String,
367    /// If `Some`, override the crate type on the command line (e.g. `cdylib`
368    /// for the simulator). `None` builds a normal executable example.
369    pub crate_type: Option<&'a str>,
370    /// Whether to set `TRYBUILD_LIB_NAME` to `bin_name` (the simulator uses this
371    /// for its `include!`-based indirection).
372    pub set_trybuild_lib_name: bool,
373    /// Whether to honor the `BOLERO_FUZZER` environment variable. Only the
374    /// simulator supports fuzzing; other targets should set this to `false`.
375    pub allow_fuzz: bool,
376}
377
378/// Returns the toolchain's target libdir (where the shared `libstd` lives), memoized.
379#[cfg(any(feature = "sim", feature = "maelstrom"))]
380fn rustc_target_libdir() -> Option<String> {
381    static LIBDIR: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
382    LIBDIR
383        .get_or_init(|| {
384            let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_owned());
385            std::process::Command::new(rustc)
386                .args(["--print", "target-libdir"])
387                .output()
388                .ok()
389                .filter(|out| out.status.success())
390                .map(|out| String::from_utf8(out.stdout).unwrap().trim().to_owned())
391        })
392        .clone()
393}
394
395/// The `CARGO_ENCODED_RUSTFLAGS` this crate was compiled with, captured by `build.rs`.
396///
397/// Generated Hydro programs are compiled by a child `cargo`, which is handed exactly these
398/// flags (see [`forward_rustflags`]) so the generated code matches the crate that produced it:
399/// same cfgs, same instrumentation, same codegen options. Unlike reading `RUSTFLAGS` from the
400/// process environment, this also sees flags injected through cargo config (`build.rustflags`,
401/// `target.*.rustflags`, `--config`), which never reach the test process's environment.
402///
403/// Also keys the trybuild dylib's target name (see [`create_trybuild`]).
404const ENCODED_RUSTFLAGS: &str = env!("HYDRO_ENCODED_RUSTFLAGS");
405
406/// Gives a child `cargo` the same rustflags as [`ENCODED_RUSTFLAGS`].
407///
408/// The flags are passed as CLI config (`--config build.rustflags=[...]`), not by setting
409/// `CARGO_ENCODED_RUSTFLAGS`/`RUSTFLAGS` in the child's environment, and the inherited
410/// environment is left untouched. Build scripts commonly emit
411/// `cargo:rerun-if-env-changed=CARGO_ENCODED_RUSTFLAGS` (e.g. `aws-lc-sys`), which cargo
412/// evaluates against *its own* process environment: a child whose environment differs from
413/// the outer build's — even by setting the variable to an empty string — gets different
414/// fingerprints and rebuilds those crates in the shared `deps/`, thrashing against the outer
415/// build and against sibling children that don't set it.
416///
417/// Precedence is consistent by construction: if `RUSTFLAGS` or `CARGO_ENCODED_RUSTFLAGS` is
418/// in the inherited environment it wins over this config in the child, but then it is also
419/// what the outer build's encoded flags were derived from, so the flags agree either way.
420/// Cargo merges `--config` arrays with those from config files, so a workspace whose
421/// `.cargo/config.toml` sets `build.rustflags` gets those flags twice in the child (once from
422/// the copied config, once forwarded); harmless to rustc, but the child's flag string then
423/// differs from the outer build's, so its dependency rlibs are not shared with it.
424#[cfg(any(feature = "sim", feature = "maelstrom"))]
425fn forward_rustflags(command: &mut std::process::Command) {
426    let flags = toml::Value::try_from(
427        ENCODED_RUSTFLAGS
428            .split('\x1f')
429            .filter(|flag| !flag.is_empty())
430            .collect::<Vec<_>>(),
431    )
432    .unwrap();
433    command.args(["--config", &format!("build.rustflags={flags}")]);
434}
435
436/// Compiles a generated trybuild example against the prebuilt dylib crate,
437/// using the shared parallel-compilation machinery (per-job target dirs with
438/// symlinked shared artifacts, plus a prebuild of the dylib dependencies).
439///
440/// Returns the path of a persistent hard link to the built artifact (a `cdylib`
441/// for the simulator, or an executable for Maelstrom) under the shared target
442/// dir's `debug/deps/hydro-coverage`. Cargo repoints the job's uplifted artifact
443/// on every rebuild, so the caller must not load that path directly; the link is
444/// taken while the job's build lock is still held, so it names exactly the build
445/// this call produced, and a later rebuild (which unlinks and re-links the
446/// uplifted name) leaves it intact. Coverage reporters also find it there (see
447/// the comment at the link site).
448#[cfg(any(feature = "sim", feature = "maelstrom"))]
449pub fn compile_trybuild_example(config: ExampleBuildConfig<'_>) -> Result<PathBuf, ()> {
450    use std::process::{Command, Stdio};
451
452    let ExampleBuildConfig {
453        trybuild,
454        bin_name,
455        runtime_feature,
456        example_name,
457        crate_type,
458        set_trybuild_lib_name,
459        allow_fuzz,
460    } = config;
461
462    let is_fuzz = allow_fuzz && std::env::var("BOLERO_FUZZER").is_ok();
463
464    // The parallel build machinery (per-job target dirs symlinking the shared `debug/`
465    // artifacts, fronted by a fingerprinted prebuild) assumes the host `debug/` layout and
466    // dynamically linked examples. Fuzz builds statically link examples from the base crate,
467    // and `CARGO_BUILD_TARGET` moves artifacts under `<triple>/debug/`; both build directly
468    // into the shared target dir instead, where cargo's own fingerprinting applies.
469    let use_prebuild = !is_fuzz && std::env::var_os("CARGO_BUILD_TARGET").is_none();
470
471    // Run from dylib-examples crate which has the dylib as a dev-dependency (only if not fuzzing)
472    let crate_to_compile = if is_fuzz {
473        trybuild.project_dir.clone()
474    } else {
475        path!(trybuild.project_dir / "dylib-examples")
476    };
477
478    let (final_target_dir, _prebuild_guard, _cargo_lock) = if use_prebuild {
479        let prebuild_span =
480            tracing::debug_span!(target: "hydro_build", "prebuild", bin_name = %bin_name).entered();
481        let shared_debug = trybuild.target_dir.join("debug");
482        let jobs_dir = trybuild.target_dir.join("jobs");
483        let per_job = hydro_concurrent_cargo::setup_job_dir(&jobs_dir, &bin_name, &shared_debug);
484
485        let mut features: Vec<String> = trybuild.features.clone().unwrap_or_default();
486        features.push(runtime_feature.to_owned());
487
488        let staged_paths = vec![
489            path!(trybuild.project_dir / "src" / "__staged.rs"),
490            path!(trybuild.project_dir / "Cargo.lock"),
491            std::env::current_exe().unwrap(),
492        ];
493
494        let features_for_closure = features.clone();
495
496        // The prebuild is fingerprinted on these features, our rustflags, and the compiler
497        // version: shared artifacts built under other flags or another toolchain are rebuilt
498        // (under the exclusive lock, after renaming the dylib for this configuration) before any
499        // final build links against them.
500        let (guard, cargo_lock) = hydro_concurrent_cargo::run_prebuild(
501            &trybuild.target_dir,
502            trybuild.project_dir.file_name().unwrap().to_str().unwrap(),
503            &features,
504            ENCODED_RUSTFLAGS,
505            &staged_paths,
506            |prebuild_target| {
507                hydro_concurrent_cargo::set_dylib_lib_name(
508                    &trybuild.project_dir,
509                    ENCODED_RUSTFLAGS,
510                );
511                let features_str = features_for_closure.join(",");
512
513                // Prebuild the lib that final builds will link against, which transitively
514                // builds the trybuild dylib *as a dependency*. This matters: cargo passes
515                // `-C prefer-dynamic` to dylib crates when they are built as dependencies
516                // (linking libstd dynamically), but *not* when they are the primary build
517                // target — and the two variants share a cargo fingerprint, so whichever is
518                // built first wins. Building the dylib as a primary target would poison the
519                // cache with a statically-linked-std variant that later fails to link into
520                // examples ("cannot satisfy dependencies so `std` only shows up once").
521                let mut lib_cmd = Command::new("cargo");
522                lib_cmd.current_dir(&crate_to_compile);
523                lib_cmd.args(["build", "--locked", "--lib"]);
524                lib_cmd.args(["--target-dir", prebuild_target.to_str().unwrap()]);
525                lib_cmd.arg("--no-default-features");
526                lib_cmd.args(["--features", &features_str]);
527                lib_cmd.args(["--config", "build.incremental = false"]);
528                lib_cmd.env("STAGELEFT_TRYBUILD_BUILD_STAGED", "1");
529                forward_rustflags(&mut lib_cmd);
530                let status = lib_cmd.stdin(Stdio::null()).status().unwrap();
531                assert!(status.success(), "dep prebuild failed")
532            },
533        );
534
535        // Close the prebuild span before returning the guards: the guards are held for the
536        // entire final build, but the prebuild phase (freshness check + possible dep build)
537        // ends here.
538        drop(prebuild_span);
539        (per_job, Some(guard), Some(cargo_lock))
540    } else {
541        (trybuild.target_dir.clone(), None, None)
542    };
543
544    // Populate per-job build/ dir right before final build. Hold guard for entire build.
545    let _job_build_guard = if use_prebuild {
546        let populate_span =
547            tracing::debug_span!(target: "hydro_build", "populate_job_dir", bin_name = %bin_name)
548                .entered();
549        let shared_debug = trybuild.target_dir.join("debug");
550        let guard = hydro_concurrent_cargo::populate_job_build_dir(
551            &final_target_dir.join("debug"),
552            &shared_debug,
553        );
554        // Close the populate span here: the returned guard is held until the final build
555        // finishes, but the population work itself ends here.
556        drop(populate_span);
557        Some(guard)
558    } else {
559        None
560    };
561
562    let final_build_span =
563        tracing::debug_span!(target: "hydro_build", "final_build", bin_name = %bin_name).entered();
564    let mut command = Command::new("cargo");
565    command.current_dir(&crate_to_compile);
566    command.args(["rustc", if use_prebuild { "--frozen" } else { "--locked" }]);
567    command.args(["--example", &example_name]);
568    command.args(["--target-dir", final_target_dir.to_str().unwrap()]);
569    // Never enable default features: the generated example gets exactly the
570    // features it needs via `--features` (plus the runtime feature). This keeps
571    // the feature set minimal and deterministic — matching what the deploy
572    // backends and the base trybuild crate (which carries the source crate's
573    // `default`) would otherwise pull in — and matters for the `is_fuzz` path
574    // that builds from the base crate directly.
575    command.arg("--no-default-features");
576    command.args([
577        "--features",
578        &trybuild
579            .features
580            .clone()
581            .into_iter()
582            .flatten()
583            .chain([runtime_feature.to_owned()])
584            .collect::<Vec<_>>()
585            .join(","),
586    ]);
587    command.args(["--config", "build.incremental = false"]);
588    forward_rustflags(&mut command);
589    if let Some(crate_type) = crate_type {
590        command.args(["--crate-type", crate_type]);
591    }
592    command.arg("--message-format=json-diagnostic-rendered-ansi");
593    command.env("STAGELEFT_TRYBUILD_BUILD_STAGED", "1");
594    if set_trybuild_lib_name {
595        command.env("TRYBUILD_LIB_NAME", &bin_name);
596    }
597
598    command.arg("--");
599
600    // Give this job its own cargo unit. The simulator builds the fixed `sim-dylib` example in
601    // every job, varying only `TRYBUILD_LIB_NAME` — which affects the unit's *freshness* but
602    // not its hash. Cargo folds these extra rustc args into the unit hash, so without a
603    // per-job argument every flow shares one `.fingerprint/` entry and one `deps/` output
604    // (both symlinked to the shared `target/debug/` under the legacy layout) and flows
605    // invalidate each other on every switch: a full relink per test, and the previous flow's
606    // artifact gets replaced while another test process may still have it loaded. (On Linux
607    // and macOS the per-job rpath arguments below happened to do this implicitly.)
608    command.arg(format!("-Cmetadata={bin_name}"));
609
610    if cfg!(any(target_os = "linux", target_os = "macos")) {
611        let (debug_path, shared_debug_path) =
612            if let Ok(target) = std::env::var("CARGO_BUILD_TARGET") {
613                (
614                    path!(final_target_dir / target / "debug"),
615                    path!(trybuild.target_dir / target / "debug"),
616                )
617            } else {
618                (
619                    path!(final_target_dir / "debug"),
620                    path!(trybuild.target_dir / "debug"),
621                )
622            };
623
624        // The built example links the trybuild dylib dynamically. Bake rpath entries for
625        // where cargo places it (which depends on the build-dir layout, see
626        // `hydro_concurrent_cargo::dylib_search_dirs`) and for the toolchain's shared libstd,
627        // so the artifact can be loaded/run without LD_LIBRARY_PATH.
628        let mut rpaths = hydro_concurrent_cargo::dylib_search_dirs(
629            &trybuild.dylib_package_name,
630            &debug_path,
631            &shared_debug_path,
632        );
633        if let Some(libdir) = rustc_target_libdir() {
634            rpaths.push(PathBuf::from(libdir));
635        }
636        for rpath in rpaths {
637            if cfg!(target_os = "macos") {
638                // On macOS rustc may invoke the linker directly (`rust-lld -flavor darwin`),
639                // which rejects `-Wl,`-wrapped arguments. Use raw ld64 syntax (`-rpath <path>`
640                // as two arguments), which the clang driver also forwards to the linker.
641                command.args([
642                    "-Clink-arg=-rpath".to_owned(),
643                    format!("-Clink-arg={}", rpath.to_str().unwrap()),
644                ]);
645            } else {
646                command.args([format!("-Clink-arg=-Wl,-rpath,{}", rpath.to_str().unwrap())]);
647            }
648        }
649
650        if cfg!(all(target_os = "linux", target_env = "gnu")) {
651            command.arg(
652                // https://github.com/rust-lang/rust/issues/91979
653                "-Clink-args=-Wl,-z,nodelete",
654            );
655        }
656    }
657
658    if allow_fuzz && let Ok(fuzzer) = std::env::var("BOLERO_FUZZER") {
659        command.env_remove("BOLERO_FUZZER");
660
661        if fuzzer == "libfuzzer" {
662            #[cfg(target_os = "macos")]
663            {
664                command.args(["-Clink-arg=-undefined", "-Clink-arg=dynamic_lookup"]);
665            }
666
667            #[cfg(target_os = "linux")]
668            {
669                command.args(["-Clink-arg=-Wl,--unresolved-symbols=ignore-all"]);
670            }
671        }
672    }
673
674    tracing::debug!(
675        target: "hydro_build",
676        "final build command (cwd={}): {:?}",
677        crate_to_compile.display(),
678        command
679    );
680
681    let mut spawned = command
682        .stdout(Stdio::piped())
683        .stderr(Stdio::piped())
684        .stdin(Stdio::null())
685        .spawn()
686        .unwrap();
687    let reader = std::io::BufReader::new(spawned.stdout.take().unwrap());
688    let stderr_handle = spawned.stderr.take().unwrap();
689    let stderr_thread = std::thread::spawn(move || {
690        use std::io::Read;
691        let mut buf = String::new();
692        std::io::BufReader::new(stderr_handle)
693            .read_to_string(&mut buf)
694            .unwrap();
695        buf
696    });
697
698    let mut out = Err(());
699    for message in cargo_metadata::Message::parse_stream(reader) {
700        match message.unwrap() {
701            cargo_metadata::Message::CompilerArtifact(artifact) => {
702                // unlike dylib, cdylib only exports the explicitly exported symbols
703                let is_output = artifact.target.is_example();
704
705                if is_output {
706                    let path = artifact.filenames.first().unwrap();
707                    let path_buf: PathBuf = path.clone().into();
708                    out = Ok(path_buf);
709                }
710            }
711            cargo_metadata::Message::CompilerMessage(mut msg) => {
712                // Update the path displayed to enable clicking in IDE.
713                // TODO(mingwei): deduplicate code with hydro_deploy rust_crate/build.rs
714                if let Some(rendered) = msg.message.rendered.as_mut() {
715                    let file_names = msg
716                        .message
717                        .spans
718                        .iter()
719                        .map(|s| &s.file_name)
720                        .collect::<std::collections::BTreeSet<_>>();
721                    for file_name in file_names {
722                        *rendered = rendered.replace(
723                            file_name,
724                            &format!("(full path) {}/{file_name}", trybuild.project_dir.display()),
725                        )
726                    }
727                }
728                eprintln!("{}", msg.message);
729            }
730            cargo_metadata::Message::TextLine(line) => {
731                eprintln!("{}", line);
732            }
733            cargo_metadata::Message::BuildFinished(_) => {}
734            cargo_metadata::Message::BuildScriptExecuted(_) => {}
735            msg => panic!("Unexpected message type: {:?}", msg),
736        }
737    }
738
739    spawned.wait().unwrap();
740    let stderr_output = stderr_thread.join().unwrap();
741    drop(final_build_span);
742
743    // Check for unexpected recompilations — only dylib-examples should be compiled.
744    // (Only relevant when prebuild is active.)
745    if use_prebuild {
746        for line in stderr_output.lines() {
747            if line.contains("Compiling") {
748                assert!(
749                    line.contains("dylib-examples"),
750                    "unexpected recompilation in final build: {line}\nfull stderr:\n{stderr_output}"
751                );
752            }
753        }
754    }
755
756    assert!(
757        out.is_ok(),
758        "final build failed to produce binary.\nstderr:\n{stderr_output}"
759    );
760
761    // Hard-link the artifact to a name keyed by the generated source's hash (embedded in
762    // `bin_name`) and by the toolchain fingerprint, under the shared target dir's
763    // `debug/deps`. This is what the caller loads, and it doubles as the artifact coverage
764    // reporters need: the coverage mapping that resolves profile data lives in the artifact
765    // itself, and reporters run only after all test processes have exited, scanning
766    // `target/debug` or `target/debug/deps` recursively (both are common
767    // `grcov --binary-path` conventions). The link is made unconditionally because whether
768    // the artifact is instrumented cannot be told from this crate's own flags: selective
769    // instrumentation may instrument the generated program but not `hydro_lang`.
770    // See https://github.com/hydro-project/hydro/issues/3160.
771    let out = out.unwrap();
772    let coverage_dir = path!(trybuild.target_dir / "debug" / "deps" / "hydro-coverage");
773    fs::create_dir_all(&coverage_dir).unwrap();
774    let artifact_name = out.file_name().unwrap().to_str().unwrap();
775    let fingerprint = hydro_concurrent_cargo::toolchain_fingerprint(ENCODED_RUSTFLAGS);
776    let persisted = path!(coverage_dir / format!("{bin_name}-{fingerprint}-{artifact_name}"));
777
778    // Job dirs are keyed by `bin_name`, so a concurrent test process building the same flow
779    // shares this job. If cargo found that build up to date, `persisted` already links the very
780    // file cargo just reported and there is nothing left to do. Skipping the relink matters on
781    // Windows, where that other process has `persisted` loaded (mapped as an image) and the
782    // `rename` below would fail with "Access is denied".
783    if same_file::is_same_file(&out, &persisted).unwrap_or(false) {
784        return Ok(persisted);
785    }
786
787    // Link under a unique name, then rename over the final one, so concurrent test processes
788    // loading the same flow never observe a missing or partially-written artifact.
789    let staging = path!(
790        coverage_dir
791            / format!(
792                ".{bin_name}.{}.{}.tmp",
793                std::process::id(),
794                std::time::SystemTime::now()
795                    .duration_since(std::time::UNIX_EPOCH)
796                    .unwrap()
797                    .as_nanos()
798            )
799    );
800    if fs::hard_link(&out, &staging).is_err() {
801        // Filesystems without hard links (or a target dir split across devices) still get a
802        // private snapshot of this build, at the cost of a copy.
803        fs::copy(&out, &staging).unwrap();
804    }
805    fs::rename(&staging, &persisted).unwrap_or_else(|err| {
806        // Windows refuses to replace a file that another process has mapped as an image. A
807        // concurrent test of the same flow having `persisted` loaded is expected (see above),
808        // but then cargo should have found the job's build up to date and we would not be
809        // here; reaching this means something rebuilt the example.
810        panic!(
811            "failed to rename {} to {}: {err}",
812            staging.display(),
813            persisted.display()
814        )
815    });
816    // If `persisted` already links the same inode (an up-to-date build linked again), POSIX
817    // `rename` succeeds without removing `staging`.
818    match fs::remove_file(&staging) {
819        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
820        result => result.unwrap(),
821    }
822    Ok(persisted)
823}
824
825/// Generates the inlined `__staged.rs` source for the source crate and writes it into the
826/// trybuild project, caching the (expensive) generation across test processes.
827///
828/// The staged source is a pure function of the source crate's files, and cargo rebuilds the
829/// test executable whenever those change, so the identity (path + mtime) of
830/// [`std::env::current_exe`] is a sound freshness proxy. All tests in a run share the same
831/// executable, so only the first test per test binary pays the ~1s `syn` parse +
832/// `prettyplease` unparse; the rest hit the cache. The stamp holds a single entry — the last
833/// executable to write `__staged.rs` — so a different test binary of the same crate
834/// regenerates on its first test (a no-op rewrite when sources are unchanged). Keeping old
835/// entries around would risk a stale match (e.g. an executable restored with an old mtime
836/// after another binary regenerated `__staged.rs` from different sources).
837pub(crate) fn write_staged_source_cached(source_dir: &Path, crate_name: &str, project_dir: &Path) {
838    let _span = tracing::debug_span!(target: "hydro_build", "gen_staged").entered();
839
840    let staged_path = path!(project_dir / "src" / "__staged.rs");
841    let stamp_path = path!(project_dir / ".hydro-staged-stamp");
842
843    let exe_stamp = std::env::current_exe().ok().and_then(|exe| {
844        let mtime = fs::metadata(&exe)
845            .ok()?
846            .modified()
847            .ok()?
848            .duration_since(std::time::SystemTime::UNIX_EPOCH)
849            .ok()?
850            .as_nanos();
851        Some(format!("{}\t{}", exe.display(), mtime))
852    });
853
854    let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
855
856    fs::create_dir_all(path!(project_dir / "src")).unwrap();
857
858    // Hold an exclusive lock on the stamp file for the entire check + generate: when many
859    // test processes start concurrently with a cold cache, the first one generates while the
860    // others block here and then hit the cache, instead of all doing the expensive work.
861    let mut stamp_file = File::options()
862        .read(true)
863        .write(true)
864        .create(true)
865        .truncate(false)
866        .open(&stamp_path)
867        .unwrap();
868    stamp_file.lock().unwrap();
869
870    let mut existing_stamp = String::new();
871    if stamp_file.read_to_string(&mut existing_stamp).is_err() {
872        existing_stamp.clear();
873    }
874
875    if let Some(stamp) = &exe_stamp
876        && existing_stamp == *stamp
877        && staged_path.exists()
878    {
879        return;
880    }
881
882    let raw_toml_manifest = toml::from_str::<toml::Value>(
883        &fs::read_to_string(path!(source_dir / "Cargo.toml")).unwrap(),
884    )
885    .unwrap();
886
887    let maybe_custom_lib_path = raw_toml_manifest
888        .get("lib")
889        .and_then(|lib| lib.get("path"))
890        .and_then(|path| path.as_str());
891
892    let mut gen_staged = stageleft_tool::gen_staged_trybuild(
893        &maybe_custom_lib_path
894            .map(|s| path!(source_dir / s))
895            .unwrap_or_else(|| path!(source_dir / "src" / "lib.rs")),
896        &path!(source_dir / "Cargo.toml"),
897        crate_name,
898        Some("hydro___test".to_owned()),
899    );
900
901    gen_staged.attrs.insert(
902        0,
903        syn::parse_quote! {
904            #![allow(
905                unused,
906                ambiguous_glob_reexports,
907                clippy::suspicious_else_formatting,
908                unexpected_cfgs,
909                reason = "generated code"
910            )]
911        },
912    );
913
914    let inlined_staged = prettyplease::unparse(&gen_staged);
915
916    write_atomic(inlined_staged.as_bytes(), &staged_path).unwrap();
917
918    if let Some(stamp) = exe_stamp {
919        stamp_file.set_len(0).unwrap();
920        stamp_file.seek(SeekFrom::Start(0)).unwrap();
921        stamp_file.write_all(stamp.as_bytes()).unwrap();
922    }
923}
924
925pub fn create_trybuild()
926-> Result<(PathBuf, PathBuf, String, Option<Vec<String>>), trybuild_internals_api::error::Error> {
927    let _span = tracing::debug_span!(target: "hydro_build", "create_trybuild").entered();
928    let Metadata {
929        target_directory: target_dir,
930        workspace_root: workspace,
931        packages,
932    } = {
933        let _span = tracing::debug_span!(target: "hydro_build", "cargo_metadata").entered();
934        cargo::metadata()?
935    };
936
937    let source_dir = cargo::manifest_dir()?;
938    let mut source_manifest = dependencies::get_manifest(&source_dir)?;
939
940    let mut dev_dependency_features = vec![];
941    source_manifest.dev_dependencies.retain(|k, v| {
942        if source_manifest.dependencies.contains_key(k) {
943            // already a non-dev dependency, so drop the dep and put the features under the test flag
944            for feat in &v.features {
945                dev_dependency_features.push(format!("{}/{}", k, feat));
946            }
947
948            false
949        } else {
950            // only enable this in test mode, so make it optional otherwise
951            dev_dependency_features.push(format!("dep:{k}"));
952
953            v.optional = true;
954            true
955        }
956    });
957
958    // When the example is re-executed from a test binary by `example_test` (signaled via this
959    // env var), skip feature discovery: it would pick up the *test* binary's features (e.g.
960    // test-only harness features). A real `cargo run --example` invocation has no fingerprint
961    // hash in `argv[0]`, so discovery finds nothing there; emulating that here ensures the test
962    // exercises the example the same way it actually runs.
963    let mut features = if std::env::var("RUNNING_AS_EXAMPLE_TEST").is_ok_and(|v| v == "1") {
964        None
965    } else {
966        features::find()
967    };
968
969    let path_dependencies = source_manifest
970        .dependencies
971        .iter()
972        .filter_map(|(name, dep)| {
973            let path = dep.path.as_ref()?;
974            if packages.iter().any(|p| &p.name == name) {
975                // Skip path dependencies coming from the workspace itself
976                None
977            } else {
978                Some(PathDependency {
979                    name: name.clone(),
980                    normalized_path: path.canonicalize().ok()?,
981                    normalization: Normalization::PathDependencies,
982                })
983            }
984        })
985        .collect();
986
987    let crate_name = source_manifest.package.name.clone();
988    let project_dir = path!(target_dir / "hydro_trybuild" / crate_name /);
989    fs::create_dir_all(&project_dir)?;
990
991    let project_name = format!("{}-hydro-trybuild", crate_name);
992    let dylib_package_name = format!("{project_name}-dylib");
993    let mut manifest = Runner::make_manifest(
994        &workspace,
995        &project_name,
996        &source_dir,
997        &packages,
998        &[],
999        source_manifest,
1000    )?;
1001
1002    if let Some(enabled_features) = &mut features {
1003        enabled_features
1004            .retain(|feature| manifest.features.contains_key(feature) || feature == "default");
1005    }
1006
1007    for runtime_feature in HYDRO_RUNTIME_FEATURES {
1008        manifest.features.insert(
1009            format!("hydro___feature_{runtime_feature}"),
1010            vec![format!("hydro_lang/{runtime_feature}")],
1011        );
1012    }
1013
1014    manifest
1015        .dependencies
1016        .get_mut("hydro_lang")
1017        .unwrap()
1018        .features
1019        .push("runtime_support".to_owned());
1020
1021    manifest
1022        .features
1023        .insert("hydro___test".to_owned(), dev_dependency_features);
1024
1025    if manifest
1026        .workspace
1027        .as_ref()
1028        .is_some_and(|w| w.dependencies.is_empty())
1029    {
1030        manifest.workspace = None;
1031    }
1032
1033    let project = Project {
1034        dir: project_dir,
1035        source_dir,
1036        target_dir,
1037        name: project_name.clone(),
1038        update: Update::env()?,
1039        has_pass: false,
1040        has_compile_fail: false,
1041        features,
1042        workspace,
1043        path_dependencies,
1044        manifest,
1045        keep_going: false,
1046    };
1047
1048    {
1049        let _span = tracing::debug_span!(target: "hydro_build", "write_project_files").entered();
1050        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
1051
1052        let _project_lock = hydro_concurrent_cargo::lock_project(project.dir.as_ref());
1053
1054        fs::create_dir_all(path!(project.dir / "src"))?;
1055        fs::create_dir_all(path!(project.dir / "examples"))?;
1056
1057        let crate_name_ident = syn::Ident::new(
1058            &crate_name.replace('-', "_"),
1059            proc_macro2::Span::call_site(),
1060        );
1061
1062        write_atomic(
1063            prettyplease::unparse(&syn::parse_quote! {
1064                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
1065
1066                pub mod __root {
1067                    pub use #crate_name_ident::*;
1068                    #[cfg(feature = "hydro___test")]
1069                    pub use super::__staged;
1070                }
1071
1072                #[cfg(feature = "hydro___test")]
1073                pub mod __staged;
1074            })
1075            .as_bytes(),
1076            &path!(project.dir / "src" / "lib.rs"),
1077        )
1078        .unwrap();
1079
1080        let base_manifest = toml::to_string(&project.manifest)?;
1081
1082        // Collect feature names for forwarding to dylib and dylib-examples crates
1083        let feature_names: Vec<_> = project.manifest.features.keys().cloned().collect();
1084
1085        // Create dylib crate directory
1086        let dylib_dir = path!(project.dir / "dylib");
1087        fs::create_dir_all(path!(dylib_dir / "src"))?;
1088
1089        let trybuild_crate_name_ident = syn::Ident::new(
1090            &project_name.replace('-', "_"),
1091            proc_macro2::Span::call_site(),
1092        );
1093        write_atomic(
1094            // The leading comment busts cargo's fingerprint for caches where the dylib was
1095            // built as a *primary* target (statically linking libstd); it must be built as a
1096            // dependency (with `-C prefer-dynamic`) for examples to link against it. The
1097            // linkage variant is not part of cargo's fingerprint, so a content change is
1098            // needed to force old caches to rebuild.
1099            [
1100                "// v2: dylib must be built as a dependency (prefer-dynamic).\n".as_bytes(),
1101                prettyplease::unparse(&syn::parse_quote! {
1102                    #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
1103                    pub use #trybuild_crate_name_ident::*;
1104                })
1105                .as_bytes(),
1106            ]
1107            .concat()
1108            .as_slice(),
1109            &path!(dylib_dir / "src" / "lib.rs"),
1110        )?;
1111
1112        let serialized_edition = toml::to_string(
1113            &vec![("edition", &project.manifest.package.edition)]
1114                .into_iter()
1115                .collect::<std::collections::HashMap<_, _>>(),
1116        )
1117        .unwrap();
1118
1119        // Dylib crate Cargo.toml - only dylib crate-type, with feature forwarding to base crate
1120        // On Windows, we currently disable dylib compilation due to https://github.com/bevyengine/bevy/pull/2016
1121        let dylib_features_section = feature_names
1122            .iter()
1123            .map(|f| format!("{f} = [\"{project_name}/{f}\"]"))
1124            .collect::<Vec<_>>()
1125            .join("\n");
1126
1127        // The dylib is the one shared artifact cargo does not name per configuration (unless
1128        // `__CARGO_DEFAULT_LIB_METADATA` is set), so `hydro_concurrent_cargo` decides its
1129        // `[lib] name` and only changes it under the exclusive prebuild lock, once every
1130        // in-flight final build that links the current name has finished. Reuse whatever name
1131        // the manifest has now; a fresh project gets this configuration's name. The package
1132        // name (and so `Cargo.lock`) never changes, and generated code never names the target
1133        // — it reaches the dylib through the `dylib-examples` dependency rename below.
1134        let dylib_lib_name = hydro_concurrent_cargo::current_dylib_lib_name(project.dir.as_ref())
1135            .unwrap_or_else(|| {
1136                hydro_concurrent_cargo::dylib_lib_name(&dylib_package_name, ENCODED_RUSTFLAGS)
1137            });
1138
1139        let dylib_manifest = format!(
1140            r#"[package]
1141name = "{dylib_package_name}"
1142version = "0.0.0"
1143{}
1144
1145[lib]
1146name = "{dylib_lib_name}"
1147crate-type = ["{}"]
1148
1149[dependencies]
1150{project_name} = {{ path = "..", default-features = false }}
1151
1152[features]
1153{dylib_features_section}
1154"#,
1155            serialized_edition,
1156            if cfg!(target_os = "windows") {
1157                "rlib"
1158            } else {
1159                "dylib"
1160            }
1161        );
1162        write_atomic(dylib_manifest.as_ref(), &path!(dylib_dir / "Cargo.toml"))?;
1163
1164        let dylib_examples_dir = path!(project.dir / "dylib-examples");
1165        fs::create_dir_all(path!(dylib_examples_dir / "src"))?;
1166        fs::create_dir_all(path!(dylib_examples_dir / "examples"))?;
1167
1168        write_atomic(
1169            b"#![allow(unused_crate_dependencies)]\n",
1170            &path!(dylib_examples_dir / "src" / "lib.rs"),
1171        )?;
1172
1173        // Build feature forwarding for dylib-examples - forward through the (renamed) dylib crate
1174        let features_section = feature_names
1175            .iter()
1176            .map(|f| format!("{f} = [\"{project_name}/{f}\"]"))
1177            .collect::<Vec<_>>()
1178            .join("\n");
1179
1180        // Dylib-examples crate Cargo.toml - depends *only* on the dylib crate, renamed to the
1181        // base crate's package name so that generated examples referencing
1182        // `{crate}_hydro_trybuild` resolve to the dylib. This is what makes dynamic linking
1183        // actually kick in: if the base crate were also a direct dependency, rustc would
1184        // statically link its rlib (and the entire dependency graph) into every example,
1185        // making the per-example "final compile" link take several seconds. With only the
1186        // dylib in scope, examples link against the prebuilt shared library instead.
1187        //
1188        // The dylib is a regular dependency (not a dev-dependency) so that prebuilding this
1189        // crate's (empty) lib builds the dylib as a dependency, which is required for cargo
1190        // to pass `-C prefer-dynamic` (see the prebuild in `compile_trybuild_example`).
1191        let dylib_examples_manifest = format!(
1192            r#"[package]
1193name = "{project_name}-dylib-examples"
1194version = "0.0.0"
1195{}
1196
1197[dependencies]
1198{project_name} = {{ package = "{project_name}-dylib", path = "../dylib", default-features = false }}
1199
1200[features]
1201{features_section}
1202
1203[[example]]
1204name = "sim-dylib"
1205crate-type = ["cdylib"]
1206"#,
1207            serialized_edition
1208        );
1209        write_atomic(
1210            dylib_examples_manifest.as_ref(),
1211            &path!(dylib_examples_dir / "Cargo.toml"),
1212        )?;
1213
1214        // sim-dylib.rs for the base crate and dylib-examples crate
1215        let sim_dylib_contents = prettyplease::unparse(&syn::parse_quote! {
1216            #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
1217            include!(std::concat!(env!("TRYBUILD_LIB_NAME"), ".rs"));
1218        });
1219        write_atomic(
1220            sim_dylib_contents.as_bytes(),
1221            &path!(project.dir / "examples" / "sim-dylib.rs"),
1222        )?;
1223        write_atomic(
1224            sim_dylib_contents.as_bytes(),
1225            &path!(dylib_examples_dir / "examples" / "sim-dylib.rs"),
1226        )?;
1227
1228        let workspace_manifest = format!(
1229            r#"{}
1230[[example]]
1231name = "sim-dylib"
1232crate-type = ["cdylib"]
1233
1234[workspace]
1235members = ["dylib", "dylib-examples"]
1236"#,
1237            base_manifest,
1238        );
1239
1240        write_atomic(
1241            workspace_manifest.as_ref(),
1242            &path!(project.dir / "Cargo.toml"),
1243        )?;
1244
1245        // Compute hash for cache invalidation, covering all generated manifests (the dylib and
1246        // dylib-examples manifests affect Cargo.lock, so they must participate in the hash).
1247        // The dylib's target name does not affect Cargo.lock (only package names do), so it is
1248        // excluded: otherwise every toolchain/rustflags switch would pay for a lockfile
1249        // regeneration.
1250        let manifest_hash = {
1251            let mut hasher = Sha256::new();
1252            hasher.update(&workspace_manifest);
1253            hasher.update(dylib_manifest.replace(&dylib_lib_name, ""));
1254            hasher.update(&dylib_examples_manifest);
1255            format!("{:X}", hasher.finalize())
1256                .chars()
1257                .take(8)
1258                .collect::<String>()
1259        };
1260
1261        let workspace_cargo_lock = path!(project.workspace / "Cargo.lock");
1262        let workspace_cargo_lock_contents_and_hash = if workspace_cargo_lock.exists() {
1263            let cargo_lock_contents = fs::read_to_string(&workspace_cargo_lock)?;
1264
1265            let hash = format!("{:X}", Sha256::digest(&cargo_lock_contents))
1266                .chars()
1267                .take(8)
1268                .collect::<String>();
1269
1270            Some((cargo_lock_contents, hash))
1271        } else {
1272            None
1273        };
1274
1275        let trybuild_hash = format!(
1276            "{}-{}",
1277            manifest_hash,
1278            workspace_cargo_lock_contents_and_hash
1279                .as_ref()
1280                .map(|(_contents, hash)| &**hash)
1281                .unwrap_or_default()
1282        );
1283
1284        if !check_contents(
1285            trybuild_hash.as_bytes(),
1286            &path!(project.dir / ".hydro-trybuild-manifest"),
1287        )
1288        .is_ok_and(|b| b)
1289        {
1290            let _span = tracing::debug_span!(target: "hydro_build", "update_lockfile").entered();
1291            // this is expensive, so we only do it if the manifest changed
1292            if let Some((cargo_lock_contents, _)) = workspace_cargo_lock_contents_and_hash {
1293                // only overwrite when the hash changed, because writing Cargo.lock must be
1294                // immediately followed by a local `cargo update -w`
1295                write_atomic(
1296                    cargo_lock_contents.as_ref(),
1297                    &path!(project.dir / "Cargo.lock"),
1298                )?;
1299            } else {
1300                let _ = cargo::cargo(&project).arg("generate-lockfile").status();
1301            }
1302
1303            // not `--offline` because some new runtime features may be enabled
1304            std::process::Command::new("cargo")
1305                .current_dir(&project.dir)
1306                .args(["update", "-w"]) // -w to not actually update any versions
1307                .stdout(std::process::Stdio::null())
1308                .stderr(std::process::Stdio::null())
1309                .status()
1310                .unwrap();
1311
1312            write_atomic(
1313                trybuild_hash.as_bytes(),
1314                &path!(project.dir / ".hydro-trybuild-manifest"),
1315            )?;
1316        }
1317
1318        // Create examples folder for base crate (static linking)
1319        let examples_folder = path!(project.dir / "examples");
1320        fs::create_dir_all(&examples_folder)?;
1321
1322        let workspace_dot_cargo_config_toml = path!(project.workspace / ".cargo" / "config.toml");
1323        if workspace_dot_cargo_config_toml.exists() {
1324            let dot_cargo_folder = path!(project.dir / ".cargo");
1325            fs::create_dir_all(&dot_cargo_folder)?;
1326
1327            write_atomic(
1328                fs::read_to_string(&workspace_dot_cargo_config_toml)?.as_ref(),
1329                &path!(dot_cargo_folder / "config.toml"),
1330            )?;
1331        }
1332
1333        let vscode_folder = path!(project.dir / ".vscode");
1334        fs::create_dir_all(&vscode_folder)?;
1335        write_atomic(
1336            include_bytes!("./vscode-trybuild.json"),
1337            &path!(vscode_folder / "settings.json"),
1338        )?;
1339    }
1340
1341    Ok((
1342        project.dir.as_ref().into(),
1343        project.target_dir.as_ref().into(),
1344        dylib_package_name,
1345        project.features,
1346    ))
1347}
1348
1349fn check_contents(contents: &[u8], path: &Path) -> Result<bool, std::io::Error> {
1350    let mut file = File::options()
1351        .read(true)
1352        .write(false)
1353        .create(false)
1354        .truncate(false)
1355        .open(path)?;
1356    file.lock()?;
1357
1358    let mut existing_contents = Vec::new();
1359    file.read_to_end(&mut existing_contents)?;
1360    Ok(existing_contents == contents)
1361}
1362
1363pub(crate) fn write_atomic(contents: &[u8], path: &Path) -> Result<(), std::io::Error> {
1364    let mut file = File::options()
1365        .read(true)
1366        .write(true)
1367        .create(true)
1368        .truncate(false)
1369        .open(path)?;
1370
1371    let mut existing_contents = Vec::new();
1372    file.read_to_end(&mut existing_contents)?;
1373    if existing_contents != contents {
1374        file.lock()?;
1375        file.seek(SeekFrom::Start(0))?;
1376        file.set_len(0)?;
1377        file.write_all(contents)?;
1378    }
1379
1380    Ok(())
1381}