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}