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-linemain.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
hotdemorunning and logging torun/hotdemo.log:nix develop tools/hotpatch/patch.sh //apps/hotdemo:hotdemoor, with the window running,
tools/hotpatch/patch.sh //apps/native:nativeafter editing a label inpackages/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:
- Has save-temps and no hot-patch-flagged dependency (
apps/hotdemo): it is the whole patchable crate, rebuilt directly. - Has save-temps and delegates to one (
apps/native:nativetoapps/native:app): it contributes only its stale on-disk "main" sentinel object, never rebuilt.subsecond::apply_patchlooks up a symbol namedmainin every patch to find where the.solanded, and onlymain.rs's own object has it. - 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)
| Edit | Full relink every time | Now |
|---|---|---|
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:subsecondcarries its own-Cdebug-assertions=yes(third-party/rust/fixups/subsecond/fixups.toml), separately from the patched target's flags:subsecond::call'scfg!(debug_assertions)gate resolves against subsecond's own compilation (research/subsecond-patch-build.md §8.1).- The patched function needs
#[inline(never)], and must reachsubsecond::call/subsecond::HotFnas a barefnitem, not a capturing closure (§8.2;apps/hotdemo/CLAUDE.md,apps/native/CLAUDE.md). -nodefaultlibsdoes not leavememcpydangling. 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 atdlopentime like any shared object's.
In this folder
| Path | What |
|---|---|
| src/main.rs | hotpatch: the undefined-symbol stub (thread-local init bytes included), the link, the jump table. |
| patch.sh | The whole rebuild-and-patch cycle, for hotdemo or the window. |
| BUCK | The rust_binary, over object, ar, libc, serde_json and subsecond-types. |
| README.md | This chapter. |
| CLAUDE.md | The silent-failure traps, for agents. |
← Previous: Chapter 26, watch/ · Up: tools · Next: Chapter 28, nix/ →