Repository navigation
feat(spike): Pace dev page as the third durable client (P3) - #430
Conversation
Pace's backend relays frames between a local Durable host and the renderer over the existing IPC as unsequenced events, without parsing them, so the renderer applies every op once. RemoteDurable gains a transport seam; the DEV-only #/durable-spike page reuses the web client's workbench over the relay. The runtime gateway client drops the new ephemeral event types. Includes a scripted three-client proof against an isolated electron-vite dev instance.
| onBackendEvent((event) => { | ||
| const relayed = relayEventOf(event); | ||
| if (relayed !== undefined) listener(relayed); |
There was a problem hiding this comment.
🔴 Backend restart strands the Pace client
When the backend restarts, backendRelay ignores its lifecycle event, leaving the page connected to a dead socket. The replacement bridge has no socket for that connection ID, so prompts disappear instead of reconnecting.
Learn more
Pace's main process emits backend lifecycle envelopes on backend exit and restart through sendBackendLifecycleEvent. The spike page only listens for frame and closed envelopes, while its WebSocket lives in the backend process. If the backend exits, the socket dies without a relay close event. The new backend has an empty connection map, so the existing client keeps its connected state and the bridge silently discards its sends.
Example: The page connects as c1; kill the backend utility process while the Durable host stays up. The backend restarts, but c1 remains marked connected in the page and a submitted prompt reaches no host.
Recommended fix: Translate backend-disconnected lifecycle envelopes into close notifications for all active relay connection IDs. Ensure the page reopens through the new backend generation rather than retaining the old connected transport.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const socket = new WebSocket(address); | ||
| sockets.set(connectionId, socket); | ||
| socket.onmessage = (event) => emit({ kind: "frame", connectionId, data: String(event.data) }); | ||
| socket.onclose = (event) => { | ||
| if (sockets.get(connectionId) === socket) sockets.delete(connectionId); | ||
| emit({ kind: "closed", connectionId, code: event.code, ...(event.reason ? { reason: event.reason } : {}) }); |
There was a problem hiding this comment.
🟡 Window reloads leak host connections
Reloading the Pace window skips renderer cleanup, so sockets retains its WebSocket. Each reload adds another host subscription and forwards its frames to a renderer that no longer uses them.
Learn more
A renderer reload does not run React effect cleanup; createMainWindow already handles this case for the embedded browser. Durable sockets remain in the backend's map until an explicit renderer disconnect, a host close, or backend disposal. A reload creates a new connection ID, leaving the previous host connection and subscriptions alive.
Example: Reload #/durable-spike three times while the host stays up. The page uses its latest connection, but the backend still owns the earlier WebSockets and forwards their frames.
Recommended fix: Tie bridge connections to the renderer's lifecycle in the main/backend IPC bridge and close them on main-frame reload or renderer detach. Alternatively, implement a lease or generation that retires connections from previous renderer instances.
Was this helpful? React with 👍 or 👎 to provide feedback.
| address.searchParams.set("token", await tokenFor(token)); | ||
| const socket = new WebSocket(address); | ||
| sockets.set(connectionId, socket); |
There was a problem hiding this comment.
🟡 Early disconnect creates an orphan socket
If disconnect runs during tokenFor, it finds no socket. The pending connect then installs a socket after the client has closed, leaving a live host connection.
Learn more
Opening the bridge first awaits a filesystem token read and only then inserts the socket into sockets. The relay's close can call disconnect before the backend has finished this read. Disconnect only looks at sockets already in the map, so it cannot cancel that pending connection. Once the read finishes, the backend opens and retains a socket for an already-closed client.
Example: Navigate away while the token read for connection c1 is pending. The c1 disconnect observes an empty map; afterward the read completes and c1 connects without any listener.
Recommended fix: Reserve the ID before awaiting tokenFor and mark it cancelled on disconnect. After each await, check cancellation before constructing and storing the socket; close any socket constructed after cancellation.
Was this helpful? React with 👍 or 👎 to provide feedback.
| return ( | ||
| pathname === "/preflight" || | ||
| pathname === "/design" || | ||
| pathname === "/durable-spike" || |
There was a problem hiding this comment.
🟡 Preflight errors block the Durable page
If the preflight status request fails, PreflightGate renders its error before checking the new route exemption. The Durable page never opens, even with its host running.
Learn more
The route exemption is calculated by PreflightGate, but that gate returns loading or error screens before allowing any exempt route through. A failed preflight status request blocks the new page even though the route is explicitly exempt. This already applies to other exempt routes, but the newly added page inherits the failure.
Example: With a working Durable host and a failed get_environment_preflight_status request, navigating to #/durable-spike shows the preflight error instead of the workbench.
Recommended fix: Let exempt paths render their children before the preflight loading and error early returns, while keeping the redirect and error gate for nonexempt paths.
Was this helpful? React with 👍 or 👎 to provide feedback.
No linked issue: fourth stage (P3) of the Pi Durable multi-client spike (
.scratch/durable-multiview/HANDOFF.md), stacked on #429. This is the first stage that touches Pace's app code. Every change is dev-only or inert, and marked for removal with the spike.Summary
packages/backend/src/spikes/durable-bridge.ts): holds the WebSocket to a local Durable host on the renderer's behalf, since the renderer's CSP blocks one. It forwards frames over the existing IPC as unsequenceddurable_spike.frame/durable_spike.closedenvelopes (seq: 0, not journaled, like terminal streams) and never parses them. The renderer therefore applies every op exactly once (ADR-0044), and the hop reuses process IPC (ADR-0042).service.ts:durable_spike_connect,durable_spike_sendanddurable_spike_disconnect.PACE_DURABLE_SPIKE_URLoverrides the default host.#/durable-spikeinsrc/dev/durable-spike/(outside the FSD layers, likesrc/dev/ui-intent/). Its route and module are registered inside theimport.meta.env.DEVbranch, and it is exempt from the preflight gate.backend-relay.tsadaptsinvoke/onBackendEventto the spike'sFrameRelay. The page reuses the web client'sRemoteWorkbench, so all three clients share one presentation and differ only in transport.runtime-gateway-client.ts: the two new event types join the list of ephemeral events that are dropped before runtime state and deduplication. The added cases failed first: without this, spike frames would have reached runtime state.RemoteDurablegains aFrameTransportseam (webSocketTransport,relayTransport); the TUI, web and tests still pass{ url, token }.scripts/three-clients.mjs(bun run verify:three) is the end-to-end proof described below.tsconfig.jsongainsallowImportingTsExtensions, needed because the spike's Node-run modules import with.tsextensions; this is safe under the root'snoEmit. It also gains a path alias@pace/durable-spike/*.vite.config.tsand the renderer config inelectron.vite.config.tsget the same alias.Test plan
bun run typecheckbun run lintbun run test: 168 files, 1975 tests. New: the service routes the relay commands and forwards relay events as unsequenced envelopes; the gateway client dropsdurable_spike.*; the renderer relay adapter maps commands and filters events.bun run build; the production renderer bundle contains no spike page.bun run typecheckandbun run test: 28 tests. New:relay.test.tsruns the real backend relay against a faux host. A relayed client and a direct client see and drive the same conversation with identical views. Also covered: reconnecting through the relay after a host restart, the configured default host, and clear errors for a missing token and for a non-loopback host.bun run verify:three) against an isolatedelectron-vite devPace, with its own data dir, agent dir and--user-data-dir,PACE_E2E=1, driven over CDP, plus the web page and the TUI on one faux host:kill -9shows the lost-host banner in Pace. After the restart Pace resumes on its own, with aninterruptedpartial followed by the full answer.electron-vite buildalways drops the DEV branch, even with--mode developmentorNODE_ENV=development. The proof useselectron-vite devwith--remoteDebuggingPortinstead.Checklist
bun run typecheck,bun run test, andbun run buildare greendocs/research/in P5)CONTEXT.mdupdated if vocabulary changed (none changed)shared/ui/components are registered on/design(none; the dev page reuses the spike's Astryx workbench)feat/; this is not a push tomain