Skip to content

enomado/egui-subsecond-example

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

egui + subsecond: Hot-patching for egui apps

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

Quick start

# Install dioxus CLI (must match the version in Cargo.toml)
cargo install dioxus-cli@0.7.4

# Run with hot-patching
dx serve --hot-patch

Edit any .rs file — changes apply in ~1 second without restarting the app.

What works

  • Tip crate (binary crate): edit src/main.rs, src/my_component.rs — patched via subsecond::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)

Architecture

How subsecond works

subsecond maintains a global jump table — a HashMap<u64, u64> mapping original function addresses to patched ones. When you edit code:

  1. dx serve --hot-patch detects the change
  2. Compiles only the changed crate into a patch .so (shared library)
  3. Sends the jump table diff over WebSocket to the running app
  4. App calls subsecond::apply_patch(jump_table) — loads the .so via dlopen and updates the global jump table
  5. Next time a function is called through subsecond::call or HotFn, the new version runs

Tip crate patching

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.

Workspace crate patching (the hard part)

This is where it gets interesting. The naive approaches don't work:

Why HotFn::current(workspace_fn).call() fails

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

Why subsecond::call inside workspace crate fails

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).

Why nesting HotFn inside subsecond::call fails

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.

The solution: direct jump table lookup with OnceLock

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.

How Bevy does it (and why it's different)

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.

Linux-specific: --export-dynamic

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.

Connection to dx devserver

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).

Project structure

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

Versions

  • egui/eframe: 0.34.1
  • subsecond: 0.7.4
  • dioxus-cli: 0.7.4

Known limitations

  • Struct layout changes require app restart (subsecond limitation)
  • Thread-locals reset on patch
  • --export-dynamic increases binary size on Linux
  • macOS/Windows may need different linker flags (untested)

About

Hotpatching for eugi using dioxus subsecond

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • Rust 100.0%