Skip to content

chore(deps): update dependency vitest to v4.1.11 [security] - #1582

Merged
edelauna merged 1 commit into
mainfrom
renovate/npm-vitest-vulnerability
Sep 20, 2026
Merged

edelauna merged 1 commit into
mainfrom
renovate/npm-vitest-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
vitest (source) 4.1.9 → 4.1.11 age confidence

Vitest: Path Traversal / Arbitrary File Read via @​vitest/mocker Redirect Mock

CVE-2026-84373 / GHSA-82fw-gwwq-j7x9

More information

Details

Summary

@vitest/mocker registers a redirect mock's target path without validating it
against the dev server's file-serving allowlist. An attacker who can reach the
dev server's WebSocket can register a redirect mock pointing outside the project
root; when the mocked module is requested, the plugin's load hook returns
readFile(<attacker path>) as the module source, disclosing local files.

This is exploitable without authentication only through the public
mockerPlugin / standalone interceptorPlugin exports (used by third-party dev
servers), which register the handler on Vite's unauthenticated HMR socket.
Vitest's own browser mode registers mocks over a token-authenticated RPC and
is not remotely reachable by default (see Scope).

Affected code

packages/mocker/src/node/interceptorPlugin.ts.

The load hook is the file-read sink:

if (mock.type === 'redirect') {
  return readFile(mock.redirect, 'utf-8')
}

mock.redirect is derived from client input at registration time with no
boundary check:

if (event.type === 'redirect') {
  const redirectUrl = new URL(event.redirect)
  event.redirect = join(server.config.root, redirectUrl.pathname)
}
registry.register(event)

There is no server.fs.allow / server.fs.deny check and no assertion that the
resolved path stays within the project root.

Registration paths and trust boundaries
  • Public mockerPlugin / interceptorPlugin (unauthenticated). In
    configureServer, the plugin registers server.ws.on('vitest:interceptor:register', …)
    on Vite's HMR WebSocket. That socket performs no token, Origin, or same-origin
    check, so any client that can reach it can register a redirect mock. This is
    the path the "unauthenticated" impact applies to.
  • Vitest browser mode (authenticated). Mocks register over the browser RPC
    (registerMock), which sits behind a per-run token (isValidApiRequest, a
    random api.token). The interceptor's configureServer socket is not used for
    registration here (in v5 it does not run at all, as the plugin is injected per
    environment). The same missing boundary check exists on the authenticated RPC
    path, but reaching it requires the token, so it is not a remote-unauthenticated
    read.
Path handling

new URL(redirect).pathname combined with join(root, pathname) does not
confine reads to the root:

  • Special/hierarchical schemes (file:, http:) are normalized by WHATWG URL,
    so .. segments are collapsed and the result stays under the root. Payloads of
    the form file:///../../etc/passwd do not escape.
  • A non-special (opaque) scheme preserves .. in pathname, so
    join(root, "../../…/etc/passwd") resolves outside the root and reads an
    arbitrary file.

Even without escaping the root, the missing server.fs check allows reading any
in-root file the dev server would otherwise refuse to serve (for example an
in-root .env or source that is denied by server.fs.deny).

Scope / preconditions
  • This is a development-server issue. The dev server binds to localhost by
    default and is not reachable from the network unless the developer exposes it
    (server.host / 0.0.0.0, a LAN bind, or a proxy).
  • A raw (non-browser) client against a reachable server bypasses browser origin
    and CORS protections entirely and can both register the mock and read the
    response.
  • A browser-based drive-by against a localhost server is substantially mitigated
    by Vite defaults: the default CORS origin allowlist is limited to localhost
    origins, and server.allowedHosts blocks DNS-rebinding, so a cross-origin page
    cannot read the file contents back.
Impact

Disclosure of local files readable by the dev-server process (source, in-root
.env/secrets, and, via the opaque-scheme payload, files outside the project
root). No integrity or availability impact.

Affected versions

Present since @vitest/mocker was introduced.

  • Affected: @vitest/mocker >= 2.1.0 (shipped in vitest and
    @vitest/browser >= 2.1.0), through 4.1.x and the 5.0.0 pre-releases.
  • Fixed: Vitest 4.1.11 and 5.0.0. Older majors (2.1.x, 3.x) are not
    maintained and are not planned to receive the fix.
Fix

Validate the resolved redirect target against Vite's file-serving allowlist
(isFileLoadingAllowed) before registering it, at every registration site, and
stop registering the interceptor WebSocket events in Vitest's browser mode
(mocks there flow through the authenticated RPC).

Severity

  • CVSS Score: 5.9 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

vitest-dev/vitest (vitest)

v4.1.11

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v4.1.10

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

Configuration

📅 Schedule: (in timezone America/Los_Angeles)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • coderabbit-review-active

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: e20fd3f2-73d6-4bf4-af68-ae27354e4889

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Review status

This PR was opened by an automated account. A human maintainer must verify the change intent, provenance, and validation before merging.

Current step: The required review sequence passed. Remaining merge requirements apply.

Review-state labels are managed by this workflow; do not edit them manually.

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions github-actions Bot added awaiting-maintainer CodeRabbit approved; waiting for a human maintainer and removed awaiting-maintainer CodeRabbit approved; waiting for a human maintainer labels Sep 10, 2026
@renovate
renovate Bot force-pushed the renovate/npm-vitest-vulnerability branch from 9785012 to ad9376a Compare September 10, 2026 21:00
@github-actions github-actions Bot added awaiting-maintainer CodeRabbit approved; waiting for a human maintainer and removed awaiting-maintainer CodeRabbit approved; waiting for a human maintainer labels Sep 10, 2026
@renovate renovate Bot changed the title Update dependency vitest to v4.1.11 [SECURITY] chore(deps): update dependency vitest to v4.1.11 [security] Sep 12, 2026
@renovate
renovate Bot force-pushed the renovate/npm-vitest-vulnerability branch from ad9376a to 0432260 Compare September 15, 2026 15:28
@github-actions github-actions Bot added awaiting-maintainer CodeRabbit approved; waiting for a human maintainer and removed awaiting-maintainer CodeRabbit approved; waiting for a human maintainer labels Sep 15, 2026
@renovate renovate Bot changed the title chore(deps): update dependency vitest to v4.1.11 [security] Update dependency vitest to v4.1.11 [SECURITY] Sep 18, 2026
@renovate renovate Bot changed the title Update dependency vitest to v4.1.11 [SECURITY] chore(deps): update dependency vitest to v4.1.11 [security] Sep 19, 2026
@edelauna
edelauna added this pull request to the merge queue Sep 20, 2026
@github-actions github-actions Bot removed the awaiting-maintainer CodeRabbit approved; waiting for a human maintainer label Sep 20, 2026
Merged via the queue into main with commit 1ebbd95 Sep 20, 2026
24 checks passed
@edelauna
edelauna deleted the renovate/npm-vitest-vulnerability branch September 20, 2026 16:43
easonLiangWorldedtech pushed a commit to easonLiangWorldedtech/Zoo-Code that referenced this pull request Sep 28, 2026
GitHub dependency review flagged vitest 4.1.9 (the root devDependency this
branch introduced) as vulnerable; the repo already moved the per-package
vitest pins to 4.1.11 in Zoo-Code-Org#1582 (security). Set the root pin to 4.1.11 and
regenerated pnpm-lock.yaml, which drops the now-unused [email protected]
package/snapshot entries.

Verified: pnpm install --frozen-lockfile passes, knip, and the 7
PR-affected vitest suites (143 tests).
KarthickMahalingam pushed a commit to KarthickMahalingam/Zoo-Code that referenced this pull request Nov 4, 2026
…oo-Code-Org#1309)

* feat(api): add throwIfAborted helper and completePrompt options regression tests

Add a fast-fail throwIfAborted guard to the shared abort-signal utilities and regression tests for the CompletePromptOptions interface (added by Zoo-Code-Org#901).

* feat(api): abort-signal wiring for lm-studio and qwen-code (round 2)

* fix(api): align lm-studio specs with abort-signal call shape and address review

CI: platform-unit-test (ubuntu-latest) was failing on four LM Studio spec
assertions that still expected the pre-PR single-argument SDK call shape.
createMessage now calls chat.completions.create(params, { signal }) with the
request-local AbortSignal, and completePrompt passes no options when no
signal/timeout is configured. Update the two existing specs to the same
two-argument assertions used by the new qwen-code-native-tools.spec.ts
(expect.any(AbortSignal) for createMessage, undefined second argument for the
no-signal completePrompt case).

Review: (1) narrow isRequestAborted to an exact match on the OpenAI SDK abort
error text ("Request was aborted.") instead of a substring scan, so unrelated
errors that merely mention "abort" are no longer normalized as user
cancellations; (2) abort the request-local controller in the createMessage
finally block so early stream termination (break/return) cancels the
in-flight SDK request.

* test(api): cover LM Studio reasoning_content delta in abort-signal spec

Changed-line coverage verification against the full PR diff
(git diff origin/main...HEAD) found two executable added lines in
lm-studio.ts uncovered by the PR's spec files: the
reasoning_content/reasoning delta branch of createMessage (upstream main
had since re-based that block into the PR diff). Add a focused streaming
regression test to lm-studio-timeout.spec.ts that exercises the branch,
restoring 100% changed-line coverage on both provider files
(lm-studio.ts 35/35, qwen-code.ts 65/65 executable added lines).

* test(api): cover qwen-code degenerate stream shapes for full branch coverage

Codecov's patch report on the previous head flagged 5 partial-coverage
lines in qwen-code.ts (lines 328, 338, 344, 398, 399): the defensive
branches of the createMessage stream loop — a chunk with no choice
(choices[0] ?? fallback), a delta that repeats the previous full content
(empty after trimming), a think block that starts the text (empty leading
split segment), and a zeroed usage payload (prompt_tokens || 0,
completion_tokens || 0).

Add one focused degenerate-stream-shapes test to qwen-code-native-tools
spec covering all five branches in a single stream. Branch-level lcov
cross-reference on the PR's added lines now reports zero partial-coverage
added lines in both provider files, and line-level changed-line coverage
stays 100% (lm-studio.ts 35/35, qwen-code.ts 65/65 executable added
lines).

* feat(api): add shared isRequestAborted and createAbortError helpers to abort-signal utils

The OpenAI-family provider PRs (Zoo-Code-Org#1309, Zoo-Code-Org#1311) carry per-provider copies of the same abort-detection helper (isRequestAborted) and the same abort-error constructor (createAbortError); only the provider name in the message differs. Per the CodeRabbit maintainability finding on Zoo-Code-Org#1309 (extract the shared abort helpers into utils/abort-signal.ts), these are now shared in the foundation utility:
- isRequestAborted(error, signal?) - true when the caller signal fired, a native AbortError / OpenAI SDK APIUserAbortError was raised, or the message is exactly "Request was aborted." (exact match; a substring match would misclassify unrelated errors that merely mention aborting)
- createAbortError(providerName) - fresh error with name === "AbortError" and message "The <providerName> request was aborted", satisfying the Task.ts abort contract
- exported OpenAiRequestOptions type
7 new tests (isRequestAborted 4, createAbortError 3).

* refactor(api): use shared abort helpers from foundation utils in lm-studio and qwen-code

The per-provider copies of isRequestAborted / createAbortError /
OpenAiRequestOptions were extracted into src/api/providers/utils/abort-signal.ts by foundation PR Zoo-Code-Org#1288 (commit a0117fb) following the CodeRabbit maintainability finding on this PR; the providers now import the shared helpers. createAbortError takes the provider name as a parameter; provider behavior and abort messages are unchanged.

* fix(api): normalize aborts during qwen-code 401 retry and lm-studio token counting

CodeRabbit follow-ups on the abort-signal series: an abort landing while the qwen-code 401 retry request is in flight escaped callApiWithRetry as the raw SDK abort error instead of the normalized 'The Qwen Code request was aborted'; the retried apiCall now goes through the same isRequestAborted normalization. In lm-studio createMessage, an abort landing while input token counting is pending was silently dropped (a listener added to an already-aborted signal never fires), so the aborted state is bridged into the request-local controller and throwIfAborted fast-fails before the request is issued. Regression tests cover both paths.

* test(api): cover the non-abort path of the qwen-code 401 retry

Closes the remaining changed-line gap in qwen-code.ts: a 401 whose retry fails with a non-abort error must be rethrown unchanged (the abort check's false branch).

* fix(ci): declare vitest as a root devDependency so the mutation gate resolves its bin

* test(api): strengthen focused tests for the changed-code mutation gate

* test(api): harden abort-wiring tests against hanging mutants

* test(api): exclude proven-equivalent Stryker mutants from the changed-code gate

* test(api): exclude unobservable inner-abort provider-name literals from the gate

The two inner throw createAbortError("LM Studio") sites in createMessage and
completePrompt always produce an error whose name is AbortError.
isRequestAborted treats name AbortError as an aborted request, so the outer
catch re-normalizes those failures into the same contract error. The
provider-name StringLiteral at the inner sites is therefore unobservable
and the StringLiteral mutants are provably equivalent.

* fix(api): settle lm-studio and qwen-code wait points against the abort signal

- add settleOnAbort helper: await pending work but reject with the request abort error the moment the signal aborts; the underlying work keeps running (a shared deduped token refresh is never cancelled by one request's signal) and the listener is removed on either outcome

- lm-studio: race the input and output token counts against the request-local signal so a Stop during a pending count settles the generator promptly; skip usage reporting if a stop lands in the microtask gap before the usage yield

- qwen-code: race ensureAuthenticated (credential load and token refresh) against the request signal in createMessage and completePrompt; read the OpenAI SDK status through a narrow shape instead of catch (error: any)

- specs: pending-count and pending-auth abort contract tests plus settleOnAbort unit tests

- eslint-suppressions: drop the qwen-code no-explicit-any entry consumed by the catch-clause fix

* test(api): add abort kill-tests for lm-studio and qwen-code

* Adapt qwen-code spec to per-scope NativeToolCallParser API

* fix(deps): repair pnpm-lock.yaml broken by merge conflict resolution

The merge of main into this branch resolved the lockfile conflict by taking
main's lockfile plus only the PR-side importers hunk, leaving the root
importer's vitest 4.1.9 reference without a matching packages/snapshots
entry. pnpm install --frozen-lockfile failed with
ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY in every CI job.

Regenerated the lockfile with pnpm install --no-frozen-lockfile at the merge
head: adds the [email protected] package/snapshot entries and the new peer
resolutions. Verified: pnpm install --frozen-lockfile passes, lint 11/11,
check-types 11/11, code-qa workflow, lifecycle model-check 7/7, mcp
integration check, translations, knip, and the 7 PR-affected vitest suites
(143 tests).

* fix(deps): align root vitest with the 4.1.11 security bump

GitHub dependency review flagged vitest 4.1.9 (the root devDependency this
branch introduced) as vulnerable; the repo already moved the per-package
vitest pins to 4.1.11 in Zoo-Code-Org#1582 (security). Set the root pin to 4.1.11 and
regenerated pnpm-lock.yaml, which drops the now-unused [email protected]
package/snapshot entries.

Verified: pnpm install --frozen-lockfile passes, knip, and the 7
PR-affected vitest suites (143 tests).

* fix(api): race the Qwen 401 refresh wait against the abort signal and tighten listener-identity assertions

Address the CodeRabbit findings on the 401 refresh path:
- callApiWithRetry now races the refresh wait against the caller's
  signal (settleOnAbort), so a stop settles promptly instead of
  blocking on the token endpoint; the shared refresh keeps running
  and only this wait is cut.
- The abort-during-401-refresh spec now keeps the token endpoint
  pending forever, proving the wait is cut by the abort (a post-hoc
  aborted check alone would hang).
- settleOnAbort listener-cleanup specs assert the exact registered
  listener reference is removed, not just that some function was
  removed.

* fix(qwen-code): keep in-memory credentials current after a cancelled 401 wait (CodeRabbit, R2, Zoo-Code-Org#1309)

doRefreshAccessToken persisted the refreshed token but left the
in-memory credentials to the waiting caller's assignment. When
settleOnAbort rejected on abort before the shared refresh settled,
that assignment was skipped: the background refresh had already saved
the new token, yet the handler kept the stale one in memory, so the
next request reused it and triggered a second 401 refresh.

Assign this.credentials in the shared refresh itself, and restructure
the refresh-wait test so the abort lands after settleOnAbort has
registered its listener (the old mock aborted synchronously inside
the fetch, only exercising the already-aborted fast path). New
regression test: abort cuts the established wait, the refresh settles
in the background, and the in-memory credentials carry the refreshed
token.

28/28 specs pass; eslint clean (suppressions unchanged).

* chore: trigger label reconcile (CI green + CR approved, cron stalled)

* Assert the abort bridge while the request is in flight

Both tests asserted after the call had already finished, so they passed even
with a broken bridge: the provider's finally block aborts the request signal on
its own, and releasing the count gate lets counting finish normally.

- the signal test now keeps the mocked stream open, aborts the caller signal and
  checks the captured request signal before the stream is released;
- the cancellation test now asserts the pending count rejects while the gate is
  still closed, and releases the gate in a finally block.

54 tests green across the two suites; prettier, eslint and tsc clean.

* Re-trigger review at the current head

Every review thread on this PR is resolved and CI is green at this head; the
review decision still points at an older commit. This empty commit re-runs the
review so the decision and the label reflect the current head.

* Address pre-merge checks: detach settleOnAbort's listener on abort; pin completePrompt fast-fail

settleOnAbort registered an abort listener that its onAbort callback
never removed: when the pending promise never settled (e.g. a wedged
shared token refresh), the listener stayed attached to the caller's
signal for the signal's whole life. A single cleanup function now
detaches the exact registered listener at most once, called from the
abort callback and from the pending promise's fulfillment/rejection
handlers. New test aborts with a never-settling pending promise and
asserts the listener is removed by the abort callback itself (negative
control: removing the cleanup call from onAbort fails exactly that
test).

Also adds the missing focused test for LmStudioHandler.completePrompt's
pre-aborted-signal fast-fail: an already-aborted signal rejects with
the contract AbortError and chat.completions.create is never called
(negative control: removing the throwIfAborted guard fails exactly
that test).

Verification at this head: 67/67 green across abort-signal.spec.ts and
lm-studio.spec.ts; tsc --noEmit 0 errors; eslint --max-warnings=0
clean on touched files with suppression counts unchanged; prettier
clean.

* Address review: pin listener removal by function reference

Both listener-removal tests accepted any function in the removeSpy
assertion, so removing a different function than the one registered
would still pass. Per the path instruction (and CR suggestions):
the lm-studio createMessage test now captures the registered listener
from addSpy and asserts removeSpy was called with that exact
reference; the settleOnAbort rejection-propagation test now uses the
same addSpy.mock.calls[0]?.[1] pattern as the other cleanup tests in
the file.

Verification at this head: 67/67 green across abort-signal.spec.ts and
lm-studio.spec.ts; tsc --noEmit 0 errors; eslint --max-warnings=0
clean on touched files; prettier clean.

* Address pre-merge warnings: guard settleOnAbort's early return; share Qwen credential loads

settleOnAbort's already-aborted early return handed the caller the
abort error without attaching a rejection handler to the pending
promise, exposing a rejecting pending promise as an unhandled
rejection. A no-op rejection guard now runs before the early return
(same shape as rejectOnAbort's existing guard). New test aborts with a
rejecting pending promise and asserts no unhandledRejection fires
while the abort error surfaces (negative control: removing the guard
fails exactly that test).

QwenCodeHandler duplicated credential loading after cancellation:
createMessage/completePrompt race ensureAuthenticated() with
settleOnAbort, so a cancelled first request left its load running and
a second request started a fresh credential read. ensureAuthenticated
now shares the in-flight load promise the same way refreshAccessToken
shares its refresh, cleared in a finally once the load settles, so a
failed load is retried by the next request. New tests: concurrent
cancel+retry reuses one credential read; success and failure both
clear the shared promise (negative control: breaking the reuse guard
fails exactly the reuse test; the test releases the hung read in a
finally so a failing run cannot leak the pending load into later
tests).

Review test-fidelity threads: the two qwen-code-native-tools 401
refresh tests now abort only after the refresh wait is established
(fetch never settles; drain until the fetch starts) instead of
aborting synchronously inside the fetch mock, which exercised only
the already-aborted fast path; the two qwen-code listener tests now
assert the exact registered listener reference is removed instead of
expect.any(Function).

Verification at this head: 128/128 green across abort-signal,
qwen-code, qwen-code-native-tools and lm-studio specs; tsc --noEmit 0
errors; eslint --max-warnings=0 clean with suppression counts
unchanged; prettier clean.

* Address review: cover LM Studio early consumer termination

createMessage's finally block cancels the request-local SDK signal and
detaches the external abort listener, but no test pinned the early
consumer exit (break/return while the SDK stream is still pending):
the existing coverage only exercised normal completion and
external-signal aborts. New test consumes one chunk from a stream
that then stays pending, calls stream.return(), and asserts the
request-local SDK signal becomes aborted and the exact registered
external listener is removed (by reference, per the established
pattern). Negative control: removing the finally-block abort fails
exactly this test (isolated run).

Verification at this head: 27/27 green in lm-studio.spec.ts; tsc
--noEmit 0 errors; eslint --max-warnings=0 clean with suppression
counts unchanged; prettier clean.

* Address review: pin no premature abort before early termination

The early-termination test only observed the request-local signal
after stream.return(), so a premature abort (one landing while the
consumer is still iterating) would also pass. The test now captures
the SDK request options after the first chunk and asserts the
request-local signal is still live (aborted === false) before the
early return, retaining the post-return aborted assertion. Negative
control: aborting the request-local controller right after the SDK
create call fails exactly this test (isolated run: the new false
assertion reddens).

Verification at this head: 27/27 green in lm-studio.spec.ts; tsc
--noEmit 0 errors; eslint --max-warnings=0 clean with suppression
counts unchanged; prettier clean.

* chore(api): remove Stryker suppression comments from touched files

Per repo policy (AGENTS.md: Stryker suppression comments are banned;
the mutation gate rejects them in touched files). Equivalence
arguments for the resulting mutants are recorded in the PR body.

* fix(api): reject non-finite and cap overflowing mergeAbortSignalAndTimeout delays

Port of the Zoo-Code-Org#1300 timeout guard finding (CodeRabbit): Node's
AbortSignal.timeout throws ERR_OUT_OF_RANGE for non-integer delays
(Infinity) and clamps delays above 2^31-1 ms to 1 ms, so a
non-finite timeoutMs threw a RangeError instead of running the
request, and an overflowing one aborted almost immediately. The
helper now treats non-finite values as "no timeout" and caps
finite delays at 2^31-1 ms.

Regression tests added; negative controls (reverting the guard)
fail exactly the two new tests.

* fix(api): round fractional timeouts before AbortSignal.timeout

CodeRabbit follow-up on the timeout guard: Node validates
AbortSignal.timeout delays as uint32, so finite fractions (1500.5,
0.5) threw ERR_OUT_OF_RANGE just like Infinity did. The helper now
rounds delays up (a positive fraction never becomes 0 ms) before
capping at 2^31-1 ms. Regression test mergeAbortSignalAndTimeout
(undefined, 1.5); negative control (reverting the rounding) fails
exactly that test.

* test(api): assert the rounded delay reaching AbortSignal.timeout

CodeRabbit: the fractional-timeout test only proved the call did
not throw and eventually aborted - a round-down mutation (1.5 -> 1)
would still pass. The test now spies AbortSignal.timeout and pins
the received delay: 2 for 1.5 and 1 for 0.5 (never 0). Negative
control: mutating Math.ceil to Math.floor fails exactly this test.

* fix(api): restore the OpenAiRequestOptions type the abort wiring imports

upstream/main removed the type in its knip dead-code pass; lm-studio.ts and qwen-code.ts still import it, so the merge has to re-add it.

* fix(api): report an elapsed per-request timeout as TimeoutError

completePrompt merged the caller signal with the timeout and mapped every abort-shaped failure to AbortError, so a deadline expiry looked like a user stop. Distinguish them via the merged signal reason: TimeoutError when the timer fired while the caller signal stayed live, AbortError for a real cancellation. Applied to both lm-studio and qwen-code (same PR, same contract) with regression tests whose negative controls fail with expected AbortError to be TimeoutError.

* refactor(providers): share the timeout-vs-abort helpers and dedupe the LM Studio catch selection

Review follow-ups: createTimeoutError/isTimeoutAbort move from qwen-code.ts and lm-studio.ts into utils/abort-signal.ts (providerName stays a createTimeoutError parameter; behavior unchanged) and both providers import them. LM Studio completePrompt selects TimeoutError vs the contract AbortError through one private helper used by the inner create catch and the outer completion catch. The qwen elapsed-timeout test now passes a live caller signal alongside timeoutMs and asserts TimeoutError, and uses mockImplementationOnce so the signal-waiting mock cannot leak into later tests. Contract tests for the shared helpers live in abort-signal.spec.ts. qwen-code + lm-studio + abort-signal suites 111 passed; abort-signal 58 passed; tsc clean.

* fix(abort-signal): classify timeout aborts by the merged signal reason

Review follow-ups: isTimeoutAbort takes only the merged requestSignal and keys on its retained abort reason, so a timeout that fires before a later caller abort stays a TimeoutError (and a caller-first abort stays a cancellation); both providers updated. Tests cover both abort orders. The elapsed-timeout completePrompt test moved into the completePrompt suite. abort-signal + qwen-code + lm-studio specs 116 passed; tsc clean.

* chore(lm-studio): drop the unused caller signal parameter

Follow-up to the isTimeoutAbort signature change: toCompletionAbortError only needs the merged request signal. tsc clean; eslint on the touched providers exits 0.

* test(lmstudio): wait for the request instead of a fixed sleep

* fix(api): settle qwen-code streams that end quietly on abort

Review threads on Zoo-Code-Org#1309:
- qwen-code.ts: the OpenAI SDK iterator returns without throwing when its
  controller aborts (openai/core/streaming.js: isAbortError -> return), so a
  stop mid-stream left the generator looking like a completed turn with no
  usage chunk. Re-check the request-local signal after the stream loop, as
  lm-studio.ts already does. Regression test included; negative control: the
  test fails with the guard removed.
- lm-studio.ts: drop the unreachable already-aborted branch - nothing is
  awaited between throwIfAborted and the listener registration - and fix the
  comment that justified it.
- abort-signal.ts: settleOnAbort delegates to rejectOnAbort/raceAbort instead
  of maintaining a second copy of the race. raceAbort now detaches its
  listener exactly once from whichever side settles first (the abort callback
  included), so both helper suites keep their leak guards; three assertions
  that pinned the addEventListener argument count now assert only that the
  registration happened once.
- abort-signal.ts: comments describe what the helpers do instead of referring
  to a PR series.
- specs: replace fixed 10 ms sleeps and fixed-depth promise ticks with
  vi.waitFor on the observable condition, and add concurrent-request tests for
  both providers (aborting one request must not cancel the other's signal).

Local verification: api/providers + api/transform 3716 tests green, tsc
--noEmit clean, eslint --max-warnings=0 clean with no suppression changes,
prettier clean, knip strict clean.

* test(api): poll instead of assuming await depth, and exercise the merged signal for real,,Three review findings on the abort-signal tests:,,- qwen-code-native-tools.spec.ts: the two 401-refresh tests drained up to 50 microtasks with,  'await Promise.resolve()' before asserting the refresh fetch had started, and waitForCreateCall,  re-implemented vi.waitFor with a setTimeout loop. Both replaced by vi.waitFor polls, matching,  qwen-code.spec.ts. Control: a 2-deep microtask drain fails with 'expected "vi.fn()" to be called,  1 times, but got 0 times' at spec:796 while the poll passes. Honest note: on the SDK-call wait a,  2-deep drain still happened to pass today, so that half is a latent trap rather than a present,  failure - the helper is gone either way.,- abort-signal.spec.ts: the isTimeoutAbort order tests built two unlinked controllers (and copied,  the reason by hand), so they passed regardless of what the merge actually preserves. Both now,  build the signal with the real mergeAbortSignals. Control: swapping the two abort calls in the,  timeout-first test now fails with "AssertionError: expected false to be true" - the test tracks,  the merge semantics it describes.,- abort-signal.spec.ts: added the null abort-reason case. Control: removing the optional chain on,  requestSignal.reason at abort-signal.ts:320 fails the new test with 'TypeError: Cannot read,  properties of null (reading name)' at abort-signal.ts:320:85, which is the surviving,  OptionalChaining mutant mutation-diff reported on that line.,,Verification: api/providers/__tests__/qwen-code-native-tools.spec.ts +,api/providers/utils/__tests__/abort-signal.spec.ts -> 2 files, 99 tests passed. tsc --noEmit: 0,errors. eslint --max-warnings=0 exit 0 on both files with src/eslint-suppressions.json untouched.,prettier --check clean.

* test(api): ban setTimeout in provider specs, use waitFor and shared helpers

---------

Co-authored-by: Eason Liang <[email protected]>
Co-authored-by: easonLiangWorldedtech <[email protected]>
Co-authored-by: easonLiangWorldedtech <[email protected]>
Co-authored-by: Elliott de Launay <[email protected]>
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