Skip to content

fix(stream): keep public SSE responses alive#2666

Open
ryanngit wants to merge 7 commits into
decolua:masterfrom
ryanngit:responses-stream-heartbeat
Open

fix(stream): keep public SSE responses alive#2666
ryanngit wants to merge 7 commits into
decolua:masterfrom
ryanngit:responses-stream-heartbeat

Conversation

@ryanngit

@ryanngit ryanngit commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • return explicit streaming /v1/responses requests as SSE before account routing or provider headers complete
  • emit comment keepalives for generic clients while provider headers are pending
  • emit ignorable Responses events for Codex before and after provider headers so Codex idle timers observe activity
  • recognize Codex/<version> only at the Responses heartbeat gate, without changing shared client detection or native passthrough
  • emit schema-valid empty chat.completion.chunk events for translated OpenAI Chat streams during output silence
  • inject keepalives only between complete SSE events, preserving fragmented upstream bytes and downstream backpressure
  • propagate downstream cancellation to provider fetches and avoid account cooldown for client-aborted attempts
  • frame delayed Responses JSON/transport failures as schema-complete response.failed events
  • preserve safe structured upstream error codes such as context_length_exceeded in delayed failures
  • keep stream:false, omitted stream, invalid JSON, provider routing, and account fallback unchanged

Root cause

Three related timeout/detection layers existed:

  1. Long account selection or provider-header waits could exceed a reverse-proxy response-header timeout. The proxy returned 524 before 9Router emitted headers.
  2. After provider headers, Codex wraps parsed SSE events in an idle timeout. SSE comments are discarded by its event parser, so comment heartbeats did not reset that timer. This affected both /v1/responses and translated /v1/chat/completions output.
  3. Current Codex Desktop sends User-Agent: Codex/<version> with X-Initiator: user. Shared detection classifies that initiator as GitHub Copilot before Codex CLI UA checks, so the Responses route selected comment keepalives. Adding Desktop to shared detection would also change native-passthrough behavior. The fix therefore checks the lowercased UA only at the route-local heartbeat decision.

Responses handling remains route-local. Chat keepalives wrap only translated OpenAI Chat output after usage and terminal processing; native provider pass-through remains unchanged.

Verification

  • focused Responses, Chat, cancellation, and route matrix: 27/27 passed on head 499d4db
  • exact-header regression: User-Agent: Codex/0.1.0 plus X-Initiator: user selects typed event keepalives
  • delayed structured-error regression preserves context_length_exceeded and rejects malformed codes
  • changed-file ESLint and git diff --check: passed
  • full unit differential from original PR: patched 1035 passed, 26 failed, 24 skipped; stock v0.5.35 1024 passed, same 26 failed assertions, 24 skipped
  • PR-range Gitleaks: no leaks
  • npm run build: passed, TypeScript checked, 130 routes generated
  • 140-second public delayed-header canary from original PR: first byte 232 ms, five keepalives, one provider request, one completion, no 524/retry
  • 52-second credential-free native Responses canary: two typed Codex event keepalives, then marker, response.completed, [DONE], and clean EOF; no comment-only heartbeat or failed/incomplete terminal
  • 130-second isolated Chat canary: five schema-valid heartbeats at 25-second gaps, one upstream request, one terminal record, one [DONE], and HTTP 200
  • public cancellation canary from original PR: provider request aborted, no active/completed orphan, no account lock/cooldown mutation
  • v0.5.40 live rollout: atomic app swap with DB/app rollback artifacts; local, raw tunnel, and short tunnel health HTTP 200; live short-URL Codex Responses canary completed normally
  • independent re-review: shared-detector blast radius and X-Initiator shadowing resolved; no blockers

No private aliases, pools, deployment scripts, credentials, or environment-specific routing are included.

@ryanngit
ryanngit force-pushed the responses-stream-heartbeat branch from d47cfc0 to f3e3ac7 Compare July 19, 2026 10:09
@ryanngit

Copy link
Copy Markdown
Contributor Author

Fresh live verification (2026-07-20):

  • Deployed v0.5.35 contains the early/pre-header bridge but its bundle has no 9router.keepalive event string.
  • Current 9Router logs show repeated Codex client_closed events around 124-126 seconds, matching the parsed-event idle timeout this PR addresses.
  • Local and short-domain health both remained HTTP 200 and the 9Router PID stayed unchanged, so this does not correlate with a tunnel or process outage.
  • After integrating current stream terminal hardening, the bridge/route/terminal/correlation matrix passes 43/43.

No live promotion was performed; the local reliability gate remains in force.

@ryanngit

Copy link
Copy Markdown
Contributor Author

v0.5.40 refresh

  • Base: upstream/master = v0.5.40 (79918c7)
  • Branch head: 171355b59f0e1ef02b368bd0ab3b49941886e684 (was dfb0ac2fe30fd0a52abda8f6f8bbad4180c022a2)
  • Merge: merged
  • Tests (TMPDIR in worktree): pass — Test Files 3 passed (3); Tests 19 passed (19);
  • Files: tests/unit/responses-early-stream.test.js tests/unit/responses-route.test.js tests/unit/headroom-chat-core.test.js
  • git diff --check: clean
  • gitleaks on range: ok

@ryanngit

Copy link
Copy Markdown
Contributor Author

Extended heartbeat coverage to OpenAI Chat Completions streams after v0.5.40 testing exposed the same client idle timeout outside the Responses route. Chat streams now emit schema-valid empty chat.completion.chunk frames every 25 seconds of output silence, only at SSE event boundaries, with backpressure and cancellation cleanup. A deterministic 130-second isolated canary observed five 25-second heartbeats, one upstream request, one terminal event, and HTTP 200. Focused tests: 19 passed; ESLint, diff check, and Gitleaks passed. Head: 79be8a1.

@ryanngit ryanngit changed the title fix(responses): keep public streams alive fix(stream): keep public SSE responses alive Jul 21, 2026
@ryanngit

Copy link
Copy Markdown
Contributor Author

Post-deploy verification on v0.5.40 (2026-07-21):

  • promoted the reviewed bundle with one atomic app swap and one process restart
  • local, raw tunnel, and short tunnel health returned HTTP 200
  • focused Chat/Responses/affinity stream tests passed 26/26
  • complete source-to-live bundle/config/database verifier returned 0 failures and 0 warnings
  • post-restart production requests completed successfully; API-key limits remained unchanged/unlimited

No private aliases, pools, deployment code, or environment-specific routing are included in this PR.

@ryanngit

Copy link
Copy Markdown
Contributor Author

Evidence correction and final route-local update

An earlier comment inferred that 124-126 second client_closed entries were native Responses requests. Rechecking stored request details shows those entries were large /v1/chat/completions requests; native /v1/responses had no post-cutover sample over 120 seconds. This corrects the log classification without changing the independently reproduced bug.

Exact reproduction uses the current Desktop header combination:

  • User-Agent: Codex/0.1.0
  • X-Initiator: user

X-Initiator makes shared detection return github-copilot, so relying on detectClientTool() === "codex" selects comment-only keepalives. The earlier shared-detector attempt also risked enabling native passthrough for Desktop. Head c49e37e instead adds only userAgent.startsWith("codex/") to the /v1/responses heartbeat gate and tests the shadowing header combination.

Fresh verification:

  • focused route/Responses/Chat/cancellation matrix: 25/25
  • changed-file ESLint: passed
  • git diff --check: passed
  • credential-free delayed-header QA: 52 seconds of provider-header silence, two typed keepalives, marker, response.completed, [DONE], clean EOF
  • v0.5.40 live rollout: source/bundle/DB verifier 0 failures and 0 warnings; local/raw/short health HTTP 200; live short-URL Codex Responses canary completed normally
  • independent re-review: no blocking findings

No private routing, aliases, credentials, pools, or deployment code are included.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant