Hot-patching of egui apps using Dioxus subsecond — including workspace crate support, which is not available in any other integration (Bevy, Dioxus native, etc.).
Untitled.mp4
# Install dioxus CLI (must match the version in Cargo.toml)
cargo install dioxus-cli@0.7.4
# Run with hot-patching
dx serve --hot-patchEdit any .rs file — changes apply in ~1 second without restarting the app.
- Tip crate (binary crate): edit
src/main.rs,src/my_component.rs— patched viasubsecond::call - Workspace crates: edit
workspace_crate/src/lib.rs— patched via direct jump table lookup - Styles, layouts, widget trees, button handlers — all hot-patchable
- State is preserved across patches (egui context, component state)
subsecond maintains a global jump table — a HashMap<u64, u64> mapping original function addresses to patched ones. When you edit code:
dx serve --hot-patchdetects the change- Compiles only the changed crate into a patch
.so(shared library) - Sends the jump table diff over WebSocket to the running app
- App calls
subsecond::apply_patch(jump_table)— loads the.soviadlopenand updates the global jump table - Next time a function is called through
subsecond::callorHotFn, the new version runs
Standard approach — wrap the entry point in subsecond::call:
impl eframe::App for App {
fn ui(&mut self, ui: &mut Ui, frame: &mut Frame) {
subsecond::call(|| {
self.subsecond_fn(ui, frame);
});
}
}subsecond::call creates a HotFn from the closure. The closure is a ZST (zero-sized type), so its call_it method IS the function pointer. On each call, subsecond looks up this pointer in the jump table and calls the new version if available. On HotFnPanic (when code above the call changed), it retries with the new closure.
This is where it gets interesting. The naive approaches don't work:
After the first patch, the entire tip crate runs from the patch .so. Inside the .so, workspace_crate_ui resolves through the .so's GOT (Global Offset Table) — giving an address inside the .so, not in the original binary. But the jump table maps original_binary_addr -> new_addr. So HotFn::call_as_ptr does transmute_copy to get the fn pointer, gets the .so address, looks it up in the jump table, doesn't find it, and calls the old version.
Before patch: workspace_fn as ptr -> 0x437f020 (binary) -> found in jump table -> OK
After patch: workspace_fn as ptr -> 0x7f3a...fb90 (.so) -> NOT in jump table -> calls old version
Wrapping workspace code in subsecond::call(|| { ... }) doesn't help because the closure is a ZST — its call_it is a static address that only changes when the crate is recompiled, but it's not in the jump table diff (the diff only contains the workspace crate's symbols, and call_it is a monomorphization in the workspace crate, not the workspace function itself).
HotFn::call inside subsecond::call causes HotFnPanic to propagate up to the outer subsecond::call, which catches it and retries — but the retry calls the same stale HotFn, creating an infinite retry loop.
static WORKSPACE_UI_ORIG_PTR: OnceLock<u64> = OnceLock::new();
fn call_workspace_ui(ui: &mut egui::Ui) {
// OnceLock captures the original fn pointer before any patch
let orig_ptr = *WORKSPACE_UI_ORIG_PTR.get_or_init(|| {
workspace_crate_ui as *const () as u64
});
// Direct lookup in jump table with the original address
let fn_ptr: fn(&mut egui::Ui) = unsafe {
if let Some(jt) = subsecond::get_jump_table() {
if let Some(&new_addr) = jt.map.get(&orig_ptr) {
std::mem::transmute::<u64, fn(&mut egui::Ui)>(new_addr)
} else {
workspace_crate_ui
}
} else {
workspace_crate_ui
}
};
fn_ptr(ui);
}Key insight: OnceLock::get_or_init runs exactly once — on the first frame, before any patch. At that point, workspace_crate_ui as *const () gives the original binary address. After patches, the jump table maps this address to the new .so address, and we call the new version directly.
Bevy's bevy_simple_subsecond_system uses a #[hot] proc macro that generates HotFn::current(system_fn).call(args) per system function.
Key differences:
| Bevy | This example | |
|---|---|---|
| Wrapper | No subsecond::call — each fn wrapped individually via #[hot] |
subsecond::call for tip crate + direct lookup for workspace |
| Workspace support | Not supported ("only patches the tip crate") | Supported via OnceLock + jump table lookup |
| GOT problem | Doesn't occur — no outer subsecond::call so code always runs from binary |
Occurs and solved — .so GOT gives wrong address |
| Integration point | ECS executor level — between system runs | eframe::App::ui — every frame |
Bevy avoids the GOT problem because #[hot] functions are called from the ECS scheduler (which is never patched), not from inside a patched closure. But this means workspace crates can't be hotpatched at all.
On Linux, dx only exports the main symbol from the binary (--export-dynamic-symbol,main). This means the patch .so can't resolve monomorphizations of egui::* / std::* that live in the main binary — causing SIGSEGV.
Fix: .cargo/config.toml exports all symbols:
[target.x86_64-unknown-linux-gnu]
rustflags = ["-Clink-arg=-Wl,--export-dynamic"]This increases binary size slightly but is required for workspace hotpatching where the .so calls into egui types defined in the main binary.
This example connects to the dx devserver directly via WebSocket, without dioxus-devtools (which pulls in dioxus-core and causes compile errors with egui-only projects):
fn connect_subsecond() {
let endpoint = dioxus_cli_config::devserver_ws_endpoint()?;
// WebSocket connection with aslr_reference, build_id, pid
// Receives HotReloadMsg { jump_table, for_pid }
// Calls subsecond::apply_patch(jump_table)
}Dependencies: dioxus-cli-config (endpoint discovery), tungstenite (WebSocket), serde_json (message parsing).
egui-subsecond-example/
src/
main.rs -- App, connect_subsecond(), call_workspace_ui()
my_component.rs -- example component (tip crate, hotpatchable)
workspace_crate/
src/lib.rs -- workspace_crate_ui() (workspace hotpatchable)
.cargo/
config.toml -- --export-dynamic for Linux
Cargo.toml -- workspace config, dependencies
- egui/eframe: 0.34.1
- subsecond: 0.7.4
- dioxus-cli: 0.7.4
- Struct layout changes require app restart (subsecond limitation)
- Thread-locals reset on patch
--export-dynamicincreases binary size on Linux- macOS/Windows may need different linker flags (untested)