frust-hotpatch 0.6.0

Native-only hot-patch runtime for Frust: a jump-table detour ported from subsecond 0.7.10, wire-compatible with dx.
Documentation

frust-hotpatch

The in-app half of Frust hot patching: a native-only runtime that detours calls through a jump table, so a patch library built from edited code takes effect in a running process without a restart. It is a frust-owned port of the subsecond 0.7.10 runtime and stays wire-compatible with dx (dioxus-cli) 0.7.10, which still builds the patches.

The crate is published with the workspace so that frust-core's optional dependency on it resolves from crates.io. It is reached only through frust-core's opt-in hotpatch feature and is absent from every default dependency graph.

Scope

  • HotFn / HotFunction (call, try_call, try_call_with_ptr, ptr_address) and the free call helper. Every call is one jump-table lookup keyed on the address of the function's call_it monomorphisation, which is sound for any FnMut (fn item, closure of any size, fn pointer). HotFn::from_fn_ptr keys a plain fn(A, ..) -> R on its own value instead; its sealed FnPointer bound is the only way into that path. There is no stale-call detection and no retry: a panic propagates untouched, and code already running when a patch lands finishes as old code.
  • Sound dispatch means the key names the right function, not that the patched function is safe to call. apply_patch's contract covers that: the table must be built against this exact executable, and every mapped function's argument, return and capture types must keep their layout between the running image and the patch. Symbol names do not encode layout, so a field added to a component's State keeps the entry and breaks the second condition (RESULTS.md row D2 in the spike); checking it is the patch builder's job (the spike's PORT.md, section 2(c)).
  • apply_patch, get_jump_table, register_handler, and load_patch_library (the platform loader apply_patch uses: libloading on desktop, memfd + android_dlopen_ext on Android).
  • The app-owned anchor: the app defines #[unsafe(no_mangle)] pub extern "C" fn __frust_hotpatch_anchor() {} and registers it once with set_anchor(__frust_hotpatch_anchor as usize) (a function-pointer address, no dlsym). The builder sends the symbol's link-time address as aslr_reference and exports the same symbol from every patch, so apply_patch resolves the patch's anchor by that name (ANCHOR_SYMBOL), not main: an Android cdylib has no main. A patch library without the symbol is refused with PatchError::Dlopen.
  • Anchor consistency: apply_patch checks that aslr_reference and new_base_address really are the link-time addresses of __frust_hotpatch_anchor in the base and patch images (the offset each implies must equal the image's slide) and otherwise refuses with PatchError::AnchorMismatch, installing nothing. Today's dx 0.7.10 tables are anchored on main, so this runtime refuses them; a builder must anchor on the symbol. The patch-side refusal happens after the library is mapped, which is never unloaded.
  • apply_from_devtools(bytes_path, table) -> ApplyReport: the safe entry the in-app devtools service calls, so the one unsafe call stays in this crate. bytes_path must be a file the app wrote from bytes received on the authenticated devtools connection, never a wire path. It refuses while any layout-mismatch record is unreported: nothing is loaded and the report says applied: false with the records. The refusal lives in apply_patch itself, under its lock and before any load (PatchError::LayoutMismatchPending(records)), so every apply entry, the raw one included, refuses until the host calls mark_layout_mismatches_reported.
  • report_layout_mismatch(type_name, stored, own): records a layout disagreement found at run time and keeps it until reported. pending_layout_mismatches() reads the list (for hotpatch_info); mark_layout_mismatches_reported(&records) removes the records a PatchOutcome or hotpatch_info answer carried (records reported meanwhile stay pending).
  • seam_hits() and missed_keys(): hits and distinct missed keys since the last patch (see below).
  • JumpTable / AddressMap: the same serde shape as subsecond-types 0.7.10. The wire_compat_with_subsecond_types test round-trips a subsecond_types::JumpTable through JSON into this crate's type and back.
  • The aarch64 Android pointer-tag handling: lookups strip the top byte and re-apply it to the patched address (the masking helper is compiled and tested on every host).

A runner hands a table received from dx to this crate by a serde round-trip (see examples/hotpatch-spike/runner/src/main.rs).

The patch boundary

Nothing rewrites process memory: the table only redirects calls that enter through a HotFn. Patch code is reached without one too. A trait object, stored closure or fn pointer created while patched code ran points straight into the patch library, so calling it later runs that code with no HotFn in between, even after a newer patch. Such values created before a patch keep running the old code until something rebuilds them. A patch that changes a generic call's type parameters (e.g. a component's State) produces a monomorphisation the running binary never calls, so the old code keeps running; the fall-through diagnostics below expose that.

Debug-only, like subsecond

The jump table is consulted only under cfg!(debug_assertions), exactly as in subsecond 0.7.10: a release-profile build calls every HotFn directly and never reads the table, even with the hotpatch feature on (try_call_with_ptr, which calls the address it is given, is the one call that honours its pointer in every profile). apply_patch is gated too: a release-profile build returns PatchError::ReleaseBuild before touching anything, so it refuses to load a patch whatever the callers do.

Differences from subsecond 0.7.10

  • Native only: all wasm32 code and dependencies (wasm-bindgen, js-sys, web-sys) are gone; a wasm32 build fails with a compile_error!.
  • No pointer-size heuristic. subsecond treats any F as large as a fn pointer as one and transmutes its bytes, which misdispatches a pointer-sized capturing closure; here only HotFn::from_fn_ptr keys on a pointer's value, and HotFunction has no call_as_ptr.
  • No HotFnPanic and no retry loop: nothing in either crate ever produced one. try_call and try_call_with_ptr return Result<_, Infallible>.
  • Ordering: the table is published with a Release store and read with an Acquire load, so a reader on another thread sees a fully built map (subsecond uses Relaxed). The anchor is an atomic rather than a static mut.
  • Serialisation: apply_patch holds a lock end to end (load, rebase, publish, handlers), so concurrent patches apply one at a time. A handler must not apply a patch itself.
  • Fail-closed anchor: while no anchor is set, apply_patch returns PatchError::AnchorUnresolved before loading the library; nothing is installed, no handler runs. The anchor is the app's __frust_hotpatch_anchor, not main; aslr_reference reads the registered value and retries an unset one rather than caching it.
  • Handlers run under catch_unwind: a panicking handler is logged to stderr, the patch stays installed, and the remaining handlers still run.
  • The Android memfd stays owned until android_dlopen_ext succeeds, so a failed patch closes it instead of leaking a descriptor.
  • load_patch_library is public, so the Android loader can be probed on its own.
  • Fall-through diagnostics: last_call_fell_through() (this thread's most recent HotFn call found a table installed but no entry for its own key) and fall_through_count() (misses on any thread since the last patch, reset by every patch). Dispatch never reads them. Beside them seam_hits() counts table hits, and missed_keys() lists each distinct missed key once as MissedKey { image, link_address }: image 0 is the base executable and n the n-th patch loaded (found from the address ranges of the images this crate loaded; an address in none is UNKNOWN_IMAGE), link_address the key minus that image's slide. This crate only records; the host classifies. Both reset with every patch. A miss is also normal for any hot function the patch did not recompile, and for every call made from patch-image code: the key is the calling image's own call_it address and the table's keys are base-image addresses, so a nested component's hot call from the newest patch's rebuild misses although it already runs the newest code. The count stays a plain count; a restart rule reads the missed keys.
  • No unwrap() outside tests: a poisoned handler or apply lock is recovered, and a patch library without the anchor symbol returns PatchError::Dlopen instead of panicking.
  • ASLR offsets use wrapping arithmetic.
  • The memfd and its pseudo-path are named frust-hotpatch instead of subsecond-patch.

Attribution

Every source file is ported from subsecond 0.7.10 and subsecond-types 0.7.10 by DioxusLabs (Jonathan Kelley), https://github.com/DioxusLabs/dioxus/tree/main/packages/subsecond, licensed MIT OR Apache-2.0 (the published crates' license field; their packages ship no LICENSE file). This crate keeps that dual license; the dispatch, ordering, serialisation and fail-closed changes listed above are frust's.