Skip to content

fix(responses-ws): handle abort during upstream handshake#1332

Closed
i-square wants to merge 1 commit into
ding113:mainfrom
i-square:localfix/0.8.10/responses-ws-abort
Closed

fix(responses-ws): handle abort during upstream handshake#1332
i-square wants to merge 1 commit into
ding113:mainfrom
i-square:localfix/0.8.10/responses-ws-abort

Conversation

@i-square

@i-square i-square commented Jul 13, 2026

Copy link
Copy Markdown

Summary

Fixes an uncaughtException that crashed and restarted the app when a client aborted a request while the upstream OpenAI Responses WebSocket was still in the CONNECTING handshake state. Also adds a tri-state ENABLE_OPENAI_RESPONSES_WEBSOCKET env override so immutable-image deployments can disable the feature without DB access.

Problem

When enableOpenaiResponsesWebsocket is on and a client disconnects (or the AbortSignal fires) before the upstream WebSocket handshake completes:

  1. onAbort() calls finishRequest({ closeCode: 1000, forgetSession: true }).
  2. finishRequest() calls cleanupRequestListeners() first, detaching the request-scoped error listener.
  3. It then calls ws.close(code) on a socket that is still CONNECTING.
  4. The ws package asynchronously emits WebSocket was closed before the connection was established.
  5. With the request-level error listener already removed, the error escapes to the process-level handler in instrumentation.ts, which calls process.exit(1).
  6. Under restart: unless-stopped, the container restarts - interrupting in-flight streams and causing repeated, spurious restarts (one deployment saw 11 auto-restarts in a few hours, all from this single exception).

The issue also notes that the system-settings UI does not render a toggle for enableOpenaiResponsesWebsocket, so operators on immutable images have no way to turn the feature off without editing the database.

Related Issues:

Solution

Two coordinated changes keep the request-level abort from becoming a process-level crash:

  1. State-aware close that always owns its errors. closeWs() / terminateWs() are replaced by a single safelyCloseWebSocket() that installs a one-shot error sink before closing, then branches on readyState: CLOSED no-ops; CLOSING keeps the sink but skips a duplicate close; CONNECTING calls terminate() (aborting the incomplete handshake) instead of close() (which is what emits the stray error); OPEN does a normal close() with a terminate() fallback on a readyState race.
  2. Idempotent finishRequest() that detaches listeners last. A requestFinished guard prevents double execution, and cleanupRequestListeners() now runs after the close so the one-shot error sink is in place when the async error fires. The first-event-timeout and open-failure paths are rerouted through this safe path instead of calling closeAndForget() / terminateWs() directly.

The env override is implemented at the read site in isOpenaiResponsesWebsocketEnabled(): unset falls through to the DB setting; true/1 or false/0 force the value; anything else warns once and falls back to the DB. This preserves existing deployments (no default change, no migration).

Changes

Core Changes

  • src/app/v1/_lib/responses-ws/upstream-adapter.ts - new safelyCloseWebSocket() (one-shot error sink + readyState-aware close/terminate), idempotent finishRequest() that cleans up listeners last, first-event-timeout and open-failure paths rerouted through the safe path.

Supporting Changes

  • src/lib/config/system-settings-cache.ts - getOpenaiResponsesWebsocketEnvOverride() tri-state parser with single-warn-on-invalid, applied in isOpenaiResponsesWebsocketEnabled().
  • .env.example - documents ENABLE_OPENAI_RESPONSES_WEBSOCKET.
  • tests/unit/lib/config/system-settings-cache.test.ts - covers true/false/1/0 override, unset fallthrough, and invalid-value warn-once behavior.
  • src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts - new tests for the CONNECTING-state abort (post-construction and pre-aborted signal), the abort/first-event-timeout race (close only once), and the CLOSING-state race (error sink retained); all assert no uncaughtException/unhandledRejection.

Breaking Changes

None. The env var is opt-in (unset preserves current behavior); the close-path changes are internal.

Testing

Automated Tests

  • Unit tests added - 4 new upstream-adapter cases + 6 system-settings-cache cases
  • Integration tests - N/A (unit tests directly cover the crash path and the env override)

Manual Testing

  1. Enable enableOpenaiResponsesWebsocket in DB (or set ENABLE_OPENAI_RESPONSES_WEBSOCKET=true).
  2. Send a /v1/responses request to a Codex provider whose upstream WebSocket handshake stalls (e.g. point at a TCP blackhole / delayed-upgrade server).
  3. Abort the client while the upstream socket is still CONNECTING.
  4. Expected: the request fails fast with aborted before first upstream WebSocket event; no uncaughtException is logged; the process does not restart; other requests are unaffected.
  5. Verify ENABLE_OPENAI_RESPONSES_WEBSOCKET=false forces HTTP Responses routing regardless of the DB value.

Checklist

  • Code follows project conventions
  • Self-review completed
  • Tests pass locally
  • Documentation updated (if needed)

Description enhanced by Claude AI

Greptile Summary

This PR fixes a crash/unhandled-error scenario that occurred when an upstream WebSocket abort signal fired while the socket was still in the CONNECTING state (handshake in progress). The previous code issued a normal close() on a CONNECTING socket, which the ws library handles by emitting an asynchronous error that had no listener — causing an unhandled exception.

  • safelyCloseWebSocket replaces the old closeWs/terminateWs pair and correctly dispatches on all four readyState values: CLOSED (no-op), CLOSING (install error sink only), CONNECTING (install sink + terminate()), OPEN (install sink + close()), with a terminate() fallback if close() races a state transition.
  • requestFinished guard in finishRequest prevents double-close when abort and the 20-second first-event timeout fire in the same event loop tick, ensuring cleanupRequestListeners and socket teardown run exactly once.
  • Env override (ENABLE_OPENAI_RESPONSES_WEBSOCKET) is added alongside the DB-backed setting, with one-shot warning deduplication for invalid values and full test coverage.

Confidence Score: 5/5

Safe to merge. The fix is narrowly scoped to the abort-during-handshake edge case and is validated by four new integration tests that exercise CONNECTING-state abort, pre-aborted signal, abort/timeout race, and CLOSING-state races.

All four readyState branches in safelyCloseWebSocket are handled explicitly and tested. The requestFinished guard correctly prevents double teardown without hiding other bugs. The env override is cleanly layered over the DB cache with no risk to existing behavior when the var is unset.

No files require special attention. The upstream-adapter changes are the most complex but the new tests give good coverage of the tricky races.

Important Files Changed

Filename Overview
src/app/v1/_lib/responses-ws/upstream-adapter.ts Core fix: replaces closeWs/terminateWs with safelyCloseWebSocket (handles all 4 readyState values correctly for CONNECTING aborts), adds requestFinished guard to prevent double-cleanup races, moves cleanupRequestListeners after closeAndForget so the temporary error sink stays alive, and guards firstEventTimer creation when abort has already resolved the open promise.
src/app/v1/_lib/responses-ws/tests/upstream-adapter.test.ts Adds four new integration tests covering: CONNECTING-state abort, pre-aborted signal, abort/timeout race (double-close prevention), and CLOSING-state race (error-sink retention). flushProcessEvents uses two setImmediate ticks which may be marginally tight for some environments but is generally sufficient.
src/lib/config/system-settings-cache.ts Adds ENABLE_OPENAI_RESPONSES_WEBSOCKET env override with tri-state logic (true/false/undefined), one-shot warning for invalid values, and DB fallback. Clean and straightforward.
tests/unit/lib/config/system-settings-cache.test.ts Adds env-override tests for isOpenaiResponsesWebsocketEnabled covering all four valid values, unset case, and invalid-value warning deduplication. Correctly resets env state in beforeEach/afterEach.
.env.example Documents the new ENABLE_OPENAI_RESPONSES_WEBSOCKET env var with accepted values and override semantics. Clear and accurate.

Reviews (1): Last reviewed commit: "fix(responses-ws): handle abort during u..." | Re-trigger Greptile

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

本次变更修复 Responses WebSocket 在握手及关闭竞态中的异常处理和重复收尾,并新增 ENABLE_OPENAI_RESPONSES_WEBSOCKET 对数据库配置的三态环境变量覆盖及测试。

Changes

Responses WebSocket 生命周期

Layer / File(s) Summary
安全关闭 WebSocket
src/app/v1/_lib/responses-ws/upstream-adapter.ts
新增按连接状态选择 closeterminate 的安全关闭逻辑,并用于持久化会话和请求级关闭。
请求收尾与超时统一
src/app/v1/_lib/responses-ws/upstream-adapter.ts
增加幂等的 finishRequest,统一首次事件超时和连接失败后的清理流程。
中止竞态测试覆盖
src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts
补充卡住握手、中止竞态、CLOSING 状态及进程级错误捕获测试。

Responses WebSocket 配置覆盖

Layer / File(s) Summary
环境变量覆盖解析
.env.example, src/lib/config/system-settings-cache.ts
支持 true/1false/0 覆盖数据库配置;未设置或非法时回退数据库值,并对非法值仅告警一次。
覆盖优先级测试
tests/unit/lib/config/system-settings-cache.test.ts
验证环境变量覆盖、数据库回退、非法值告警及测试间环境隔离。

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed 实现了 CONNECTING 阶段取消的安全终止、无进程级错误,以及可选的环境变量三态覆盖。
Out of Scope Changes check ✅ Passed 未见明显超出 issue 目标的改动,新增配置说明与测试都服务于该修复。
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed 标题准确概括了本次变更的核心:处理上游握手阶段的 abort。
Description check ✅ Passed 描述与代码变更一致,清楚说明了握手中止崩溃修复和环境变量覆盖。
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an environment variable override (ENABLE_OPENAI_RESPONSES_WEBSOCKET) to control the OpenAI Responses WebSocket setting, along with corresponding unit tests. It also refactors the WebSocket closing logic in upstream-adapter.ts to safely handle socket termination and prevent unhandled asynchronous errors from escaping to process-level crash handlers. The review feedback suggests improving safelyCloseWebSocket to prevent duplicate listener registration on already-closing sockets and to avoid potential listener leaks by properly cleaning up both error and close listeners when either fires.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +210 to +223
if (ws.readyState === 3) return;

// Active closes can still surface an asynchronous error after request-level
// listeners are detached. This one-shot sink keeps the error owned by this
// socket instead of letting it escape to process-level crash handlers.
const consumeCloseError = () => {};
ws.once("error", consumeCloseError);
ws.once("close", () => {
ws.off("error", consumeCloseError);
});

// Another close path may already have started the handshake. Keep the
// temporary sink above, but do not issue a duplicate close or terminate.
if (ws.readyState === 2) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are two potential issues with the current implementation of safelyCloseWebSocket:

  1. Duplicate Listener Registration: If safelyCloseWebSocket is called on a socket that is already in the CLOSING state (readyState === 2), it will register another set of error and close listeners before returning early. Moving the readyState check to the top of the function (e.g., ws.readyState >= 2) prevents this redundant registration.
  2. Listener Leak: If an error event is emitted, the once("error") listener is automatically removed, but the once("close") listener remains registered. Cleaning up both listeners when either fires ensures no listener leaks occur on the WebSocket instance.

Using hoisted function declarations for cleanup and consumeCloseError makes the cleanup logic robust and clean.

Suggested change
if (ws.readyState === 3) return;
// Active closes can still surface an asynchronous error after request-level
// listeners are detached. This one-shot sink keeps the error owned by this
// socket instead of letting it escape to process-level crash handlers.
const consumeCloseError = () => {};
ws.once("error", consumeCloseError);
ws.once("close", () => {
ws.off("error", consumeCloseError);
});
// Another close path may already have started the handshake. Keep the
// temporary sink above, but do not issue a duplicate close or terminate.
if (ws.readyState === 2) return;
if (ws.readyState >= 2) return;
// Active closes can still surface an asynchronous error after request-level
// listeners are detached. This one-shot sink keeps the error owned by this
// socket instead of letting it escape to process-level crash handlers.
function cleanup() {
ws.off("error", consumeCloseError);
ws.off("close", cleanup);
}
function consumeCloseError() {
cleanup();
}
ws.once("error", consumeCloseError);
ws.once("close", cleanup);

@github-actions github-actions Bot added bug Something isn't working area:OpenAI labels Jul 13, 2026
@github-actions github-actions Bot added the size/M Medium PR (< 500 lines) label Jul 13, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

No significant issues identified in this PR. The fix correctly consolidates WebSocket teardown into safelyCloseWebSocket, which terminates CONNECTING sockets (avoiding the async error that previously escaped to process-level crash handlers) and installs a one-shot error sink. The new requestFinished re-entrancy guard and the openResolved-aware first-event timer cleanly close the abort/timeout race windows. The ENABLE_OPENAI_RESPONSES_WEBSOCKET tri-state env override is a small, independent addition with correct warn-once behavior and accurate .env.example docs.

PR Size: M

  • Lines changed: 508 (478 additions, 30 deletions)
  • Files changed: 5
  • Note: ~320 of 478 additions are test code; the substantive source change is ~65 lines. The two logical changes (abort-handling fix + env override) are separable but small enough to ship together.

Review Coverage

  • Logic and correctness - Clean. Re-traced abort-during-CONNECTING, already-aborted signal, abort/timeout race, and CLOSING-state race; all paths terminate exactly once with no escaped async errors.
  • Security (OWASP Top 10) - Clean. No new user-input handling, injection surface, or auth-path changes.
  • Error handling - Clean. The // ignore catches in safelyCloseWebSocket are intentional, non-actionable teardown-path swallows consistent with the pre-existing closeWs/terminateWs pattern; the one-shot consumeCloseError sink is the meaningful error ownership mechanism.
  • Type safety - Clean. No any in new code; the test-only _readyState cast is a deliberate private-state probe.
  • Documentation accuracy - Clean. Comments explain the CONNECTING-terminate rationale and the close-race fallback; .env.example accurately describes accepted values.
  • Test coverage - Adequate. Four new cases cover CONNECTING-abort-after-construction, already-aborted signal (asserts no 20s timer), abort/timeout close-once race, and CLOSING-state error-sink retention; all assert zero uncaughtException/unhandledRejection.
  • Code clarity - Good.

Automated review by Claude AI

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

Labels

area:OpenAI bug Something isn't working size/M Medium PR (< 500 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Bug] Responses WebSocket 在 CONNECTING 阶段取消会触发 uncaughtException 并重启 app

1 participant