whisker_dev_server/lib.rs
1//! Host-side dev server for `whisker run`.
2//!
3//! Owns the long-running dev loop: file watch, cargo rebuild, install
4//! to the device, subsecond patch construction, and WebSocket push.
5//! `whisker-cli`'s `run` subcommand is a thin wrapper that builds a
6//! [`Config`] and calls [`DevServer::run`] — every piece of
7//! UX-shaped logic lives here so future host shells (an editor
8//! plugin, a notebook, a remote-controlled CI build) can reuse it.
9//!
10//! ## Architecture
11//!
12//! Constructed once via [`Config`], the dev server spins up six
13//! cooperating pieces:
14//!
15//! - `builder` — translates [`Config`] into a `whisker-build`
16//! invocation (cargo + per-platform packaging) and runs it.
17//! Honours `RUSTC_WORKSPACE_WRAPPER` + linker shim env so the fat
18//! build doubles as a capture pass for Tier 1.
19//! - `installer` — for the cold-rebuild path: shells out to
20//! `adb install` / `simctl install + launch`. Identity (bundle id,
21//! applicationId, scheme, …) comes in flat via
22//! [`AndroidParams`] / [`IosParams`]; the cli resolves these from
23//! the user's `whisker.rs::configure(&mut Config)`. This crate
24//! never depends on `whisker-config`.
25//! - `watcher` — `notify`-based, debounced, classifies events into
26//! `ChangeKind::{RustCode, CargoToml, Other}`.
27//! - `server` — `axum` WebSocket endpoint at
28//! `ws://<bind>/whisker-dev`. Devices dial in, send a `hello`
29//! carrying their `subsecond::aslr_reference()`, then receive
30//! patch envelopes.
31//! - `hotpatch` — Tier 1 implementation. Builds a thin `.o` from the
32//! changed user crate via captured rustc args, links it into a
33//! patch dylib with a stub-object of host-symbol jumps, ships the
34//! resulting `subsecond_types::JumpTable` to connected clients.
35//! - `lib.rs::run` — the orchestrator: file event → `decide_action`
36//! (Tier 1 patch vs Tier 2 rebuild) → builder/hotpatch/sender.
37//!
38//! ## Layering
39//!
40//! Stays manifest-agnostic on purpose. The cli does the
41//! `whisker.rs` → `Config` translation; this crate accepts only
42//! flat `String` / `PathBuf` fields. That keeps the dev-server
43//! reusable from any host shell that can produce the same flat
44//! `Config` (the cli is one; an editor plugin could be another).
45
46use anyhow::{Context, Result};
47use std::net::SocketAddr;
48use std::path::{Path, PathBuf};
49use std::sync::Arc;
50
51pub mod builder;
52pub mod hotpatch;
53pub mod installer;
54pub mod server;
55pub mod watcher;
56pub mod workspace;
57
58pub use builder::Builder;
59pub use installer::Installer;
60pub use server::{Patch, PatchSender};
61pub use watcher::{Change, ChangeKind};
62pub use whisker_build::CaptureShims;
63pub use workspace::{discover_path_deps, identify_crate_for_paths, PathDepCrate};
64
65// ----- Config & enums --------------------------------------------------------
66
67/// Where the dev loop should run, what to build, and how to behave.
68/// Constructed by `whisker-cli` from CLI flags + the user's
69/// `whisker.rs` (via the cli's manifest/probe pipeline); or by an
70/// editor plugin / test harness directly.
71///
72/// **Flat params, not Config.** Anything platform-specific lives
73/// inside [`AndroidParams`] / [`IosParams`] as simple strings and
74/// paths — the dev-server intentionally doesn't depend on
75/// `whisker-config`. Translating the user's `configure(&mut
76/// Config)` into these fields is the cli's job.
77#[derive(Debug, Clone)]
78pub struct Config {
79 /// Workspace root (`Cargo.toml` with `[workspace]`). Used by
80 /// `whisker-build` invocations + RUSTC capture directories.
81 pub workspace_root: PathBuf,
82 /// User-crate directory (`Cargo.toml` with `[package]`). This
83 /// is what `whisker run --manifest-path` resolves to; for
84 /// in-workspace examples it's `examples/<pkg>/`, for an
85 /// external user it's wherever they keep their app.
86 pub crate_dir: PathBuf,
87 /// User-crate package name (e.g. "podcast").
88 pub package: String,
89 /// Where the rebuilt artifact gets installed + launched.
90 pub target: Target,
91 /// Directories to watch for source changes. Empty defaults to
92 /// `<crate_dir>/src`.
93 pub watch_paths: Vec<PathBuf>,
94 /// Address the WebSocket server binds.
95 pub bind_addr: SocketAddr,
96 /// Strategy for reflecting code edits onto the running app.
97 pub hot_patch_mode: HotPatchMode,
98 /// Android install / launch params. Required iff
99 /// `target == Target::Android`; absent for other targets.
100 pub android: Option<AndroidParams>,
101 /// iOS install / launch params. Required iff
102 /// `target == Target::IosSimulator`; absent for other targets.
103 pub ios: Option<IosParams>,
104}
105
106impl Config {
107 /// A starting point with sensible defaults; callers override fields.
108 pub fn defaults_for(workspace_root: PathBuf, package: String, target: Target) -> Self {
109 Self {
110 workspace_root: workspace_root.clone(),
111 crate_dir: workspace_root,
112 package,
113 target,
114 watch_paths: Vec::new(),
115 bind_addr: "127.0.0.1:9876".parse().expect("valid default addr"),
116 hot_patch_mode: HotPatchMode::Tier2ColdRebuild,
117 android: None,
118 ios: None,
119 }
120 }
121}
122
123/// Flat Android install/launch parameters. Populated by `whisker-cli`
124/// from the user's `whisker.rs::configure(&mut Config)` plus a few
125/// hard defaults (jniLibs lives at `<project_dir>/app/src/main/jniLibs`,
126/// APK at `<project_dir>/app/build/outputs/apk/debug/app-debug.apk`,
127/// etc.). The dev-server never invents these values — if any are
128/// missing the cli is expected to error out before constructing
129/// `Config`.
130#[derive(Debug, Clone)]
131pub struct AndroidParams {
132 /// Absolute path to the Gradle project (= the dir with
133 /// `app/build.gradle.kts`). For the in-workspace podcast
134 /// example this is `examples/podcast/android/`.
135 pub project_dir: PathBuf,
136 /// `applicationId` — used by `adb am start -n
137 /// <application_id>/<launcher_activity>`.
138 pub application_id: String,
139 /// Launcher activity. Always starts with a dot
140 /// (e.g. `.MainActivity`); `am start` expands it against
141 /// `application_id`.
142 pub launcher_activity: String,
143 /// ABI directory under `jniLibs/` (e.g. `"arm64-v8a"`). Hard-
144 /// coded by the cli for now; multi-ABI builds aren't on the
145 /// dev loop's path.
146 pub abi: String,
147}
148
149/// Flat iOS Simulator install/launch parameters. Same pattern as
150/// [`AndroidParams`] — populated by the cli, consumed by the
151/// dev-server's installer.
152#[derive(Debug, Clone)]
153pub struct IosParams {
154 /// Absolute path to the Xcode project's parent dir (= where
155 /// `<Scheme>.xcodeproj` lives). For podcast this is
156 /// `examples/podcast/ios/`.
157 pub project_dir: PathBuf,
158 /// Xcode scheme. Doubles as the `.app` filename xcodebuild
159 /// produces (`<Scheme>.app`). With XcodeGen this always
160 /// matches the project name.
161 pub scheme: String,
162 /// CFBundleIdentifier. Used by `simctl install / terminate /
163 /// launch` as the right-hand identifier.
164 pub bundle_id: String,
165 /// Optional simulator-device override; usually `None` to let
166 /// the cli pick the first available iPhone. Honored if set.
167 pub device_override: Option<String>,
168}
169
170/// What kind of binary the dev server is rebuilding.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum Target {
173 /// Android cdylib + APK + adb install + launch.
174 Android,
175 /// iOS Simulator app + xcrun simctl install + launch.
176 IosSimulator,
177}
178
179/// How aggressive the dev loop is about reflecting edits.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum HotPatchMode {
182 /// Don't even try — every change requires a manual `whisker run` rerun.
183 /// Useful for CI smoke-tests of the dev server itself.
184 Disabled,
185 /// Full cargo rebuild + reinstall + relaunch (5–30s). The default.
186 Tier2ColdRebuild,
187 /// `subsecond` JumpTable patches (sub-second). Requires the I4g
188 /// pipeline to be wired up; otherwise behaves as `Tier2ColdRebuild`.
189 Tier1Subsecond,
190}
191
192// ----- Public events ---------------------------------------------------------
193
194/// Observable events that bubble out of the dev loop. `whisker-cli` uses
195/// these to render terminal UI; an editor plugin would use them to
196/// drive its own UX.
197#[derive(Debug, Clone)]
198pub enum Event {
199 Started,
200 BuildingFull,
201 BuildSucceeded,
202 BuildFailed(String),
203 ClientConnected,
204 ClientDisconnected,
205 /// A Tier 1 hot patch build kicked off. Fires *before* the
206 /// `Patcher::build_patch` call so consumers (the cli TUI) can
207 /// flip into "patching" state while the patch is still being
208 /// compiled — without this paired event, `PatchSent` is the
209 /// only signal and arrives so close to its own completion that
210 /// any UI keying off it never shows a patch-in-flight indicator.
211 PatchBuilding,
212 PatchSent,
213 /// A line captured from the device-side app's stdout / stderr (via
214 /// the `whisker-dev-runtime::log_capture` `dup2` hook), forwarded
215 /// over the WS connection. `whisker-cli` surfaces these in the
216 /// dev-loop UI so users don't need a separate `adb logcat` /
217 /// `simctl log stream` terminal to read their own `println!`s.
218 DeviceLog {
219 /// `"stdout"` or `"stderr"` — kept as a string mirror of the
220 /// on-wire field so the variant stays trivially serialisable
221 /// without dragging a `LogStream` enum across crate
222 /// boundaries.
223 stream: String,
224 line: String,
225 /// Device-stamped microseconds since UNIX_EPOCH. `0` if the
226 /// device's clock was unavailable when the line was captured.
227 ts_micros: u128,
228 },
229}
230
231// ----- Server ---------------------------------------------------------------
232
233/// The dev loop. Construct with [`DevServer::new`], then drive with
234/// [`DevServer::run`] (which returns when the server shuts down).
235pub struct DevServer {
236 config: Config,
237 on_event: Option<Arc<dyn Fn(Event) + Send + Sync>>,
238}
239
240impl DevServer {
241 pub fn new(config: Config) -> Result<Self> {
242 Ok(Self {
243 config,
244 on_event: None,
245 })
246 }
247
248 /// Attach an observer for `Event`s — connect / disconnect /
249 /// build progress. The CLI uses this to drive its terminal UI;
250 /// other host shells (editor plugins) do their own thing.
251 pub fn on_event(mut self, cb: impl Fn(Event) + Send + Sync + 'static) -> Self {
252 self.on_event = Some(Arc::new(cb));
253 self
254 }
255
256 /// Bring the dev loop up. The Tier 2 cold rebuild loop:
257 ///
258 /// notify → debounce → cargo build → adb install → relaunch
259 /// → broadcast "rebuilt" hint over WebSocket.
260 ///
261 /// When `hot_patch_mode == Tier1Subsecond`, the initial build
262 /// also captures rustc + linker invocations through the
263 /// `whisker-{rustc,linker}-shim` binaries, and a `Patcher` is
264 /// initialised from those captures + the original binary's
265 /// symbol table. The change loop then prefers Tier 1
266 /// `subsecond::JumpTable` patches over cold rebuilds for
267 /// `ChangeKind::RustCode` events. Patcher initialisation or
268 /// `build_patch` failure falls back to Tier 2 silently.
269 pub async fn run(self) -> Result<()> {
270 // In TUI mode the live region's header already shows the
271 // package + target + phase; the `──── whisker run ────` +
272 // `· podcast · Android` rows above just duplicated that
273 // information. The `mode={:?}` debug line is debug-only
274 // anyway, so it never made it to scrollback in the
275 // production curated path. Non-TUI runs (CI, `--no-tui`)
276 // still get the intro section + info line.
277 if !whisker_build::ui::is_tui() {
278 whisker_build::ui::section("whisker run");
279 whisker_build::ui::info(format!(
280 "{} · {:?}",
281 self.config.package, self.config.target,
282 ));
283 }
284 whisker_build::ui::debug(format!("mode={:?}", self.config.hot_patch_mode));
285
286 // Configure the initial build first. The Builder + Installer
287 // pair doesn't need the WS server, so we wire them up before
288 // touching the socket — this lets the user see a clean
289 // "Initial build" section open immediately after the
290 // top-level "whisker run" section, with no intervening
291 // dev-server chatter. Once the cargo step (the long pole)
292 // succeeds we bind the WS, then `install_and_launch` so the
293 // device app has somewhere to connect to.
294 //
295 // For Tier 1 mode this build doubles as the fat build that
296 // fills the rustc / linker capture caches; the shims are
297 // resolved (built if missing) and installed into the builder
298 // *before* the spawn. The same Builder is reused for Tier 2
299 // fallback rebuilds inside the change loop.
300 let mut builder = Builder::new(
301 self.config.workspace_root.clone(),
302 self.config.crate_dir.clone(),
303 self.config.package.clone(),
304 self.config.target,
305 )
306 .with_features(vec!["whisker/hot-reload".into()]);
307
308 let tier1_init = if self.config.hot_patch_mode == HotPatchMode::Tier1Subsecond {
309 match prepare_tier1_capture(&self.config) {
310 Ok(prep) => {
311 builder = builder.with_capture(prep.capture.clone());
312 Some(prep)
313 }
314 Err(e) => {
315 whisker_build::ui::warn(format!(
316 "Tier 1 capture setup failed ({e:#}); falling back to Tier 2 cold rebuilds",
317 ));
318 None
319 }
320 }
321 } else {
322 None
323 };
324
325 let installer = Installer::new(
326 self.config.target,
327 self.config.android.clone(),
328 self.config.ios.clone(),
329 self.config.workspace_root.clone(),
330 self.config.package.clone(),
331 tier1_init.as_ref().map(|p| p.capture.clone()),
332 builder.features().to_vec(),
333 );
334
335 // Initial build — cargo only. `install_and_launch` is
336 // deferred until *after* the WS is bound, because the device
337 // app spins up its `whisker-dev-runtime` socket as soon as it
338 // launches and would race a not-yet-bound dev-server.
339 //
340 // A build failure here is fatal: there's nothing actionable
341 // the dev-loop can do (no app to patch, no install to
342 // recover from a source-edit save), so we surface the error
343 // and exit. The previous behaviour of "enter the loop anyway
344 // and recover on next save" was misleading — users routinely
345 // missed the warn line and assumed the build had succeeded.
346 //
347 // The `──── Initial build ────` section header is only
348 // emitted in non-TUI mode. In TUI mode the live region's
349 // phase indicator (`building`) + spinner already make it
350 // obvious that the build started; the section divider
351 // becomes pure noise above the in-line cargo/gradle/
352 // xcodebuild step rows.
353 if !whisker_build::ui::is_tui() {
354 whisker_build::ui::section("Initial build");
355 }
356 emit(&self.on_event, Event::BuildingFull);
357 if let Err(e) = builder.build().await {
358 let msg = format!("{e:#}");
359 emit(&self.on_event, Event::BuildFailed(msg.clone()));
360 // cli main prints the bail message via `ui::error` (it
361 // formats `e.root_cause()`), so emitting our own
362 // `ui::error` here would double-print every install /
363 // build failure to the user. Keep the bail message
364 // user-actionable; the verbose chain is still reachable
365 // via `WHISKER_VERBOSE=1`.
366 anyhow::bail!("initial build failed: {msg}");
367 }
368 emit(&self.on_event, Event::BuildSucceeded);
369
370 // Now bind the WS so `install_and_launch` (next) has
371 // somewhere for the device's `whisker-dev-runtime` to dial.
372 // `whisker_build::ui::section("dev server")` used to live
373 // here as a visual divider between the cargo build and the
374 // device install/launch. The TUI's live region already
375 // surfaces the ws addr + client count, so the section
376 // header was a redundant row of dashes. `ensure_status` /
377 // `set_status` are no-ops in TUI mode (see
378 // `whisker_build::ui::set_status`); we keep them for the
379 // `--no-tui` and CI paths where the legacy status surface
380 // is still the only signal.
381 whisker_build::ui::ensure_status("dev-server");
382 let (sender, bound, _server_handle) =
383 server::serve(self.config.bind_addr, self.on_event.clone()).await?;
384 whisker_build::ui::set_status(format!("ws://{bound} · 0 client(s)"));
385 whisker_build::ui::debug(format!("ws://{bound}/whisker-dev"));
386
387 // Walk the user crate's dep graph for every workspace path
388 // dep. The watcher attaches one notify root per `src/`, and
389 // the change loop uses the same list to map a changed file
390 // back to its owning crate. Registry / git deps are excluded
391 // — their sources live outside the workspace; Cargo.toml /
392 // Cargo.lock edits trigger a Tier 2 rebuild and pick them
393 // up that way.
394 let path_deps = workspace::discover_path_deps(
395 &self.config.crate_dir.join("Cargo.toml"),
396 &self.config.package,
397 )
398 .unwrap_or_else(|e| {
399 whisker_build::ui::warn(format!(
400 "cargo metadata failed ({e:#}); falling back to user crate only",
401 ));
402 Vec::new()
403 });
404 // Always include the user crate's src dir as a fallback even
405 // if cargo metadata returned nothing — the dev loop should
406 // still work in degraded mode.
407 let user_src = self.config.crate_dir.join("src");
408 let mut watch_roots: Vec<PathBuf> = path_deps
409 .iter()
410 .map(|c| c.src_dir.clone())
411 .filter(|p| p.is_dir())
412 .collect();
413 if !watch_roots.iter().any(|p| p == &user_src) && user_src.is_dir() {
414 watch_roots.push(user_src.clone());
415 }
416 if watch_roots.is_empty() {
417 // Last-resort: watch the user_src path even if it doesn't
418 // exist yet — notify will fail and we'll surface the
419 // error to the user.
420 watch_roots.push(user_src.clone());
421 }
422 let (tx, mut rx) = tokio::sync::mpsc::channel::<watcher::Change>(8);
423 let _watcher = watcher::spawn_watcher(
424 watch_roots.clone(),
425 std::time::Duration::from_millis(200),
426 tx,
427 )?;
428 for root in &watch_roots {
429 whisker_build::ui::debug(format!("watching {}", root.display()));
430 }
431 emit(&self.on_event, Event::Started);
432
433 // Install + launch the freshly-built artifact. A failure
434 // here is fatal for the same reason a build failure is —
435 // there's nothing to dev-loop against if the app never made
436 // it onto the device (no `INSTALL_FAILED_INSUFFICIENT_STORAGE`
437 // recovery story over file watching). `run_build_cycle`
438 // reuses the build + install codepath for rebuilds inside
439 // the loop (where WS is already bound and a failure there
440 // does fall through — the user can then save again to retry).
441 if let Err(e) = installer.install_and_launch().await {
442 // See the `initial build failed` arm above for why we
443 // bail instead of `ui::error`-ing here.
444 anyhow::bail!("initial install failed: {e:#}");
445 }
446 whisker_build::ui::info(format!(
447 "initial done · {} client(s) connected",
448 sender.client_count()
449 ));
450
451 // After the fat build has happened, Patcher::initialize can
452 // read the now-populated caches. Failure here is non-fatal
453 // — log and proceed with Tier 2 only.
454 let patcher = match tier1_init {
455 Some(prep) => match init_patcher_for(&self.config, &prep) {
456 Ok(p) => {
457 whisker_build::ui::debug("Tier 1 patcher ready");
458 Some(p)
459 }
460 Err(e) => {
461 whisker_build::ui::warn(format!(
462 "Tier 1 patcher init failed ({e:#}); falling back to Tier 2 cold rebuilds",
463 ));
464 None
465 }
466 },
467 None => None,
468 };
469
470 // Change loop. For each debounced change, decide the
471 // action (Tier 1 patch / Tier 2 rebuild / ignore) using
472 // the kind + whether we have a Patcher, then execute it.
473 // Tier 1 failures silently fall through to Tier 2 — saves
474 // the dev loop from being killed by a transient build
475 // glitch.
476 while let Some(change) = rx.recv().await {
477 // `──── Change ────` only in non-TUI mode (live
478 // region's phase flip to `building` / `patching`
479 // already announces a save has been picked up).
480 if !whisker_build::ui::is_tui() {
481 whisker_build::ui::section("Change");
482 }
483 whisker_build::ui::debug(format!(
484 "{:?} — {} path(s)",
485 change.kind,
486 change.paths.len(),
487 ));
488 let action = decide_action(change.kind, patcher.is_some());
489 match action {
490 LoopAction::Ignore => {
491 whisker_build::ui::debug(format!("ignored ({:?})", change.kind));
492 }
493 LoopAction::Tier1Patch => {
494 let p = patcher.as_ref().expect("decide_action guarantees Some");
495 // Open the step as soon as we know we're patching;
496 // the spinner runs across both the build + the
497 // wire-up so the user sees a single elapsed
498 // duration for "edit → app updated".
499 let patch_step = whisker_build::ui::step("patch", "tier 1");
500 // Tell the cli to flip into "patching" right now —
501 // the patcher work that follows (`build_patch` +
502 // dylib read + send) is the wall-clock-heavy bit,
503 // and the matching `PatchSent` flips back to Idle.
504 emit(&self.on_event, Event::PatchBuilding);
505 // Map the changed file paths to the owning crate.
506 // None = batch spans multiple crates, or a path
507 // outside every known src dir — fall back to a
508 // cold rebuild since we can only patch one crate
509 // per batch.
510 let crate_key = workspace::identify_crate_for_paths(&change.paths, &path_deps);
511 if !path_deps.is_empty() && crate_key.is_none() {
512 patch_step.fail("multi-crate change batch; using Tier 2");
513 run_build_cycle(
514 &builder,
515 &installer,
516 &self.on_event,
517 &sender,
518 "rebuild (tier2 fallback, multi-crate batch)",
519 )
520 .await;
521 continue;
522 }
523 let Some(aslr_reference) = sender.latest_aslr_reference() else {
524 // No client has reported its `aslr_reference` yet
525 // (handshake hasn't completed, or never connected).
526 // Without that value we can't build a stub-asm-style
527 // patch — fall back to Tier 2 cold rebuild.
528 patch_step.fail("no client aslr_reference yet; using Tier 2");
529 run_build_cycle(
530 &builder,
531 &installer,
532 &self.on_event,
533 &sender,
534 "rebuild (tier2 fallback, no aslr_reference)",
535 )
536 .await;
537 continue;
538 };
539 let started = std::time::Instant::now();
540 match p.build_patch(aslr_reference, crate_key.as_deref()).await {
541 Ok(plan) => {
542 let built_in = started.elapsed();
543 log_patch_diff(&plan.report);
544 let dylib_bytes = match read_lib_bytes(&plan.table.lib) {
545 Ok(b) => Arc::new(b),
546 Err(e) => {
547 patch_step.fail(format!(
548 "could not read dylib bytes ({}): {e:#}; using Tier 2",
549 plan.table.lib.display(),
550 ));
551 run_build_cycle(
552 &builder,
553 &installer,
554 &self.on_event,
555 &sender,
556 "rebuild (tier2 fallback)",
557 )
558 .await;
559 continue;
560 }
561 };
562 let send_started = std::time::Instant::now();
563 let n = sender.send(Patch {
564 table: plan.table,
565 dylib_bytes,
566 });
567 whisker_build::ui::debug(format!(
568 "built {built_in:?} · queued {:?}",
569 send_started.elapsed()
570 ));
571 patch_step.done(format!("{n} client(s)"));
572 emit(&self.on_event, Event::PatchSent);
573 }
574 Err(e) => {
575 patch_step.fail(format!("{e:#}; using Tier 2 cold rebuild"));
576 run_build_cycle(
577 &builder,
578 &installer,
579 &self.on_event,
580 &sender,
581 "rebuild (tier2 fallback)",
582 )
583 .await;
584 }
585 }
586 }
587 LoopAction::Tier2Rebuild => {
588 run_build_cycle(&builder, &installer, &self.on_event, &sender, "rebuild").await;
589 }
590 }
591 }
592
593 Ok(())
594 }
595}
596
597/// Decision the change loop makes for one debounced change.
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
599pub enum LoopAction {
600 /// Drop on the floor — `ChangeKind::Other` doesn't warrant
601 /// either a patch or a rebuild.
602 Ignore,
603 /// Try a Tier 1 subsecond patch. Caller falls back to Tier 2
604 /// on Patcher error.
605 Tier1Patch,
606 /// Full cargo rebuild + reinstall + relaunch (Tier 2). Used
607 /// when the change is dependency-shaped (`Cargo.toml`) or
608 /// when no Patcher is configured.
609 Tier2Rebuild,
610}
611
612/// Pure decision helper for the change loop. Tier 1 only handles
613/// `ChangeKind::RustCode` and only when a Patcher is available;
614/// `Cargo.toml` always needs a full rebuild because the dependency
615/// graph may have shifted; everything else is ignored.
616pub fn decide_action(kind: ChangeKind, has_patcher: bool) -> LoopAction {
617 match kind {
618 ChangeKind::Other => LoopAction::Ignore,
619 ChangeKind::CargoToml => LoopAction::Tier2Rebuild,
620 ChangeKind::RustCode if has_patcher => LoopAction::Tier1Patch,
621 ChangeKind::RustCode => LoopAction::Tier2Rebuild,
622 }
623}
624
625/// Log added / removed symbols from a Tier 1 diff. Quiet when both
626/// lists are empty (the common case) so the dev terminal stays
627/// readable; loud when something interesting happens (`pub fn`
628/// added or removed) so the user notices.
629fn log_patch_diff(report: &hotpatch::DiffReport) {
630 if report.added.is_empty() && report.removed.is_empty() {
631 return;
632 }
633 if !report.added.is_empty() {
634 whisker_build::ui::debug(format!(
635 "patch added {} symbol(s): {:?}",
636 report.added.len(),
637 report.added.iter().take(5).collect::<Vec<_>>(),
638 ));
639 }
640 if !report.removed.is_empty() {
641 // Removed-symbol counts are noisy on every patch (typically
642 // thousands of `GCC_except_table*` entries that compiled
643 // away). Surface only in verbose mode; the "host shell may
644 // crash" warning was alarmist for the normal case.
645 whisker_build::ui::debug(format!(
646 "patch removed {} symbol(s): {:?}",
647 report.removed.len(),
648 report.removed.iter().take(5).collect::<Vec<_>>(),
649 ));
650 }
651}
652
653/// State produced by [`prepare_tier1_capture`]: enough to make the
654/// initial build a fat build, and to construct the patcher after the
655/// build completes.
656#[derive(Debug, Clone)]
657struct Tier1Prep {
658 capture: CaptureShims,
659 real_linker: PathBuf,
660}
661
662/// Resolve shim paths (building them if missing) and assemble the
663/// CaptureShims wiring. Returns `Err` if the shim binaries can't be
664/// produced, in which case the caller falls back to Tier 2.
665///
666/// `config` carries the workspace + target + android/ios params the
667/// linker/triple pickers need:
668/// - Android → NDK clang for `config.android.abi`.
669/// - others → host clang via [`hotpatch::wrapper::resolve_host_linker`].
670fn prepare_tier1_capture(config: &Config) -> Result<Tier1Prep> {
671 let shims = hotpatch::resolve_shim_paths(&config.workspace_root)?;
672 let rustc_cache_dir = hotpatch::wrapper::default_cache_dir(&config.workspace_root);
673 let linker_cache_dir = hotpatch::wrapper::default_linker_cache_dir(&config.workspace_root);
674 let real_linker = resolve_linker_for(config)?;
675 let target_triple = target_triple_for(config);
676 Ok(Tier1Prep {
677 capture: CaptureShims {
678 rustc_shim: shims.rustc_shim,
679 linker_shim: shims.linker_shim,
680 rustc_cache_dir,
681 linker_cache_dir,
682 real_linker: real_linker.clone(),
683 target_triple,
684 },
685 real_linker,
686 })
687}
688
689/// What Rust target triple `config.target` compiles for. Android
690/// derives the triple from `Config::android.abi`. Host returns
691/// `None`, falling back to the global RUSTFLAGS form.
692fn target_triple_for(config: &Config) -> Option<String> {
693 match config.target {
694 Target::Android => {
695 let abi = config.android.as_ref().map(|a| a.abi.as_str())?;
696 let triple = match abi {
697 "arm64-v8a" => "aarch64-linux-android",
698 "armeabi-v7a" => "armv7-linux-androideabi",
699 "x86_64" => "x86_64-linux-android",
700 "x86" => "i686-linux-android",
701 _ => return None,
702 };
703 Some(triple.to_string())
704 }
705 Target::IosSimulator => {
706 // Pick the simulator triple that matches the host arch
707 // running `whisker run`. Both arm64 Macs (`aarch64-apple-
708 // ios-sim`) and Intel Macs (`x86_64-apple-ios`) need a
709 // simulator slice. The Build Phase that xcodebuild fires
710 // (via `whisker-build ios`) cross-compiles whichever arch
711 // Xcode requests via `$ARCHS`; the hot-patch path rebuilds
712 // just the thin obj for whichever triple the user is on.
713 let triple = match std::env::consts::ARCH {
714 "aarch64" => "aarch64-apple-ios-sim",
715 "x86_64" => "x86_64-apple-ios",
716 _ => return None,
717 };
718 Some(triple.to_string())
719 }
720 }
721}
722
723/// Pick the linker driver to use for `config.target`. Returned path
724/// is what the linker shim forwards to during the fat build *and*
725/// what the thin-rebuild link step spawns directly — the same binary
726/// on both sides keeps SDK / sysroot resolution consistent.
727fn resolve_linker_for(config: &Config) -> Result<PathBuf> {
728 match config.target {
729 Target::Android => {
730 let abi = config
731 .android
732 .as_ref()
733 .map(|a| a.abi.as_str())
734 .unwrap_or("arm64-v8a");
735 // API level: env override > 21 (Lynx baseline).
736 let api = std::env::var("WHISKER_ANDROID_API")
737 .ok()
738 .and_then(|s| s.parse::<u32>().ok())
739 .unwrap_or(21);
740 hotpatch::android_ndk::android_clang_for(abi, api)
741 .with_context(|| format!("resolve NDK clang for ABI {abi} API {api}"))
742 }
743 Target::IosSimulator => Ok(hotpatch::wrapper::resolve_host_linker()),
744 }
745}
746
747/// Construct the patcher from the captures the fat build just wrote.
748/// Splits out so [`DevServer::run`] is easier to read.
749fn init_patcher_for(config: &Config, prep: &Tier1Prep) -> Result<hotpatch::Patcher> {
750 let original_binary = original_binary_path(config)?;
751 hotpatch::Patcher::initialize(
752 &config.workspace_root,
753 config.package.clone(),
754 &prep.capture.rustc_cache_dir,
755 &prep.capture.linker_cache_dir,
756 &prep.real_linker,
757 &original_binary,
758 target_os_for(config.target),
759 prep.capture.target_triple.as_deref(),
760 )
761}
762
763/// Locate the device-loadable original binary for the configured
764/// target. Both [`Target::Android`] and [`Target::IosSimulator`]
765/// produce a `.so` / `.dylib` we can mmap and diff against; reads
766/// the paths from `Config::android` / `Config::ios` rather than
767/// guessing — the cli populates these from the user's
768/// `whisker.rs::configure` output.
769fn original_binary_path(config: &Config) -> Result<PathBuf> {
770 let crate_underscored = config.package.replace('-', "_");
771 match config.target {
772 Target::Android => {
773 // Read from the *gradle plugin's* output directory rather
774 // than from `<workspace>/target/<triple>/debug/`. Why:
775 // gradle's `WhiskerBuildTask` declares its `jniLibsDir`
776 // as an `@OutputDirectory` but the cargo target dir as
777 // `@Internal` (see
778 // `platforms/android/gradle-plugin/whisker-gradle-plugin/
779 // src/main/kotlin/rs/whisker/gradle/WhiskerBuildTask.kt`),
780 // which means gradle treats the jniLibs path as the
781 // ground-truth output it must guarantee, but happily
782 // skips the task when only the cargo target dir is
783 // missing. If the user runs `cargo clean` (or anything
784 // that nukes `target/<triple>/debug/`) between sessions
785 // gradle still reports UP-TO-DATE and the dev-server
786 // sees nothing under the workspace's target dir.
787 //
788 // Stage location: `whisker_build::android::stage_so_files`
789 // copies the freshly-built `.so` into the abi subdir of
790 // gradle's `@OutputDirectory`. The directory layout is
791 // `gen/android/app/build/generated/jniLibs/
792 // whiskerBuild<Variant><AbiCamel>/<abi>/lib<pkg>.so`,
793 // where `<AbiCamel>` is the abi name with each `-`/`_`
794 // segment titlecased (`arm64-v8a` → `Arm64V8a`,
795 // `x86_64` → `X8664`) and `<Variant>` is the AGP build
796 // type ("Debug" for the dev loop).
797 let android = config.android.as_ref().ok_or_else(|| {
798 anyhow::anyhow!(
799 "target=Android but Config.android is None — cli should have populated it from whisker.rs"
800 )
801 })?;
802 let so_name = format!("lib{crate_underscored}.so");
803 let abi_camel = android_abi_to_camel(&android.abi);
804 let candidate = config
805 .crate_dir
806 .join("gen/android/app/build/generated/jniLibs")
807 .join(format!("whiskerBuildDebug{abi_camel}"))
808 .join(&android.abi)
809 .join(&so_name);
810 if !candidate.is_file() {
811 anyhow::bail!(
812 "no Android cdylib at {} — gradle's whiskerBuildDebug{abi_camel} task didn't produce its output (run `whisker run android` first)",
813 candidate.display(),
814 );
815 }
816 Ok(candidate)
817 }
818 Target::IosSimulator => {
819 // Use the single-arch dylib that cargo dropped directly,
820 // not the lipo'd fat binary inside the xcframework. The
821 // `object` crate doesn't auto-resolve Mach-O FAT_MAGIC
822 // (it requires the caller to pick a slice first via
823 // `MachOFatFile`), and the static symbol layout of each
824 // slice is byte-identical to the single-arch input —
825 // lipo just prepends a fat header.
826 //
827 // Match the host arch so the slice we read corresponds
828 // to what the Simulator actually loads at runtime (the
829 // arm64 Mac runs the arm64-sim slice natively; Intel
830 // Macs run the x86_64-sim slice).
831 let _ios = config.ios.as_ref().ok_or_else(|| {
832 anyhow::anyhow!(
833 "target=IosSimulator but Config.ios is None — cli should have populated it from whisker.rs"
834 )
835 })?;
836 let dylib_name = format!("lib{crate_underscored}.dylib");
837 let triple = match std::env::consts::ARCH {
838 "aarch64" => "aarch64-apple-ios-sim",
839 "x86_64" => "x86_64-apple-ios",
840 arch => anyhow::bail!("unsupported host arch {arch} for iOS Simulator target"),
841 };
842 // xcodebuild's Build Phase Run Script (`whisker-build
843 // ios`) invokes cargo with `--release` (see
844 // `crates/whisker-build/src/ios.rs::cargo_build_ios_dylib`:
845 // the comment there spells out that iOS dev wants the
846 // same optimised codegen prod ships, so debug profile is
847 // deliberately not used). Android uses Debug; the two
848 // platforms can't share this path.
849 let dylib = config
850 .workspace_root
851 .join("target")
852 .join(triple)
853 .join("release")
854 .join(&dylib_name);
855 if !dylib.is_file() {
856 anyhow::bail!(
857 "no iOS Simulator dylib at {} — \
858 initial xcodebuild didn't drop the artifact where the dev loop expects it",
859 dylib.display(),
860 );
861 }
862 Ok(dylib)
863 }
864 }
865}
866
867/// Map an Android ABI name to the camel-cased form gradle's
868/// `WhiskerProjectPlugin` uses when synthesising
869/// `whiskerBuild<Variant><AbiCamel>` task names. Each `-` or `_`
870/// segment is titlecased and the parts are concatenated:
871/// `arm64-v8a` → `Arm64V8a`, `armeabi-v7a` → `ArmeabiV7a`,
872/// `x86_64` → `X8664`, `x86` → `X86`. Mirrors `String.toCamelCase()`
873/// in `WhiskerProjectPlugin.kt`.
874fn android_abi_to_camel(abi: &str) -> String {
875 abi.split(['-', '_'])
876 .map(|seg| {
877 let mut chars = seg.chars();
878 match chars.next() {
879 Some(c) => c.to_uppercase().chain(chars).collect::<String>(),
880 None => String::new(),
881 }
882 })
883 .collect()
884}
885
886fn target_os_for(target: Target) -> hotpatch::LinkerOs {
887 match target {
888 Target::Android => hotpatch::LinkerOs::Linux,
889 Target::IosSimulator => hotpatch::LinkerOs::Macos,
890 }
891}
892
893/// Slurp the patch dylib off disk so the dev-loop can hand it to the
894/// WebSocket sender. The size is typically tens of KB (only the
895/// changed crate's `.o` linked with `-undefined dynamic_lookup`), and
896/// since switching to the binary frame protocol we ship it verbatim
897/// — no base64.
898fn read_lib_bytes(path: &Path) -> Result<Vec<u8>> {
899 std::fs::read(path).with_context(|| format!("read {}", path.display()))
900}
901
902async fn run_build_cycle(
903 builder: &Builder,
904 installer: &Installer,
905 on_event: &Option<Arc<dyn Fn(Event) + Send + Sync>>,
906 sender: &PatchSender,
907 label: &str,
908) {
909 emit(on_event, Event::BuildingFull);
910 match builder.build().await {
911 Ok(()) => {
912 emit(on_event, Event::BuildSucceeded);
913 if let Err(e) = installer.install_and_launch().await {
914 whisker_build::ui::error(format!("{label} install failed: {e}"));
915 }
916 whisker_build::ui::info(format!(
917 "{label} done · {} client(s) connected",
918 sender.client_count()
919 ));
920 }
921 Err(e) => {
922 let msg = format!("{e:#}");
923 whisker_build::ui::error(format!("{label} build failed: {msg}"));
924 emit(on_event, Event::BuildFailed(msg));
925 }
926 }
927}
928
929fn emit(on_event: &Option<Arc<dyn Fn(Event) + Send + Sync>>, ev: Event) {
930 if let Some(cb) = on_event {
931 cb(ev);
932 }
933}
934
935// ============================================================================
936// Tests
937// ============================================================================
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942 use std::path::Path;
943
944 #[test]
945 fn config_defaults_pick_loopback_and_tier2() {
946 let cfg = Config::defaults_for(
947 PathBuf::from("/tmp/ws"),
948 "hello-world".to_string(),
949 Target::Android,
950 );
951 assert_eq!(cfg.workspace_root, Path::new("/tmp/ws"));
952 assert_eq!(cfg.package, "hello-world");
953 assert_eq!(cfg.target, Target::Android);
954 assert_eq!(cfg.bind_addr.port(), 9876);
955 assert!(cfg.bind_addr.ip().is_loopback());
956 assert_eq!(cfg.hot_patch_mode, HotPatchMode::Tier2ColdRebuild);
957 assert!(cfg.watch_paths.is_empty());
958 }
959
960 #[test]
961 fn target_variants_compare_by_value() {
962 assert_eq!(Target::Android, Target::Android);
963 assert_ne!(Target::Android, Target::IosSimulator);
964 }
965
966 #[test]
967 fn hot_patch_mode_variants_compare_by_value() {
968 assert_eq!(HotPatchMode::Disabled, HotPatchMode::Disabled);
969 assert_ne!(HotPatchMode::Tier1Subsecond, HotPatchMode::Tier2ColdRebuild,);
970 }
971
972 #[test]
973 fn dev_server_new_does_not_fail_for_a_well_formed_config() {
974 let cfg = Config::defaults_for(
975 PathBuf::from("/tmp/ws"),
976 "hello-world".to_string(),
977 Target::Android,
978 );
979 assert!(DevServer::new(cfg).is_ok());
980 }
981
982 // ----- original_binary_path ----------------------------------------
983
984 fn mk_config(workspace_root: PathBuf, target: Target) -> Config {
985 let mut cfg = Config::defaults_for(workspace_root.clone(), "hello-world".into(), target);
986 cfg.crate_dir = workspace_root.clone();
987 match target {
988 Target::Android => {
989 cfg.android = Some(crate::AndroidParams {
990 project_dir: workspace_root.join("android"),
991 application_id: "rs.whisker.examples.helloworld".into(),
992 launcher_activity: ".MainActivity".into(),
993 abi: "arm64-v8a".into(),
994 });
995 }
996 Target::IosSimulator => {
997 cfg.ios = Some(crate::IosParams {
998 project_dir: workspace_root.join("ios"),
999 scheme: "HelloWorld".into(),
1000 bundle_id: "rs.whisker.examples.helloWorld".into(),
1001 device_override: None,
1002 });
1003 }
1004 }
1005 cfg
1006 }
1007
1008 #[test]
1009 fn original_binary_path_finds_ios_simulator_dylib_under_target() {
1010 use std::sync::atomic::{AtomicU64, Ordering};
1011 static SEQ: AtomicU64 = AtomicU64::new(0);
1012 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1013 let pid = std::process::id();
1014 let ws = std::env::temp_dir().join(format!("whisker-dev-test-ios-{pid}-{n}"));
1015 let _ = std::fs::remove_dir_all(&ws);
1016 let triple = match std::env::consts::ARCH {
1017 "aarch64" => "aarch64-apple-ios-sim",
1018 "x86_64" => "x86_64-apple-ios",
1019 other => panic!("unsupported test host arch {other}"),
1020 };
1021 let release_dir = ws.join("target").join(triple).join("release");
1022 std::fs::create_dir_all(&release_dir).unwrap();
1023 let dylib = release_dir.join("libhello_world.dylib");
1024 std::fs::write(&dylib, b"fake-macho").unwrap();
1025
1026 let cfg = mk_config(ws.clone(), Target::IosSimulator);
1027 let resolved = original_binary_path(&cfg).unwrap();
1028 assert_eq!(resolved, dylib);
1029
1030 let _ = std::fs::remove_dir_all(&ws);
1031 }
1032
1033 #[test]
1034 fn original_binary_path_errors_when_ios_simulator_dylib_missing() {
1035 let cfg = mk_config(PathBuf::from("/nonexistent/ws"), Target::IosSimulator);
1036 let res = original_binary_path(&cfg);
1037 assert!(res.is_err());
1038 }
1039
1040 #[test]
1041 fn original_binary_path_finds_android_so_under_gradle_output() {
1042 // Reads from the gradle plugin's `@OutputDirectory`, not from
1043 // `target/<triple>/debug/` — the latter can be cleaned out by
1044 // `cargo clean` while gradle still reports its task as
1045 // UP-TO-DATE (the cargo target dir is `@Internal`, not an
1046 // input). See `original_binary_path` for the rationale.
1047 use std::sync::atomic::{AtomicU64, Ordering};
1048 static SEQ: AtomicU64 = AtomicU64::new(0);
1049 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1050 let pid = std::process::id();
1051 let ws = std::env::temp_dir().join(format!("whisker-dev-test-orig-{pid}-{n}"));
1052 let _ = std::fs::remove_dir_all(&ws);
1053 // `mk_config` sets `crate_dir = ws` for Android, so the path
1054 // the patcher checks is `<ws>/gen/android/app/build/generated/
1055 // jniLibs/whiskerBuildDebug<AbiCamel>/<abi>/lib<pkg>.so`.
1056 let gradle_out_dir = ws
1057 .join("gen/android/app/build/generated/jniLibs")
1058 .join("whiskerBuildDebugArm64V8a")
1059 .join("arm64-v8a");
1060 std::fs::create_dir_all(&gradle_out_dir).unwrap();
1061 let so = gradle_out_dir.join("libhello_world.so");
1062 std::fs::write(&so, b"fake").unwrap();
1063
1064 let cfg = mk_config(ws.clone(), Target::Android);
1065 let resolved = original_binary_path(&cfg).unwrap();
1066 assert_eq!(resolved, so);
1067
1068 let _ = std::fs::remove_dir_all(&ws);
1069 }
1070
1071 #[test]
1072 fn android_abi_to_camel_matches_gradle_plugin_naming() {
1073 // Mirrors `WhiskerProjectPlugin.kt::String.toCamelCase`. The
1074 // patcher's task-name suffix has to match exactly or the
1075 // gradle output path won't resolve.
1076 assert_eq!(android_abi_to_camel("arm64-v8a"), "Arm64V8a");
1077 assert_eq!(android_abi_to_camel("armeabi-v7a"), "ArmeabiV7a");
1078 assert_eq!(android_abi_to_camel("x86_64"), "X8664");
1079 assert_eq!(android_abi_to_camel("x86"), "X86");
1080 }
1081
1082 #[test]
1083 fn original_binary_path_errors_when_android_so_missing() {
1084 let cfg = mk_config(PathBuf::from("/nonexistent/ws"), Target::Android);
1085 let res = original_binary_path(&cfg);
1086 assert!(res.is_err());
1087 }
1088
1089 // ----- target_os_for -----------------------------------------------
1090
1091 #[test]
1092 fn target_os_for_maps_android_to_linux() {
1093 assert_eq!(target_os_for(Target::Android), hotpatch::LinkerOs::Linux);
1094 }
1095
1096 #[test]
1097 fn target_os_for_maps_ios_to_macos() {
1098 assert_eq!(
1099 target_os_for(Target::IosSimulator),
1100 hotpatch::LinkerOs::Macos,
1101 );
1102 }
1103
1104 // ----- decide_action -----------------------------------------------
1105
1106 #[test]
1107 fn rust_code_with_patcher_chooses_tier1_patch() {
1108 assert_eq!(
1109 decide_action(ChangeKind::RustCode, true),
1110 LoopAction::Tier1Patch,
1111 );
1112 }
1113
1114 #[test]
1115 fn rust_code_without_patcher_falls_through_to_tier2_rebuild() {
1116 assert_eq!(
1117 decide_action(ChangeKind::RustCode, false),
1118 LoopAction::Tier2Rebuild,
1119 );
1120 }
1121
1122 #[test]
1123 fn cargo_toml_always_chooses_tier2_rebuild_even_with_patcher() {
1124 // Patcher can't reload deps — Cargo.toml needs a full
1125 // rebuild regardless of which mode we're in.
1126 assert_eq!(
1127 decide_action(ChangeKind::CargoToml, true),
1128 LoopAction::Tier2Rebuild,
1129 );
1130 assert_eq!(
1131 decide_action(ChangeKind::CargoToml, false),
1132 LoopAction::Tier2Rebuild,
1133 );
1134 }
1135
1136 #[test]
1137 fn other_changes_are_ignored() {
1138 assert_eq!(decide_action(ChangeKind::Other, true), LoopAction::Ignore);
1139 assert_eq!(decide_action(ChangeKind::Other, false), LoopAction::Ignore);
1140 }
1141
1142 // ----- log_patch_diff (smoke: shouldn't panic) ---------------------
1143
1144 #[test]
1145 fn log_patch_diff_handles_empty_report_silently() {
1146 let r = hotpatch::DiffReport {
1147 added: vec![],
1148 removed: vec![],
1149 weak: vec![],
1150 };
1151 log_patch_diff(&r); // no panic, no output
1152 }
1153
1154 #[test]
1155 fn log_patch_diff_summarises_added_and_removed() {
1156 let r = hotpatch::DiffReport {
1157 added: vec!["new1".into(), "new2".into()],
1158 removed: vec!["old1".into()],
1159 weak: vec![],
1160 };
1161 log_patch_diff(&r); // smoke — output goes to stderr
1162 }
1163}