Skip to content

fix(openai): lease WebSocket connections exclusively - #4346

Merged
dgageot merged 6 commits into
mainfrom
fix/websocket-stream-lease
Sep 18, 2026
Merged

dgageot merged 6 commits into
mainfrom
fix/websocket-stream-lease

Conversation

@dgageot

@dgageot dgageot commented Sep 17, 2026

Copy link
Copy Markdown
Member

The OpenAI WebSocket pool allowed a single idle connection to be shared across concurrent streaming requests. Because each WebSocket carries exactly one in-flight exchange at a time, two concurrent callers could interleave reads on the same connection, silently corrupting both responses.

A new Lint/ExclusiveStreamLease analyzer was written first and run against the codebase to confirm the problem. It uses resolved-type, intra-procedural CFG and origin tracking via the existing prog.Cop framework to flag WebSocket wrapper values that alias a retained receiver field and are returned to callers — covering branch paths and named-return cases. The analyzer flagged exactly two violations at ws_pool.go:103 and ws_pool.go:127. It is intra-procedural only and does not prove whole-program concurrency safety; helper side-effects, receiver aliases in callers, container tracking, and general lease protocols are out of scope.

The fix gives the idle slot take-and-clear semantics: consuming it transfers exclusive connection ownership to the caller for the lifetime of its streaming request. A second concurrent request dials its own connection rather than blocking or sharing. Active connections are tracked so pool shutdown closes both idle and in-flight connections. Close is idempotent; connections that have not finished are closed to unblock the reader; a request already cancelled at entry leaves the idle slot unchanged.

task build and task lint pass. The targeted race tests pass with -race -count=3. task test fails only on TestLoadExamples entries that require Docker Model Runner to be running locally; this failure reproduces identically on the pre-fix commit and is unrelated to this change.

The rest of the suite passes with go test ./... -skip '^TestLoadExamples$'. Pool-wide lastResponseID semantics and holding the pool mutex during dialing predate this PR and remain out of scope.

@dgageot
dgageot requested a review from a team as a code owner September 17, 2026 16:30
@aheritier aheritier added area/providers For features/issues/fixes related to LLM providers (Bedrock, LiteLLM, Qwen, custom, etc.) area/providers/openai For features/issues/fixes related to the usage of OpenAI models kind/fix PR fixes a bug (maps to fix:). Use on PRs only. labels Sep 17, 2026
@dgageot
dgageot enabled auto-merge September 18, 2026 12:03
gtardif
gtardif previously approved these changes Sep 18, 2026
@dgageot
dgageot added this pull request to the merge queue Sep 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 18, 2026
dgageot and others added 6 commits September 18, 2026 14:22
Adds ExclusiveStreamLease, a program-level cop that flags any function
returning a WebSocket stream that aliases a receiver field — a pattern
that lets the caller and pool share the same connection concurrently.

Red-first evidence (go run ./lint . before production fix):
  ws_pool.go:103  Lint/ExclusiveStreamLease: returned WebSocket stream aliases receiver field conn
  ws_pool.go:127  Lint/ExclusiveStreamLease: returned WebSocket stream aliases receiver field conn
  2 offense(s) detected

Signed-off-by: David Gageot <david.gageot@docker.com>
Assisted-By: claude-opus-4-5
Pool is idle-only: the idle slot is cleared the moment a stream takes
ownership, so overlapping requests each dial their own connection.
Active connections are tracked separately for shutdown only.

Key points:
- terminal-event completion sets a flag; Close is idempotent via sync.Once
- early Close discards the lease and closes the connection immediately
- active map lets Close() shut down in-flight connections cleanly
- cancelled requests (ctx.Err at entry) don't consume the idle slot

Signed-off-by: David Gageot <david@gageot.net>
Assisted-By: Claude
Use atomic.Bool for done, sync.Once for Close, and WriteControl
(concurrency-safe, bounded timeout) for the close handshake so
concurrent Next/Close calls cannot race or double-close the socket.

Assisted-By: Claude
Signed-off-by: David Gageot <david.gageot@docker.com>
Protect Close read vs retry pointer write with mutex; close flag ensures replacement returned after closure is discarded. Both adapters delegate to synchronized close. Tests cover standard/beta Close before/after retry and successful retry; race detector confirmed issues before and clean after.

Assisted-By: claude-sonnet-4-5
Signed-off-by: David Gageot <david.gageot@docker.com>
fix: detect and repair unsafe stream closure
@aheritier aheritier added the status/needs-rebase PR has merge conflicts or is out of date with main label Sep 18, 2026
@aheritier

Copy link
Copy Markdown
Collaborator

👋 This PR has merge conflicts with the base branch. Please rebase or merge the latest base branch and resolve them. I've moved it to draft and added status/needs-rebase; it'll be picked back up automatically once the conflicts are cleared.

@dgageot
dgageot added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit d40bb9d Sep 18, 2026
15 checks passed
@dgageot
dgageot deleted the fix/websocket-stream-lease branch September 18, 2026 12:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/providers/openai For features/issues/fixes related to the usage of OpenAI models area/providers For features/issues/fixes related to LLM providers (Bedrock, LiteLLM, Qwen, custom, etc.) kind/fix PR fixes a bug (maps to fix:). Use on PRs only. status/needs-rebase PR has merge conflicts or is out of date with main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants