jevsnes.git / research / subsecond-patch-build.md
1# Subsecond hot-patching: what `dx` does, and how to reimplement it under buck2
2
3Source: `~/src/github.com/DioxusLabs/dioxus` at tag `v0.7.10` (commit
4`57d6794ad60b949e5bd8aa282f6f8c3dc97a365e`), a blobless clone (`git show`
5fetches blobs on demand). All cited files are dual `MIT OR Apache-2.0`
6(`packages/subsecond/subsecond/Cargo.toml:7`,
7`packages/subsecond/subsecond-types/Cargo.toml:6`,
8`packages/cli/Cargo.toml:8`, `packages/devtools/Cargo.toml:6`,
9`packages/devtools-types/Cargo.toml:6`; repo root carries `LICENSE-MIT` +
10`LICENSE-APACHE`), so copying code/logic from them and keeping the licence
11notice is fine.
12
13Scope note: `subsecond-cli`/a standalone harness binary does **not exist** in
14this checkout — I grepped `git ls-tree -r --name-only HEAD | grep subsecond`
15and the only subsecond dirs are `subsecond/`, `subsecond-types/`, and
16`subsecond-tests/{cross-tls-crate,cross-tls-crate-dylib,cross-tls-test}`
17(cross-TLS regression fixtures, not a build-tool example). All "how does the
18patch get built" logic lives in `packages/cli/src/build/{request.rs,link.rs,
19patch.rs}` and `packages/cli/src/{rustcwrapper.rs,cli/link.rs}`, not in a
20separate crate.
21
22## The mechanism dx uses to see rustc/linker invocations at all
23
24`dx` never parses `cargo`'s own progress output for build flags — it makes
25**itself** both the rustc wrapper and the linker, for every workspace-member
26crate, on **every** build (fat or thin):
27
28- `request.rs:1646`: `cmd.env("RUSTC_WORKSPACE_WRAPPER", Workspace::path_to_dx()?);`
29  — this wraps only workspace-member crates (not external deps), letting cargo
30  invoke `dx rustc <real-args>` for each one.
31- `request.rs:1637-1641`: `DX_RUSTC_WRAPPER_ENV_VAR` (`"DX_RUSTC"`, defined
32  `rustcwrapper.rs:36`) points at a per-build-mode scope directory where
33  captured args get written.
34- `rustcwrapper.rs:46-48`: `is_wrapping_rustc()` just checks that env var is set.
35- `rustcwrapper.rs:53-94` (`run_rustc`): captures `args()` + `vars()` into a
36  `RustcArgs{args, envs}`, **always** writes it to
37  `{crate_name}.{lib|bin}.json` (`write_rustc_args`, `rustcwrapper.rs:96-136`)
38  even for link steps ("the tip crate's bin target is typically only observed
39  during the final link invocation" — comment at `link.rs:68`), then either
40  delegates to the linker-interception path (`has_linking_args()`,
41  `rustcwrapper.rs:138-170`, which checks for a `.o` arg or `-flavor`, including
42  inside `@responsefile` command files) or actually execs `rustc` with the
43  captured args/envs (`rustcwrapper.rs:79-93`).
44- Simultaneously `dx` sets itself as the **linker** too
45  (`cargo_build_arguments`, `request.rs:1748-1753`:
46  `-Clinker={path to dx}`, gated on `BuildMode::Thin | BuildMode::Fat`), and
47  `LinkAction::write_env_vars` (`cli/link.rs:85-109`) exports `DX_LINK=1`,
48  `DX_LINK_ARGS_FILE`, `DX_LINK_ERR_FILE`, `DX_LINK_TRIPLE`, optionally
49  `DX_LINK_CUSTOM_LINKER`.
50- `LinkAction::run_link_inner` (`cli/link.rs:131-242`) is "NoLink": it writes
51  the linker's argv to `DX_LINK_ARGS_FILE` and then, if no real linker was
52  given, **does not link at all** — it writes a dummy empty object file
53  (`object::write::Object::new(...).write()`) at the expected `-o`/`/OUT:`
54  path "to satisfy rustc's use of llvm-objcopy" (comment `cli/link.rs:191-196`).
55  The doc comment on `LinkAction` (`cli/link.rs:7-28`) is explicit about why:
56  rustc has no supported way to stop short of a full link, so this is a hack
57  to get the *arguments* rustc would have used without paying for (or
58  trusting) a real link.
59
60This two-pronged interception (`RUSTC_WORKSPACE_WRAPPER` + `-Clinker=dx`) is
61how `dx` gets a reliable, per-crate, per-platform record of every rustc
62invocation's exact args/envs **and** the exact final link line, without
63depending on cargo's own diagnostic JSON (comment `link.rs:1-16` explains the
64alternative — parsing cargo's printed link args — was rejected as unreliable
65across platforms, esp. Windows response files).
66
67## 1. The "fat" (initial) build
68
69Fat is a normal `cargo rustc` invocation (`request.rs:1613-1649`,
70`cargo_build_command`), with these additions layered on top of the user's own
71profile/rustflags:
72
73- **`-Csave-temps=true`** and **`-Clink-dead-code`**
74  (`request.rs:1799-1801`), added whenever `build_mode` is `Thin` or `Fat`
75  (`request.rs:1795`). Comment: "link-dead-code: prevents rust from passing
76  `-dead_strip` to the linker since that's the default" / "save-temps=true:
77  keeps the incremental object files around, which we need for manually
78  linking" (`request.rs:1796-1798`). These are captured by the rustc wrapper
79  and so get **replayed automatically** on every future thin build too
80  (comment `request.rs:1792-1794`).
81- **`-Clinker={dx}`** (`request.rs:1748-1753`) — always for `Thin`/`Fat`, so
82  the "NoLink" capture above fires.
83- Platform rpath flags (`-Wl,-rpath,$ORIGIN` etc., `request.rs:1775-1778` for
84  Linux) and, for wasm only, PIC/export flags (`request.rs:1852-1866`) — not
85  relevant to native Linux.
86- **No `-Zunstable-options`, no nightly requirement, and no explicit
87  `-Crelocation-model=pic` on native (non-wasm) targets.** Proof of absence:
88  `grep -n "relocation-model" packages/cli/src/build/*.rs` returns exactly two
89  hits, both gated on wasm/wasi (`request.rs:1594-1596`,
90  `link.rs:573-575`, `link.rs:358-363` doc comment "I think we can make
91  relocation-model=pic work for non-wasm platforms" — i.e. **not done today**
92  for native). Nightly is used **only** for an unrelated unit-count estimate
93  (`cargo +nightly unit-graph`, `request.rs:2349-2360`), not for hotpatch
94  compilation; `grep -rn nightly` over `packages/cli/src` and
95  `packages/subsecond` turns up nothing else load-bearing.
96- No `--export-dynamic`/`-rdynamic` blanket flag on Linux at the `cargo_args`
97  level; instead the **fat link step** (next section) surgically exports just
98  `main` via `-Wl,--export-dynamic-symbol,main` (`link.rs:995`).
99- Debug-assertions/opt-level are **not forced** by dx — they come from
100  whatever cargo profile the user picked. But `subsecond::call`/`try_call`
101  are `cfg!(debug_assertions)`-gated no-ops in release
102  (`subsecond/src/lib.rs:250-254`, `:411-414`), so hot-patching is a no-op
103  unless the profile has `debug-assertions = true` (dev profile, by default).
104
105**What's captured, and by what:** the rustc-wrapper writes one JSON per
106workspace crate (`{crate}.lib.json` / `{crate}.bin.json`) containing
107`{args: Vec<String>, envs: Vec<(String,String)>}` (`rustcwrapper.rs:26-30`);
108the linker-wrapper writes the raw final link argv, one arg per line, to
109`link_args_file` (`cli/link.rs:145`). Both are read back after the `cargo
110build` child process exits: `workspace_rustc_args = self.load_rustc_argset()`
111(`request.rs:1152`) assembles a `WorkspaceRustcArgs{link_args, rustc_args:
112HashMap<String,RustcArgs>}` (`rustcwrapper.rs:11-24`) from the on-disk JSONs
113plus the plain-text link-args file.
114
115For **Fat** specifically, `run_fat_link` (`link.rs:788-1142`) is then invoked
116(`request.rs:1157-1160`) to do the *real* link, because the wrapper only wrote
117a dummy object file:
118
119- Takes the tip crate's captured `.bin.json` args + the captured `link_args`.
120- Filters `link_args` down to the `.rlib` entries (`link.rs:809-815`).
121- Builds (and caches, keyed on rlib name+size+mtime+dx's own git commit hash,
122  `link.rs:817-849`) a **"fat archive"**: an `ar` archive
123  (`libdeps-{hash}.a`) containing every `.rcgu.o`/`.obj` member pulled out of
124  every workspace `.rlib` (external/toolchain rlibs are kept as normal `.rlib`
125  refs instead, `link.rs:865-922` — "Skip compiler rlibs since they're
126  missing bitcode").
127- Splices that archive into the original link command with
128  `-Wl,--whole-archive … -Wl,--no-whole-archive` on the `Gnu` flavor
129  (`link.rs:957-965`) — i.e. it forces every object, not just what the linker
130  would keep, into the final binary, so those symbols exist to be jumped back
131  to later.
132- Appends `-Wl,--export-dynamic-symbol,main` (Gnu/Linux, `link.rs:993-996`;
133  `_main`+`-Wl,-exported_symbol,_main` on Darwin, `/EXPORT:main` on MSVC) —
134  this is **the only blanket "export a symbol" flag on Linux**, and it's
135  `main` specifically, used purely as the ASLR reference point (see §3), not
136  a general `--export-dynamic`.
137- Runs the real linker (`select_linker`, `link.rs:1240-1277`: `cc` on
138  Gnu/Linux) directly (`link.rs:1084-1089`), i.e. dx invokes `cc` itself here,
139  bypassing rustc entirely for this final link.
140
141## 2. The "thin" (patch) build
142
143Trigger: `BuildMode::Thin{ modified_crates, workspace_rustc_args, aslr_reference, cache, .. }`
144(`compile_workspace_hotpatch`, `link.rs:132-335`).
145
146**Which crates get recompiled:** every crate in the *cumulative* (since the
147last fat build) `modified_crates` set gets replayed — not just the tip.
148`AppBuilder::patch_rebuild` (`builder.rs:364-456`) BFS-walks
149`workspace_dependents_of` from the changed files' crates to add the cascade
150(comment `builder.rs:353-358`: "editing a leaf crate forces parent crates'
151generic instantiations to change too"), then keeps that set forever until the
152next full/fat rebuild (`builder.rs:466-467`: "A full rebuild resets all
153accumulated hotpatch state"). This **contradicts** the subsecond crate's own
154doc comment ("Subsecond currently only patches the 'tip' crate… We plan to
155add full workspace support in the future," `subsecond/src/lib.rs:67-75`) —
156that doc appears stale relative to the CLI's actual v0.7.10 implementation.
157UNVERIFIED: whether this workspace-replay path is fully reliable for
158arbitrary dependency-crate changes (the subsecond doc's caveats about
159non-deterministic build graphs and generic-forwarding cascades may still
160apply in edge cases) — I found no test exercising a multi-crate thin build
161in this checkout.
162
163**How non-tip crates are recompiled:** `workspace_hotpatch_replay_order`
164(`link.rs:612-662`, Kahn's-algorithm topological sort over the *modified*
165subgraph only) determines order; for each, `workspace_hotpatch_replay_args`
166(`link.rs:586-603`) looks up its **original captured `.lib.json`** args, and
167`compile_dep_crate` (`link.rs:518-584`) runs `rustc` **directly** (not
168`cargo`) with those exact args verbatim (env-cleared, replaying the captured
169`envs`, stripping `-Clinker=…` so it doesn't recurse into dx's link
170interception, and adding `-Crelocation-model=pic` only for wasm/wasi). This
171overwrites the crate's `.rlib` **in place** at its original path
172(`link.rs:46-47` doc comment).
173
174**The tip crate** is then rebuilt via the normal `cargo_build` path
175(`link.rs:161`, i.e. `cargo rustc` again) but under `BuildMode::Thin`, which
176routes `cargo_build_command` into the *other* branch (`request.rs:1571-1601`):
177runs `rustc` directly (not `cargo`) with the tip's **captured fat-build args**
178(`rustc_args.args[1..]`), env-cleared and wrapper vars removed, plus
179`-Clinker={dx}` again so this recompile also gets intercepted for object
180capture. **No new flags are added for the tip crate on native platforms** in
181this thin path — it reuses exactly what was captured during the fat build
182(the `-Csave-temps`/`-Clink-dead-code` already baked in).
183
184**What gets linked into the patch, and into what:**
185
186- `temp_objects`: every `.rcgu.o` from the *tip's* fresh link args
187  (`link.rs:190-197`) — these are per-codegen-unit object files straight from
188  rustc's `-Csave-temps` output, i.e. **only the tip crate's own new/changed
189  translation units**, not whole-crate objects.
190- `workspace_rlibs`: the (now freshly overwritten) `.rlib` files for every
191  *other* replayed crate (`link.rs:199-200`, `workspace_hotpatch_link_rlibs`
192  at `link.rs:672-723`), resolved by reconstructing the exact rlib filename
193  from each crate's captured `--out-dir` + `-C extra-filename`
194  (`find_rlib_for_crate`, `link.rs:1285-1349`).
195- On non-wasm (our case): a **generated stub object** —
196  `create_undefined_symbol_stub` (`patch.rs:852-1259`) — is appended
197  (`link.rs:219-231`). This is the "resolve missing symbols against the
198  running binary" step (§ next paragraph).
199- Everything is linked with `thin_link_args` (`link.rs:341-511`), which for
200  `LinkerFlavor::Gnu` (Linux) starts from **`-shared`**
201  (`link.rs:429-436`: `-shared -Wl,--eh-frame-hdr -Wl,-z,noexecstack
202  -Wl,-z,relro,-z,now -nodefaultlibs -Wl,-Bdynamic`), i.e. **the patch output
203  is a shared object (`.so`)**, not an executable — confirmed by
204  `patch_exe` (`link.rs:737-756`): extension is `"so"` for `LinkerFlavor::Gnu`,
205  filename `lib{name}-patch-{timestamp_ms}.so` next to the main exe. Original
206  args are filtered down to just `-L`, `-l*`, `-m*`, `-fuse-ld*`/`-Wl,-fuse-ld*`,
207  `-B*` (needed because "Rust 1.86+ injects `-B/gcc-ld` + `-fuse-ld=lld`",
208  comment `link.rs:442-444`), and `-ld-path` (`link.rs:447-463`) — i.e. **no
209  rlibs/objects from the original fat link are reused as linker inputs
210  here**, only search-path/toolchain-selection flags. The actual linker
211  binary is `select_linker()` → `self.workspace.cc()` for Gnu (`link.rs:1255`),
212  i.e. the same `cc` used for the fat link, invoked directly (not through
213  rustc), env-cleared but with `PATH` restored on Linux specifically
214  (`link.rs:277-280`, `compile_workspace_hotpatch`'s own linker call at
215  `link.rs:287-292`).
216
217**How symbols that live in the ORIGINAL binary are resolved (Linux/native,
218not the WASM ifunc path):** `create_undefined_symbol_stub`
219(`patch.rs:852-1259`) — **not `--just-symbols`, not an asm/ELF diff, a
220hand-generated object file of absolute-address jump stubs**:
221
2221. Collect every undefined symbol across the objects about to be linked
223   (`collect_stub_symbols_from_path`/`_bytes`, `patch.rs:1262-1308`, via the
224   `object` crate reading `.o`/`.rlib`/`.a` member symbol tables), minus
225   anything also defined among them (`patch.rs:867-870`).
2262. Compute `aslr_offset = aslr_reference - aslr_ref_address` where
227   `aslr_ref_address` is the **original fat binary's on-disk `main` symbol
228   address** (from the cached symbol table, `patch.rs:926-939`) and
229   `aslr_reference` is the **live process's actual `main` address**, reported
230   by the running app over the devtools websocket (§4) and threaded in from
231   `BuildMode::Thin{ aslr_reference, .. }`.
2323. For each undefined symbol found in the original binary's cached symbol
233   table (`HotpatchModuleCache.symbol_table`, populated once per fat build via
234   the `object` crate, `patch.rs:263-291`, and reused across thin builds for
235   speed — "dropped the patch time from 3s to 1.1s", `patch.rs:66`):
236   `abs_addr = sym.address + aslr_offset` (`patch.rs:966`), then **hand-emit
237   machine code that jumps to that absolute address** and register it as a
238   new defined symbol with the *undefined* symbol's exact name in a freshly
239   built `object::write::Object`:
240   - Linux/x86_64 text symbols: `SymbolKind::Text` branch
241     (`patch.rs:1083-1091`): `FF 25 00 00 00 00` (`jmp [rip+0]`) followed by
242     the raw 8-byte absolute address appended right after — i.e. a RIP-relative
243     indirect jump whose target slot is the very next 8 bytes.
244   - Data symbols: written as `SymbolSection::Absolute` at `abs_addr` directly
245     (`patch.rs:1245-1254`) — no jump stub needed, the symbol just *is* that
246     address.
247   - TLS symbols: handled specially by copying real init bytes out of the
248     cached `.tdata`/`__thread_data` section into a new TLS symbol
249     (`patch.rs:1163-1226`) rather than writing a bogus absolute address.
2504. This generated object (`stub.o`) is linked in alongside the tip's
251   `.rcgu.o`s and the workspace `.rlib`s.
252
253So on Linux/x86_64 the whole "resolve against the live process" trick is:
254**one small hand-built ELF object of `jmp [rip+0]; <8-byte address>` stubs**,
255computed from (a) the fat build's own captured symbol table and (b) the
256process-reported ASLR reference — no `--just-symbols`, no dlopen-time symbol
257patching, no linker feature at all beyond ordinary symbol resolution against
258this synthetic object.
259
260## 3. The jump table
261
262`subsecond_types::JumpTable` (`subsecond-types/src/lib.rs:8-47`):
263
264```rust
265pub struct JumpTable {
266    pub lib: PathBuf,               // the patch .so/.dylib/.dll/.wasm path
267    pub map: AddressMap,            // old_addr -> new_addr, u64 keys, identity-hashed (no re-hash of an address)
268    pub aslr_reference: u64,        // "main" symbol's address in the OLD (running) binary
269    pub new_base_address: u64,      // "main" symbol's address as recorded IN the patch's own symbol table
270    pub ifunc_count: u64,           // wasm-only: size to grow the indirect function table by
271}
272```
273`AddressMap = HashMap<u64, u64, BuildHasherDefault<AddressHasher>>` where
274`AddressHasher` (`subsecond-types/src/lib.rs:53-92`) is a **no-op/identity**
275hasher (`write_u64` just stores the value) — addresses are unique by
276construction so hashing them is wasted work.
277
278**How "changed" is decided — it isn't, at the jump-table level.** There is no
279symbol-diffing step anywhere I could find (grepped `patch.rs`/`link.rs` for
280"diff"/"changed" — only comments describing intent, e.g. `link.rs:177`
281"ThinLink… Diffing of object files for function invalidation" is aspirational
282doc-prose, not implemented code found in this checkout). What actually
283happens: `create_native_jump_table` (`patch.rs:403-443`) parses the **whole**
284old binary's symbol table (cached) and the **whole** freshly-linked patch
285`.so`'s symbol table (`obj2.symbol_map()`), and inserts an entry for
286**every** name present in both (`patch.rs:420-424`):
287```rust
288for (new_name, new_addr) in new_name_to_addr.iter() {
289    if let Some(old_addr) = old_name_to_addr.get(*new_name) {
290        map.insert(old_addr.address, *new_addr);
291    }
292}
293```
294"Changed" is decided **upstream**, structurally: only the tip crate's
295`.rcgu.o`s (i.e. whatever codegen units rustc actually re-emitted) plus
296replayed dependency crates make it into the patch `.so` at all; anything not
297recompiled simply isn't in `new_name_to_addr` and gets no entry (its old
298address is used unchanged, since callers only redirect through entries that
299exist in `jump_table.map`, `subsecond/src/lib.rs:396-403,919`/`936`). So the
300"diff" is really "what did rustc choose to recompile", not a post-hoc
301comparison — every symbol common to old and new gets remapped whether or not
302its bytes actually changed, which is why `subsecond/src/lib.rs:118-120` notes
303`ptr_address` "will always return the most up-to-date version… equivalent to
304a version of the function where every function is considered 'new'."
305
306`sentinel = main_sentinel(triple)` (referenced `patch.rs:426-434`, "main" on
307Linux) is used as the reference: `new_base_address` = patch's own `main`
308address, `aslr_reference` = the **original** binary's cached `main` address
309(not yet corrected for the live process — that correction happens at apply
310time, see below).
311
312**What `apply_patch` does on Linux/unix** (`subsecond/src/lib.rs:498-547`,
313`pub unsafe fn apply_patch(mut table: JumpTable) -> Result<(), PatchError>`):
3141. `libloading::Library::new(&table.lib)` — an ordinary `dlopen`
315   (`subsecond/src/lib.rs:507-512`), leaked forever (never `dlclose`'d —
316   comment explains dropping could run destructors mid-crash-prone-state).
3172. `old_offset = aslr_reference() - table.aslr_reference` — `aslr_reference()`
318   (`subsecond/src/lib.rs:716-750`) is `dlsym(RTLD_DEFAULT, "main")` in the
319   **currently running process**, so this is the ASLR slide of the *original*
320   binary between compile time and now.
3213. `new_offset = dlopen'd_lib.get::<*const ()>("main") - table.new_base_address`
322   — same idea for the just-loaded patch `.so`.
3234. Every entry in `table.map` gets rebased:
324   `(old_key + old_offset, new_val + new_offset)` (`subsecond/src/lib.rs:535-544`).
3255. `commit_patch(table)` — atomically swaps a leaked, `Box`-owned `JumpTable`
326   into a global `AtomicPtr` (`Relaxed` ordering, `subsecond/src/lib.rs:279`,
327   `308-321`) and runs any registered hot-reload handlers.
328
329Callers then look up `known_fn_ptr` (the function's **compile-time** address,
330taken via `transmute` of the function item, `subsecond/src/lib.rs:396`/`430`)
331in `jump_table.map` and, if present, call through the mapped address instead
332(`HotFn::try_call`, `subsecond/src/lib.rs:411-441`; the macro-generated
333`call_as_ptr`, `subsecond/src/lib.rs:902-941`, handles Android pointer-tagging
334specially but is a no-op branch on Linux).
335
336## 4. The wire protocol
337
338Types: `packages/devtools-types/src/lib.rs`.
339```rust
340pub enum DevserverMsg {
341    HotReload(HotReloadMsg), HotPatchStart, FullReloadStart,
342    FullReloadFailed, FullReloadCommand, Shutdown,
343}                                                    // lines 9-28
344pub struct HotReloadMsg {
345    pub templates: Vec<HotReloadTemplateWithLocation>,
346    pub assets: Vec<PathBuf>,
347    pub ms_elapsed: u64,
348    pub jump_table: Option<JumpTable>,
349    pub for_build_id: Option<u64>,
350    pub for_pid: Option<u32>,
351}                                                    // lines 42-50
352```
353Both derive plain `Serialize, Deserialize` with no `#[serde(tag=...)]`
354override, so `DevserverMsg` serializes as serde's default externally-tagged
355JSON, e.g. `{"HotReload":{"templates":[],"assets":[],"ms_elapsed":0,
356"jump_table":{...JumpTable fields...},"for_build_id":1,"for_pid":12345}}`.
357Transport is a plain `tungstenite` WebSocket carrying `Message::Text(json)`
358(`devtools/src/lib.rs:98-109`), explicitly **not authenticated**: "This
359doesn't use any form of security or protocol, so it's not safe to expose to
360the internet" (`devtools/src/lib.rs:59`).
361
362**Connection URL** (`connect_at`, `devtools/src/lib.rs:89-101`):
363```
364{endpoint}?aslr_reference={subsecond::aslr_reference()}&build_id={dioxus_cli_config::build_id()}&pid={std::process::id()}
365```
366— the client (the running app) reports its **own live `main` address** as a
367query param when it connects; this is exactly the `aslr_reference` value
368`patch_rebuild` threads into `BuildMode::Thin` (`builder.rs:380-395` — dx
369*waits* for a client to be connected before it will do a thin build on
370non-wasm targets, since it needs this value: "Ignoring hotpatch since there
371is no ASLR reference. Is the client connected?", `builder.rs:390-391`).
372
373**Receiving/applying** (`connect_subsecond`, `devtools/src/lib.rs:76-86`):
374```rust
375pub fn connect_subsecond() {
376    connect(|msg| {
377        if let DevserverMsg::HotReload(hot_reload_msg) = msg {
378            if let Some(jumptable) = hot_reload_msg.jump_table {
379                if hot_reload_msg.for_pid == Some(std::process::id()) {
380                    unsafe { subsecond::apply_patch(jumptable).unwrap() };
381                }
382            }
383        }
384    });
385}
386```
387`for_pid` gates whether *this* process applies it (a dev server can be
388talking to several connected clients/processes at once); `for_build_id` is
389checked similarly in the dioxus-VirtualDom-aware `try_apply_changes`
390(`devtools/src/lib.rs:19-55`, checks `msg.for_build_id ==
391Some(dioxus_cli_config::build_id())`) but **not** in the bare
392`connect_subsecond` path — for a non-Dioxus app (jev is eframe/egui, not
393Dioxus), `connect_subsecond`'s simpler pid-only check is the relevant
394precedent.
395
396**Confirmed: `subsecond::apply_patch` is `pub unsafe fn apply_patch(table:
397JumpTable) -> Result<(), PatchError>`** (`subsecond/src/lib.rs:498`) — it can
398be called directly with a hand-constructed `JumpTable`, with no dependency on
399the websocket protocol, `dx`, or Dioxus at all. A buck2-driven tool can build
400its own `JumpTable` (e.g. by shelling out to the same `create_native_jump_table`
401logic, or a from-scratch equivalent using the `object` crate) and either (a)
402open its own tiny websocket/HTTP server that `jev`'s MCP server dials into and
403speaks a `{jump_table: JumpTable}`-shaped JSON, mirroring `DevserverMsg`, or
404(b) skip the network entirely and have the MCP `hot_patch` tool call
405`subsecond::apply_patch` **in-process**, since jev's own binary already links
406`subsecond` and already has an MCP server loop (`apps/native/src/mcp.rs`) that
407can receive a file path + deserialize a `JumpTable` from disk/stdin and call
408`apply_patch` directly on the UI/tool thread. This avoids reimplementing the
409websocket handshake and the `aslr_reference` query-param dance — the tool call
410itself can read `subsecond::aslr_reference()` in-process before invoking the
411build.
412
413## 5. Hard requirements/limits for the buck2 design
414
415- **No nightly rustc requirement.** (See §1 — the only nightly use is an
416  unrelated build-progress estimate.) Stable rustc + stable `-C`/`-Z`-free
417  flags (`-Csave-temps=true`, `-Clink-dead-code`) suffice for native/Linux.
418- **`debug_assertions` must be on** in the profile being patched — `call`/
419  `try_call` collapse to a plain call in release (`subsecond/src/lib.rs:250-254`,
420  `:411-414`); patching a release-profile binary compiles but every
421  `subsecond::call` site is a dead no-op, so `apply_patch` succeeds but
422  nothing ever redirects. jev's buck2 `system_rust_toolchain` sets
423  `rustc_flags = ["-Copt-level=3", "-Cdebuginfo=1"]` (`toolchains/BUCK:16`)
424  with no explicit `-Cdebug-assertions`. **VERIFIED 2026-09-20, measured**:
425  built `//apps/hotdemo:hotdemo` with `println!("{}",
426  cfg!(debug_assertions))` at the top of `main` and no `-Cdebug-assertions`
427  flag on the target (toolchain's `-Copt-level=3` only) — ran it and it
428  printed `hotdemo debug_assertions=false`. So buck2's `system_rust_toolchain`
429  at `opt-level=3` does **not** imply debug-assertions on; it must be set
430  explicitly per hot-patch target. `apps/hotdemo/BUCK` does this
431  (`-Cdebug-assertions=yes` in `rustc_flags`).
432- **Thread-locals in the tip crate reset on every patch** — "not currently
433  bind[ing] thread-locals in the patches to their original addresses"
434  (`subsecond/src/lib.rs:88-92`). Not a buck2-specific concern, but a
435  correctness limit to document for whoever writes jev's hot-patched code.
436- **No struct-layout/ABI safety net.** Layout or size changes to structs
437  referenced across the patch boundary crash outright — frameworks are
438  expected to throw away and rebuild affected state (`subsecond/src/lib.rs:94-109`).
439- **"Tip crate only" in the subsecond crate's *doc comment* is stale**
440  relative to the CLI's actual v0.7.10 code: `compile_workspace_hotpatch` +
441  `workspace_hotpatch_replay_order`/`_link_rlibs` (`link.rs:132-335,
442  586-723`) DO recompile and relink modified **library** crates (topologically
443  replayed via direct `rustc` invocations reusing captured args), not just the
444  tip. So `//packages/panels` (or `//packages/console`, `//packages/alttp`)
445  changing **can** be folded into a patch, *provided* the buck2 tool replays
446  that crate's rustc invocation with `--out-dir`/`extra-filename` unchanged
447  (so the resulting `.rlib` lands exactly where the tip's link line already
448  expects it) and the tip crate itself gets relinked against the new `.rlib`.
449  UNVERIFIED beyond what's in this checkout: no test in this repo exercises
450  a multi-crate thin build, so treat this as "the code path exists and is
451  structured to support it" rather than "empirically proven robust."
452- **The stub-symbol resolver (`create_undefined_symbol_stub`) is unimplemented
453  for WASM** ("this function is not defined to run on WASM binaries",
454  `patch.rs:849-850`) — irrelevant to jev's native build but worth noting if a
455  wasm target is ever added; that path instead rewrites the `.wasm` module's
456  ifunc/GOT imports directly (`create_wasm_jump_table`, `patch.rs:458-761`).
457- **A live client connection is required before any thin build can even
458  start**, because `aslr_reference` (the process's real `main` address) has
459  to be known (`builder.rs:380-395`). For jev's own tool, the MCP server
460  already running inside the app window is the natural source of this value
461  — it can report `subsecond::aslr_reference()` directly to the `hot_patch`
462  tool caller instead of over a websocket query param.
463
464## 6. Buck2 prelude: what's available, and a concrete design
465
466Prelude location (this repo): `~/nixos-config`-independent, resolved via
467`nix develop -c buck2 audit cell prelude` → `/home/nixos/jev/prelude` (which
468is itself a redirect to the bundled cell at
469`/home/nixos/jev/buck-out/v2/external_cells/bundled/prelude`, where the actual
470`.bzl` files live).
471
472Relevant prelude machinery (paths relative to that prelude root):
473
474- `rust/build_params.bzl:22-30` — `CrateType = enum("bin","rlib","dylib",
475  "proc-macro","cdylib","staticlib")`. **`cdylib` already exists** as a
476  first-class crate type — the natural output type for a hand-built
477  `rust_binary`-like "patch" rule, since it's exactly a `.so` with C-style
478  exports, matching what `dx`'s thin link produces by hand.
479  `crate_type_linked` (`build_params.bzl:35-36`) includes `"cdylib"`.
480- `rust/build.bzl:950` (`_rustc_flags`) and the `emit`-keyed dispatch starting
481  `build.bzl:1012` — this is where per-target `rustc_flags` (a plain
482  `attrs.list(attrs.arg())` on `rust_library`/`rust_binary`, standard prelude
483  attr) get assembled with toolchain-level `rustc_flags`/`extra_rustc_flags`
484  (`build.bzl:1226-1232`). A buck2 rule can add `-Csave-temps=true
485  -Clink-dead-code` (and, if confirmed necessary per §5,
486  `-Cdebug-assertions=yes`) the same way jev's `toolchains/BUCK:16` already
487  adds `-Copt-level=3 -Cdebuginfo=1` — no new mechanism needed, just more
488  flags on the existing `rustc_flags` toolchain attr or a target-level
489  override.
490- `rust/build.bzl:933-947` (`_check_restricted_rustc_flags`) — the prelude can
491  *restrict* certain rustc flags per toolchain (`uses_restricted_rustc_flags`
492  escape hatch); worth checking jev's toolchain doesn't block `-Csave-temps`/
493  `-Clink-dead-code` (it doesn't set `restricted_rustc_flags` today —
494  `toolchains/BUCK` has no such field).
495- `rust/rust_library.bzl:557-558,627-628,636-637` — `exported_linker_flags`/
496  `exported_post_linker_flags` (plus the plain non-exported `linker_flags`
497  attr inherited from the common Rust rule attrs) is the existing plumbing
498  for arg like `-Wl,--export-dynamic-symbol,main`.
499- `rust/rust_binary.bzl:148` — `link_style`/`LinkStrategy` attr, confirming
500  buck2's rust rules already have a first-class notion of static vs
501  shared-dependency linking, relevant to deciding whether the "fat" analogue
502  (a binary linking every workspace `.rcgu.o` unconditionally, undead-code
503  stripped) is better modeled as a `link_style = "static"` `rust_binary` with
504  the save-temps/link-dead-code flags, vs. a wholly custom `genrule`/action.
505- Object files: `--emit`/temp objects come out of the ordinary rustc action
506  buck2 already runs per crate (`build.bzl` `Emit("link")` etc,
507  `build.bzl:1012` on); with `-Csave-temps=true` those `.rcgu.o` files land in
508  rustc's own temp dir the same way they do under cargo — buck2's rust rules
509  don't need new emit plumbing, just the flag, though the tool will need to
510  locate rustc's actual (buck2-managed, hashed) `--out-dir`/temp dir rather
511  than cargo's, since buck2 controls that path differently than cargo does.
512  **VERIFIED 2026-09-20, measured — see §7.1**: they land in a normal
513  *declared* buck2 output directory, not ephemeral scratch, at a path
514  mechanically derivable from the target's own main-output path.
515
516**Proposed design**, given all of the above:
517
5181. **A `hotpatch` Rust binary** (new crate, e.g. `//tools/hotpatch`) that
519   reimplements, against the `object` crate directly (no dioxus/cli
520   dependency needed — `subsecond-types` is a tiny, license-compatible,
521   directly-vendorable crate for the `JumpTable`/`AddressMap` types):
522   - `create_native_jump_table`-equivalent (`patch.rs:403-443`): parse old
523     binary + new `.so` symbol tables via `object`, build the old→new address
524     map keyed on shared names, using `main` as the ASLR sentinel.
525   - `create_undefined_symbol_stub`-equivalent (`patch.rs:852-1259`, Linux
526     x86_64 branch only needed: `patch.rs:1083-1091` for the `jmp [rip+0]`
527     text stub, `patch.rs:1245-1254` for data, TLS can likely be deferred
528     given jev is a single-binary eframe app unlikely to lean on TLS
529     initializers).
530   - A `hot_patch` mode driven by a small CLI: given the base exe path (buck2
531     output), the new `.rcgu.o`/`.rlib` set, and the live process's
532     `aslr_reference` (queried from jev's own MCP server), emit the stub
533     object, link (`cc -shared -Wl,--eh-frame-hdr … -nodefaultlibs` per
534     `link.rs:429-436`, plus `-Wl,--export-dynamic-symbol,main` carried over
535     from the base build) and print/serialize the resulting `JumpTable` as
536     JSON.
5372. **Buck2 side**: two new targets alongside `apps/native:native` —
538   - `native` itself gains `-Csave-temps=true -Clink-dead-code
539     -Wl,--export-dynamic-symbol,main` (via `rustc_flags`/`linker_flags`) only
540     under a `hotpatch`-flavored build (e.g. a buck2 `select()` on a
541     `config_setting`, so ordinary release/dev builds are unaffected) — the
542     buck2 analogue of dx's "fat" build. No archive-splicing step is
543     necessary the way `run_fat_link` does it (`link.rs:788-1142`): `object`
544     files inside a `bin` crate that are reachable from `main` are always
545     linked (the compiler wouldn't have generated them otherwise), and
546     `-Clink-dead-code` alone is what stops the linker from then dropping
547     *dependency-rlib* symbols that are compiled in (rlibs retain every
548     public item, cargo/buck2-reachability rules) but not called from
549     anywhere in the current build. **VERIFIED 2026-09-20, measured — see
550     §7.2**: without `-Clink-dead-code`, roughly two-thirds of all
551     Rust-mangled symbols in `hotdemo`'s own binary were gone from `nm`,
552     including half of the `subsecond`/`subsecond-types`-related symbols. dx's
553     fat-archive dance is unnecessary for this design, but the flag is not
554     optional.
555   - A per-crate (or per-changed-crate) **thin recompile** path: since buck2
556     already tracks per-crate build graphs and caches, there is no need to
557     replay captured rustc args the way `compile_dep_crate` does
558     (`link.rs:518-584`) — buck2 already knows how to rebuild exactly the
559     crates whose inputs changed, with the *same* flags, because that's
560     buck2's whole job. The buck2-native replacement for `workspace_hotpatch_replay_order`
561     is simply: ask buck2 to build the (now on-disk-cached, per-`cdylib`- or
562     per-`rlib`-target) outputs for the changed target and its buck2-graph
563     dependents, then hand *those* `.rlib`/`.rcgu.o` paths to the `hotpatch`
564     tool for the final ad-hoc link — i.e. buck2's own incrementality
565     subsumes dx's rustc-arg-replay machinery entirely; only the *final,
566     ad-hoc, non-buck2-native* stub-object-and-link step needs the custom
567     tool, mirroring `compile_workspace_hotpatch`'s tail
568     (`link.rs:186-334`) but sourcing its object/rlib inputs from buck2
569     outputs instead of a hand-rolled JSON cache.
5703. **Delivery to the running window**: extend `apps/native/src/mcp.rs`'s
571   dev-mode tool set (already gated by `DevMode`, per its module doc,
572   `mcp.rs:8-12`) with a `hot_patch` tool that (a) on request, reports
573   `subsecond::aslr_reference()` back to the caller (buck2/the hotpatch tool
574   invocation) so it can compute `aslr_offset`, and (b) accepts a path to a
575   just-built `JumpTable` (JSON) or patch `.so` + JSON sidecar, and calls
576   `unsafe { subsecond::apply_patch(table) }` on it — in-process, no
577   websocket, no `dioxus_cli_config`/`dioxus-devtools` dependency at all. This
578   is strictly simpler than reimplementing `DevserverMsg`/`tungstenite` (§4),
579   since jev already has a live, bidirectional, already-authenticated
580   (loopback-only) channel into the running window via MCP.
581
582## 7. The two UNVERIFIED points from §6, resolved by measurement (2026-09-20)
583
584Both measured against `//apps/hotdemo:hotdemo` (`apps/hotdemo/BUCK`,
585`rustc_flags = ["-Csave-temps=true", "-Clink-dead-code",
586"-Cdebug-assertions=yes"]`), in this repo's own buck2/nix devshell, not the
587dioxus checkout.
588
589### 7.1 Where `-Csave-temps=true` leaves the `.rcgu.o` objects
590
591**They land in a normal declared buck2 output directory, not scratch —
592no `ar x` fallback needed.**
593
594`buck2 build //apps/hotdemo:hotdemo --show-full-output` reports the main
595binary at
596`buck-out/v2/art/root/<cfg-hash>/apps/hotdemo/__hotdemo__/hotdemo`.
597`buck2 log what-ran` (after touching a source line to force a rebuild) shows
598the actual rustc invocation's args file
599(`…/apps/hotdemo/__hotdemo__/<hash>/XIPL/hotdemo-link-diag.args`), which
600contains:
601```
602-Csave-temps=true
603-Clink-dead-code
604-Cdebug-assertions=yes
605--emit=link=buck-out/v2/art/root/<cfg-hash>/apps/hotdemo/__hotdemo__/hotdemo
606--out-dir=buck-out/v2/art/root/<cfg-hash>/apps/hotdemo/__hotdemo__/XIPL/extras/hotdemo
607```
608The prelude explains why that `--out-dir` exists at all, independent of
609`-Csave-temps`: `rust/build.bzl:1451-1453` always declares an "extras"
610directory as a real buck2 output (`extra_dir = subdir + "/extras/" +
611base`; `ctx.actions.declare_output(extra_dir, dir = True, …)`) for
612per-codegen-unit side artifacts (originally split-DWARF `.dwo` files,
613`build.bzl:1173-1180`'s comment). `-Csave-temps=true` doesn't need new buck2
614plumbing — it just makes rustc *also* drop its `.rcgu.o`/`.rcgu.bc` files
615into whatever `--out-dir` it's already given, and that directory happens to
616be a persistent, declared output rather than the action's `TMPDIR` (a
617separate, genuinely-ephemeral path under `buck-out/v2/tmp/…/rustc/_buck_…`
618that the args file also references, for the compiler's own scratch use).
619Confirmed by listing the directory after a clean build — it survives, with
620one `.rcgu.o` per codegen unit (`hotdemo.hotdemo.<hash>-cgu.<N>.rcgu.o`) plus
621one for the crate's monomorphization glue (`hotdemo.<hash>.rcgu.o`).
622
623So the relationship a caller needs is entirely mechanical, from
624`--show-full-output`'s own answer:
625```
626extras_dir = dirname(main_output) + "/XIPL/extras/<crate-file-stem>"
627```
628`tools/hotpatch/patch.sh` computes it this way rather than hard-coding a
629path.
630
631### 7.2 Whether the plain static link keeps every symbol a patch will need
632
633**No — `-Clink-dead-code` is doing real, necessary work, not a defensive
634no-op.**
635
636Built `hotdemo` twice, flipping only `-Clink-dead-code`
637(`-Csave-temps=true`/`-Cdebug-assertions=yes` held fixed), and compared
638`nm` on the resulting binary:
639
640| | total symbols (`nm \| wc -l`) | `subsecond`/`subsecond-types`-mangled symbols |
641| --- | --- | --- |
642| with `-Clink-dead-code` | 3847 | 67 |
643| without `-Clink-dead-code` | 1394 | 36 |
644
645Without the flag, the plain static link (buck2's default, no
646`--gc-sections` override of its own) drops roughly two-thirds of every
647Rust-mangled symbol in the binary, including about half of the
648`subsecond`/`subsecond-types` ones — dependency rlibs (subsecond,
649subsecond-types, hashbrown, serde, …) retain every public item at
650compile time (a `rlib`'s reachability set is "visible from the crate
651root", not "called by this program"), and the linker's default dead-code
652elimination then strips whichever of those never end up referenced by
653`hotdemo`'s own call graph. A function a *future* patch needs to jump back
654into — one `hotdemo` doesn't call today but a later edit will — is exactly
655the kind of thing this strips if not held open. `apps/hotdemo`'s own
656`tick`/`main`/`apply_pending_patch` were unaffected either way (directly
657reachable from `main`, so always kept), which is why this had to be
658measured against the *dependency* rlibs' symbol counts, not `hotdemo`'s own
659three functions, to be a real test. This matches dx's own rationale
660(`request.rs:1796-1798`'s comment, quoted in §1) exactly, and confirms the
661flag stays on every hot-patch-flavored buck2 target — including, later,
662`apps/native` and any of its library deps whose changes should be
663patchable.
664
665### 7.3 Bonus: the `-Cdebug-assertions` default at `-Copt-level=3`, resolved
666
667§5 flagged this as unconfirmed too. Built `hotdemo` with the toolchain's
668plain `-Copt-level=3 -Cdebuginfo=1` and *no* explicit `-Cdebug-assertions`
669flag, printing `cfg!(debug_assertions)` at startup: it printed
670`hotdemo debug_assertions=false`. So buck2's `system_rust_toolchain` does
671**not** imply debug-assertions from `opt-level=3` — every hot-patch target
672needs `-Cdebug-assertions=yes` explicitly, which `apps/hotdemo/BUCK` now
673carries.
674
675## 8. Two more traps found getting the end-to-end proof to actually redirect (2026-09-20)
676
677`tools/hotpatch`/`apps/hotdemo` built and linked cleanly, `apply_patch`
678returned `Ok(())`, and "patch applied" printed - and the log kept printing
679the OLD string anyway. Both root causes below are `nm`/disassembly-verified,
680not guessed, and both are silent: nothing errors, the patch mechanism just
681does nothing.
682
683### 8.1 `subsecond` needs its OWN `-Cdebug-assertions=yes`, not just the tip crate's
684
685`subsecond::call`/`try_call` are `cfg!(debug_assertions)`-gated no-ops (§5).
686`apps/hotdemo/BUCK` had `-Cdebug-assertions=yes` from the start. The redirect
687still never fired.
688
689`cfg!()` is resolved against **the compilation that contains the macro
690invocation**, not the crate that later monomorphizes a generic function.
691Under cargo, a single profile's `debug-assertions` setting applies
692uniformly to the whole dependency graph — `subsecond` gets built with
693whatever flag the TIP crate's profile carries, because cargo compiles
694every crate in that one profile. Under buck2/reindeer, each third-party
695crate is its own independent `cargo.rust_library` target, built with
696whatever `rustc_flags` ITS OWN target carries — which, absent a fixup, is
697just the toolchain default (`-Copt-level=3 -Cdebuginfo=1`, no
698debug-assertions, confirmed false in §7.3). `apps/hotdemo`'s own
699`-Cdebug-assertions=yes` therefore had **zero effect on `subsecond`'s
700internal gate** — verified by `nm` on the built binary: no call to
701`subsecond`'s own (real, addressable — `_RNvCs41PE3uYsSey_9subsecond14get_jump_table`)
702`get_jump_table` function appears anywhere inside `main`'s compiled code.
703The fast `if !cfg!(debug_assertions) { return f() }` path was compiled in
704as unconditionally true, because subsecond's OWN build never turned
705debug-assertions on.
706
707Fix: `third-party/rust/fixups/subsecond/fixups.toml` —
708```toml
709rustc_flags = ["-Cdebug-assertions=yes"]
710```
711reindeer's `FixupConfig` supports `rustc_flags` per crate directly; no
712custom buck2 plumbing needed. **Any first-party crate that hot-patches
713under buck2/reindeer needs this same fixup on `subsecond`** — it is not
714specific to `apps/hotdemo` and will apply equally to `apps/native`.
715
716### 8.2 `subsecond::call` needs a bare `fn` item, not a closure — for two independent reasons
717
718The first working version of `tick` was `fn tick(n: u64)`, called as
719`subsecond::call(|| tick(n))`. Even after fixing §8.1, the redirect still
720never fired. Two separate, unrelated causes, found by disassembling `main`
721(`objdump -d`, `nm`) and reading `HotFn::try_call`/`call_as_ptr`
722(`subsecond/src/lib.rs:395-424`, `:877-943`) line by line:
723
7241. **The redirect key depends on `size_of::<F>()`.** `try_call` branches:
725   if the closure/fn type `F` is exactly pointer-width
726   (`size_of::<F>() == size_of::<fn() -> ()>()`, 8 bytes on x86_64), it
727   calls `call_as_ptr`, which does
728   `std::mem::transmute_copy::<Self, Self::Real>(&self)` — reinterpreting
729   `F`'s own in-memory bytes AS a function pointer. This is correct **only**
730   when `F` genuinely IS a bare fn pointer value (then `Self ==
731   Self::Real` and the transmute is an identity). Our closure captured
732   `n: u64` — also 8 bytes — so it took the SAME branch, and the transmute
733   reinterpreted `n`'s VALUE as a function address: garbage, not a lookup
734   key subsecond's map would ever contain. Any closure whose captured
735   environment happens to be pointer-sized (one `u64`, one reference, …)
736   hits this, silently. `apps/hotdemo`'s fix: no closure at all — `tick`
737   takes no parameters, reads a `static AtomicU64` for its counter, and
738   `main` calls `subsecond::call(tick)` directly, so `F` is a genuine
739   zero-sized function-item type and the *other* branch
740   (`<F as HotFunction<A,M>>::call_it as *const ()`, subsecond/src/lib.rs:428-435)
741   runs instead.
7422. **`-Copt-level=3`'s default cross-CGU ThinLTO inlines small,
743   single-call-site functions across the whole chain.** Fixing (1) alone
744   was not enough: `nm` on the resulting binary still showed no `tick`
745   symbol and no `call_it`/`HotFunction` symbol anywhere — the entire
746   `subsecond::call` → `HotFn::try_call` → `HotFunction::call_it` → `tick`
747   chain had been inlined into one block inside `main`, leaving nothing to
748   redirect a call to. `subsecond`'s own source carries **no
749   `#[inline(never)]` anywhere** (confirmed by grep) — it is written
750   assuming a dev-profile-style build (`opt-level = 0`), where LLVM
751   performs little to no inlining by default and this is a non-issue. This
752   project forces `-Copt-level=3` for every target unconditionally
753   (`toolchains/BUCK`, a real requirement — the emulator core is unusable
754   below realtime at `-O0`), which is a mode `subsecond` was never designed
755   against. Fix (for the tip crate's OWN patchable functions):
756   `#[inline(never)]` on `tick`. This does not, by itself, force `call_it`
757   to remain a distinct out-of-line symbol too — but with `tick` pinned,
758   `call_it`'s inlined copy contains a genuine `call` instruction to a
759   real, address-stable `tick`, which turned out to be sufficient in this
760   build (confirmed by the working v1→v2→v3 proof, §8.3) even though
761   `nm` still shows no separate `call_it` symbol. Treat this as "empirically
762   sufficient for a trivial single-statement function," not as a proof that
763   inlining of the wrapper machinery itself can never matter — a much
764   larger patchable function, or a different LLVM version's inlining
765   heuristics, could still behave differently.
766
767**Consequence for `apps/native`:** every function meant to be hot-patchable
768needs `#[inline(never)]`, and the call site needs to pass it as a bare `fn`
769item (or a zero-sized, non-capturing closure) — not a closure over any
770pointer-sized local state. A method taking `&mut self` is already safe from
771the size-8 coincidence in the common case (`&mut self` alone is exactly one
772pointer *is* 8 bytes, so this needs re-checking per call site, not assumed
773safe by shape).
774
775### 8.3 The actual end-to-end proof, once both were fixed
776
777`tools/hotpatch/patch.sh //apps/hotdemo:hotdemo`, twice in a row against a
778single running process (pid unchanged throughout, `run/hotdemo.log`):
779```
780hotdemo pid=42801 aslr_reference=0x5555555adf20
781hotdemo v1 tick=0
782...
783hotdemo v1 tick=14
784hotdemo: patch applied
785hotdemo v2 tick=0
786...
787hotdemo v2 tick=45
788hotdemo: patch applied
789hotdemo v3 tick=0
790...
791```
792`TICKS` (a `static`) resets to 0 on each patch rather than continuing its
793count — expected, not a bug: it lives in the tip crate, which gets fully
794recompiled every patch, so the freshly-`dlopen`'d `.so` gets its own fresh
795`.bss` copy rather than being rebased onto the original process's static
796(subsecond's "globals are tracked" claim, `subsecond/src/lib.rs`'s module
797doc, applies to statics that live in an UNCHANGED dependency crate reused
798via its cached `.rlib` — not to one recompiled as part of the tip). Same
799family of limitation as the already-documented thread-local reset (§5).
800
801Files most load-bearing for this design, for a follow-up implementer to open
802directly: `packages/cli/src/build/link.rs` (thin/fat link logic),
803`packages/cli/src/build/patch.rs` (jump table + stub generation),
804`packages/cli/src/rustcwrapper.rs` + `packages/cli/src/cli/link.rs` (why/how
805dx wraps rustc+linker — buck2 needs none of this, since it already owns its
806own build graph), `packages/subsecond/subsecond/src/lib.rs` (`apply_patch`,
807`aslr_reference`, the `HotFn`/`call` runtime), `packages/subsecond/subsecond-types/src/lib.rs`
808(`JumpTable`), `packages/devtools/src/lib.rs` + `packages/devtools-types/src/lib.rs`
809(wire protocol, useful only as a fallback if in-process MCP delivery turns
810out to be insufficient), and, in this repo, `/home/nixos/jev/toolchains/BUCK`
811+ `/home/nixos/jev/apps/native/BUCK` + `/home/nixos/jev/apps/native/src/mcp.rs`
812as the integration points.