Fix: flush the CLI start-up diagnostic to stderr before exiting - #591
Open
AmaadMartin wants to merge 3 commits into
Open
Fix: flush the CLI start-up diagnostic to stderr before exiting#591AmaadMartin wants to merge 3 commits into
AmaadMartin wants to merge 3 commits into
Conversation
added 3 commits
August 3, 2026 13:54
`adk web` and `adk api_server` logged a start-up failure through winston and then called `process.exit(1)` in the same synchronous block. `process.exit()` discards pending asynchronous writes and writes to a pipe are asynchronous on Windows, so the only explanation of the failure was routinely dropped there -- which is why a spawned server that dies during start-up reports nothing but its exit code in CI. The record also went to stdout rather than stderr, and only carried `.message`, so a bundling failure lost its location. Both catch blocks now render the error (preferring its stack) and await the stderr write before exiting. The exit code stays 1.
… tests Reach AdkApiServer.start() through one hoisted mock instead of replacing the constructor per test, so injecting a rejection needs no `as unknown as Mock`. Existing assertions on the instance's start() are unaffected.
flushStderr had one caller, so the named indirection bought nothing that the comment does not already say; fold it into exitOnStartupFailure. Also drop the `: unknown` annotations on the two catch bindings -- the tsconfig resolves useUnknownInCatchVariables to true, so they were no-ops that made two otherwise untouched lines part of the diff.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
No existing issue. This targets the intermittently red
windows-latestleg of therun-testsmatrix in.github/workflows/validation.yaml.Problem: when
adk weboradk api_serverfails to start, the reason is routinely lost — and on Windows it is lost silently, which is what makes a flaky CI leg undebuggable.Both commands did the same thing (
dev/src/cli/cli.ts:253-256and:298-301onmain):Three defects in three lines:
process.exit()outruns the write. Node documents thatprocess.exit()terminates "even if there are still asynchronous operations pending … including I/O operations toprocess.stdoutandprocess.stderr", and that writes to pipes are asynchronous on Windows (synchronous on POSIX). The log call and the exit are in the same synchronous block, so on Windows the diagnostic is very likely dropped. Winston adds at least a tick on top of that, because the record goes through a stream pipeline before it reaches the fd. On a POSIX dev machine the message lands; on a Windows runner writing to a captured pipe it does not — which is exactly the shape of "fails only on Windows, and says nothing when it does".AdkLogger's Console transport (dev/src/utils/logger.ts:72) is constructed withoutstderrLevels, so winston routes error-level records to stdout. A parent process listening on stderr for a failure reason hears nothing. (FixingAdkLoggerin general is out of scope here and is queued separately; this change stops the two fatal start-up paths depending on it.).messagesurvived.AdkApiServer.start()rejects for two very different reasons — the listen failed (EADDRINUSE/EACCES), orinitA2A()threw while esbuild-bundling the agent fixtures.(error as Error).messagethrows away the stack, so the second case loses its location entirely.This is the child-process half of the
CLI exited prematurely with code 1failure: exit code1is produced in exactly this one place on theapi_serverpath, and the message explaining it never reached the pipe.Solution: render the error preferring its stack, write it to stderr, and await the flush before exiting. One module-level helper in
dev/src/cli/cli.ts, shared by both commands so the logic is not duplicated in each catch block:Notes on the shape:
process.exit()we are about to call. Every otherlogger.errorcall site incli.tsis untouched.write's callback fires when the chunk is flushed, including after backpressure drains, so an extra'drain'listener would be redundant.exitOnStartupFailurereturnsPromise<never>; the exit code stays1, and the oldas Errorcast disappears rather than moving. Thecatch (error)bindings are left exactly as they were — this tsconfig resolvesuseUnknownInCatchVariablestotrue, soerroris alreadyunknownand an explicit annotation would be a no-op that dirties two otherwise untouched lines.'ADK CLI'label is now one constant used by both the logger and the diagnostic, so the two cannot drift.flushStderrhelper: it has exactly one caller, so the function boundary bought nothing the comment does not already say.error instanceof Erroris the repo's existing idiom for narrowing a caught value (10 sites incore/src, e.g.core/src/tools/function_tool.ts:166). The "noinstanceof" rule targets ADK/SDK classes that can exist as two copies in one runtime;Erroris a realm intrinsic.Scope — this PR is deliberately one part of a larger plan, because two sibling PRs already land the rest. Stated explicitly so the omission is not read as an oversight:
startProcessfailures, OS-assigned ports, settle-once + timer cleanup,stop()awaits exittests/integration/test_case_utils.ts,test_api_server.ts,go_server.ts) — CI-green onwindows-latestAdkApiServer: publish the bound port in A2A agent cards when--port 0dev/src/server/adk_api_server.ts)Collision check (recorded as required):
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000returned 489 open PRs. I rangh pr diff --name-onlyover all 489 and filtered fordev/src/cli/cli.ts/dev/test/cli/cli_test.ts: 23 PRs touch them (#285, #289, #328, #333, #346, #358, #363, #385, #422, #433, #447, #448, #451, #455, #485, #511, #540, #547, #548, #550, #585, #586, #587). I then grepped every one of those diffs for addedprocess.exit/stderr.write/Error starting …/flushStderrlines. None touches theweb/api_servercatch blocks. The nearest, #455, setsprocess.exitCode = 1on the deploy commands. Separately, #546 and #300 (see the table above) land the harness andAdkApiServerhalves; I did not reimplement either, and I did not stack on them because this change shares no file with either PR — branching frommainkeeps the diff conflict-free and lets the fork'spull_request: branches: [main]workflow actually run.CI on this branch (fork, commit
42b2a631; the simplification commitd1dd33a0re-runs it):run-tests (windows-latest)pass (9m13s),run-tests (macos-latest)pass,run-tests (ubuntu-latest)pass,run-tests(cross-language) pass,check-licensepass.The first attempt was red on
macos-latest:tests/integration/app_loader/app_loader_test.ts > should discover apps vs agents across directories and standalone filestimed out at 40000 ms. That test is nowhere near this diff (this PR touches onlydev/src/cli/cli.tsanddev/test/cli/cli_test.ts), it is a known intermittent timeout on the macOS/Windows runners with several open PRs against it, andubuntu-latestwas green on the same commit. Because the matrix has nofail-fast: false, that failure cancelled thewindows-latestleg — which is the leg that matters here — so I re-ran the workflow rather than changing anything, and all three legs passed on the same commit.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Six new cases in
dev/test/cli/cli_test.ts(24 → 30).A third commit applies the complexity review:
flushStderris inlined into its single caller, and the two redundantcatch (error: unknown)annotations are reverted so those lines leave the diff entirely (net -13 lines incli.ts). All seven mutations below were re-run against the restructured source and still fail; every command, number and coverage figure quoted here is from after that commit.npx tsc --noEmit(npm run ts:check) reports 2853 errors onmainand 2853 with this change — none of them in either file this PR touches. That gate is pre-existing and is the subject of several other open PRs.npx vitest run --project unit:dev dev/test/clialso reports one failure,cli_create_test.ts > should handle Vertex AI selection with gcloud defaults. It fails identically on a cleanmain(verified withgit stash) — it is the known ambient-GOOGLE_CLOUD_*hermeticity bug, in a file this PR does not touch.Prove each test can fail. Each mutation was applied to the fixed source, the suite run, the mutation reverted; the suite is green again afterwards. Run one at a time.
api_servercatch (logger.error(...)+ bareprocess.exit(1))AssertionError: expected '' to contain '[ADK CLI] Error starting API server: 'webcatchshould write the web server failure and its stack to stderrAssertionError: expected '' to contain '[ADK CLI] Error starting web server: 'void new Promise(...)instead ofawait new Promise(...)around the writeshould exit only once the stderr diagnostic has flushedAssertionError: expected "spy" to not be called at all, but actually been called 1 timeserror.messageinstead oferror.stackAssertionError: expected '[ADK CLI] Error starting API server: …' to contain 'Error: Port 41234 is already in use\n…'Errorbranch ((error as Error).stack ?? (error as Error).message)should report a rejection value that is not an ErrorAssertionError: expected '…API server: …' to contain '…API server: the agent directory does not exist'error.stack ?? '')should fall back to the message when the error carries no stackAssertionError: expected '…API server: ' to contain '…API server: Port 41234 is already in use'port: parseInt(options['port'], 10) || 8000should forward --port 0 to AdkApiServer unchangedAssertionError: expected 8000 to be +0Mutation 3 is the one that pins the actual Windows fix: with a withheld flush callback the test asserts
process.exithas not been called yet, so a fire-and-forget write fails it. Mutations 1 and 2 pin that the diagnostic reaches stderr at all (onmainit goes to stdout through winston, so the capture is empty).Coverage.
dev/src/cli/cli.tsmeasured with--coverage.include='dev/src/cli/cli.ts'over this test file: 95.32% → 96.23% statements, 73.17% → 78.00% branches. Every statement and branch of the new code (lines 195-222) is executed — verified againstcoverage-final.json, not just the summary. The two lines v8 still reports uncovered in the changed region are the}afterawait exitOnStartupFailure(...)in each catch block: the helper returnsPromise<never>, so the continuation is unreachable by construction, in tests and in production alike. That is a synthetic-branch artifact of the return type, and I did not restructure the code (e.g. by having the helper return normally) to make the number move.No new suppressions.
git diff main -U0 | grep -E '@ts-expect-error|@ts-ignore|eslint-disable|as any|as never|as unknown as|: any'returns nothing. Theprocess.exitstub is typed without a cast by throwing (a function that only throws infersnever), which also stops the test continuing past a point the real process never returns from. Injecting astart()rejection is done through one hoisted mock (second commit) instead of replacing the mocked constructor per test, which removes theas unknown as Mockthe first draft needed; all existing assertions oninstance.startare unchanged and still pass.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Before (
main) — exit1;/tmp/err.txtempty,/tmp/out.txt:After (this branch) — exit
1;/tmp/out.txtempty,/tmp/err.txt:The same holds for
web([ADK CLI] Error starting web server: …). The happy path is unchanged —api_server … --port 0still prints theADK API Server startedbanner with the bound port on stdout, which is what the integration harness matches on.Windows caveat, stated plainly. The dropped-write behaviour this fixes is Windows-specific and cannot be reproduced on a Linux host: locally, both before and after, the text arrives. What is verified locally is everything that is verifiable here — the diagnostic now goes to stderr rather than stdout, carries the stack, and the process does not exit until the write has flushed (mutation 3). The Windows claim rests on Node's documented
process.exit()and pipe-write semantics, plus thewindows-latestCI leg on this branch.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.