Files
1jehuang--jcode/docs/DESKTOP_STABLE_HOST_RELOAD_STARTUP.md
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

20 KiB

Desktop Stable Host, Hot Reload, and Fast Startup Plan

Status: In progress Updated: 2026-05-24

Why this exists

The current desktop hot reload path starts a replacement jcode-desktop process. The replacement process creates a new winit window, waits behind a reload handoff marker, then the old process exits. This is enough to preserve session continuity, but it cannot reliably preserve the physical OS window.

On Wayland in particular, a client cannot count on restoring global window position, and a top-level window cannot be transferred from one process to another. That means a process-level desktop reload is inherently allowed to close/reopen or move the window.

The goal of this plan is to make the desktop architecture support:

  • hot reload without replacing the OS window
  • fast warm reloads during self-development
  • better perceived cold starts
  • a clean long-term split between platform/window/rendering, app logic, and server/session runtime

Current desktop ownership model

Today crates/jcode-desktop/src/main.rs owns almost everything in one process:

jcode-desktop process
  run()
    winit EventLoop<DesktopUserEvent>
    winit Window
    Canvas
      wgpu Surface / Device / Queue
      glyphon FontSystem / text atlases / render caches
      primitive and text render caches
    DesktopApp
      SingleSessionApp or Workspace
      local UI state
      session handles / async event channels
    DesktopHotReloader
      watches binary mtime
      spawns replacement process

Current reload flow:

  1. DesktopHotReloader notices the binary changed or receives force reload.
  2. It captures window.outer_position() and window.inner_size().
  3. It spawns a replacement process with reload placement and handoff marker env vars.
  4. The new process creates a hidden window and initializes its canvas.
  5. The new process writes a ready marker.
  6. The old process writes a release marker and exits.
  7. The new process makes its new window visible.

This is visually better than an immediate exit, but it is still a new OS window.

Current startup critical path

The current startup trace marks these milestones:

args parsed
winit event loop created
window created
app state initialized
font loader spawned
wgpu instance created
wgpu surface created
wgpu adapter selected
wgpu device ready
surface configured
canvas ready
first frame presented
first content frame presented

Important observations:

  • The process does not enter the event loop until after Canvas::new(...) completes.
  • Canvas::new(...) performs WGPU instance/surface/adapter/device setup.
  • Font loading is already moved to a background thread, but text rendering still depends on the host-side font/text renderer once real content is drawn.
  • Workspace session-card loading is asynchronous after startup, which is good.
  • Server/session reloads already reconnect over sockets without closing the desktop window. The problematic reload is the desktop UI binary itself.

Constraints

Window identity

A persistent window requires a persistent process that owns that window. The TUI can exec() because the terminal emulator owns the OS window. The desktop process owns its own OS window, so exec() or spawning a replacement destroys the top-level window.

Wayland

Wayland does not provide reliable global window positioning or cross-process top-level window transfer. Any solution that depends on setting x,y on a new window will remain best-effort.

Rust dynamic plugins

Loading desktop UI code as a Rust dynamic library could keep the process/window alive, but it is high risk as a first step:

  • Rust has no stable plugin ABI for rich internal types.
  • Unloading code safely is difficult with threads, statics, allocators, GPU handles, and panic/runtime state.
  • Compiler/version mismatches can crash rather than fail gracefully.
  • The boundary would need to be C-ABI-like or explicitly serialized anyway.

Dynamic plugins can be revisited later, but a process boundary is safer and easier to test.

Architecture options

Option Window survives UI reload? Cold-start benefit Risk Notes
Keep current process handoff No None Low Cannot solve Wayland movement.
Desktop exec() like TUI No None Low Process image changes, but the OS window still dies.
Dynamic library plugin Yes Medium High Fastest theoretical reload, but ABI/unload hazards.
Stable host + reloadable app worker + display-list protocol Yes High perceived benefit Medium Recommended.
Stable host + child renders pixels offscreen Yes Medium High More bandwidth/GPU sharing complexity, less flexible.

Introduce a small stable desktop host that owns platform and rendering resources. The reloadable desktop app code runs behind a protocol boundary.

flowchart LR
  Launcher[jcode-desktop launcher] --> Host[jcode-desktop-host]
  Host --> Window[winit window]
  Host --> Renderer[wgpu renderer + font/text atlases]
  Host <-->|input, display updates, snapshots| Worker[jcode-desktop-app worker]
  Worker <-->|session protocol| Server[jcode server/daemon]

Stable host responsibilities

The host should own the things that must not disappear during a UI reload:

  • OS window and event loop
  • WGPU instance/device/queue/surface
  • font system and glyph/text atlases
  • image atlases and primitive buffers
  • clipboard/platform adapters where practical
  • app-worker lifecycle and restart policy
  • last known UI snapshot for reload handoff
  • immediate boot/splash/error UI

The host should be intentionally small and stable. Most product UI behavior should not live here.

Reloadable app worker responsibilities

The worker should own product logic that we want to iterate on quickly:

  • workspace/single-session reducers
  • command handling
  • layout decisions
  • markdown/transcript shaping decisions at a logical level
  • session/server protocol client
  • view-model construction
  • surface-local UI state, with snapshot export/import

The worker does not own the OS window. If it exits or is replaced, the host keeps showing the last committed frame, an overlay like “reloading UI…”, or a host-rendered fallback.

Host/worker protocol

Start with a typed, versioned, local-only protocol over a Unix domain socket or inherited stdio pipes. JSON is fine for the first prototype because debuggability matters; switch to bincode/postcard only if measurements require it.

Minimum message families:

Host -> Worker
  Init { protocol_version, window_size, scale_factor, restored_snapshot }
  Input { key/mouse/scroll/text/clipboard/drop }
  Window { resized, scale_factor_changed, focus_changed }
  Tick { now, animation_budget }
  Command { reload_requested, shutdown_requested }

Worker -> Host
  Ready { capabilities }
  Frame { display_list, title, cursor, animation_state }
  Patch { display_list_delta }
  Snapshot { opaque_versioned_state }
  Request { clipboard_read, clipboard_write, open_url, spawn_terminal }
  Log / Error / Panic

The first implementation can send full frames. Add diffing once frame sizes are known.

Display-list boundary

The host should render a semantic display list rather than pixels.

A useful first display list can include:

  • rectangles, rounded rectangles, borders, rules
  • text boxes with font family, size, color, wrap width, selection ranges
  • image references
  • clips/layers/transforms
  • hit-test IDs/accessibility labels later

This keeps GPU, glyph, and image caches in the persistent host while letting worker code change layout and UI behavior. Text shaping can remain host-side for cache reuse: the worker says “draw this text in this box with these styles”, and the host shapes/renders it.

Startup strategy

The same host architecture can improve startup if we treat the host as a fast shell, not merely as a reload wrapper.

Cold start from no running processes

A true cold start still must pay for process launch and window creation. It should not have to block window visibility on WGPU device initialization.

The host should have a staged startup model:

WindowHost phase
  create event loop/window
  make the OS window visible quickly
  optionally draw a native/CPU/blank splash
  start WGPU initialization asynchronously
  start app-worker/server/session loading asynchronously

GpuHost phase
  WGPU surface/device/queue ready
  swap in the real renderer
  draw cached snapshot or latest worker scene

LiveApp phase
  worker ready
  server/session state caught up
  normal display-list rendering

This separates three different metrics that are currently coupled:

  • time to window visible: should not wait for WGPU
  • time to first host pixels: can use a no-GPU/native/CPU fallback if worthwhile
  • time to first real GPU content: still depends on WGPU init

We can improve perceived and measured startup by:

  1. Keeping the host binary small.
  2. Creating and showing the window before loading product/session state.
  3. Not blocking the host event loop on Canvas::new(...) / WGPU setup.
  4. Starting WGPU init, app-worker launch, server connection, font loading, and session-card loading in parallel.
  5. Drawing a host-owned GPU boot frame as soon as the WGPU surface is configured.
  6. Showing a cached workspace/session snapshot immediately while live data catches up.
  7. Lazily creating expensive text/image renderer resources on first real use.
  8. Avoiding synchronous disk scans on the UI thread.

Target cold-start sequence:

host process starts
  parse minimal host args
  create event loop/window
  show window immediately
  enter event loop
  start worker process asynchronously
  start/attach server asynchronously
  start WGPU init asynchronously
  optionally show no-GPU/native/CPU splash
  WGPU ready -> paint host GPU boot frame
  load cached UI snapshot
  receive worker Ready/Frame
  replace boot frame with live UI

No-GPU boot phase

The host does not need to construct WGPU before it owns a window. Treat WGPU as a renderer backend that can appear later.

Implementation shape:

enum HostRendererState {
    NoGpuBoot,
    GpuInitializing,
    GpuReady(Canvas),
    GpuFailed { message: String },
}

In NoGpuBoot, the host can choose one of three fallback strategies:

  1. Blank visible window: fastest and simplest, but may look unfinished.
  2. Native/platform splash: use platform APIs where practical. Fast, but less portable.
  3. CPU framebuffer splash: use a tiny software path such as softbuffer for a basic solid background/logo/status. More portable than native drawing, but adds another rendering backend.

The first implementation should probably start with a blank or minimal native-color visible window and metrics. Add a CPU splash only if measurements show a real user-visible gap worth covering.

The important rule is that WGPU readiness should upgrade the renderer in place; it should not recreate the OS window.

User-perceived cold starts with a resident host

For the fastest user-visible launch, add an optional linger/autostart host:

  • jcode-desktop-host --daemon starts at login or after first launch.
  • It keeps WGPU device, font DB, and maybe recent workspace snapshot warm.
  • Opening the app asks the resident host to show/create a window.
  • The daemon exits after an idle timeout if low idle resource use is preferred.

This is not a true machine-cold start improvement, but it is the common “I opened the app and it appeared instantly” path.

Warm reload

On app-worker reload:

  1. Host asks old worker for a snapshot.
  2. Host keeps the existing frame visible and overlays “Reloading UI…”.
  3. Host starts the new worker binary.
  4. New worker receives the snapshot and reconnects to the server/session.
  5. New worker sends Ready and then a new Frame.
  6. Host swaps to the new frame without moving or hiding the OS window.

If the new worker fails, the host keeps the old frame or a host-rendered error UI and can retry.

Incremental migration plan

Phase 0: measurement first

Add or preserve metrics for:

  • process start to window created
  • process start to window visible / event loop entered
  • process start to WGPU init started
  • process start to surface configured / WGPU ready
  • process start to first no-GPU host pixels, if a fallback renderer exists
  • process start to first GPU host frame
  • process start to first live worker content frame
  • reload blackout duration
  • worker restart duration
  • display-list bytes per frame
  • input event to presented frame latency

The existing --startup-log, --startup-benchmark, and frame profiler are a good base. Extend them instead of inventing a second measurement system.

Phase 1: display-list extraction in-process

Before adding IPC, make the current renderer consume a display-list-like structure instead of directly reading DesktopApp everywhere.

Expected outcome:

DesktopApp + layout code -> DesktopScene / DisplayList -> Canvas::render_scene(...)

This is the safest first refactor because tests can compare current rendering behavior while creating the boundary the host/worker protocol will need.

Phase 2: app-driver trait in-process

Introduce an internal boundary like:

trait DesktopAppDriver {
    fn handle_input(&mut self, input: DesktopInputEvent) -> Vec<DesktopHostCommand>;
    fn handle_session_events(&mut self, events: Vec<DesktopSessionEvent>) -> Vec<DesktopHostCommand>;
    fn build_scene(&mut self, viewport: DesktopViewport) -> DesktopScene;
    fn snapshot(&self) -> DesktopUiSnapshot;
    fn restore(&mut self, snapshot: DesktopUiSnapshot);
}

Initially, the existing DesktopApp implements this in the same process.

Phase 3: host mode and worker mode

Add two modes, possibly in the same binary first:

  • jcode-desktop --host
  • jcode-desktop --app-worker --ipc <fd-or-socket>

The host owns the current run() window/event loop/canvas path. The worker owns the app driver and session protocol. Use a socket/stdio protocol to pass inputs and display-list frames.

Phase 4: worker hot reload

Change DesktopHotReloader so the host watches the worker binary and restarts only the worker. The host process and OS window remain alive.

Host binary reload can remain the current process handoff path because host changes should be rarer.

Phase 5: split crates/binaries

Once the protocol is real, split into clearer crates/binaries:

crates/jcode-desktop-protocol   # host/worker messages and scene types
crates/jcode-desktop-renderer   # Canvas/rendering/display-list renderer
crates/jcode-desktop-host       # window/event loop/worker lifecycle
crates/jcode-desktop-app        # product UI worker
crates/jcode-desktop            # launcher or compatibility wrapper

This also supports cold-start optimization because the host can stay small and stable while app code grows.

Risks and mitigations

Display-list size and IPC overhead

Mitigation: start with full frames and measure. Add scene hashes, retained nodes, and patches only after we have real data.

State loss on reload

Mitigation: define DesktopUiSnapshot early. Include draft text, focus, scroll offsets, selected session/surface, workspace placement, and pending local UI state. Keep it versioned and best-effort so incompatible snapshots fail gracefully.

Protocol skew

Mitigation: explicit protocol version and capabilities. If a new worker is incompatible, the host shows a clear error and offers to restart the whole desktop app.

Too much logic in the host

Mitigation: host owns platform/rendering/lifecycle only. If a behavior is product-specific and safe to reload, it belongs in the worker.

Startup daemon resource use

Mitigation: make resident host optional, idle-timeout based, and measurable. Keep default behavior on-demand until data shows the daemon is worth it.

The smallest useful MVP is not a separate process yet. It is:

  1. Define DesktopScene / display-list types.
  2. Refactor Canvas::render(&DesktopApp, ...) into Canvas::render_scene(&DesktopScene, ...) while still building the scene in-process.
  3. Add startup/reload metrics around scene building and first host frame.
  4. Add DesktopUiSnapshot types for the state we need to preserve.

After that, moving the scene builder into a worker process becomes a mechanical host/worker transport problem instead of a giant UI rewrite.

Bottom line

For reloads that never move the window, we need a stable window owner. For faster starts, that stable owner should also be a small fast shell that can draw immediately, keep expensive renderer resources warm, and load product/session logic asynchronously.

The recommended path is a stable jcode-desktop-host plus a reloadable jcode-desktop-app worker communicating via a typed display-list protocol. It avoids Wayland window-position limitations, avoids Rust plugin ABI hazards, and creates a natural path to both smooth hot reloads and better perceived cold-start performance.

Current implementation snapshot

As of 2026-05-24, the first stable-host slice exists in the current jcode-desktop binary:

  • WGPU/canvas initialization is spawned after the winit window is created, so the event loop can enter before GPU setup finishes.
  • DesktopScene, DesktopUiSnapshot, host/worker protocol messages, JSON-lines IPC framing, and protocol-version validation are in place.
  • --desktop-process-role stable-host owns the OS window and renderer and starts a headless app-worker child process.
  • Stable-host reload uses DesktopReloadStrategy::AppWorkerRestart, killing/restarting only the app worker while the host window stays alive.
  • The app worker initializes a real DesktopAppRuntime, restores the host snapshot, applies key/session IPC events, and emits updated scenes back to the host.
  • The host renders worker-produced scenes through Canvas::render_scene(...) when available.

Validation performed for this slice:

  • cargo test -p jcode-desktop desktop_
  • cargo check -p jcode-desktop
  • selfdev build target=desktop
  • runtime smoke: cargo run -p jcode-desktop --bin jcode-desktop -- --desktop-process-role stable-host --startup-benchmark --startup-log

Crate-split and cold-start assessment

The next split should not be a separate binary yet. The code is functionally split, but crates/jcode-desktop/src/main.rs is still a large 10k+ line compilation unit that imports both heavy renderer dependencies (wgpu, glyphon) and app/session logic. Splitting crates before moving more code out of main.rs would add build-system complexity without materially reducing cold-start work.

Recommended order:

  1. Module split inside jcode-desktop first. Move stable-host/window/reload code, worker-process loop, and canvas/rendering code out of main.rs into focused modules. This reduces merge conflicts and makes crate boundaries obvious without changing packaging.
  2. Promote protocol and scene types to a small crate. desktop_protocol, desktop_scene, and IPC framing are already mostly independent. A future jcode-desktop-protocol crate can compile quickly and be shared by host and worker without pulling WGPU.
  3. Create a host binary only after the host module no longer depends on app reducers. The host binary should depend on winit, wgpu, glyphon, protocol/scene types, and worker lifecycle only. It should not depend on session reducers except for versioned snapshots and host fallback/error UI.
  4. Create an app-worker binary after product UI state is behind DesktopAppDriver. The worker binary should own SingleSessionApp, Workspace, session/server integration, and scene construction. It should not link WGPU or glyph atlases.
  5. Measure before adding a resident daemon. A resident host can make user-perceived launch nearly instant, but it also adds idle memory/GPU cost. Add it only after we have startup metrics for cold host process, WGPU readiness, worker ready, and first live scene.

Expected cold-start impact:

  • On-demand cold start: splitting crates/binaries mainly helps by keeping host initialization small and allowing worker/app loading to happen in parallel. It does not remove WGPU cost for first GPU pixels.
  • Perceived cold start: the stable host can show the window immediately and keep a boot/cached frame visible while WGPU and worker startup race in parallel.
  • Warm/reload start: this is where the architecture already wins. The host keeps the window and renderer alive, and only the worker restarts.

Current recommendation: defer physical crate split until the host/worker modules are cleaner and the debug-socket E2E reload test is in place. The next best engineering step is stronger end-to-end validation, not more crate boundaries.