jevsnes.git / tools / hotpatch

Chapter 27: hotpatch, editing a running program

Chapter 15 introduced Dioxus subsecond: a library that, inside a running Rust process, can redirect calls to a function to a new version of it loaded from a shared object. Hot reloading, for native code. The catch is building that shared object. Its own tool, dx, makes itself the compiler wrapper and the linker so it can capture every invocation and replay pieces of them later.

This project already has something that knows exactly what changed and how to rebuild it: buck2. So hotpatch does only the part buck2 genuinely cannot. Given the running binary and the objects buck2 just rebuilt, it resolves every symbol the new code needs that exists only in the running process (not in any file on disk, since nothing was relinked), links the new objects into a .so, and writes a JumpTable saying which old function addresses now mean what. The running program loads the .so and from its next call onwards runs the new code.

sequenceDiagram
  participant P as patch.sh
  participant B as buck2
  participant H as hotpatch
  participant W as the running window
  P->>B: rebuild //apps/native:app and packages/panels (objects only)
  P->>W: patch_info (MCP): pid, aslr_reference
  P->>H: --base /proc/PID/exe --objects ... --aslr-reference ...
  H-->>P: patch-N.so and patch-N.json
  P->>W: dev_mode on, hot_patch patch-N.json, dev_mode off
  W->>W: applies it at the next frame boundary

Aside: the executable is never rebuilt. Relinking the window's whole eframe/wgpu binary took about 21 seconds, over 97% of every cycle. So the window's code is a library (//apps/native:app) behind a three-line main.rs, and the patch is built from that library's fresh objects. The "base" is /proc/$PID/exe, the exact bytes the process is running, as the operating system reports them. A panels edit now lands in about 1.1 to 1.6 seconds.

Try it. With chapter 15's hotdemo running and logging to run/hotdemo.log:

nix develop
tools/hotpatch/patch.sh //apps/hotdemo:hotdemo

or, with the window running, tools/hotpatch/patch.sh //apps/native:native after editing a label in packages/panels/src/state.rs.

For the people who maintain it

The tool on its own

nix develop -c buck2 run //tools/hotpatch:hotpatch -- \
  --base <path to the base exe> \
  --objects <one or more .o/.rlib paths> \
  --aslr-reference <the running process's subsecond::aslr_reference(), hex or decimal> \
  --out-dir <where to write patch-N.so / patch-N.json>

writes patch-<n>.so (the shared object) and patch-<n>.json (the JumpTable, ready for subsecond::apply_patch). Linux/x86-64 ELF only. It ports the logic (not the code) of create_undefined_symbol_stub and create_native_jump_table from dioxus-cli's patch.rs at tag v0.7.10; src/main.rs's header has the licence notice.

patch.sh, the whole cycle in one command

Run it from inside nix develop: it checks IN_NIX_SHELL and only re-enters the devshell per buck2/curl call when it is not already in one, which used to be most of the cycle time.

For //apps/hotdemo:hotdemo (already running and logging to run/hotdemo.log): it rebuilds the target, finds its fresh .rcgu.o objects, reads the pid and aslr_reference out of the log, runs hotpatch, and drops the result at run/hotdemo.patch.json, which hotdemo picks up on its next once-a-second check.

For //apps/native:native (the window): the same rebuild, but the pid and aslr_reference come from its patch_info MCP tool, and the patch is delivered by turning dev_mode on, calling hot_patch with the new patch-N.json, and turning dev_mode off, all over the window's own MCP endpoint (http://127.0.0.1:7637/mcp, session lifecycle: initialize gets an Mcp-Session-Id header back, every further call carries it). The window applies the patch at its next frame boundary.

patch.sh also folds in every first-party (//apps/... or //packages/...) dependency of the target, transitively, that is itself built with -Csave-temps=true (packages/panels is; packages/alttp, packages/console and packages/mcp are not, and are skipped without being built).

A tip target is one of three shapes, told apart by its own and its dependencies' -Csave-temps=true, never by name:

  1. Has save-temps and no hot-patch-flagged dependency (apps/hotdemo): it is the whole patchable crate, rebuilt directly.
  2. Has save-temps and delegates to one (apps/native:native to apps/native:app): it contributes only its stale on-disk "main" sentinel object, never rebuilt. subsecond::apply_patch looks up a symbol named main in every patch to find where the .so landed, and only main.rs's own object has it.
  3. No save-temps of its own: it contributes nothing directly; every object comes from its dependencies.

Where a target's objects land is read from buck2's own record of the rustc invocation (the --out-dir= in <crate>-link-diag.args, found under the target's output directory), not guessed from buck2's internal directory codes, which differ by target kind and buck2 version (measured 2026-09-20: XIPL for a rust_binary, LPPL for a library's [static_pic]).

Cycle time (measured 2026-09-20, in-shell, against the real window)

EditFull relink every timeNow
packages/panels/src/state.rs~21.3 s~1.1 to 1.6 s
apps/native/src/app.rs~20.8 s~4.0 s

app[static_pic] alone still costs ~3.5 s to compile, flat whether the toolchain's -Copt-level=3 is overridden to 1 or -Ccodegen-units=16 on that target: the cost is LLVM codegen against egui/eframe's generic surface, so -Copt-level=3 stays project-wide. Other numbers: nix develop -c true costs 3.4 to 6.7 s per invocation; a no-change buck2 build ~17 ms; hotpatch's own link and jump table ~1.8 s for the window, under a second for hotdemo.

What it depends on

  • //third-party/rust:subsecond carries its own -Cdebug-assertions=yes (third-party/rust/fixups/subsecond/fixups.toml), separately from the patched target's flags: subsecond::call's cfg!(debug_assertions) gate resolves against subsecond's own compilation (research/subsecond-patch-build.md §8.1).
  • The patched function needs #[inline(never)], and must reach subsecond::call/subsecond::HotFn as a bare fn item, not a capturing closure (§8.2; apps/hotdemo/CLAUDE.md, apps/native/CLAUDE.md).
  • -nodefaultlibs does not leave memcpy dangling. The stub only resolves names the base binary's own symbol table defines. Ordinary libc functions were never statically linked into the base to begin with, so they stay ordinary undefined dynamic symbols in the patch, resolved by the loader at dlopen time like any shared object's.

In this folder

PathWhat
src/main.rshotpatch: the undefined-symbol stub (thread-local init bytes included), the link, the jump table.
patch.shThe whole rebuild-and-patch cycle, for hotdemo or the window.
BUCKThe rust_binary, over object, ar, libc, serde_json and subsecond-types.
README.mdThis chapter.
CLAUDE.mdThe silent-failure traps, for agents.

← Previous: Chapter 26, watch/ · Up: tools · Next: Chapter 28, nix/ →