fix(cli): surface startup warnings on stderr before TUI render (#4448)#4461
Conversation
…M#4448) When settings.json has invalid JSON, the file is renamed to .corrupted.<timestamp> and a warning is added to startupWarnings. Previously these warnings were only rendered inside the TUI's Notifications component, which can be obscured by the onboarding flow ('Connect a provider') — leaving users unaware their settings were silently reset. Now all startup warnings are written to stderr before the TUI takes over. This ensures the message appears in the terminal scrollback regardless of what the TUI shows. In non-interactive mode (--prompt, piped stdin) this is the *only* output channel for these warnings, closing a gap where they were collected but never emitted.
| // onboarding flow can obscure the notification area, leaving users | ||
| // unaware that their settings were reset. Writing to stderr before | ||
| // the TUI takes over ensures the message is visible in the terminal | ||
| // scrollback. In non-interactive mode this is the *only* channel. |
There was a problem hiding this comment.
[Suggestion] The new writeStderrLine loop for startup warnings has no test coverage. The mock infrastructure (mockWriteStderrLine) already exists (used in other test paths), but all warning sources (getStartupWarnings, getUserStartupWarnings, getSettingsWarnings, config.getWarnings) are mocked to return [] in gemini.test.tsx, so the loop body never executes in any test. A regression that removes this loop — the primary fix for #4448 — would not be caught.
Consider adding a test case where one warning source returns a non-empty array and asserting mockWriteStderrLine is called with the expected message.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review
Verification: surface startup warnings on stderr before TUI render (PR #4461)Verdict: ❌ FAIL — the PR's mechanical change is correct, but the user-facing fix it promises does not deliver. The corrupted-settings warning that the PR's description (and issue #4448) says will now appear on stderr does not appear in my runtime tests, because at the point the new for-loop runs Claim (from PR): Before — TUI shows "Connect a provider" with no visible error. After — stderr shows Method: Cold start. Branch Steps
Sample (one frame)Findings
Recommendation for the maintainerDon't merge as-is. Either: (a) Add a direct runtime test that reproduces the user-facing flow (broken (b) Investigate why (c) Land the for-loop on its own (it's harmless and helps the OAuth-expiry and other startup-warning sources that DO populate the array), and open a separate issue for the corrupted-settings propagation bug — which would then justify the merge by acknowledging this PR doesn't close #4448 yet. Environment: Linux x86_64, Node v22.22.2, npm 10.9.7, tmux 3.5a. HEAD = |
|
Thank you for the incredibly detailed verification @wenshao — this is a really valuable analysis. You're right that the for-loop itself is mechanically correct (step 4 proves it), but the corruption warning never reaches I think option (c) from your recommendation makes the most sense: land the for-loop as-is (it's harmless and already helps OAuth-expiry and other warning sources), update the PR description to acknowledge it doesn't fully close #4448, and open a separate issue for the propagation bug investigation. Let me know if you'd prefer a different approach. |
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review
|
Surface settings.json parse warnings on stderr before TUI renders, so users see diagnostic output instead of it being swallowed by the onboarding flow. |
wenshao
left a comment
There was a problem hiding this comment.
Built this PR locally (92bfe37) and ran the manual scenario from the PR description against both main and this branch. The corrupted-settings warning does not reach stderr in the standard launch flow — behavior is identical to main.
Test matrix
Fixture: ~/.qwen/settings.json containing { "ui": { theme: "Default" } } (unquoted key, invalid JSON). Fixture reset before every run; stderr redirected to a file; ANSI stripped before grepping.
| Mode | main stderr |
this PR stderr |
|---|---|---|
| Interactive (TTY) | no warning | no warning |
--prompt non-interactive |
no warning | no warning |
Any mode with QWEN_CODE_NO_RELAUNCH=true |
no warning | warning visible |
The new for (warning of startupWarnings) writeStderrLine(warning) loop only fires when QWEN_CODE_NO_RELAUNCH=true is set (an internal env var the parent injects into the child for in-process restarts). End users never set this, so they still see no warning at all.
In the default interactive run, the stderr file contains only \e[?25h (6 bytes), yet ~/.qwen/settings.json.corrupted.<ts> is created — confirming the parent did detect corruption and rename the file. The warning is just never surfaced.
Root cause
relaunchAppInChildProcess at gemini.tsx:580 runs before the new loop at line 771:
parent main():
423: loadSettings() ← detects bad JSON, renames file, populates migrationWarnings
580: await relaunchAppInChildProcess(...)
└─ spawns child, awaits, then process.exit(child_code) inside relaunchOnExitCode
◀── parent never reaches line 771
child main() (QWEN_CODE_NO_RELAUNCH=true):
423: loadSettings() ← settings.json already renamed by parent → migrationWarnings is empty
580: relaunchAppInChildProcess is a no-op
745: startupWarnings = []
771: for (warning of []) writeStderrLine(...) ← no-op
Parent has the warning but exits before line 771. Child reaches line 771 but has nothing to emit because the parent already cleaned up the corrupted file. Nothing propagates migrationWarnings across the parent→child boundary.
Test coverage
The PR description says settings.test.ts covers the corrupted-settings path, but that test only asserts migrationWarnings is populated on the LoadedSettings object — not that it reaches stderr. No new test exercises the parent→child→TUI flow. An e2e test that spawns the built CLI with a broken settings fixture and asserts on stderr content would catch this regression.
Suggested fixes
- Recommended: move the new loop before line 580 (the relaunch). The parent then emits the warning before spawning the child; the child sees empty warnings and the loop is a no-op there.
- Or: have the parent pass
migrationWarningsto the child via env var / temp file, and emit from the child only. - Or: defer the rename to the child, so the child itself walks the corruption branch and the warning is generated where the new loop already lives.
Whichever route, please add a regression test that drives the actual launcher with a broken settings fixture and asserts on stderr — otherwise this category of bug will keep slipping past CI.
Repro
mkdir -p /tmp/qwen-test-home/.qwen
printf '{\n "ui": { theme: "Default" }\n}\n' > /tmp/qwen-test-home/.qwen/settings.json
# build this PR's CLI, then:
HOME=/tmp/qwen-test-home timeout 5 node packages/cli/dist/index.js 2>err.log >/dev/null || true
strings err.log | grep -i 'invalid\|corrupted\|warning' || echo "(no warning emitted)"
ls /tmp/qwen-test-home/.qwen/ | grep corrupted # confirms file was renamed…faces them Move getSettingsWarnings() stderr emission to right after loadSettings(), before the sandbox/relaunch block. This ensures the parent process prints corruption warnings before relaunchAppInChildProcess() spawns the child and exits. Add regression test verifying getSettingsWarnings returns non-empty, human-readable warnings containing 'invalid JSON' when settings.json has broken content. Addresses review feedback from @wenshao on QwenLM#4461. Fixes QwenLM#4448 Signed-off-by: kagura-agent <[email protected]>
|
Thanks for the thorough root-cause analysis @wenshao — you're absolutely right that the parent exits at Fix (option 1 as you recommended): Moved the settings warning emission to right after Regression test added: New test in Pushed as |
|
Hi @wenshao — this has been APPROVED for a few hours now. Could you merge it when you get a chance? Happy to rebase if needed. Thanks! |
|
I'll re-test and verify whether the fix actually surfaces the corruption warning on stderr in the standard launch flow. Will update the review status afterward. |
Local end-to-end test report — PR 4461 verification (for merge reference)I checked out Environment
Actual scope of the PR
Two insertion points:
Checks summary
Honest end-to-end smoke (reproducing #4448's path)Under an isolated {
theme: "Dark",
selectedAuthType: "openai"
}Then exercised 4 launch modes. All hit the expected code path and the observed behavior matches the PR's claims:
These four scenarios jointly confirm the key claim in the PR description regarding #4448:
Real captured stderr (scenario 1, The file was indeed renamed to One minor follow-up worth tracking (non-blocking)The PR emits stderr at two sites and relies on an implicit timing contract — "in the normal path the parent is replaced by the child via End users do not normally set
…the duplicate emit will surface immediately. Suggested minimal dedup: // At the second for-loop, filter out warnings already emitted by the early site
const earlyEmitted = new Set(settingsWarnings);
for (const w of startupWarnings) {
if (earlyEmitted.has(w)) continue;
writeStderrLine(w);
}Or: keep a single emit site at line 779 and move the first emit into the Conclusion / merge recommendationThe core problem this PR addresses (#4448: "after Recommend merge, with the following (non-blocking):
|
|
Hi @wenshao — thanks for the approval! Could this be merged when convenient? Happy to rebase if needed. |
Summary
When
settings.jsonhas invalid JSON, the file is silently renamed to.corrupted.<timestamp>and a warning is generated. Previously these warnings were only rendered inside the TUI'sNotificationscomponent, which can be obscured by the onboarding flow ("Connect a provider") — leaving users unaware their settings were silently reset.This change writes all startup warnings to stderr before the TUI takes over, ensuring the message appears in the terminal scrollback regardless of what the TUI shows. In non-interactive mode (
--prompt, piped stdin) this closes a gap where warnings were collected but never emitted.Closes #4448
Validation
eslint --fix --max-warnings 0✅prettier --write✅ (pre-commit hook)settings.test.tsconfirms the warning generation path (corrupted settings →migrationWarnings) is testedManual test scenario
~/.qwen/settings.json(e.g. remove quotes around a key)qwenSettings file ... has invalid JSON and was renamed to ... Your settings have been reset.Scope / Risk