Skip to main content

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}