Skip to content

Devcontainer SSE Resilience — Comprehensive Issue #415

Description

@gennadiryan

Problem Statement

In devcontainer environments, the Amicode webview panel stops receiving responses
from the opencode server after container rebuilds or server restarts. The root
cause is a persistence lifetime mismatch: the webview's localStorage (containing
the server URL, session tabs, and connection state) lives on the host machine and
survives container rebuilds, while the server port is ephemeral and changes on
each rebuild.

The SSE event stream connects to the stale URL, fails indefinitely, and delivers
no events to the UI. The user sees no responses despite the harness functioning
correctly underneath.


#414
harmoniqs/opencode#212


Implemented Fixes

1. Server boot-ID generation

The opencode server generates a crypto.randomUUID() on each Server.listen()
call and emits it in the server.connected SSE event's properties field. This
gives clients a reliable mechanism to detect server restarts — even when the port
stays the same.

Files: packages/opencode/src/server/boot-id.ts, server.ts, SSE handlers.

2. Stale URL prevention in the Amicode webview

When running inside the Amicode webview (iframe), the defaultServerUrl
localStorage key is never consulted. location.origin is used unconditionally
because the iframe IS served by the running server — its origin is always the
correct URL. This prevents stale URLs from a previous session from overriding the
correct one.

File: packages/app/src/entry.tsx

3. SSE reconnect escalation

A consecutive-failure counter in the SSE reconnect loop stops retrying after 10
failures (2.5 seconds). This prevents infinite CPU burn on genuinely unreachable
servers and surfaces a "disconnected" state to the ConnectionBanner. A page
visibility cycle (switching away and back) restarts the loop.

File: packages/app/src/context/server-sdk.tsx

4. Boot-ID persistence and mismatch detection

The client persists the server's boot-ID in localStorage (per server scope). On
each server.connected event, it compares the new boot-ID to the stored value and
logs a warning on mismatch. The existing server-sync refresh logic (session list
refetch, directory re-bootstrap) already fires on server.connected — the boot-ID
provides additional observability.

Files: packages/app/src/context/server.tsx, server-sdk.tsx

5. Extension host URL push on server restart

The Amicode extension host pushes the server URL to the webview after every
successful server start (via serverManager.onReady). If the port changed
(ephemeral mode), the panel is disposed and recreated with the correct iframe src.
If the port is the same, a server-url-changed message is posted to inform the
webview that a restart occurred.

Files: packages/extension/src/chat_panel.ts, extension.ts, deck/shell.ts

6. Fixed port in devcontainer configuration

Both repos' .devcontainer/devcontainer.json now set amicode.opencodePort: 43117
in VS Code settings. This ensures the port is stable across container rebuilds,
making localStorage URLs valid indefinitely. Documentation covers three
mechanisms for injecting the port (VS Code settings, OPENCODE_CONFIG_CONTENT
env var, opencode.json) across three use cases (marketplace install, Dockerfile
build, CI/headless).

Files: .devcontainer/devcontainer.json (both repos),
docs/devcontainers.md (amicode)


Deferred Improvements

Multi-instance localStorage isolation

Multiple Amicode webviews on the same VS Code installation share a single
localStorage scope (keyed by extension ID origin). Two workspaces with different
servers can overwrite each other's connection state. The boot-ID mechanism
provides detection (each webview sees stale state and refreshes) but not
prevention.

Future direction: Key connection-related localStorage entries by a workspace
or container identifier. Global preferences (theme, zoom) remain shared.

Session tab pruning on boot-ID mismatch

Dead session tabs from previous server instances accumulate in localStorage. The
boot-ID mismatch is logged but tabs are not validated against the server. The
existing server-sync refresh handles sessions that still exist; dead entries
remain until the 50-key pruning threshold fires or the user manually closes them.

Future direction: On boot-ID mismatch, validate all open tabs by fetching
their session metadata. Move 404s to the closed list with a toast notification.

Credential invalidation on boot-ID mismatch

If OPENCODE_SERVER_PASSWORD changes across restarts, persisted credentials in
localStorage are stale. The current implementation does not clear them on boot-ID
change. In practice, the extension injects fresh credentials via the iframe's
?auth_token= query param on each panel creation, limiting exposure to long-lived
panels across manual restarts.

Future direction: On boot-ID mismatch, clear persisted credentials and
re-read from the iframe URL query parameter.

Quota-aware eviction priority

The localStorage eviction logic removes the largest keys first under quota
pressure. The server key (connection state) is a prime eviction target as it
grows. With the Phase 2 inAmicode() gate, eviction of this key is less critical
(the app falls back to location.origin), but it could still lose project and
session state.

Future direction: Maintain a protected-keys list that the eviction logic
never removes.

Connection banner guaranteed visibility

The ConnectionBanner reads streamStatus but its visibility depends on layout
configuration. A persistently disconnected state may go unnoticed if the banner
is scrolled off or hidden by a panel.

Future direction: After 5 seconds of continuous disconnection, surface a
VS Code notification (via postMessage to the extension host) in addition to the
in-webview banner.

Self-healing reconnect (upgrade from abort)

The current escalation strategy aborts the SSE loop after 10 failures. A future
upgrade posts server-url-changed to self (triggering the AmicodeServerBridge to
update the URL and reconnect), providing seamless recovery without user
intervention.

Future direction: Documented in plans/followup-self-healing-reconnect.md.

Terminal-mode extension fixes

The opencode terminal extension (sdks/vscode/) has separate issues: wrong
readiness probe URL (/app instead of /health), dead terminal reuse without
liveness check, RPC promise leaks in the worker transport, and silent error
swallowing in the worker process. These are on a different code path from the
Amicode webview and do not cause the "no GUI response" bug.

Future direction: Documented in notes/fix-plan.md.


Design Principles Established

  1. The server URL is ephemeral, not a preference. It is determined at runtime
    by the extension host and must not be persisted across server restarts.

  2. The extension host is authoritative for runtime parameters. The webview
    receives connection parameters from the host via the postMessage bridge;
    localStorage is a cache for UI preferences only.

  3. The boot-ID is the staleness signal. Any restart (container rebuild,
    process crash, explicit restart) produces a new boot-ID. Clients that persist
    and compare it can reliably detect that cached state is stale, regardless of
    whether the port changed.

  4. Fixed port is the primary defense. The default amicode.opencodePort = 43117 eliminates the stale-URL problem for most users. All other mechanisms
    are defense-in-depth for ephemeral-port users and edge cases.

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