Skip to content

fix(cli): TUI crashes with unhandled transport defect during post-update service restart #36688

Open

Description

@kitlangton

Summary

During a routine auto-update service restart, every open TUI went to the "Connection lost / Reconnecting" screen, and one TUI crashed to the terminal with an unhandled transport defect. The root problem: the reconnect budget (3 attempts, ~16s) is much shorter than a realistic post-update election plus cold location boot (~45-60s observed), and when the budget is exhausted the ClientError escapes as a raw UnknownError stack dump instead of a clean exit.

ERROR (#1): UnknownError: An error occurred in Effect.tryPromise
    at Tui.run (src/commands/handlers/default.ts:43:12)
    at Tui.run (definition) (../tui/src/app.tsx:110:27) {
  [cause]: ClientError: Transport
    [cause]: Error: Unable to connect. Is the computer able to access the url?
}

Version: 0.0.0-next-15427, macOS arm64.

Timeline (from ~/.local/share/opencode/log/opencode.log, UTC)

Time Event
14:56:29 Elected server (run 5170d17b, still v15422) auto-updates the package on disk to v15427 (updated OpenCode ... method=bun)
14:58:45 User starts a new TUI (oc, run fc781962, launcher already v15427)
14:58:46-47 Old server tears down (mass watcher stopped, then silence): version-mismatch restart
14:58:50-59:17 Election herd: ~10 serve --service contenders spawn from open TUIs
14:58:55-59:15 Contender fb876f01 wins, logs role=server, cold-boots ~10 location graphs over 5-15s, then at 14:59:15 tears down again, displaced without ever serving the client
14:59:01.331 The new TUI exhausts 3 reconnect attempts and crashes with the unhandled error above
14:59:31 Second-wave contender db40851e (PID 5056) wins, registers, and serves healthily

Problems

  1. TUI reconnect gives up too early and crashes ungracefully. 3 attempts / ~16s loses the race against any post-update restart. When it does give up, the failure should be a friendly "server unavailable" message and clean exit, not an escaped defect through Effect.tryPromise in Tui.run (packages/cli/src/commands/handlers/default.ts:43, packages/tui/src/app.tsx:110).
  2. Election double-bounce. The server restarted twice in one update cycle: 5170d17b -> fb876f01 (won, booted, torn down 20s later) -> db40851e. A second herd of contenders spawned while fb876f01 was still mid-boot and displaced it, doubling the outage window. Consistent with the known multi-holder service lock race.
  3. Leaked contender process. A serve --service process from the previous day (PID 91121, ~1GB RSS, parented by a TUI) was still alive and unregistered long after losing its election. Losing contenders should exit.

Expected

An auto-update restart should be invisible or near-invisible to open clients: at most a brief reconnecting state, one server bounce, no crashes, no leaked processes.

Activity

  1. holny commented on Sep 2, 2026

    @holny
    Contributor

    I'd like to take this. The budget half is gone on v2 (indefinite retry), but the crash is still live — startup probes fail during a restart window and the ClientError escapes Tui.run unhandled. Plan: route it through the existing CLI error channel with a clean exit. Election double-bounce is server-side, out of scope.

  2. holny commented on Sep 2, 2026

    @holny
    Contributor

    PR #46726 is up with the fix — 4 required checks green. Only blocker: GitHub didn't pick up Fixes #36688 from the fork PR, so it needs a manual link in the PR's Development panel before it can merge. Everything else is ready for review. @kitlangton

  3. holny commented on Sep 9, 2026

    @holny
    Contributor

    Heads-up: the PR for this issue (#46726) was accidentally closed earlier today - its head fork got deleted unintentionally, which force-closed it. Re-filed as #48162 with the same commits. Sorry for the noise.

  4. Bearmancer commented on Oct 5, 2026

    @Bearmancer

    Windows / v2.0.23 datapoint: the benign branch of this same post-update race — one reconnect attempt, 6.8 s outage, no crash, no ERROR.

    Mechanism (source verified against local clone, dev @ 907b3bc)

    1. packages/client/src/generated/client.ts:167-168 — every fetch throw is wrapped:
      } catch (cause) {
        throw new ClientError("Transport", { cause })
      ClientError (packages/client/src/generated/client-error.ts) does super(reason, options), so message === "Transport" and the Bun/socket error rides in cause. Whatever formats the log line or the TUI toast emits Transport: <cause.message> — which is exactly the string I saw on screen:
      Transport: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()
      
      The same text is logged verbatim by the event-stream client (17× in my log since 2026-09-28), and the wrap itself is visible in a logged ERROR:
      timestamp=2026-10-01T04:13:01.219Z level=ERROR run=090ea0c4 message="unhandled rejection" cause="ClientError: Transport: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() (cause: TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch())" role=cli
      
    2. packages/cli/src/services/daemon.ts:110-135 — start() reuses a running server only on found?.version === InstallationVersion && compiled (line 114); otherwise stopProcess(found) (line 115) then spawn a new one. That is the version-mismatch restart, which my log records as reason=version-mismatch previousVersion=….
    3. packages/tui/src/context/sync.tsx:451-547 — bootstrap()'s catch logs tui bootstrap failed (line 541) and then exit(e) when fatal (lines 546-547). That is the crash half of this issue. On my machine that path did not fire.

    Timeline (verbatim from C:\Users\Lance\.local\share\opencode\log\opencode.log, UTC, 2026-10-05)

    timestamp=2026-10-05T08:38:25.361Z level=INFO run=cc5beb9c message="updated OpenCode" from=2.0.22 to=2.0.23 method=bun role=cli
    timestamp=2026-10-05T08:38:28.433Z level=INFO run=38b70a8a cause="InterruptError: All fibers interrupted without error […] http.span=67279 role=server http.method=GET http.url=/api/event http.status=200
    timestamp=2026-10-05T08:38:36.441Z level=INFO run=6d168471 message="cli starting" version=2.0.23 channel=latest local=false args=[] role=cli
    timestamp=2026-10-05T08:38:36.541Z level=INFO run=6d168471 message="background service starting" reason=version-mismatch previousVersion=2.0.22 role=cli
    timestamp=2026-10-05T08:38:36.700Z level=INFO run=60edadc9 message="cli starting" version=2.0.23 channel=latest local=false args="[\"serve\",\"--service\"]" role=cli
    timestamp=2026-10-05T08:38:40.254Z level=INFO run=6d168471 message="event stream connecting" component=client attempt=0 role=cli
    timestamp=2026-10-05T08:38:42.258Z level=INFO run=6d168471 message="event stream disconnected" component=client attempt=1 error="Timed out connecting to server" role=cli
    timestamp=2026-10-05T08:38:48.521Z level=INFO run=92b823b7 message="cli starting" version=2.0.23 channel=latest local=false args="[\"serve\",\"--service\"]" role=cli
    timestamp=2026-10-05T08:38:49.036Z level=INFO run=6d168471 message="event stream connecting" component=client attempt=1 role=cli
    timestamp=2026-10-05T08:38:49.042Z level=INFO run=6d168471 message="event stream connected" component=client role=cli
    

    ([…] = the multi-line stack frames of the InterruptError cause, elided; no other part of that line altered.)

    Two supporting observations from the same window:

    • Run 60edadc9 (first serve --service spawn) emits 13 lines, the last at 08:38:37.377Z, then never logs again — no shutting down line — and it is not in the live process table. Run 92b823b7 (spawned 08:38:48.521Z) is the service that serves today (service.json pid 13344). Cause of the first spawn's disappearance: not determinable from the log.
    • Between 08:38:25Z and 08:40:46Z there are zero level=ERROR lines. The first ERROR after the update is at 08:40:47.458Z: 3× EPERM … rename 'session.json…​.tmp' (a separate mechanism — atomic-write rename collisions, not transport).

    Expect vs Actual

    • Expect: an auto-update restart is invisible to open clients — at most a brief Reconnecting blip, no crash, no escaped ClientError defect.
    • Actual (this machine): that expectation held. Connect at 08:38:40.254Z, one failed attempt at 08:38:42.258Z, connected at 08:38:49.042Z → 6.78 s outage, 1 attempt, no tui bootstrap failed, no UnknownError stack, no ERROR line. The client-side screen text I saw at startup was Transport: The socket connection was closed unexpectedly… (see hygiene note below).

    Repro (observed, not reproducible on demand)

    1. Windows machine, opencode 2.0.22, shared managed service running.
    2. Trigger the auto-updater (or start a CLI right after an update lands): log records updated OpenCode from=2.0.22 to=2.0.23 method=bun.
    3. Start a TUI immediately after (cli starting version=2.0.23).
    4. Client detects the registered server is 2.0.22 → background service starting reason=version-mismatch previousVersion=2.0.22 → old server tears down (InterruptError on /api/event) → new serve --service spawns.
    5. During that window the TUI's event stream attempt fails (error="Timed out connecting to server", or on other runs error="Transport: The socket connection was closed unexpectedly…").
    6. Observe: the retry succeeds ~7 s later, the TUI keeps running, and no ERROR is logged.

    Counter-scenarios tried

    # Check (how) Result Alternative explanation it rules out
    1 Every message="event stream disconnected" line was re-paired with the same run='s next event stream connected (log starts 2026-09-27T22:39:12.657Z; all counted events are 2026-09-28 or later) 33 disconnects, all level=INFO; 31 reconnected within 0.1–9.3 s; 1 (d6f9fff6, 09-28T06:40:17Z) re-disconnected 7 s later and then reconnected 9.3 s after that; 1 (090ea0c4, 10-01T05:41:22Z) has no reconnect because that line is the run's last log line — process exit "The Transport text is a fatal/crash signature" — it never is here; the SSE retry-with-backoff path absorbs it every time
    2 Get-CimInstance Win32_Process -Filter "Name='opencode.exe'" (PID, PPID, CreationDate, CommandLine) 3 processes only: 19636 .bun\bin\opencode.exe (08:38:36Z), 21136 its child, no args (08:38:36Z, = run 6d168471, confirmed by the session.json.21136.*.tmp names in the 08:40:47 EPERM lines), 13344 …\@opencode\cli\bin\opencode.exe serve --service (08:38:48Z, = service.json pid) "A lingering 2.0.22 daemon from before the update caused the failure" — nothing older than 08:38:36Z is alive, i.e. everything started after the 08:38:25Z update line
    3 ~/.local/state/opencode/service.json exactly one registration: {"id":"45b432f6-…","version":"2.0.23","url":"http://127.0.0.1:49374","pid":13344,"password":"<redacted>"} "corrupt / duplicated / stale registration file"
    4 Get-ChildItem ~\.local\state\opencode -Filter *.tmp -Recurse 0 files "leftover atomic-write .tmp litter from the EPERM renames is still around"
    5 level=ERROR between 08:38:25Z and 08:40:46Z 0 lines (first ERROR after the update is the 3× EPERM block at 08:40:47.458-459Z) "the TUI crashed / bootstrap() took the exit(e) path at this update" — corroborated by 0 genuine hits for tui bootstrap failed and UnknownError as application log lines (the only whole-log matches are our own grep commands echoed back by the server)

    Evidence hygiene (what I am not claiming)

    • The literal string Transport: The socket connection was closed unexpectedly… does not appear in opencode.log on 2026-10-05. The log's record of that startup failure reads error="Timed out connecting to server" (quoted above); the on-screen Transport: … text is user-observed in the TUI, and its provenance from a specific call at 08:38Z is UNVERIFIED. What is verified: that exact string is logged 17× as an event-stream error= value, and once as a ClientError: Transport: … (cause: TypeError: …) unhandled rejection in a role=cli process.
    • Log emitters for event stream disconnected and background service starting … reason=version-mismatch were not located in my local clone (dev @ 907b3bc) — the strings are verified from the shipped 2.0.23 binary's output only. The version-gated restart logic they describe is verified at daemon.ts:110-135.

    Environment

    Caption        : Microsoft Windows 10 Pro
    Version        : 10.0.19045
    BuildNumber    : 19045
    OSArchitecture : 64-bit
    
    opencode v2.0.23
    bun 1.4.2
    gh version 2.102.0 (2026-09-30)
    git version 2.55.0.windows.5
    

    Why this helps this issue

    This is the good outcome of the same race this issue reports the bad outcome of: same trigger (version-mismatch restart after auto-update), same ClientError("Transport") surface, and on this machine the reconnect budget was never exhausted, so bootstrap() never reached its exit(e). It brackets severity for #36688: the failure is not "Windows always crashes", it is "whoever loses the ~7 s race crashes — and this run won". It also shows the same double-spawn shape as the issue's problem #2 (two serve --service spawns 11.8 s apart in one update cycle, the first one's log trail ending with no shutdown line).

  5. vladkosinov commented on Oct 26, 2026

    @vladkosinov

    Seen on Windows 11 (TUI 2.0.27, Desktop app 2.0.32). Trigger here is the version-mismatch replace from #55429: the TUI starts its own service while the Desktop app restarts its own, the TUI's startup probe fails, and it exits with the same unhandled error:

    cli process failed cause=... UnknownError: An error occurred in Effect.tryPromise (cause: ClientError: Transport: Unable to connect. Is the computer able to access the url?)
    

    The fix PR #48162 was auto-closed on 9 Oct without a review. Could it be reopened?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions