jevsnes.git / tools / hotpatch / README.md
README.mdpreviewREADME.mdsource154 lines · 7.4 KB · raw
1# Chapter 27: hotpatch, editing a running program
2
3Chapter 15 introduced [Dioxus subsecond](https://github.com/DioxusLabs/dioxus):
4a library that, inside a running Rust process, can redirect calls to a
5function to a new version of it loaded from a shared object. Hot reloading,
6for native code. The catch is building that shared object. Its own tool,
7`dx`, makes itself the compiler wrapper and the linker so it can capture
8every invocation and replay pieces of them later.
9
10This project already has something that knows exactly what changed and how
11to rebuild it: buck2. So `hotpatch` does only the part buck2 genuinely
12cannot. Given the running binary and the objects buck2 just rebuilt, it
13resolves every symbol the new code needs that exists only in the *running*
14process (not in any file on disk, since nothing was relinked), links the
15new objects into a `.so`, and writes a `JumpTable` saying which old
16function addresses now mean what. The running program loads the `.so` and
17from its next call onwards runs the new code.
18
19```mermaid
20sequenceDiagram
21  participant P as patch.sh
22  participant B as buck2
23  participant H as hotpatch
24  participant W as the running window
25  P->>B: rebuild //apps/native:app and packages/panels (objects only)
26  P->>W: patch_info (MCP): pid, aslr_reference
27  P->>H: --base /proc/PID/exe --objects ... --aslr-reference ...
28  H-->>P: patch-N.so and patch-N.json
29  P->>W: dev_mode on, hot_patch patch-N.json, dev_mode off
30  W->>W: applies it at the next frame boundary
31```
32
33> **Aside: the executable is never rebuilt.** Relinking the window's whole
34> eframe/wgpu binary took about 21 seconds, over 97% of every cycle. So the
35> window's code is a library (`//apps/native:app`) behind a three-line
36> `main.rs`, and the patch is built from that library's fresh objects. The
37> "base" is `/proc/$PID/exe`, the exact bytes the process is running, as the
38> operating system reports them. A panels edit now lands in about 1.1 to 1.6
39> seconds.
40
41> **Try it.** With chapter 15's `hotdemo` running and logging to
42> `run/hotdemo.log`:
43>
44>     nix develop
45>     tools/hotpatch/patch.sh //apps/hotdemo:hotdemo
46>
47> or, with the window running, `tools/hotpatch/patch.sh //apps/native:native`
48> after editing a label in `packages/panels/src/state.rs`.
49
50## For the people who maintain it
51
52### The tool on its own
53
54```bash
55nix develop -c buck2 run //tools/hotpatch:hotpatch -- \
56  --base <path to the base exe> \
57  --objects <one or more .o/.rlib paths> \
58  --aslr-reference <the running process's subsecond::aslr_reference(), hex or decimal> \
59  --out-dir <where to write patch-N.so / patch-N.json>
60```
61
62writes `patch-<n>.so` (the shared object) and `patch-<n>.json` (the
63`JumpTable`, ready for `subsecond::apply_patch`). Linux/x86-64 ELF only. It
64ports the logic (not the code) of `create_undefined_symbol_stub` and
65`create_native_jump_table` from `dioxus-cli`'s `patch.rs` at tag `v0.7.10`;
66`src/main.rs`'s header has the licence notice.
67
68### `patch.sh`, the whole cycle in one command
69
70Run it from inside `nix develop`: it checks `IN_NIX_SHELL` and only
71re-enters the devshell per `buck2`/`curl` call when it is not already in
72one, which used to be most of the cycle time.
73
74For `//apps/hotdemo:hotdemo` (already running and logging to
75`run/hotdemo.log`): it rebuilds the target, finds its fresh `.rcgu.o`
76objects, reads the pid and `aslr_reference` out of the log, runs `hotpatch`,
77and drops the result at `run/hotdemo.patch.json`, which hotdemo picks up on
78its next once-a-second check.
79
80For `//apps/native:native` (the window): the same rebuild, but the pid and
81`aslr_reference` come from its `patch_info` MCP tool, and the patch is
82delivered by turning `dev_mode` on, calling `hot_patch` with the new
83`patch-N.json`, and turning `dev_mode` off, all over the window's own MCP
84endpoint (`http://127.0.0.1:7637/mcp`, session lifecycle: `initialize` gets
85an `Mcp-Session-Id` header back, every further call carries it). The window
86applies the patch at its next frame boundary.
87
88`patch.sh` also folds in every first-party (`//apps/...` or `//packages/...`)
89dependency of the target, transitively, that is itself built with
90`-Csave-temps=true` (`packages/panels` is; `packages/alttp`,
91`packages/console` and `packages/mcp` are not, and are skipped without being
92built).
93
94A tip target is one of three shapes, told apart by its own and its
95dependencies' `-Csave-temps=true`, never by name:
96
971. Has save-temps and no hot-patch-flagged dependency (`apps/hotdemo`): it
98   is the whole patchable crate, rebuilt directly.
992. Has save-temps and delegates to one (`apps/native:native` to
100   `apps/native:app`): it contributes only its stale on-disk "main" sentinel
101   object, never rebuilt. `subsecond::apply_patch` looks up a symbol named
102   `main` in every patch to find where the `.so` landed, and only `main.rs`'s
103   own object has it.
1043. No save-temps of its own: it contributes nothing directly; every object
105   comes from its dependencies.
106
107Where a target's objects land is read from buck2's own record of the rustc
108invocation (the `--out-dir=` in `<crate>-link-diag.args`, found under the
109target's output directory), not guessed from buck2's internal directory
110codes, which differ by target kind and buck2 version (measured 2026-09-20:
111`XIPL` for a `rust_binary`, `LPPL` for a library's `[static_pic]`).
112
113### Cycle time (measured 2026-09-20, in-shell, against the real window)
114
115| Edit | Full relink every time | Now |
116| --- | --- | --- |
117| `packages/panels/src/state.rs` | ~21.3 s | ~1.1 to 1.6 s |
118| `apps/native/src/app.rs` | ~20.8 s | ~4.0 s |
119
120`app[static_pic]` alone still costs ~3.5 s to compile, flat whether the
121toolchain's `-Copt-level=3` is overridden to 1 or `-Ccodegen-units=16` on
122that target: the cost is LLVM codegen against egui/eframe's generic
123surface, so `-Copt-level=3` stays project-wide. Other numbers: `nix develop
124-c true` costs 3.4 to 6.7 s per invocation; a no-change `buck2 build` ~17 ms;
125`hotpatch`'s own link and jump table ~1.8 s for the window, under a second
126for hotdemo.
127
128### What it depends on
129
130- **`//third-party/rust:subsecond` carries its own `-Cdebug-assertions=yes`**
131  (`third-party/rust/fixups/subsecond/fixups.toml`), separately from the
132  patched target's flags: `subsecond::call`'s `cfg!(debug_assertions)` gate
133  resolves against subsecond's own compilation
134  ([research/subsecond-patch-build.md](../../research/subsecond-patch-build.md) §8.1).
135- **The patched function needs `#[inline(never)]`, and must reach
136  `subsecond::call`/`subsecond::HotFn` as a bare `fn` item, not a capturing
137  closure** (§8.2; `apps/hotdemo/CLAUDE.md`, `apps/native/CLAUDE.md`).
138- **`-nodefaultlibs` does not leave `memcpy` dangling.** The stub only
139  resolves names the base binary's own symbol table defines. Ordinary libc
140  functions were never statically linked into the base to begin with, so
141  they stay ordinary undefined dynamic symbols in the patch, resolved by the
142  loader at `dlopen` time like any shared object's.
143
144### In this folder
145
146| Path | What |
147| --- | --- |
148| [src/main.rs](src/main.rs) | `hotpatch`: the undefined-symbol stub (thread-local init bytes included), the link, the jump table. |
149| [patch.sh](patch.sh) | The whole rebuild-and-patch cycle, for hotdemo or the window. |
150| [BUCK](BUCK) | The `rust_binary`, over `object`, `ar`, `libc`, `serde_json` and `subsecond-types`. |
151| [README.md](README.md) | This chapter. |
152| [CLAUDE.md](CLAUDE.md) | The silent-failure traps, for agents. |
153
154← Previous: [Chapter 26, watch/](../watch/) · Up: [tools](../) · Next: [Chapter 28, nix/](../../nix/) →