Skip to content

Mid-stream 1006 with no close frame kills continuation turns that have emitted nothing user-visible #146

Description

@iceteaSA

Recurrent on my machine. A turn dies mid-stream and is deliberately not retried, so the user just loses it:

WebSocket closed before response.completed (code 1006: socket closed)
(continuation of resp_0507483a3bdfa140016aa4748e597c87d09bc900a67570f5f0)
(not retried: output already emitted)

This is distinct from #135, which was the 1009 Message Too Big case and is fixed. Here the peer closes the TCP connection with no close frame at all.

What v0.7.1's new fields say about it

The dual-id logging and frame counters added in v0.7.1 make this much more legible than it was in September. The full record for the instance above:

[2026-09-11T21:37:39.363Z] WARN [transport] stream failed after output; not retried
  reason: "WebSocket closed before response.completed (code 1006: socket closed)"
  emittedOutput: true
  responseID: resp_0507483a3bdfa140016aa474963b6487d0b4aa987513e167c2
  previousResponseID: resp_0507483a3bdfa140016aa4748e597c87d09bc900a67570f5f0
  hasContinuation: true
  framesSinceCreated: 4
  msSinceLastFrame: 9099

Four frames after response.created, then nine seconds of silence, then the socket dies. It is a continuation carrying previous_response_id. That matches the profile I measured across seven instances on #135: follow-on continuations that die in the opening seconds, silence windows of 4-11s, no close frame.

The part worth changing

emittedOutput is set for every event except response.created and response.in_progress (ws.ts, the translatedEvent.type !== 'response.created' && ... !== 'response.in_progress' branch). With four frames total, what flipped it was something like output_item.added or a reasoning-summary part - no user-visible text, no completed tool call.

So the no-replay gate from #92 is doing the right thing for the wrong reason here. Its purpose is to prevent duplicated text and re-run side-effecting tools, and neither is at risk after four lifecycle-adjacent frames. But emittedOutput cannot tell that apart from a response that streamed two paragraphs and called a tool.

A sharper predicate, using what v0.7.1 already measures: replay is unsafe once a response.output_item.done has been seen (opencode dispatches tool execution on that event) or once any text delta has reached the user. Before either, a 1006 with no close frame is indistinguishable from a connection that never produced anything, and the existing HTTP fallback path added for 1009 in e68ab8f already knows how to serve a request whose socket died before output.

I am not suggesting the gate be relaxed generally - it exists because retrying after real output duplicates text and double-bills. The suggestion is that emittedOutput is coarser than the invariant it protects, and the frame counters now give enough information to narrow it.

Frequency

One occurrence in the current log window (which starts at 20:41 today). I see it regularly across sessions; #135 carries seven measured instances from 09-05 and 09-06 with the same signature. Happy to collect a larger sample with /openai-dump on if the frame profile above is not enough to work from.

Version: v0.7.1 (fb1402e), Bun raw WebSocket client, Linux.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions