Skip to content

TUI client fails with socket closed error on stale keep-alive connections #54160

Open

Description

@96Ems

Describe the bug

The TUI HTTP client fails with "The socket connection was closed unexpectedly" when reusing stale HTTP keep-alive connections. Users must press Enter multiple times to retry manually.

To Reproduce

  1. Start opencode TUI
  2. Send a message
  3. Wait for idle period (duration varies by network environment)
  4. Send another message
  5. Toast shows: "Transport: The socket connection was closed unexpectedly"

Environment

  • OS: Ubuntu 22.04 LTS
  • Network: Corporate proxy + CrowdStrike Falcon endpoint agent
  • Version: v2.0.26

The issue is more frequent in enterprise environments where:

  • Corporate proxies terminate idle connections
  • Security agents (CrowdStrike, Zscaler) inspect and may reset connections
  • VPNs and firewalls have aggressive idle timeouts

Expected behavior

The client should retry transparently on transient socket errors instead of surfacing the error to the user.

Related issues

Activity

  1. opencode-agent commented on Oct 9, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I could reproduce this on v2.0.26, and it still happens on the latest v2 branch (5cfe3b4).

    What I did, using a proxy between the TUI and the server:

    1. Sent a first message and waited for the reply.
    2. Made the idle keep-alive connections stop forwarding without notifying the client, which is what an aggressive proxy or endpoint agent does.
    3. Submitted a second message, then had the proxy reset the stale connection.

    The TUI showed "Failed to send prompt — Failed to switch model: Transport: The socket connection was closed unexpectedly" and left the prompt unsent. Pressing Enter again sent it on a fresh connection, as you described. The recording and screenshot below show this, and the script below reproduces it.

    A side observation: Bun's fetch already retried GET requests on a stale connection by itself, but not POSTs. That is why sending a message (the model switch and the prompt itself) is where this shows up. Your PR #54154 is noted alongside this issue for the maintainers.

    Related reports:

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Reproduces #54160: after an idle keep-alive connection between the TUI and the
    // server is dropped by the network (proxy / firewall / security agent), the next
    // prompt fails with "Transport: The socket connection was closed unexpectedly"
    // instead of being retried on a fresh connection.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        network: true, // route the TUI <-> server traffic through a controllable proxy
        tui: { recording: true },
      },
      ({ ui, llm, network }) =>
        Effect.gen(function* () {
          yield* llm.serve((_req, i) => Stream.make(Llm.text(`Reply number ${i + 1}.`)))
          yield* ui.submit("first message")
          yield* ui.waitFor("Reply number 1.")
          yield* Effect.sleep(2000)
          // Idle period: a middlebox (corporate proxy / EDR agent) has silently stopped
          // forwarding the idle keep-alive connections; the client still believes they are open.
          yield* network.set({ blackhole: true })
          yield* ui.submit("second message")
          yield* Effect.sleep(1500)
          // The middlebox answers the request written to the stale socket by resetting it.
          const killed = yield* network.killConnections()
          yield* network.clear()
          yield* Effect.log(`reset ${killed} stale proxied connections`)
          yield* Effect.sleep(300)
          yield* ui.screenshot("right-after-reset")
          // Either the second reply arrives, or the transport error toast appears.
          const outcome = yield* Effect.raceFirst(
            ui.waitFor("Reply number 2.", { timeout: 20000 }).pipe(Effect.as("NOT REPRODUCED: second reply arrived")),
            ui.waitFor("Transport", { timeout: 20000 }).pipe(Effect.as("REPRODUCED: transport error shown")),
          ).pipe(Effect.catch(() => Effect.succeed("neither reply nor error within 20s")))
          yield* ui.screenshot("after-second-message")
          yield* Effect.log(outcome)
          // The prompt is still in the input; a manual retry (Enter) goes through on a fresh connection.
          yield* Effect.sleep(2000)
          yield* ui.enter()
          yield* ui.waitFor("Reply number 2.", { timeout: 15000 }).pipe(
            Effect.tap(() => Effect.log("manual retry with Enter succeeded")),
            Effect.catch(() => Effect.log("manual retry did not show the reply")),
          )
          yield* Effect.sleep(3000)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions