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