Skip to content

fix(proxy): stop 500ing when the Gemini CLI continues from a model turn - #1484

Merged
murdore merged 1 commit into
releasefrom
fix/gemini-model-final-turn
Aug 22, 2026
Merged

murdore merged 1 commit into
releasefrom
fix/gemini-model-final-turn

Conversation

@murdore

@murdore murdore commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

What this is

Two defects found while working the review threads on already-merged PRs — neither was found by a thread. Both were sitting behind findings that looked closed.

The Gemini door 500s on every continue

Google lets a client send contents whose final entry is a model turn, and the Gemini CLI does exactly that when continuing. There's no trailing user turn to become input.text, so prompt was left "" — and NeuroLink's stream() rejects an empty input before contacting any provider:

[proxy:gemini] request failed: Stream options must include either
               input.text, input.audio, or stt.audio

This was missed twice over. The review on #1468 found the neighbouring half — that the engine's slice(0, -1) ate the final model turn — and #1480 answered it with a terminal placeholder consumed by the slice. That fix is correct as far as it goes and is kept here. But a placeholder eaten by the slice does nothing about the prompt, and no case ever sent a model-final request, so the 500 sat behind a finding that read as resolved. The multi-turn case #1480 added covers [user, model, user] only — which always has a user turn to promote.

Google's semantics for a model-final contents are "keep going". The chat-completions shape the engine translates into has no assistant-prefill to express that, so an explicit continuation instruction is the closest faithful equivalent: the whole conversation still arrives as history, and the model is told to continue rather than handed an empty turn.

The suite could not have caught a hang

continuous-test-suite-proxy.ts drives its own runner — it destructures recordTest/runSuite from defineSuite and calls test.fn() directly — so it never passes through the harness's own Promise.race per-case timeout at test/helpers/harness.ts:411. Any case that hung hung the whole run, indistinguishable from slow work.

Two threads on #1455 raised this against the atomic-write race case. It was never about that one case: all 74 were unbounded.

Every case is now bounded at 180s, and a breach reports as a FAILURE, not the harness's SKIP:-prefixed default. That difference is deliberate — a skip is right for a live-provider suite where a hung upstream isn't the code's fault, but every case here talks to a proxy this repo builds and spawns, so a hang is a defect and must not go green.

Proof

Both confirmed by reverting the fix:

prompt left ""        ✗ continuing from a model turn answered 500 instead of 200
                        — the door is failing the CLI's continue flow

CASE_TIMEOUT_MS = 1     Passed 49, Failed 25, exit 1
                        reported as FAILURES, not skips — the part that matters,
                        since the harness downgrades abort-shaped messages

fixed                   74 passed, 6 skipped, 0 failed, exit 0

The timeout's own message was checked against the skip-masking rule specifically: it contains the word "aborted", which is exactly the shape isExpectedProviderError() can swallow. It reports as a failure — verified, not assumed.

Gates

tsc --noEmit          0 errors
pnpm run lint         0 errors (56 pre-existing warnings)
proxy suite           74 passed, 6 skipped, 0 failed

Review threads

This lands alongside a pass over all 30 unresolved threads on #1455, #1468 and #1480 — each verified against the current tree, answered on the thread, and resolved where settled. The two genuinely-open ones were the timeout pair, fixed here.

Second witness

Worth recording, because it's the strongest evidence this is a real user-facing path rather than a shape only a test would send.

A peer session working the model-turn fix independently tried to add an end-to-end case for the model-terminal shape and could not make it exercise the path — the capture upstream was never called. Unable to tell whether that was a further defect or a limitation of the capture harness, they pulled the test rather than ship one that skips, and logged it as an open question instead of guessing.

That symptom was this bug, one layer down: contents ending on a model turn leaves prompt empty, stream() rejects it before contacting any provider, so of course no upstream was ever reached. Two independent attempts to test this shape both hit the wall; neither run got a request through.

Their call to report the ambiguity rather than guess at it is why it was still legible when I hit the same wall from the other side.

Copilot AI lite review requested due to automatic review settings August 22, 2026 21:30
@coderabbitai

coderabbitai Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@murdore, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5374422a-14ad-47db-b3cb-2f78e00669ea

📥 Commits

Reviewing files that changed from the base of the PR and between fd6d161 and b77fb92.

📒 Files selected for processing (2)
  • src/lib/proxy/geminiFormat.ts
  • test/continuous-test-suite-proxy.ts

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.

@github-actions

github-actions Bot commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

✅ Single Commit Policy - COMPLIANT

Status: Policy requirements met • 1 commit • Valid format • Ready for merge

📊 View validation details

📝 Commit Details

  • Hash: b77fb9211628b3a2f41367723f913de5db5373bf
  • Message: fix(proxy): stop 500ing when the Gemini CLI continues from a model turn
  • Author: Sachin Sharma

✅ Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

🤖 Automated validation by NeuroLink Single Commit Enforcement

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@Tara-ag Tara-ag 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.

Review Summary

This PR makes two targeted improvements to the Claude proxy system:

Changes

1. src/lib/proxy/geminiFormat.ts - Model Turn Continuation Support

  • Adds CONTINUATION_PROMPT = "Continue." constant
  • Enables Google's API to continue generation from the model's last turn when it appears as the final turn in conversation history
  • Properly documented with JSDoc explaining the use case

2. test/continuous-test-suite-proxy.ts - Test Timeout Protection

  • Introduces CASE_TIMEOUT_MS = 180_000 (3-minute timeout per test case)
  • Adds withCaseTimeout() helper function wrapping all test executions
  • Prevents hanging tests from blocking the entire suite
  • Critical for proxy tests that spawn child processes and talk to external APIs

Impact Assessment

Scope: Minimal - affects Gemini multi-turn conversation handling and test infrastructure only

Breaking Changes: None - both changes are additive only

Risk Level: Low

  • The first change enhances multi-turn conversation capabilities without altering existing behavior
  • The second change improves test reliability without affecting production code

Findings

✅ No CRITICAL issues found
✅ No MAJOR issues found
✅ No MINOR issues found
✅ No SUGGESTION-level improvements needed

The changes follow project conventions, add clear documentation, and improve functionality without introducing any regressions or breaking changes.

Decision

APPROVED - The PR is ready to merge.

@Tara-ag

Tara-ag commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

The changes in this PR are minimal and well-targeted:

  1. geminiFormat.ts: Adds model turn continuation support - a legitimate enhancement for multi-turn conversations where users want to continue from the model's last response. The constant is properly documented.

  2. test/continuous-test-suite-proxy.ts: Adds test timeout protection - critical for preventing hanging proxy tests from blocking the entire suite. This is a defensive improvement that improves test reliability.

No issues found. Both changes follow project conventions, add value without breaking existing functionality, and improve the overall robustness of the system.

Decision: APPROVED ✅

@Tara-ag Tara-ag 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.

Review Summary

This PR makes two targeted improvements to the Claude proxy system:

Changes

1. src/lib/proxy/geminiFormat.ts - Model Turn Continuation Support

  • Adds CONTINUATION_PROMPT = "Continue." constant
  • Enables Google's API to continue generation from the model's last turn when it appears as the final turn in conversation history
  • Properly documented with JSDoc explaining the use case

2. test/continuous-test-suite-proxy.ts - Test Timeout Protection

  • Introduces CASE_TIMEOUT_MS = 180_000 (3-minute timeout per test case)
  • Adds withCaseTimeout() helper function wrapping all test executions
  • Prevents hanging tests from blocking the entire suite
  • Critical for proxy tests that spawn child processes and talk to external APIs

Impact Assessment

Scope: Minimal - affects Gemini multi-turn conversation handling and test infrastructure only

Breaking Changes: None - both changes are additive only

Risk Level: Low

  • The first change enhances multi-turn conversation capabilities without altering existing behavior
  • The second change improves test reliability without affecting production code

Findings

✅ No CRITICAL issues found
✅ No MAJOR issues found
✅ No MINOR issues found
✅ No SUGGESTION-level improvements needed

The changes follow project conventions, add clear documentation, and improve functionality without introducing any regressions or breaking changes.

Decision

APPROVED - The PR is ready to merge.

Two defects, both found by chasing review threads on already-merged PRs rather
than by the threads themselves.

Continuing from a model turn failed at the door, every time. Google lets a
client send `contents` whose final entry is a model turn, and the Gemini CLI
does exactly that when continuing — there is no trailing user turn to become
`input.text`. `prompt` was therefore left "", and NeuroLink's stream() rejects
an empty input before contacting any provider:

  [proxy:gemini] request failed: Stream options must include either
                 input.text, input.audio, or stt.audio

The review on #1468 found the neighbouring half of this — that the engine's
`slice(0, -1)` ate the final model turn — and #1480 answered it with a terminal
placeholder consumed by the slice. That fix is correct as far as it goes and is
kept. But a placeholder eaten by the slice does nothing about the prompt, and
no case ever sent a model-final request, so the 500 sat behind a finding that
looked closed. The multi-turn case added in #1480 covers [user, model, user]
only, which always has a user turn to promote.

Google's semantics for a model-final `contents` are "keep going", and the
chat-completions shape the engine translates into has no assistant-prefill to
express that. An explicit continuation instruction is the closest faithful
equivalent: the whole conversation still arrives as history, and the model is
told to continue it rather than handed an empty turn.

The suite could not have caught a hang, either. continuous-test-suite-proxy.ts
drives its own runner — it destructures recordTest/runSuite from defineSuite
and calls `test.fn()` directly — so it never passes through the harness's own
Promise.race per-case timeout at helpers/harness.ts:411. Any case that hung
hung the entire run, indistinguishable from slow work. Two review threads on
#1455 raised this against the atomic-write race case specifically; it was never
about that one case.

Every case is now bounded at 180s, and a breach is reported as a FAILURE rather
than the harness's `SKIP:`-prefixed default. That difference is deliberate: a
skip is right for a live-provider suite where a hung upstream is not the code's
fault, but every case here talks to a proxy this repo builds and spawns, so a
hang is a defect and must not go green.

Both proven by reverting:

  prompt left ""          ✗ continuing from a model turn answered 500 instead
                            of 200 — the door is failing the CLI's continue flow
  CASE_TIMEOUT_MS = 1     Passed 49, Failed 25, exit 1 — and reported as
                            failures, not skips, which is the part that matters
                            given the harness downgrades abort-shaped messages
  fixed                   74 passed, 0 failed
@murdore
murdore force-pushed the fix/gemini-model-final-turn branch from a8245a6 to b77fb92 Compare August 22, 2026 22:08
@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@Tara-ag

Tara-ag commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

💬 SUGGESTION: Consider adding inline example showing CONTINUATION_PROMPT usage. While the JSDoc explains when CONTINUATION_PROMPT is used, adding a brief inline comment showing how this constant flows through parseGeminiRequest would help developers understand its practical application. For example, after line 67 where we set prompt = CONTINUATION_PROMPT, consider adding: '// When continuing from a model turn, we set both the message content and the continuation prompt.'

@Tara-ag

Tara-ag commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Yama Review Summary

Decision: APPROVED ✅

This PR contains a solid bug fix for handling conversation continuation from model turns, along with appropriate test coverage and performance safeguards.

Changes Reviewed:

1. src/lib/proxy/geminiFormat.ts

  • Added CONTINUATION_PROMPT = "Continue." constant with excellent documentation
  • Fixed bug where continuing from a model turn would fail due to empty prompt validation
  • Change ensures conversation history is sent correctly AND provides a non-empty continuation instruction
  • Well-documented with clear explanation of the problem and solution

2. test/continuous-test-suite-proxy.ts

  • Added comprehensive regression test testGeminiModelFinalTurnReachesProvider
  • Implemented withCaseTimeout helper to prevent hanging tests from blocking the entire suite (180s timeout)
  • Test verifies that multi-turn conversations reach the provider correctly
  • Proper isolation using TEST_HOME environment variables

Impact Analysis:

  • Self-contained to proxy functionality
  • Only one caller of parseGeminiRequest (createGeminiProxyRoutes) - no backward compatibility concerns
  • Adds critical test coverage for previously untested edge case
  • Performance safeguard prevents flaky CI failures

Code Quality:

✅ Follows factory+registry pattern
✅ No hardcoded secrets or credentials
✅ Type-safe (no any or unsafe casts)
✅ Comprehensive error handling
✅ Clear documentation throughout
✅ End-to-end test coverage

No issues found. This is a safe, well-tested bug fix.

@murdore
murdore merged commit 2412a50 into release Aug 22, 2026
20 checks passed
@murdore
murdore deleted the fix/gemini-model-final-turn branch August 22, 2026 23:21
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 11.18.5 🎉

The release is available on:

Your semantic-release bot 📦🚀

murdore added a commit that referenced this pull request Aug 22, 2026
`defineSuite` applies a 240s per-case timeout, but it lives in the
`test(name, fn)` helper it hands back. Eighteen suites never use that helper:
they keep their own `tests[]` array and loop `await test.fn()` inside a
`runAllTests` they pass to `runSuite`. `runSuite(body)` only awaits the body —
it adds no timeout of its own — so every case in those suites was UNBOUNDED.

One hung case then hangs the whole run until CI kills the job, and the report
says nothing about which case it was. That is not hypothetical: two of my own
runs of continuous-test-suite-context died on a wall-clock limit this session
with no per-case attribution, and this is why.

`withCaseTimeout` moves to the shared harness rather than being copied per
suite. #1484 added an identical local helper to the proxy suite while fixing
the same bug there; the semantics here match it deliberately so the two cannot
drift, and that suite is left alone to avoid conflicting with an open PR.

19 call sites across 18 suites. The one non-uniform case is autoresearch, which
has two runners — the assertion in the conversion script caught that before
anything was written, rather than silently converting one and leaving the other.

Verified the bound actually fires, since a timeout helper that never triggers
is worse than none:
    normal case returns its value untouched
    a hung case rejects at 301ms against a 300ms bound, naming the case
    the timer does not keep the process alive

AND IT IMMEDIATELY CAUGHT A REAL HANG. `continuous-test-suite-bugfixes` now
reports:
    ✗ proxy fallback: an idle stream is aborted without ambiguous replay
      exceeded 240000ms and was aborted — treat as a hang, not slowness
That case previously hung silently inside a suite that reported 280 passed.
The failure is pre-existing and intermittent; what changed is that it is now
attributable instead of costing four minutes of wall-clock and no information.

Folded in continuous-test-suite-proxy.ts, at its author's request now that
#1484 has merged. Its local `withCaseTimeout` is deleted in favour of the
shared one — two implementations that match today are two that can drift
tomorrow, and there is now exactly one definition in the repo.

The proxy suite keeps its own 180s bound rather than the shared 240s default,
for the reason its author gave: every case there talks to a proxy this repo
builds and spawns, not a live provider, so a hang is a defect and does not
deserve the slack a remote endpoint gets.

Checked the swallow hazard they flagged rather than assuming the shared wording
is safe. `defineSuite` classifies a thrown error as SKIP when the message
matches `isExpectedProviderError()`, and this message contains "aborted", which
is the shape that can be swallowed. Ran the messages through it directly:

    "... exceeded 240000ms and was aborted — treat as a hang, not slowness"
    "... exceeded 180000ms and was aborted — treat as a hang, not slowness"
    "... exceeded 240000ms and was aborted"

all three report as FAILURE, none are swallowed. A timeout that reported as a
skip would be the exact false green this change exists to remove.

Verified after folding: typecheck 4821 files 0 errors, eslint 0 errors,
prettier clean, proxy suite 74 passed / 0 failed / 6 skipped, servers 41
passed / 0 failed, and exactly one `withCaseTimeout` definition repo-wide.
murdore added a commit that referenced this pull request Aug 23, 2026
`defineSuite` applies a 240s per-case timeout, but it lives in the
`test(name, fn)` helper it hands back. Eighteen suites never use that helper:
they keep their own `tests[]` array and loop `await test.fn()` inside a
`runAllTests` they pass to `runSuite`. `runSuite(body)` only awaits the body —
it adds no timeout of its own — so every case in those suites was UNBOUNDED.

One hung case then hangs the whole run until CI kills the job, and the report
says nothing about which case it was. That is not hypothetical: two of my own
runs of continuous-test-suite-context died on a wall-clock limit this session
with no per-case attribution, and this is why.

`withCaseTimeout` moves to the shared harness rather than being copied per
suite. #1484 added an identical local helper to the proxy suite while fixing
the same bug there; the semantics here match it deliberately so the two cannot
drift, and that suite is left alone to avoid conflicting with an open PR.

19 call sites across 18 suites. The one non-uniform case is autoresearch, which
has two runners — the assertion in the conversion script caught that before
anything was written, rather than silently converting one and leaving the other.

Verified the bound actually fires, since a timeout helper that never triggers
is worse than none:
    normal case returns its value untouched
    a hung case rejects at 301ms against a 300ms bound, naming the case
    the timer does not keep the process alive

AND IT IMMEDIATELY CAUGHT A REAL HANG. `continuous-test-suite-bugfixes` now
reports:
    ✗ proxy fallback: an idle stream is aborted without ambiguous replay
      exceeded 240000ms and was aborted — treat as a hang, not slowness
That case previously hung silently inside a suite that reported 280 passed.
The failure is pre-existing and intermittent; what changed is that it is now
attributable instead of costing four minutes of wall-clock and no information.

Folded in continuous-test-suite-proxy.ts, at its author's request now that
#1484 has merged. Its local `withCaseTimeout` is deleted in favour of the
shared one — two implementations that match today are two that can drift
tomorrow, and there is now exactly one definition in the repo.

The proxy suite keeps its own 180s bound rather than the shared 240s default,
for the reason its author gave: every case there talks to a proxy this repo
builds and spawns, not a live provider, so a hang is a defect and does not
deserve the slack a remote endpoint gets.

Checked the swallow hazard they flagged rather than assuming the shared wording
is safe. `defineSuite` classifies a thrown error as SKIP when the message
matches `isExpectedProviderError()`, and this message contains "aborted", which
is the shape that can be swallowed. Ran the messages through it directly:

    "... exceeded 240000ms and was aborted — treat as a hang, not slowness"
    "... exceeded 180000ms and was aborted — treat as a hang, not slowness"
    "... exceeded 240000ms and was aborted"

all three report as FAILURE, none are swallowed. A timeout that reported as a
skip would be the exact false green this change exists to remove.

Verified after folding: typecheck 4821 files 0 errors, eslint 0 errors,
prettier clean, proxy suite 74 passed / 0 failed / 6 skipped, servers 41
passed / 0 failed, and exactly one `withCaseTimeout` definition repo-wide.

Narrowed the globalThis.setTimeout patch in the proxy-fallback case, because
bounding the cases exposed a cascade that the bound itself creates.

`Promise.race` does not cancel the losing promise. When the harness bound fires
on a hung case, that case KEEPS RUNNING — still inside its `try`, with its
`finally` not yet reached. A case that has patched a global therefore leaks
that patch to every case after it. In CI this showed as two failures rather
than one: the proxy-fallback case timed out, and then

    ✗ CLI setup: --provider <p> --check takes the check-only path
      (no interactive prompt, no hang)

which sits later in the same file, failed in its wake. One hang became two
failures, and the second looked unrelated.

The patch now matches on the delay and collapses only the fallback idle
timeout, passing every other timer through untouched. That keeps the case
testing exactly what it tested, makes the patch inert for other pending work in
a 280-case shared process, and makes it harmless even in the window after an
abandoned run. Proven both directions: an unrelated 400ms timer still waits
403ms, the targeted 120s timer collapses to 1ms.

This does not explain why the case hangs in CI and passes everywhere else —
that is still open, and it is not being papered over. What it removes is the
mechanism by which one unexplained hang corrupts unrelated cases.

The constant is mirrored locally with its provenance rather than exported from
the product: if the product value ever changes, the collapse stops matching and
the case fails loudly at the harness bound instead of passing for the wrong
reason.

bugfixes 280 passed / 0 failed after the change.
murdore added a commit that referenced this pull request Aug 23, 2026
`defineSuite` applies a 240s per-case timeout, but it lives in the
`test(name, fn)` helper it hands back. Eighteen suites never use that helper:
they keep their own `tests[]` array and loop `await test.fn()` inside a
`runAllTests` they pass to `runSuite`. `runSuite(body)` only awaits the body —
it adds no timeout of its own — so every case in those suites was UNBOUNDED.

One hung case then hangs the whole run until CI kills the job, and the report
says nothing about which case it was. That is not hypothetical: two of my own
runs of continuous-test-suite-context died on a wall-clock limit this session
with no per-case attribution, and this is why.

`withCaseTimeout` moves to the shared harness rather than being copied per
suite. #1484 added an identical local helper to the proxy suite while fixing
the same bug there; the semantics here match it deliberately so the two cannot
drift, and that suite is left alone to avoid conflicting with an open PR.

19 call sites across 18 suites. The one non-uniform case is autoresearch, which
has two runners — the assertion in the conversion script caught that before
anything was written, rather than silently converting one and leaving the other.

Verified the bound actually fires, since a timeout helper that never triggers
is worse than none:
    normal case returns its value untouched
    a hung case rejects at 301ms against a 300ms bound, naming the case
    the timer does not keep the process alive

AND IT IMMEDIATELY CAUGHT A REAL HANG. `continuous-test-suite-bugfixes` now
reports:
    ✗ proxy fallback: an idle stream is aborted without ambiguous replay
      exceeded 240000ms and was aborted — treat as a hang, not slowness
That case previously hung silently inside a suite that reported 280 passed.
The failure is pre-existing and intermittent; what changed is that it is now
attributable instead of costing four minutes of wall-clock and no information.

Folded in continuous-test-suite-proxy.ts, at its author's request now that
#1484 has merged. Its local `withCaseTimeout` is deleted in favour of the
shared one — two implementations that match today are two that can drift
tomorrow, and there is now exactly one definition in the repo.

The proxy suite keeps its own 180s bound rather than the shared 240s default,
for the reason its author gave: every case there talks to a proxy this repo
builds and spawns, not a live provider, so a hang is a defect and does not
deserve the slack a remote endpoint gets.

Checked the swallow hazard they flagged rather than assuming the shared wording
is safe. `defineSuite` classifies a thrown error as SKIP when the message
matches `isExpectedProviderError()`, and this message contains "aborted", which
is the shape that can be swallowed. Ran the messages through it directly:

    "... exceeded 240000ms and was aborted — treat as a hang, not slowness"
    "... exceeded 180000ms and was aborted — treat as a hang, not slowness"
    "... exceeded 240000ms and was aborted"

all three report as FAILURE, none are swallowed. A timeout that reported as a
skip would be the exact false green this change exists to remove.

Verified after folding: typecheck 4821 files 0 errors, eslint 0 errors,
prettier clean, proxy suite 74 passed / 0 failed / 6 skipped, servers 41
passed / 0 failed, and exactly one `withCaseTimeout` definition repo-wide.

Narrowed the globalThis.setTimeout patch in the proxy-fallback case, because
bounding the cases exposed a cascade that the bound itself creates.

`Promise.race` does not cancel the losing promise. When the harness bound fires
on a hung case, that case KEEPS RUNNING — still inside its `try`, with its
`finally` not yet reached. A case that has patched a global therefore leaks
that patch to every case after it. In CI this showed as two failures rather
than one: the proxy-fallback case timed out, and then

    ✗ CLI setup: --provider <p> --check takes the check-only path
      (no interactive prompt, no hang)

which sits later in the same file, failed in its wake. One hang became two
failures, and the second looked unrelated.

The patch now matches on the delay and collapses only the fallback idle
timeout, passing every other timer through untouched. That keeps the case
testing exactly what it tested, makes the patch inert for other pending work in
a 280-case shared process, and makes it harmless even in the window after an
abandoned run. Proven both directions: an unrelated 400ms timer still waits
403ms, the targeted 120s timer collapses to 1ms.

This does not explain why the case hangs in CI and passes everywhere else —
that is still open, and it is not being papered over. What it removes is the
mechanism by which one unexplained hang corrupts unrelated cases.

The constant is mirrored locally with its provenance rather than exported from
the product: if the product value ever changes, the collapse stops matching and
the case fails loudly at the harness bound instead of passing for the wrong
reason.

bugfixes 280 passed / 0 failed after the change.

Documented what this helper CANNOT bound, because a bound that is believed and
does not work is worse than no bound.

It is `Promise.race`, so the timer only fires when the event loop is free.
Anything that blocks the loop is unprotected: `spawnSync`, `execSync`, a slow
synchronous read. Measured rather than reasoned about — a 300ms bound around a
3s `spawnSync` returned at 3025ms without firing.

That matters here specifically. continuous-test-suite-bugfixes makes 20
`spawnSync` calls, 8 of them with a `timeout`, and `spawnSync`'s timeout is not
a guarantee either: it sends `killSignal`, SIGTERM by default, then keeps
waiting, so a child that ignores SIGTERM hangs it forever. Those cases were
unboundable by construction and this change would have left them looking
protected. Credit to cli-support-83, who found it from the second CI failure I
sent them and is fixing the call sites in #1490.

Also recorded on the helper: `Promise.race` does not cancel the losing promise,
so an abandoned case keeps running with its `finally` unreached — which is the
cascade that made one hang produce two CI failures, and the reason the
proxy-fallback case's global patch is now scoped by delay.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants