Skip to content

SSE Connection Reliability Improvements #413

Description

@gennadiryan

Actionable fixes to prevent the "no GUI response" failure class and harden the
webview-server connection lifecycle. Each item includes acceptance criteria.

See also:

  • ./issue-no-gui-response.md — the specific bug these fixes address
  • ../notes/local-storage-customization.md — parameter inventory and design analysis
  • ../notes/fix-plan.md — terminal-mode fixes (subset included here as Tier 3)

Tier 1 — Critical Path

These fixes directly prevent or mitigate the "no GUI response" bug. They should be
prioritized for immediate implementation.


1. [CRITICAL] Remove defaultServerUrl localStorage override in webview context

File: packages/app/src/entry.tsx (lines 157–161)

Problem: getDefaultUrl() reads opencode.settings.dat:defaultServerUrl from
localStorage before consulting location.origin. In the Amicode webview, the
iframe is served by the running server — location.origin is always the correct
URL. But if a previous session wrote defaultServerUrl (pointing to a now-dead
port), it permanently overrides the correct origin.

Fix: When running inside the Amicode webview (detectable via inAmicode() from
utils/amicode-bridge.ts), skip the localStorage defaultServerUrl lookup
entirely. Use location.origin unconditionally.

Acceptance criteria:

  • When inAmicode() returns true, getDefaultUrl() returns getCurrentUrl()
    without consulting localStorage.
  • The defaultServerUrl localStorage key is still honored in the standalone web
    app context (when inAmicode() is false).
  • After a server restart (new port), reopening the Amicode webview connects to the
    new server without requiring localStorage to be cleared.

2. [CRITICAL] SSE reconnect escalation: fall back to location.origin after persistent failures

File: packages/app/src/context/server-sdk.tsx (lines 268–338)

Problem: The SSE reconnect loop retries the same URL every 250 ms indefinitely.
It does not distinguish "connection refused" (server gone) from "server error"
(server exists). If the URL is stale, this loops forever without recovery.

Fix: Add an escalation path: after N consecutive connection-refused failures
(suggested: 10, i.e., 2.5 seconds), attempt to reconnect using location.origin
instead of the persisted server URL. If location.origin succeeds, update the
persisted server store to reflect the correct URL.

Acceptance criteria:

  • After 10 consecutive connection-refused errors (not HTTP errors — specifically
    TypeError: Failed to fetch or equivalent network failure), the loop switches to
    location.origin as the target URL.
  • If location.origin connects successfully, the persisted server store entry is
    updated in-place so subsequent reconnections use the correct URL directly.
  • If both the persisted URL and location.origin fail, the loop continues retrying
    location.origin at 250 ms intervals (as today, but to the correct URL).
  • A streamStatus value of "reconnecting" or "discovering" is surfaced (for
    the ConnectionBanner to display).

3. [CRITICAL] Server boot-ID stamping

Files:

  • Server: packages/opencode/src/server/server.ts or the SSE event handler
  • Client: packages/app/src/context/server-sdk.tsx and server.tsx

Problem: The client cannot distinguish "same server reconnected" from "different
server on same port" from "stale URL, server gone." Without a server identity
token, all reconnection heuristics are fragile.

Fix: The server generates a random boot-ID (e.g., UUID v4) at startup and
includes it in:

  1. The server.connected SSE event payload (so the client receives it on every
    reconnection).
  2. A response header on all API responses (e.g., X-OpenCode-Boot-ID), so the
    client can detect mid-session server restarts even outside the SSE stream.

The client persists the boot-ID alongside the server URL in localStorage. On
reconnection:

  • If boot-ID matches → normal reconnect; session state is valid.
  • If boot-ID differs → server restarted; invalidate session tab list, refetch all
    state, and optionally re-validate credentials.
  • If boot-ID is absent (older server version) → treat as "unknown," fall back to
    current behavior.

Acceptance criteria:

  • Server emits a bootId field in the server.connected event.
  • Server includes X-OpenCode-Boot-ID: <uuid> header on all HTTP responses.
  • Client stores lastBootId per server entry in the persisted store.
  • On boot-ID mismatch, client runs a "full refresh" path: refetch sessions list,
    prune tabs with 404 sessions, reset workspace caches.
  • On boot-ID mismatch, if auth credentials produce 401, the client surfaces an
    auth-required prompt (rather than silently failing).

4. [CRITICAL] Configurable fixed port per container

Files:

  • Config schema: packages/core/src/v1/config/server.ts
  • Devcontainer: .devcontainer/devcontainer.json
  • Documentation

Problem: In a devcontainer, the server port is ephemeral (random or 4096+N).
Because the webview's localStorage persists across container rebuilds (it lives on
the host), a new port on restart means a stale persisted URL. Users in devcontainer
workflows hit this on every rebuild.

Fix: Allow users to pin a stable port per container via either:

  1. opencode.json{ "server": { "port": 4096 } } (already supported by the
    schema but not widely documented or surfaced).
  2. .devcontainer.json"containerEnv": { "OPENCODE_PORT": "4096" } (read by
    the Amicode extension host when spawning the server).

Document the recommendation: in devcontainer-based workflows, set a fixed port so
that localStorage's persisted URL remains valid across container rebuilds.

Acceptance criteria:

  • Documentation (README or extension settings description) explicitly recommends
    setting server.port in opencode.json for devcontainer workflows.
  • The Amicode extension host reads OPENCODE_PORT from the container environment
    (if available) and uses it when launching the server.
  • When server.port is set in opencode.json, the server binds to exactly that
    port (no fallback) and fails loudly if the port is in use (rather than silently
    falling back to a random port).
  • The .devcontainer/devcontainer.json in this repo is updated to include a
    commented-out example: "containerEnv": { "OPENCODE_PORT": "4096" }.

Tier 2 — High Importance

These fixes prevent related failure modes and harden the connection lifecycle.
Implement after Tier 1.


5. Multi-instance localStorage isolation

File: packages/app/src/utils/persist.ts

Problem: All Amicode webview instances on the same VS Code installation share a
single localStorage scope (keyed by extension ID origin). Two windows with
different servers overwrite each other's server entries.

Fix: Key all connection-related localStorage entries by a workspace
identifier
(e.g., a hash of the container's filesystem root or the server URL at
first successful connection). Non-connection state (theme, zoom) remains global.

Acceptance criteria:

  • Two VS Code windows with Amicode, connected to different servers, do not
    interfere with each other's connection state.
  • Opening a new window for a previously-unknown workspace starts fresh (no stale
    entries from another workspace).
  • Global preferences (theme, solver mode) remain shared across all instances.
  • Migration: on first load with the new keying scheme, existing global state is
    migrated into the appropriate workspace bucket.

6. Session tab validation on load

File: packages/app/src/context/tabs.tsx

Problem: Session tabs persist indefinitely (pruning only fires at 50+ keys).
Dead session references from previous server instances accumulate, causing burst
fetches to stale/non-existent endpoints on reload.

Fix: On the server.connected event (which fires on every SSE reconnection),
validate all open session tabs by checking their existence against the server. Tabs
whose session IDs return 404 are moved to the closed list (not deleted — user can
re-open if the session reappears after a migration/restore).

Acceptance criteria:

  • Within 5 seconds of server.connected, all open tabs are validated.
  • Tabs with 404 sessions are moved to closed with a reason: "not_found" marker.
  • A toast notification summarizes: "N sessions from a previous server instance were
    closed."
  • The validation is non-blocking (does not prevent the app from rendering).
  • If the server is unreachable during validation (e.g., the server.connected
    event was a false positive), validation is skipped gracefully.

7. Credential invalidation on boot-ID change

File: packages/app/src/context/server.tsx (inside resolveServerList)

Problem: If the server regenerates OPENCODE_SERVER_PASSWORD on restart,
persisted credentials in the localStorage server.list entry are stale. Every API
call returns 401, but the client does not surface this or attempt to refresh.

Fix: When the boot-ID changes (see item #3), clear persisted credentials for
that server entry and re-read them from the iframe URL query param (auth_token).
If no auth_token is present in the URL and the server requires auth, surface an
auth prompt.

Acceptance criteria:

  • On boot-ID mismatch, the persisted username/password for the affected server
    entry are cleared.
  • The app re-reads auth_token from location.search (the iframe URL injected by
    the extension host).
  • If auth is required and no valid credentials are available, a modal prompts the
    user (rather than silently failing with 401s).

8. Extension host → webview URL push on server restart

File: The Amicode extension host (in harmoniqs/amicode repo — not this repo)

Problem: When the extension host restarts the server (via
amicode.restartServer command), it re-launches the opencode process on a
potentially different port. The webview SSE stream is connected to the old port and
must wait for connection-refused → escalation (item #2) to recover.

Fix: The extension host should postMessage a { source: "amicode", kind: "server-url-changed", url: "http://localhost:NEW_PORT" } message to the webview
iframe immediately after the new server is confirmed listening. The webview handles
this message by updating its active server URL and immediately reconnecting SSE to
the new URL.

Acceptance criteria:

  • The webview registers a listener for kind: "server-url-changed" messages.
  • On receiving this message, the webview updates its persisted server store and
    reconnects SSE within 1 second (no 250 ms retry loop needed).
  • If the message arrives while the webview is already connected (race condition),
    it is a no-op.
  • The webview emits a route-info message back to confirm it received the update.

Tier 3 — Improvements

These are well-advised hardening measures. They do not directly prevent the "no GUI
response" bug but reduce adjacent failure surfaces.


9. Quota-aware eviction priority

File: packages/app/src/utils/persist.ts (lines 112–165)

Problem: The localStorage eviction logic removes the largest opencode.* keys
first. The server key (connection state) and tabs key (session history) grow
over time and become prime eviction targets.

Fix: Maintain a "protected keys" list that the eviction logic never removes.
At minimum: opencode.global.dat:server, opencode.settings.dat:defaultServerUrl.

Acceptance criteria:

  • opencode.global.dat:server is never evicted by the quota handler.
  • Eviction preferentially targets workspace and session-scoped keys.
  • If eviction cannot free enough space without touching protected keys, the write
    fails gracefully (the app continues to function with the existing state).

10. Connection banner always visible on persistent disconnection

File: packages/app/src/components/connection-banner.tsx

Problem: The ConnectionBanner component shows when streamStatus is
"disconnected", but its visibility depends on layout configuration. If the banner
is scrolled off or hidden by a panel, the user has no indication that the
connection is broken.

Fix: After 5 seconds of continuous "disconnected" state, surface a
VS Code-style notification (via postMessage to the extension host, which calls
vscode.window.showWarningMessage) in addition to the in-webview banner.

Acceptance criteria:

  • If the SSE stream is disconnected for > 5 continuous seconds, a warning
    notification appears in VS Code's notification area.
  • The notification includes an action button: "Reconnect" (which triggers URL
    rediscovery from item β.2 — Vendor + pin opencode in the VSIX #2).
  • The notification is not repeated more than once per 60 seconds.

11. Terminal extension: readiness probe fix (/app/health)

File: sdks/vscode/src/extension.ts (line 78)

Problem: The extension probes GET /app (a catch-all UI route) with no
status-code check. Should probe GET /health and check response.ok.

Fix: Change the URL to /health and gate connected = true on response.ok.

Acceptance criteria:

  • The probe hits GET /health.
  • connected is only set to true if the response status is 2xx.
  • A 404 or 500 from a partially-initialized server does not set connected = true.

12. Terminal extension: dead terminal detection

File: sdks/vscode/src/extension.ts (lines 15–19)

Problem: opencode.openTerminal reuses a terminal by name without checking if
the process has exited.

Fix: Filter vscode.window.terminals by both name === TERMINAL_NAME and
exitStatus === undefined.

Acceptance criteria:

  • A terminal whose process has exited is not reused.
  • The user gets a fresh terminal with a new server instance.

13. RPC error propagation

File: packages/opencode/src/util/rpc.ts (lines 5–12, 23, 47)

Problem: If a worker-side RPC method throws, the pending promise in
client.call() is never settled. The caller hangs forever.

Fix:

  • Server side: wrap rpc[method](input) in try/catch; post rpc.error message
    with the error details and request ID.
  • Client side: store { resolve, reject } pairs in pending; handle rpc.error
    messages by calling reject(new Error(...)).

Acceptance criteria:

  • If a worker method throws, the client-side promise rejects with an Error
    containing the original error message.
  • The pending Map entry is cleaned up (no memory leak).
  • Existing callers of client.call() that do not handle rejection see an unhandled
    rejection (which is logged by item feat: plot the pulse with Piccolo plot_pulse (not hand-rolled Makie) #14) rather than hanging forever.

14. Worker error logging

File: packages/opencode/src/cli/tui/worker.ts (lines 16–21)

Problem: unhandledRejection and uncaughtException handlers discard all
errors silently, making worker failures invisible.

Fix: Log errors to stderr with a [worker] prefix.

Acceptance criteria:

  • Unhandled rejections log the error object to stderr.
  • Uncaught exceptions log the error message and stack to stderr.
  • The worker process does NOT exit on these errors (existing keep-alive behavior
    is preserved).

15. Terminal extension: retry window + user-visible warning

File: sdks/vscode/src/extension.ts (lines 73–90)

Problem: The retry loop tries 10 times (2 s total). If the server doesn't
respond (port collision, slow startup), the file reference is silently dropped.

Fix: Increase to 20 retries (4 s). After the loop, if not connected, show
vscode.window.showWarningMessage(...) so the user knows something went wrong.

Acceptance criteria:

  • The retry window is 4 seconds (20 * 200 ms).
  • If connected is still false after the loop, a warning message is shown.
  • The warning message suggests "try again" or "the port may be in use."

Metadata

Metadata

Assignees

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