Skip to content

feat: thread-safe WebView reload API for sidecar-respawn use cases #15

@dennisonbertram

Description

@dennisonbertram

Why

In a desktop app that uses zero-native to host a sidecar process (Node, Python, etc.) and points the WebView at that sidecar's ephemeral loopback URL, the sidecar can crash mid-session. A robust shell respawns the sidecar, captures the new port, and needs to reload the WebView to the new URL.

Today there is no thread-safe API to trigger that reload. The watcher thread that detects the sidecar exit cannot call into the platform-specific runtime to navigate the WebView, because the AppKit event loop (and the equivalent on other platforms) is single-threaded and has no documented thread-safe inject mechanism.

The downstream consumer (pi-zero — porting a Gmail/email assistant from Electron) currently has a known limitation: after sidecar respawn, the WebView shows the old URL (connection error or stale content) until the user takes a navigation action. This makes restart-once a half-recovery instead of a transparent one.

What

Add a thread-safe API to Runtime:

pub fn reloadWebViewUrl(self: *Runtime, new_url: []const u8) !void;

The function MUST be safe to call from any thread. The implementation should:

  1. Atomically stash the new URL in a runtime-owned, lock-protected pending_reload field.
  2. Wake the platform event loop (e.g., [NSApp postEvent:atStart:] on macOS, g_main_context_invoke on GTK, PostMessage on Win32).
  3. The next event-loop iteration drains pending_reload and performs the navigation on the platform-correct thread.

Optional refinement: also expose reloadWebView(self: *Runtime) !void (no URL change, just a reload) for the cookie-mid-session-invalidation case.

Use case (concrete)

pi-zero's Phase 1 (https://github.com/dennisonbertram/pi-zero/commit/cdbe6b4) implements a sidecar lifecycle with restart-once policy. The watcher thread in src/main.zig is structured roughly as:

fn watcher(args: *WatcherArgs) void {
    while (true) {
        // wait for sidecar exit
        const exit = waitpid(args.handle.pid, ...);
        if (!args.policy.shouldRestart(now())) {
            // load connection-lost fallback — currently NOT possible from this thread
            // runtime.reloadWebViewUrl("zero://inline/connection-lost.html") would solve this
            return;
        }
        // respawn
        const new = sidecar.Spawn.spawn(...) catch ...;
        args.policy.recordRestart(now());
        // would also call runtime.reloadWebViewUrl(new_url) here
    }
}

The pi-zero shell currently mitigates this by leaving the WebView pointing at the old (now-respawned) port — which sometimes works (same port, different process) but generally doesn't because port-binding uses 127.0.0.1:0 ephemeral.

Acceptance criteria

  • Runtime.reloadWebViewUrl(new_url) is callable from any thread without panicking or causing UB.
  • On macOS: navigation actually happens via WKWebView.load: on the main thread within ~1 frame of the call.
  • On Linux (WebKitGTK 6): same via webkit_web_view_load_uri on the GTK main thread.
  • On Windows (Edge WebView2): same via the WebView2 marshaling.
  • A test exercises calling the API from a non-main thread and asserts navigation completes.

Out of scope

  • Reloading multiple windows simultaneously (single primary window only for v1).
  • Custom protocol scheme handling beyond http://, https://, zero:// (already handled).
  • Cookie / storage clearing on reload — that's a separate API.

Workaround in the meantime

pi-zero is shipping Phase 1 with this limitation documented. Phase 2's frontend (Next.js) can detect the WebSocket / fetch failure pattern and self-navigate via window.location.replace(<new-url>) — but that requires the frontend to know the new URL, which means the bridge surface needs to expose a getCurrentSidecarUrl() call. That's a fragile workaround compared to a framework-level reload API.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions