Skip to main content

whisker_build/
capture.rs

1//! Tier 1 fat-build capture shim wiring.
2//!
3//! When the dev-server runs a Tier 2 cold rebuild for hot-reload, it
4//! transparently elevates that build into a **fat build**: cargo
5//! still produces the same artifact, but the rustc and linker
6//! invocations get intercepted by [`whisker-rustc-shim`] and
7//! [`whisker-linker-shim`] respectively, which dump their argv to
8//! JSON files under the configured cache dirs. The Tier 1 thin
9//! rebuild later replays those argvs to produce a patch dylib.
10//!
11//! The setup is just env vars (cargo's RUSTC_WORKSPACE_WRAPPER +
12//! per-target linker overrides). [`capture_env_vars`] computes the
13//! map; callers merge it into their `Command`.
14//!
15//! Moved here from `whisker-dev-server::builder` so `whisker-cli`'s
16//! `whisker run` path (which lives outside dev-server in Phase 3+)
17//! can also drive fat builds when it wants Tier 1 ready.
18
19use std::path::PathBuf;
20
21/// Shim wiring that turns a plain cargo invocation into a Tier 1
22/// fat build. All paths are absolute; the dev-server creates the
23/// cache dirs on demand. `real_linker` is what the linker shim
24/// forwards to (typically the same `cc`/`clang` cargo would have
25/// used).
26///
27/// `target_triple` is the **Rust target triple** the user code will
28/// compile for. When set, the linker shim is installed only for
29/// that triple via cargo's `CARGO_TARGET_<UPPER>_LINKER` env var —
30/// host-only artifacts (build scripts, proc-macros) keep their
31/// default linker. When `None`, the shim is installed globally via
32/// `RUSTFLAGS=-Clinker=…` (fine for host-only Tier 1 setups).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct CaptureShims {
35    pub rustc_shim: PathBuf,
36    pub linker_shim: PathBuf,
37    pub rustc_cache_dir: PathBuf,
38    pub linker_cache_dir: PathBuf,
39    pub real_linker: PathBuf,
40    pub target_triple: Option<String>,
41}
42
43/// Compute the env vars that turn a plain `cargo` invocation into a
44/// fat build that captures rustc + linker args. Caller is expected
45/// to merge these into a `Command` (test helper / production code
46/// share this function).
47///
48/// When `c.target_triple` is `Some(t)`, the linker shim is installed
49/// **only** for that triple via
50/// `CARGO_TARGET_<TRIPLE_UPPER>_LINKER=<shim>` — cargo's own
51/// mechanism for per-target linker selection. This is the critical
52/// piece for cross-compilation: build scripts and proc-macros, which
53/// are compiled for the **host** triple, keep their default host
54/// linker, so they don't get redirected at the NDK / cross linker.
55///
56/// When `c.target_triple` is `None`, the shim is installed via
57/// `RUSTFLAGS=-Clinker=…` (the global form). Pre-existing
58/// `RUSTFLAGS` in the dev-server's env are preserved.
59pub fn capture_env_vars(c: &CaptureShims) -> Vec<(String, String)> {
60    capture_env_vars_for_triple(c, c.target_triple.as_deref())
61}
62
63/// Like [`capture_env_vars`] but applies the linker shim + rustflags
64/// to `triple_override` instead of `c.target_triple`. Used by
65/// multi-triple builds (e.g. iOS, which emits dylibs for
66/// device + intel-sim + arm64-sim) where every per-target slice
67/// needs the same Tier-1 capture envelope — the original
68/// `CaptureShims::target_triple` only carries one slot, so a naive
69/// `capture_env_vars(c)` call would only set
70/// `CARGO_TARGET_<that-one-triple>_RUSTFLAGS` and silently leave
71/// the other slices building without `-Cdebug-assertions=on` /
72/// `-Csave-temps` / export-dynamic. The downstream symptom on iOS
73/// was that `subsecond::call` got inlined to the early-`return f()`
74/// branch in every slice except the configured one, so hot reload
75/// dispatched to OLD code on device / intel-sim no matter what the
76/// JumpTable contained.
77pub fn capture_env_vars_for_triple(
78    c: &CaptureShims,
79    triple_override: Option<&str>,
80) -> Vec<(String, String)> {
81    let mut out = vec![
82        (
83            "RUSTC_WORKSPACE_WRAPPER".into(),
84            c.rustc_shim.to_string_lossy().into(),
85        ),
86        (
87            "WHISKER_RUSTC_CACHE_DIR".into(),
88            c.rustc_cache_dir.to_string_lossy().into(),
89        ),
90        (
91            "WHISKER_LINKER_CACHE_DIR".into(),
92            c.linker_cache_dir.to_string_lossy().into(),
93        ),
94        (
95            "WHISKER_REAL_LINKER".into(),
96            c.real_linker.to_string_lossy().into(),
97        ),
98    ];
99
100    let shim = c.linker_shim.to_string_lossy().to_string();
101    // Three flags every fat build needs for Tier 1 to work:
102    //
103    // `-Csave-temps=y` keeps rustc's temp dir (containing the
104    // version script and bridge-static archive the linker args
105    // reference) on disk after the fat build finishes — without it,
106    // rustc deletes everything in `/var/folders/.../rustc*/` on
107    // exit and the captured linker invocation becomes unreplayable.
108    //
109    // `-Clink-arg=-Wl,--export-dynamic` (Linux/Android) /
110    // `-Clink-arg=-Wl,-export_dynamic` (macOS) exports every
111    // symbol from the original cdylib into its dynamic-symbol
112    // table. The patch dylib references std::fmt, alloc, etc.
113    // via undefined refs and resolves them against the loaded
114    // process at `dlopen` time — but cdylib's default symbol
115    // visibility hides those internal-to-the-library symbols.
116    // Without --export-dynamic, `dlopen` on the patch fails with
117    // "cannot locate symbol _ZN4core3fmt3num...". The cost is a
118    // slightly larger .so (the dynamic symbol table grows);
119    // acceptable for dev builds.
120    //
121    // `-Cdebug-assertions=on` toggles the only `cfg!(debug_assertions)`
122    // branch in `subsecond::HotFn::try_call` — in release builds
123    // without this, subsecond compiles to `self.inner.call_it(args)`
124    // and skips the JumpTable entirely (apply_patch becomes a no-op
125    // from the caller's perspective). Tier 1 dev builds want the
126    // JumpTable lookup but otherwise keep release-level optimization;
127    // this flag flips the cfg without dropping to the dev profile.
128    //
129    // Pick the export-dynamic flag spelling for the *target* triple,
130    // not the host — Apple linkers take `-export_dynamic`; GNU / lld
131    // take `--export-dynamic`. Default to the GNU form when
132    // target_triple is None (host-only setups land here).
133    let export_dynamic = match triple_override {
134        Some(t) if t.contains("apple") => "-Clink-arg=-Wl,-export_dynamic",
135        _ => "-Clink-arg=-Wl,--export-dynamic",
136    };
137    let save_temps = format!("-Csave-temps=y -Cdebug-assertions=on {export_dynamic}");
138    let save_temps = save_temps.as_str();
139    match triple_override {
140        Some(triple) => {
141            out.push((target_linker_env_var(triple), shim));
142            let prior = std::env::var(target_rustflags_env_var(triple)).unwrap_or_default();
143            let mut rustflags = String::new();
144            if !prior.is_empty() {
145                rustflags.push_str(&prior);
146                rustflags.push(' ');
147            }
148            rustflags.push_str(save_temps);
149            out.push((target_rustflags_env_var(triple), rustflags));
150        }
151        None => {
152            let prior = std::env::var("RUSTFLAGS").unwrap_or_default();
153            let mut rustflags = String::new();
154            if !prior.is_empty() {
155                rustflags.push_str(&prior);
156                rustflags.push(' ');
157            }
158            rustflags.push_str(&format!("-Clinker={shim} {save_temps}"));
159            out.push(("RUSTFLAGS".into(), rustflags));
160        }
161    }
162    out
163}
164
165/// Same uppercasing rule as [`target_linker_env_var`] but for the
166/// `…_RUSTFLAGS` variant. Cargo applies these flags only when
167/// building for the given triple, so they don't break host build
168/// scripts.
169pub fn target_rustflags_env_var(triple: &str) -> String {
170    let mut s = String::with_capacity(triple.len() + 24);
171    s.push_str("CARGO_TARGET_");
172    for ch in triple.chars() {
173        if ch.is_ascii_alphanumeric() {
174            s.push(ch.to_ascii_uppercase());
175        } else {
176            s.push('_');
177        }
178    }
179    s.push_str("_RUSTFLAGS");
180    s
181}
182
183/// Translate a Rust target triple to the cargo env var that selects
184/// its linker. Cargo's rule: uppercase the triple and replace
185/// non-alphanumerics with `_`, then prepend `CARGO_TARGET_` and
186/// append `_LINKER`.
187///
188/// e.g. `aarch64-linux-android` → `CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER`.
189pub fn target_linker_env_var(triple: &str) -> String {
190    let mut s = String::with_capacity(triple.len() + 22);
191    s.push_str("CARGO_TARGET_");
192    for ch in triple.chars() {
193        if ch.is_ascii_alphanumeric() {
194            s.push(ch.to_ascii_uppercase());
195        } else {
196            s.push('_');
197        }
198    }
199    s.push_str("_LINKER");
200    s
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn shim_for_triple(triple: Option<&str>) -> CaptureShims {
208        CaptureShims {
209            rustc_shim: PathBuf::from("/tmp/rustc-shim"),
210            linker_shim: PathBuf::from("/tmp/linker-shim"),
211            rustc_cache_dir: PathBuf::from("/tmp/rustc-cache"),
212            linker_cache_dir: PathBuf::from("/tmp/linker-cache"),
213            real_linker: PathBuf::from("/usr/bin/cc"),
214            target_triple: triple.map(String::from),
215        }
216    }
217
218    #[test]
219    fn target_linker_env_var_uppercases_and_replaces_separators() {
220        assert_eq!(
221            target_linker_env_var("aarch64-linux-android"),
222            "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER",
223        );
224    }
225
226    #[test]
227    fn target_rustflags_env_var_matches_cargo_convention() {
228        assert_eq!(
229            target_rustflags_env_var("aarch64-apple-ios-sim"),
230            "CARGO_TARGET_AARCH64_APPLE_IOS_SIM_RUSTFLAGS",
231        );
232    }
233
234    #[test]
235    fn capture_env_vars_emits_workspace_wrapper_and_cache_dirs() {
236        let vars = capture_env_vars(&shim_for_triple(Some("aarch64-linux-android")));
237        let names: std::collections::HashSet<&str> = vars.iter().map(|(k, _)| k.as_str()).collect();
238        assert!(names.contains("RUSTC_WORKSPACE_WRAPPER"));
239        assert!(names.contains("WHISKER_RUSTC_CACHE_DIR"));
240        assert!(names.contains("WHISKER_LINKER_CACHE_DIR"));
241        assert!(names.contains("WHISKER_REAL_LINKER"));
242        assert!(names.contains("CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER"));
243        assert!(names.contains("CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS"));
244    }
245
246    #[test]
247    fn capture_env_vars_picks_apple_export_dynamic_for_ios_triples() {
248        let vars = capture_env_vars(&shim_for_triple(Some("aarch64-apple-ios-sim")));
249        let rustflags = vars
250            .iter()
251            .find(|(k, _)| k == "CARGO_TARGET_AARCH64_APPLE_IOS_SIM_RUSTFLAGS")
252            .map(|(_, v)| v.as_str())
253            .unwrap();
254        assert!(rustflags.contains("-Wl,-export_dynamic"));
255        assert!(!rustflags.contains("-Wl,--export-dynamic"));
256    }
257
258    #[test]
259    fn capture_env_vars_picks_gnu_export_dynamic_for_android_triples() {
260        let vars = capture_env_vars(&shim_for_triple(Some("aarch64-linux-android")));
261        let rustflags = vars
262            .iter()
263            .find(|(k, _)| k == "CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS")
264            .map(|(_, v)| v.as_str())
265            .unwrap();
266        assert!(rustflags.contains("-Wl,--export-dynamic"));
267    }
268
269    #[test]
270    fn capture_env_vars_no_triple_falls_back_to_global_rustflags() {
271        let vars = capture_env_vars(&shim_for_triple(None));
272        let names: std::collections::HashSet<&str> = vars.iter().map(|(k, _)| k.as_str()).collect();
273        assert!(names.contains("RUSTFLAGS"));
274        // Per-target keys should not appear.
275        assert!(!names.iter().any(|k| k.contains("CARGO_TARGET_")));
276    }
277}