fix(cli): stop double-wrapping and double-printing API errors in non-interactive mode#3749
Conversation
…interactive mode In non-interactive (-p) mode, any upstream 4xx ended up on stderr three times: once from the stream-error handler, once from handleError after the thrown Error.message (already containing the formatted text) was fed back through parseAndFormatApiError producing "[API Error: [API Error: ...]]", and once more from JsonOutputAdapter.emitResult writing the same errorMessage out in TEXT mode. The top-level catch then framed the resulting throw as "An unexpected critical error occurred:" with a stack trace, which made a routine 4xx look like a CLI crash. Three coordinated changes: - AlreadyReportedError marks a throw whose message is already on the wire. handleError short-circuits on it: no second writeStderrLine, no second parseAndFormatApiError, just propagate the exit code. - The non-interactive stream-error handler now throws AlreadyReportedError, and the catch block skips the adapter's emitResult in TEXT mode so we don't get a third copy. - The top-level .catch in packages/cli/index.ts treats AlreadyReportedError as a routine, already-reported failure: exit with the carried code without printing the "unexpected critical" framing or the stack trace. parseAndFormatApiError is also made idempotent — input that already starts with "[API Error: " and ends with "]" is returned unchanged. That is the safety net: even if a future caller forgets to mark its throw, the double-wrap symptom is impossible. Tests cover all three layers: idempotency in errorParsing, the short-circuit in handleError, and a regression test on runNonInteractive that asserts no "[API Error: [API Error: ...]" line is ever produced on stderr. Fixes QwenLM#3748
- google-gemini/gemini-cli#26225: MessageBus.publish now throws after emit('error'), MessageBus.request awaits and rejects on publish failure to fix latent hang where invalid/policy-denied requests would set up a response listener that never resolved (merge-after-nits) - QwenLM/qwen-code#3749: AlreadyReportedError marker + handleError/JsonAdapter short-circuit + parseAndFormatApiError idempotency net to stop double-wrap '[API Error: [API Error: ...]]' and duplicate stderr emission in non-interactive mode (merge-as-is) - INDEX.md: append drip-188 section (8 entries, 4 merge-as-is + 4 merge-after-nits, 5/6 repo coverage with goose skipped)
| function isAlreadyFormatted(value: string): boolean { | ||
| // Trailing ']' check guards against false positives where a raw upstream | ||
| // message just happens to start with the prefix mid-string. | ||
| return value.startsWith(API_ERROR_PREFIX) && value.trimEnd().endsWith(']'); |
There was a problem hiding this comment.
[Critical] isAlreadyFormatted() only recognizes already-formatted messages when the whole string ends with ], but 429/rate-limit formatting appends quota guidance after the closing bracket. If one of those formatted 429 messages is parsed again, this guard does not match and the message can still be double-wrapped as [API Error: [API Error: ...]...]; for StructuredError with status === 429, the second pass can also append quota guidance again, possibly with a different auth-specific suffix.
Please make the idempotency check accept the known 429 suffix form, for example by detecting the formatted [API Error: ...] header without requiring the entire trimmed string to end with ], and add a regression test for an already-formatted 429 string with appended quota guidance.
— gpt-5.5 via Qwen Code /review
| // primary output channel there, not a duplicate of stderr. | ||
| const isAlreadyReportedError = | ||
| error instanceof Error && | ||
| (error as { isAlreadyReported?: boolean }).isAlreadyReported === true; |
There was a problem hiding this comment.
AlreadyReportedError is already imported here, so error instanceof AlreadyReportedError would be simpler and consistent with index.ts:98. The duck-typing check duplicates the logic from errors.ts without the helper.
…lreadyReportedError
|
Addressed both review points in 96a460e.
Thanks for the careful review. |
wenshao
left a comment
There was a problem hiding this comment.
Review summary
Multi-agent review (Correctness, Test Coverage, 3am-oncall, Maintainer; Security/Performance skipped — error-pipeline change with no perf surface and no auth/data handling). The fix is correct: stream-error → AlreadyReportedError marker → top-level catch propagates exit code without reformat/reprint, with parseAndFormatApiError idempotency as the safety net. The two prior open inline reviews are demonstrably addressed in 96a460e:
- ✅ @wenshao's Critical on
isAlreadyFormatted()429 handling — now checks all three rate-limit suffix constants inerrorParsing.ts:58-62, with new idempotency tests aterrorParsing.test.ts:133/146. - ✅ @yiliang114's
instanceof AlreadyReportedErrorsuggestion — applied atnonInteractiveCli.ts:779andindex.ts:102. Theerrors.tshelper retains duck-typing intentionally as a separate decision (see Suggestion 3 inline).
Verdict: Comment — 0 Critical, 7 Suggestion (inline). The Suggestions cluster around three durable concerns:
- Hidden coupling between
getRateLimitMessage()andisAlreadyFormatted()with no test that they stay in sync — the next authType-specific suffix added togetRateLimitMessagewill silently regress the 429 idempotency guard. The current 429 idempotency tests only pin the DEFAULT suffix; USE_GEMINI / VERTEX branches are uncovered. (Inline 1 + 4) - Three different conventions for detecting the marker (duck-type helper in
errors.ts,instanceofinindex.ts/nonInteractiveCli.ts, plus a discriminator field whose JSDoc rationale doesn't match how callers actually use it). Future maintainers won't know which convention to copy. (Inline 3) - The most user-visible part of the fix — the
main().catchshort-circuit atpackages/cli/index.ts:96-104that suppresses the "unexpected critical error" framing — has no test coverage at all. Removing theinstanceof AlreadyReportedErrorblock would not fail any test, just regress the original bug. The companion JSON-mode short-circuit inerrors.ts:161-167is also untested. (Inline 5 + 6)
Also flagged: API_ERROR_PREFIX is exported with a comment referencing a non-existent ALREADY_FORMATTED symbol and has no external consumer (Inline 2); the regression test's rejects.toBeTruthy() doesn't pin the marker contract that the rest of the fix depends on (Inline 7).
Skipped (terminal-only Nice-to-have)
- Cron-drain second
AlreadyReportedErrorthrow site atnonInteractiveCli.ts:570is structurally untested (cron mocked off in all suites). Risk low — literal copy of tested site — but author's "shares the regression test path" is inaccurate for that branch. - Regression-test regex
/\[API Error: /gcount atnonInteractiveCli.test.ts:920-927is fragile to future fixtures whose upstream message contains[API Error:legitimately.
Pipeline note
1 prior @wenshao Critical comment classified as Stale (on superseded commit 128430de) — verified addressed in HEAD, not re-raised. @yiliang114's open inline comment (no via Qwen Code footer, so not in presubmit's bucket) also verified addressed at nonInteractiveCli.ts:779.
via Qwen Code /review
| // Prefix this function emits when wrapping any error message. Exported so | ||
| // callers (and the function itself) can detect already-formatted strings and | ||
| // skip re-wrapping. See ALREADY_FORMATTED below. | ||
| export const API_ERROR_PREFIX = '[API Error: '; |
There was a problem hiding this comment.
[Suggestion] API_ERROR_PREFIX is exported (and re-exported via packages/core/src/index.ts) but has no external consumer — its only reader is isAlreadyFormatted() two lines below, which is itself unexported. The header comment also says "See ALREADY_FORMATTED below" but the symbol below is named isAlreadyFormatted, not ALREADY_FORMATTED — that reference is dangling.
Future-maintainer trap: a reader sees an exported API_ERROR_PREFIX with a comment promising callers can detect already-formatted strings, hunts for ALREADY_FORMATTED, finds nothing, then either gives up or re-implements the prefix-only check (the broken-on-429 logic this PR just fixed) instead of consolidating on one tested entry point.
Pick one:
- Drop the export.
API_ERROR_PREFIXbecomes a file-localconstand the comment loses the misleading "Exported so callers can detect…" line. - Promote
isAlreadyFormattedto the export. Addexportto its declaration, fix the comment to say "SeeisAlreadyFormattedbelow for the matching guard", and external callers get a single tested entry point.
via Qwen Code /review
| * Error.message, and the message reaches us a second time, we should return it | ||
| * unchanged rather than producing "[API Error: [API Error: ...]]". | ||
| */ | ||
| function isAlreadyFormatted(value: string): boolean { |
There was a problem hiding this comment.
[Suggestion] isAlreadyFormatted and getRateLimitMessage are coupled by hand: every authType-specific suffix added to the switch in getRateLimitMessage (lines 18-28) must also be appended to the three-way || chain at lines 58-62. Today there are five AuthType values but only three distinct suffix constants (USE_OPENAI / QWEN_OAUTH / USE_ANTHROPIC all fall through to _DEFAULT), so the coupling happens to be intact — but it's a side effect, not an invariant.
When a future patch adds, say, case AuthType.USE_OPENAI: return RATE_LIMIT_ERROR_MESSAGE_OPENAI;, the idempotency guard silently drops to "matches none of the three known suffixes", and the OpenAI 429 path quietly regresses to [API Error: [API Error: ...]]\n<openai message> — exactly the bug this PR fixes. The omission is invisible at the diff site (the developer is editing getRateLimitMessage, not isAlreadyFormatted).
Suggested fix: drive both functions from a single source of truth — e.g. const RATE_LIMIT_SUFFIXES = { USE_GEMINI: '...', VERTEX: '...', DEFAULT: '...' } as const; — and have isAlreadyFormatted iterate over Object.values(RATE_LIMIT_SUFFIXES). Pair with a parametric test that loops every AuthType value through getRateLimitMessage and asserts the constructed [API Error: foo]<suffix> round-trips through parseAndFormatApiError unchanged.
via Qwen Code /review
| } | ||
| } | ||
|
|
||
| function isAlreadyReported(error: unknown): error is AlreadyReportedError { |
There was a problem hiding this comment.
[Suggestion] Three different detection conventions for the same marker:
errors.ts:46-50— duck-type helper (error.isAlreadyReported === true)index.ts:102—instanceof AlreadyReportedErrornonInteractiveCli.ts:779—instanceof AlreadyReportedError
The discriminator field isAlreadyReported = true as const (line 35) carries the JSDoc "Discriminator so tests and other consumers don't need instanceof" — but errors.test.ts imports the class and uses new AlreadyReportedError(...) directly, no consumer reaches for the discriminator. There's also no cross-realm scenario in this repo (no vm, no Workers in the CLI bundle) that would justify duck-typing over instanceof.
The current half-and-half is the worst of both worlds: a future maintainer adding a fourth detection site has no rule to follow. Pick one:
- Drop the discriminator and helper, use
instanceofeverywhere. Consistent with the two existing call sites; no behavior change. - Export the helper, use it everywhere. Add a comment "
isAlreadyReported(err)instead oferr instanceof AlreadyReportedErrorto tolerate cross-bundle class-identity mismatches" so future readers know why the indirection exists.
The @yiliang114 comment was about the call-site duck-typing in nonInteractiveCli.ts (correctly fixed); this is the orthogonal helper-vs-instanceof inconsistency that remains.
via Qwen Code /review
| expect(parseAndFormatApiError(formatted)).toBe(formatted); | ||
| }); | ||
|
|
||
| it('returns an already-formatted 429 plain string with quota guidance unchanged', () => { |
There was a problem hiding this comment.
[Suggestion] Both new "already-formatted 429" idempotency cases (lines 133-138 and 146-151) pin the same RATE_LIMIT_ERROR_MESSAGE_DEFAULT suffix ("Possible quota limitations in place..."). The other two suffix branches in isAlreadyFormatted — RATE_LIMIT_ERROR_MESSAGE_USE_GEMINI ("...request a quota increase through AI Studio...") and RATE_LIMIT_ERROR_MESSAGE_VERTEX ("...through Vertex...") — are not covered.
This is exactly the regression class @wenshao's prior [Critical] comment was protecting against. If a future refactor renames a quota-suffix constant or drops one branch from the || chain at errorParsing.ts:58-62, USE_GEMINI- or VERTEX-authenticated 429s would silently regress to double-wrap and these tests would still pass.
Suggested addition:
it('returns an already-formatted 429 USE_GEMINI message unchanged', () => {
const formatted =
'[API Error: Rate limit exceeded]\nPlease wait and try again later. To increase your limits, request a quota increase through AI Studio, or switch to another /auth method';
expect(parseAndFormatApiError(formatted, AuthType.USE_GEMINI)).toBe(formatted);
});
it('returns an already-formatted 429 VERTEX message unchanged', () => {
const formatted =
'[API Error: Rate limit exceeded]\nPlease wait and try again later. To increase your limits, request a quota increase through Vertex, or switch to another /auth method';
expect(parseAndFormatApiError(formatted, AuthType.USE_VERTEX_AI)).toBe(formatted);
});via Qwen Code /review
| }); | ||
| }); | ||
|
|
||
| describe('in JSON mode', () => { |
There was a problem hiding this comment.
[Suggestion] The 'in JSON mode' describe block has no test for the isAlreadyReported short-circuit at errors.ts:160-167 — only the TEXT-mode case is pinned (line 208). The JSON-mode branch allocates a JsonFormatter, picks customErrorCode ?? error.exitCode, calls formatter.formatError, writes via writeStderrLine, and exits with getNumericExitCode. None of that is exercised.
A refactor of JsonFormatter.formatError or the exit-code resolution path could silently break JSON-mode error reporting for AlreadyReportedError — exactly the regression category this PR set out to make robust. The failure surface is "headless JSON consumers see the wrong exit code or a malformed error envelope".
Suggested test (sibling to the existing TEXT-mode case):
it('does not reformat or reprint AlreadyReportedError in JSON mode', async () => {
// ...mode=JSON setup...
const reported = new AlreadyReportedError('[API Error: 402 ...]', 42);
await expect(handleError(reported, mockConfig)).rejects.toThrow();
expect(mockWriteStderrLine).toHaveBeenCalledTimes(1);
expect(mockWriteStderrLine.mock.calls[0][0]).toContain('"errorCode":42'); // or whatever JsonFormatter envelope shape
expect(processExitMock).toHaveBeenCalledWith(42);
});via Qwen Code /review
| // "An unexpected critical error occurred:" with a stack trace — that | ||
| // framing is for genuinely unexpected, programmer-level bugs, and a | ||
| // routine 4xx from an upstream API does not qualify. | ||
| if (error instanceof AlreadyReportedError) { |
There was a problem hiding this comment.
[Suggestion] This block — the part of the fix responsible for suppressing the literal An unexpected critical error occurred:\n<stack trace> framing called out in the bug report — has zero test coverage. There is no index.test.ts, and the runNonInteractive regression tests can't reach main().catch (they call runNonInteractive directly).
Deleting these three lines (if (error instanceof AlreadyReportedError) { process.exit(error.exitCode); }) would not fail any test. The control flow falls through to console.error('An unexpected critical error occurred:') plus the stack trace — exactly the original bug.
Two options to pin this:
- Pin the producer end via the regression test. In
nonInteractiveCli.test.ts:902, replacerejects.toBeTruthy()withrejects.toBeInstanceOf(AlreadyReportedError)(see separate inline). That at least guaranteesrunNonInteractivethrows the marker; theindex.tscatch then just has to recognize it. - Add a small
index.test.ts. Mockmainto reject withnew AlreadyReportedError('x', 7); assertconsole.errorwas NOT called with the framing string andprocess.exitwas called with7.
Both together is even better — option 1 pins the contract, option 2 pins the consumer.
via Qwen Code /review
| 'Test input', | ||
| 'prompt-id-double-wrap', | ||
| ), | ||
| ).rejects.toBeTruthy(); |
There was a problem hiding this comment.
[Suggestion] rejects.toBeTruthy() only verifies that something was thrown. It does not pin the contract that the rest of the fix depends on:
index.ts:102short-circuits onerror instanceof AlreadyReportedError. If a refactor of this code path replacesAlreadyReportedErrorwith a plainnew Error(text)while still preventing the double-wrap, this test passes — but theindex.tsshort-circuit silently breaks, and the user is back to seeingAn unexpected critical error occurred:+ stack trace (exactly the symptom the bug report names as item Are you interested in AI Terminal? #4 in the "before" output).error.exitCode === 1is also unverified.
Suggested tightening:
const thrown = await expect(
runNonInteractive(/* ... */),
).rejects.toBeInstanceOf(AlreadyReportedError);
// or if you'd rather assert against the rejected value:
await expect(runNonInteractive(/* ... */)).rejects.toMatchObject({
isAlreadyReported: true,
exitCode: 1,
});This pins the marker contract end-to-end (producer and consumer) without adding a separate index.test.ts.
via Qwen Code /review
|
Thanks, I addressed the follow-up review comments in e3d3f3c. Changes in this pass:
|
| // See the matching note in the first stream loop above — | ||
| // we mark the throw so handleError doesn't reformat or | ||
| // reprint downstream. | ||
| throw new AlreadyReportedError(errorText); |
There was a problem hiding this comment.
[Suggestion] This second throw new AlreadyReportedError(errorText) mirrors the first-turn loop at line 399, but the regression test at nonInteractiveCli.test.ts:867 only exercises the first. Every test in the suite mocks isCronEnabled to false (line 161-162), so the drain-loop branch is unreachable from CI today. A future refactor that changes the throw class here (or drops the throw) would silently re-introduce the [API Error: [API Error: ...]] double-wrap for cron / background-task notification flows, and CI would stay green.
Consider adding a sibling regression test that enables isCronEnabled, seeds a job whose itemStream yields a GeminiEventType.Error event, and asserts the same triple the first-loop test pins: rejects.toBeInstanceOf(AlreadyReportedError), no [API Error: [API Error: substring, and at most one [API Error: per stderr line.
— claude-opus-4-7[1m] via Qwen Code /qreview
| const skipAdapterEmit = | ||
| outputFormat === OutputFormat.TEXT && isAlreadyReportedError; | ||
|
|
||
| if (!skipAdapterEmit) { |
There was a problem hiding this comment.
[Suggestion] The skipAdapterEmit guard is the part of the fix that suppresses the duplicate [API Error: ...] stderr line (per the comment at lines 772-778), but the regression test at nonInteractiveCli.test.ts:867 doesn't directly pin this behavior. The existing assertion (nonInteractiveCli.test.ts:928) counts [API Error: occurrences per line — if a future refactor deletes this guard, two separate stderr writes appear (the stream-error line and the adapter's emit), each with only one prefix per line, and the per-line assertion still passes. The test comment at 874-877 explicitly says "JsonOutputAdapter also emits the result message on the error path, which legitimately hits stderr in TEXT mode" — that's now stale: with this guard the adapter intentionally does NOT emit in TEXT-mode AlreadyReportedError.
A total-count assertion would close the gap. The test already joins all stderr writes into stderrOutput at line 909 — adding the following one-liner pins the contract:
expect(stderrOutput.match(/\[API Error: /g)?.length ?? 0).toBe(1);Also worth refreshing the comment at 874-877 to reflect post-PR behavior.
— claude-opus-4-7[1m] via Qwen Code /qreview
…interactive mode (QwenLM#3749)
…interactive mode (#3749) * fix(cli): stop double-wrapping and double-printing API errors in non-interactive mode In non-interactive (-p) mode, any upstream 4xx ended up on stderr three times: once from the stream-error handler, once from handleError after the thrown Error.message (already containing the formatted text) was fed back through parseAndFormatApiError producing "[API Error: [API Error: ...]]", and once more from JsonOutputAdapter.emitResult writing the same errorMessage out in TEXT mode. The top-level catch then framed the resulting throw as "An unexpected critical error occurred:" with a stack trace, which made a routine 4xx look like a CLI crash. Three coordinated changes: - AlreadyReportedError marks a throw whose message is already on the wire. handleError short-circuits on it: no second writeStderrLine, no second parseAndFormatApiError, just propagate the exit code. - The non-interactive stream-error handler now throws AlreadyReportedError, and the catch block skips the adapter's emitResult in TEXT mode so we don't get a third copy. - The top-level .catch in packages/cli/index.ts treats AlreadyReportedError as a routine, already-reported failure: exit with the carried code without printing the "unexpected critical" framing or the stack trace. parseAndFormatApiError is also made idempotent — input that already starts with "[API Error: " and ends with "]" is returned unchanged. That is the safety net: even if a future caller forgets to mark its throw, the double-wrap symptom is impossible. Tests cover all three layers: idempotency in errorParsing, the short-circuit in handleError, and a regression test on runNonInteractive that asserts no "[API Error: [API Error: ...]" line is ever produced on stderr. Fixes #3748 * fix(cli): make API error formatting idempotent for 429 suffix + use AlreadyReportedError * fix(cli): follow up on error reporting review
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com> Co-authored-by: Aryan Sawant <156219699+aryanjsawant@users.noreply.github.com> Co-authored-by: neo.alienson <neo@01man.com>
…interactive mode (QwenLM#3749) * fix(cli): stop double-wrapping and double-printing API errors in non-interactive mode In non-interactive (-p) mode, any upstream 4xx ended up on stderr three times: once from the stream-error handler, once from handleError after the thrown Error.message (already containing the formatted text) was fed back through parseAndFormatApiError producing "[API Error: [API Error: ...]]", and once more from JsonOutputAdapter.emitResult writing the same errorMessage out in TEXT mode. The top-level catch then framed the resulting throw as "An unexpected critical error occurred:" with a stack trace, which made a routine 4xx look like a CLI crash. Three coordinated changes: - AlreadyReportedError marks a throw whose message is already on the wire. handleError short-circuits on it: no second writeStderrLine, no second parseAndFormatApiError, just propagate the exit code. - The non-interactive stream-error handler now throws AlreadyReportedError, and the catch block skips the adapter's emitResult in TEXT mode so we don't get a third copy. - The top-level .catch in packages/cli/index.ts treats AlreadyReportedError as a routine, already-reported failure: exit with the carried code without printing the "unexpected critical" framing or the stack trace. parseAndFormatApiError is also made idempotent — input that already starts with "[API Error: " and ends with "]" is returned unchanged. That is the safety net: even if a future caller forgets to mark its throw, the double-wrap symptom is impossible. Tests cover all three layers: idempotency in errorParsing, the short-circuit in handleError, and a regression test on runNonInteractive that asserts no "[API Error: [API Error: ...]" line is ever produced on stderr. Fixes QwenLM#3748 * fix(cli): make API error formatting idempotent for 429 suffix + use AlreadyReportedError * fix(cli): follow up on error reporting review
Summary
-p) error pipeline so an upstream 4xx prints exactly one cleanly-formatted line to stderr and exits with a non-zero code, instead of three lines (the second double-wrapped) followed by a stack trace under "An unexpected critical error occurred:".handleError, andJsonOutputAdapter.emitResultin TEXT mode — withparseAndFormatApiErrorre-running on its own already-wrapped output along the way.packages/cli/src/utils/errors.ts(the newAlreadyReportedErrormarker +handleErrorshort-circuit) and the catch block inpackages/cli/src/nonInteractiveCli.ts(skips adapter emit in TEXT mode for already-reported errors). The idempotency guard inparseAndFormatApiErroris the safety net so a future caller can't regress the double-wrap symptom by forgetting to mark its throw.Validation
Commands run:
Prompts / inputs used:
nonexistent-model-12345against DeepSeek's API to force a deterministic 400 from a real provider, plus the new vitest cases for the unit layers.Expected result (after the fix):
Observed result: matches expected. Single line, no double-wrap, no
An unexpected critical error occurred:framing, exit code is 1. A successful happy-path call with the same flags continues to print only the model output and exit 0 — verified with a known-good model on a separate provider.Quickest reviewer verification path: run the DeepSeek command above (any OpenAI-compatible endpoint with a wrong model name will reproduce the same 4xx; what matters is exactly one stderr line, no double-wrap, no stack trace, exit code != 0). Then
git stashthe patch and re-run to see the three-line / double-wrap baseline.Evidence:
Before:
After:
All targeted vitest suites pass:
errorParsing.test.ts: 14/14 (3 new idempotency cases)errors.test.ts: 39/39 (1 newAlreadyReportedErrorshort-circuit case)nonInteractiveCli.test.ts: 30/30 (1 new regression case asserting no double-wrap on stderr)Pre-existing UI snapshot failures in
StatsDisplay,SessionSummaryDisplay, andArenaCardsreproduce on a clean checkout ofmainand are unrelated to this change.Scope / Risk
parseAndFormatApiErrorexactly once and stores the formatted string as a pending UI item) — no change there. The streaming-without-finish_reasonpath that surfaces "Model stream ended without a finish reason." flows through the same handler now and benefits from the same de-duplication; no separate test added for that variant since it shares the regression test path.AlreadyReportedErroris a new export from./utils/errors.jsfor callers that want to opt in; existing throws are unaffected.Testing Matrix
Testing matrix notes: developed and validated end-to-end on macOS 26.2 (Node v24.7.0) via
npm run bundle+node dist/cli.js. The fix is pure TypeScript with no platform-specific code, runtime, or syscall behaviour, so I'd expect parity on Linux/Windows; happy to extend the matrix if a maintainer prefers.Fixes #3748