Skip to content

fix(sse): add first-byte watchdog to the TLS-fingerprint transport (#12656) - #13272

Merged
diegosouzapw merged 1 commit into
release/v3.8.51from
fix/12656-wreq-stream-watchdog
Sep 12, 2026
Merged

diegosouzapw merged 1 commit into
release/v3.8.51from
fix/12656-wreq-stream-watchdog

Conversation

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Closes #12656

Root cause

When ENABLE_TLS_FINGERPRINT=true, the wreq-js (TLS fingerprint) branch of proxyFetch() /
TlsClient.fetch() returned the upstream Response as soon as headers resolved, with zero
protection around how long the caller then waited for the body's first byte. The only timing
guard on that path was TlsClient's single overall timeout option, which defaults to
600_000ms (10 min) — matching the reported 90-600s stall range exactly. A stalled wreq body was
returned to the caller and simply hung; the only generic body-phase guard elsewhere
(ensureStreamReadiness in chatCore.ts) is transport-agnostic and deliberately loose (up to
600_000ms in agent/long-running-goal mode) so it can't distinguish "upstream genuinely warming
up" from "the wreq transport itself is wedged."

Fix

  • open-sse/utils/tlsFirstByteWatchdog.ts (new): guardTlsFirstByte() races the response body's
    first read() against a short, env-overridable watchdog (TLS_FIRST_BYTE_WATCHDOG_MS, default
    10000, 0 disables it). A healthy/fast body is completely unaffected — the already-read first
    chunk is replayed through a passthrough ReadableStream so no bytes are lost. A body that never
    yields within the deadline cancels the wreq reader and throws.
  • open-sse/utils/proxyFetch.ts: both TLS-fingerprint branches (direct and proxied) now await
    guardTlsFirstByte(response) instead of returning the raw response. The watchdog's timeout
    error is caught by the existing TLS-fallback catch blocks, so replay-safety
    (isTlsFallbackReplaySafe) is reused, not reimplemented — a non-idempotent request (e.g. POST
    with a body) still throws instead of being silently retried on the direct/proxy dispatcher.
  • src/shared/utils/runtimeTimeouts.ts: getTlsFirstByteWatchdogMs() resolves the new env var,
    following the existing readTimeoutMs()/allowZero pattern.
  • open-sse/utils/tlsClient.ts re-exports guardTlsFirstByte so proxyFetch.ts (frozen at its
    file-size cap, see config/quality/file-size-baseline.json) can import it without adding a
    line to a file that cannot grow.
  • Docs: docs/security/STEALTH_GUIDE.md, docs/reference/ENVIRONMENT.md, .env.example.

Regression test

tests/unit/tls-first-byte-watchdog-12656.test.ts — promotes the plan-file's RED probe into a
permanent suite:

  • (a) a stalled wreq body falls back to the direct dispatcher within the watchdog window
  • (b) a healthy/fast wreq body is unaffected by the watchdog (no dispatcher fallback call)
  • (c) a non-replay-safe POST throws on watchdog timeout instead of silently retrying

RED → GREEN evidence (test file run against the code before/after the fix, TLS_FIRST_BYTE_WATCHDOG_MS=80):

# before the fix (return response; unguarded)
✖ #12656 (a) ... AssertionError: 0 !== 1   (no fallback call fired)
✔ #12656 (b) ...
✖ #12656 (c) ... AssertionError: Missing expected rejection

# after the fix
✔ #12656 (a) a stalled wreq body falls back to the direct dispatcher within the watchdog window
✔ #12656 (b) a healthy/fast wreq body is unaffected by the watchdog
✔ #12656 (c) a non-replay-safe POST throws on watchdog timeout instead of silently retrying
ℹ tests 3 / pass 3 / fail 0

Existing tls/proxy regression suites re-run clean (121/122 pass, 1 pre-existing skip):
proxy-fetch.test.ts, proxy-fetch-dns-retry-10443.test.ts, proxyfetch-bun.test.ts,
proxyfetch-caller-abort-log-suppression.test.ts,
proxyfetch-direct-response-start-timeout-10214.test.ts,
proxyfetch-retry-fresh-socket-4252.test.ts, proxyfetch-undici-retry.test.ts,
proxyfetch-vercel-relay-2743.test.ts, 9551-proxyfetch-no-proxy-context-bypass.test.ts,
proxy-nested-context-skip.test.ts, tls-proxy-context.test.ts,
tlsClient-circuit-breaker.test.ts, tls-client-wreq-migration.test.ts,
tls-client-wreq-residue.test.ts, tls-options.test.ts.

Gates run

  • node scripts/check/check-file-size.mjs — OK (proxyFetch.ts stays at its frozen 1271 LOC cap;
    the new watchdog logic was extracted into its own module rather than growing the frozen file)
  • node scripts/check/check-complexity.mjs — OK (2798 vs baseline 3218)
  • node scripts/check/check-cognitive-complexity.mjs — OK (1265 vs baseline 1437)
  • npm run typecheck:core — exit 0
  • npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files> — exit 0
  • npm run check:docs-all — exit 0 (env/doc contract in sync, no fabricated claims)
  • node scripts/check/check-changelog-integrity.mjs — OK

⚠️ base-red inherited: #12732 — unit #12058, integration codex-cache, package-artifact, tarball-smoke, agent-skills-sync

…12656)

The wreq-js TLS-fingerprint transport resolved the Response as soon as
upstream headers arrived, with zero guard around how long the caller then
waited for the body's first byte. TlsClient's only timing control, a flat
timeout, defaults to 600_000ms, matching the reported 90-600s stall window
exactly.

Add guardTlsFirstByte() (open-sse/utils/tlsFirstByteWatchdog.ts), which
races the body's first read() against a short, env-overridable watchdog
(TLS_FIRST_BYTE_WATCHDOG_MS, default 10s). A healthy body is unaffected
(bytes already buffered are replayed through a passthrough stream); a
stalled body cancels the wreq reader and throws, letting proxyFetch's
existing TLS-fallback catch blocks fall through to the direct/proxy
dispatcher. A non-replay-safe request (e.g. a POST with a body) still
throws instead of being silently retried, reusing isTlsFallbackReplaySafe.
@diegosouzapw
diegosouzapw merged commit 1361a9d into release/v3.8.51 Sep 12, 2026
15 of 21 checks passed
Githab-capibara added a commit to Githab-capibara/OmniRoute that referenced this pull request Sep 17, 2026
…heaper-inference#12656) (cheaper-inference#13272)

Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit.

Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them.

- ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on cheaper-inference#13243)
- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline
- 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs
- `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for cheaper-inference#13278 and cheaper-inference#13276, annotated and landed on cheaper-inference#13243

⚠️ base-red inherited: cheaper-inference#12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…heaper-inference#12656) (cheaper-inference#13272)

Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit.

Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them.

- ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on cheaper-inference#13243)
- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline
- 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs
- `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for cheaper-inference#13278 and cheaper-inference#13276, annotated and landed on cheaper-inference#13243

⚠️ base-red inherited: cheaper-inference#12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch.
@diegosouzapw
diegosouzapw deleted the fix/12656-wreq-stream-watchdog branch October 18, 2026 23:48
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.

fix(tls): wreq-js transport stalls streaming inference (zero bytes for minutes) — make the fingerprint transport adaptive with a first-byte watchdog

1 participant