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