Skip to main content

whisker_dev_server/hotpatch/
runner.rs

1//! Spawn-side companions to [`super::thin_build::build_obj_plan`]
2//! and [`super::link_plan::build_link_plan`].
3//!
4//! Two stages, one helper that wires them together:
5//!
6//!   - [`run_obj_plan`] — invoke rustc with an [`ObjBuildPlan`] and
7//!     return the path of the emitted object file.
8//!   - [`run_link_plan`] — invoke the linker driver with a
9//!     [`LinkPlan`] and return the path of the produced
10//!     `.so` / `.dylib`.
11//!   - [`thin_rebuild_obj`] — composes both: build the obj plan,
12//!     spawn rustc, build the link plan, spawn the linker, return
13//!     the dylib path.
14//!
15//! All three inherit stdout/stderr so compile / link errors land
16//! in the dev-server's terminal instead of being swallowed.
17
18use anyhow::{Context, Result};
19use std::path::{Path, PathBuf};
20
21use super::link_plan::{build_link_plan, LinkPlan, LinkerOs};
22use super::thin_build::{build_obj_plan, ObjBuildPlan};
23use super::wrapper::CapturedRustcInvocation;
24
25/// Spawn rustc with `plan.args` from `cwd`. On success, returns
26/// the path of the emitted object (= `plan.expected_object`).
27///
28/// rustc with `--emit=obj <path>` writes exactly one `.o`; we don't
29/// need to scan the directory after the fact.
30pub async fn run_obj_plan(plan: &ObjBuildPlan, rustc_path: &Path, cwd: &Path) -> Result<PathBuf> {
31    std::fs::create_dir_all(&plan.output_dir)
32        .with_context(|| format!("create out dir {}", plan.output_dir.display()))?;
33    let status = tokio::process::Command::new(rustc_path)
34        .args(&plan.args)
35        .current_dir(cwd)
36        .status()
37        .await
38        .with_context(|| format!("spawn {}", rustc_path.display()))?;
39    if !status.success() {
40        anyhow::bail!(
41            "rustc exited {} during obj rebuild (out_dir={})",
42            status,
43            plan.output_dir.display(),
44        );
45    }
46    if !plan.expected_object.is_file() {
47        anyhow::bail!(
48            "rustc succeeded but `{}` was not produced",
49            plan.expected_object.display(),
50        );
51    }
52    Ok(plan.expected_object.clone())
53}
54
55/// Spawn the linker driver with `plan.args` from `cwd`. On success,
56/// returns the path of the produced shared object (= `plan.output`).
57///
58/// `linker_path` is typically the same `cc`/`clang` rustc would use.
59/// On macOS, `xcrun -f clang` resolves to the active toolchain's
60/// driver. On Linux/Android, the NDK ships a per-API-level wrapper
61/// (e.g. `aarch64-linux-android21-clang`).
62pub async fn run_link_plan(plan: &LinkPlan, linker_path: &Path, cwd: &Path) -> Result<PathBuf> {
63    if let Some(parent) = plan.output.parent() {
64        std::fs::create_dir_all(parent)
65            .with_context(|| format!("create out dir {}", parent.display()))?;
66    }
67    // Capture stderr so a failed link surfaces *why* (e.g. unresolved
68    // symbols, missing libraries) instead of just "exit 1". stdout is
69    // inherited so progress / warnings remain visible.
70    let out = tokio::process::Command::new(linker_path)
71        .args(&plan.args)
72        .current_dir(cwd)
73        .stderr(std::process::Stdio::piped())
74        .output()
75        .await
76        .with_context(|| format!("spawn {}", linker_path.display()))?;
77    if !out.status.success() {
78        let stderr = String::from_utf8_lossy(&out.stderr);
79        anyhow::bail!(
80            "linker `{}` exited {} during patch link (output={})\n\
81             argv: {:?}\n\
82             stderr:\n{}",
83            linker_path.display(),
84            out.status,
85            plan.output.display(),
86            plan.args,
87            stderr.trim_end(),
88        );
89    }
90    if !plan.output.is_file() {
91        anyhow::bail!(
92            "linker succeeded but `{}` was not produced",
93            plan.output.display(),
94        );
95    }
96    Ok(plan.output.clone())
97}
98
99/// Compose [`run_obj_plan`] and [`run_link_plan`] into the full
100/// hot-patch rebuild.
101///
102/// Inputs are the **captured** rustc + linker calls from the fat
103/// build, plus where the patch should land and which OS the patch
104/// is going to run on. Returns the final `.so`/`.dylib` path that
105/// can be packaged into a `JumpTable` and sent to the device.
106///
107/// This function is the "happy path" — the production code (Patcher,
108/// I4g-X3) calls this directly when neither captured call is missing
109/// and the target is supported.
110///
111/// `aslr_stub` is an optional pre-built jump-stub object
112/// ([`crate::hotpatch::create_undefined_symbol_stub`]). When `Some`,
113/// it gets linked into the patch dylib alongside the freshly rebuilt
114/// `.o`, supplying every host symbol the patch references as a
115/// hardcoded runtime-address trampoline. When `None`, the patch is
116/// linked with `--unresolved-symbols=ignore-all` only — fine for
117/// host-only fixtures where the test process satisfies the patch via
118/// `dynamic_lookup`.
119#[allow(clippy::too_many_arguments)]
120pub async fn thin_rebuild_obj(
121    captured_rustc: &CapturedRustcInvocation,
122    captured_linker_args: &[String],
123    output_dir: &Path,
124    output_dylib: &Path,
125    rustc_path: &Path,
126    linker_path: &Path,
127    cwd: &Path,
128    target_os: LinkerOs,
129    aslr_stub: Option<&Path>,
130) -> Result<PathBuf> {
131    let obj_plan = build_obj_plan(captured_rustc, output_dir);
132    let object = run_obj_plan(&obj_plan, rustc_path, cwd).await?;
133    let extras: Vec<PathBuf> = aslr_stub.map(|p| vec![p.to_path_buf()]).unwrap_or_default();
134    let link_plan = build_link_plan(
135        captured_linker_args,
136        &object,
137        output_dylib,
138        target_os,
139        &extras,
140        &[],
141    );
142    run_link_plan(&link_plan, linker_path, cwd).await
143}
144
145// ============================================================================
146// Tests
147// ============================================================================
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn s(v: &[&str]) -> Vec<String> {
154        v.iter().map(|s| s.to_string()).collect()
155    }
156
157    fn unique_tempdir() -> PathBuf {
158        use std::sync::atomic::{AtomicU64, Ordering};
159        static SEQ: AtomicU64 = AtomicU64::new(0);
160        let n = SEQ.fetch_add(1, Ordering::Relaxed);
161        let pid = std::process::id();
162        let p = std::env::temp_dir().join(format!("whisker-runner-test-{pid}-{n}"));
163        let _ = std::fs::remove_dir_all(&p);
164        std::fs::create_dir_all(&p).unwrap();
165        p
166    }
167
168    // ----- run_obj_plan ------------------------------------------------
169
170    #[tokio::test]
171    async fn run_obj_plan_surfaces_rustc_failure_with_path_context() {
172        // Plan that will fail because the source file doesn't exist.
173        let dir = unique_tempdir();
174        let plan = ObjBuildPlan {
175            args: s(&[
176                "--edition=2021",
177                "--crate-name",
178                "demo",
179                "--crate-type",
180                "rlib",
181                "--out-dir",
182                dir.to_string_lossy().as_ref(),
183                "/nope/this/source/does/not/exist.rs",
184                "--emit",
185                &format!("obj={}/demo.o", dir.display()),
186            ]),
187            output_dir: dir.clone(),
188            expected_object: dir.join("demo.o"),
189        };
190        let res = run_obj_plan(&plan, Path::new("rustc"), &dir).await;
191        let err = res.unwrap_err();
192        let msg = format!("{err:#}");
193        assert!(
194            msg.contains("rustc exited") || msg.contains("spawn"),
195            "msg should mention rustc exit or spawn: {msg}",
196        );
197        let _ = std::fs::remove_dir_all(&dir);
198    }
199
200    #[tokio::test]
201    async fn run_obj_plan_errors_when_rustc_binary_doesnt_exist() {
202        let dir = unique_tempdir();
203        let plan = ObjBuildPlan {
204            args: vec![],
205            output_dir: dir.clone(),
206            expected_object: dir.join("demo.o"),
207        };
208        let res = run_obj_plan(&plan, Path::new("/nope/no-such-rustc"), &dir).await;
209        assert!(res.is_err());
210        let _ = std::fs::remove_dir_all(&dir);
211    }
212
213    // ----- run_link_plan -----------------------------------------------
214
215    #[tokio::test]
216    async fn run_link_plan_creates_output_parent_dir() {
217        // The linker call will fail (we use /usr/bin/true so it
218        // returns success without writing anything), then run_link_plan
219        // surfaces "succeeded but file not produced". The test's
220        // load-bearing claim is "parent dir was created up front
221        // even though the call ultimately failed".
222        let dir = unique_tempdir();
223        let nested_out = dir.join("nested/sub").join("libfoo.dylib");
224        let plan = LinkPlan {
225            args: vec![],
226            output: nested_out.clone(),
227        };
228        let res = run_link_plan(&plan, Path::new("/usr/bin/true"), &dir).await;
229        assert!(res.is_err(), "true returns success but writes no file");
230        let parent = nested_out.parent().unwrap();
231        assert!(parent.is_dir(), "parent should have been created");
232        let _ = std::fs::remove_dir_all(&dir);
233    }
234
235    #[tokio::test]
236    async fn run_link_plan_surfaces_linker_nonzero_exit() {
237        // /usr/bin/false exits 1 — we want a clear error, not a
238        // "file not found" misdirection.
239        let dir = unique_tempdir();
240        let plan = LinkPlan {
241            args: vec![],
242            output: dir.join("libfoo.dylib"),
243        };
244        let res = run_link_plan(&plan, Path::new("/usr/bin/false"), &dir).await;
245        let err = res.unwrap_err();
246        let msg = format!("{err:#}");
247        assert!(msg.contains("linker"), "msg: {msg}");
248        let _ = std::fs::remove_dir_all(&dir);
249    }
250}