Fix: release the start-up watchdog timer and listeners in BaseTestServer.startProcess() - #375
Open
AmaadMartin wants to merge 5 commits into
Open
Fix: release the start-up watchdog timer and listeners in BaseTestServer.startProcess()#375AmaadMartin wants to merge 5 commits into
AmaadMartin wants to merge 5 commits into
Conversation
added 5 commits
July 31, 2026 01:26
…artProcess The start handshake attached a watchdog timer, a stdout listener and an 'exit' listener to the spawned child and never released them. The timer stayed ref'd for up to 60s after a successful start, the stdout listener kept growing an unread buffer and kept rewriting this.port from any later loopback URL, and the 'exit' listener printed a crash-looking "<server> exited with code null" line on every clean SIGINT teardown. Release all three from a finally block so cleanup also runs on the timeout and premature-exit paths, keep stdout draining with an explicit resume(), and broaden the URL read-back to 127.0.0.1 and [::1] so the Go backend's banner is recognised. The 'error' and stderr listeners stay attached on purpose: an 'error' event with no listener is thrown by EventEmitter.
Adds a hermetic suite that drives BaseTestServer through a `node -e` child: it asserts the watchdog handle is passed to clearTimeout, that the stdout/'exit' listeners are detached (and the 'error'/stderr ones are not) on the success, timeout and premature-exit paths, that a clean stop() logs no exit line, that a post-start URL no longer rewrites the port, and that the banner read-back works for localhost, 127.0.0.1 and [::1].
Dropping the two off() calls left the clean-shutdown case logging the premature-exit diagnostic instead of the old exit line, which the /exited with code/ assertion alone did not catch.
The inline start-handshake option type was repeated at two call sites, and the fake server's constructor port happened to share the watchdog timeout's literal value, which made the read-back assertions hard to read.
…elease closure Removing the last 'data' listener does not switch a Readable out of flowing mode, so the paired resume() could never do anything; a comment now records why detaching is safe. stdoutChunks is a local referenced only by the two detached handlers and the release closure, so clearing it freed nothing. The closure is now the three operations that matter. Verified unchanged behaviour: all 9 cases in tests/integration/test_case_utils_test.ts still pass (including the stdout.isPaused() === false assertion), dropping the two off() calls still fails 4 of them, and emptying stdoutChunks inside onExit still fails the premature-exit diagnostic test.
This was referenced Aug 2, 2026
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
Closes: #issue_number
Related: #issue_number
Problem:
BaseTestServer.startProcess()(tests/integration/test_case_utils.ts) attaches four things to the freshly spawned child during the start handshake and releases none of them when the handshake settles:setTimeout(..., timeoutMs)handle was never captured and never cleared.timeoutMsis 60000 forAdkTsApiServer(tests/integration/test_api_server.ts) and 30000/60000 forAdkGoServer(tests/cross_language/a2a/ts_go/go_backend/go_server.ts,tests/cross_language/a2a/ts_go/ts_a2a_go_test.ts), so a ref'd handle survived in the vitest worker for up to a full minute after a successful start. Its callback was a dead branch by then (startedwastrue).'exit'listener that was never detached.stop()sendsSIGINT, so the handler fired on every cleanafterAlland printed a crash-looking"<serverName> exited with code ..."line. Reproduced below.stdoutChunksis only read on the premature-exit path, but the'data'handler kept pushing to it for the whole lifetime of the server. Worse, the URL regex kept running on post-start output, so any later log line containing a loopback URL silently rewrotethis.port/this.urlmid-suite.localhosthosts./http:\/\/localhost:([0-9]+)/icannot match the Go backend'sA2A Server started on http://127.0.0.1:<port>banner (tests/cross_language/a2a/ts_go/go_backend/server.go), so the read-back path was dead forAdkGoServerand for any subclass binding127.0.0.1or[::1].This is test-infrastructure only. Nothing under
core/src,dev/srcorintegrations/srcis touched, there is no public API change, and both existing subclasses callstartProcesswith a byte-identical argument shape.Solution: Give the handshake a single
releaseStartHandshake()closure and call it from afinally, so cleanup runs on the success path and on both rejection paths (timeout, premature exit). It clears the watchdog and detaches the stdout'data'and'exit'listeners — three lines, nothing else. The URL regex moves to a module-levelSERVER_URL_REGEXnext to the other regex constants and now acceptslocalhost,127.0.0.1and[::1].Design notes, and things deliberately not changed:
'error'listener and the stderr'data'listener stay attached on purpose.ChildProcessinheritsEventEmitter's special handling of'error': an emitted'error'with no listener is thrown, which would crash the vitest worker — and akill()that fails emits one after the handshake. Rejecting an already-settled promise from it is a documented no-op. The stderr listener stays both for diagnostics and to keep stderr draining. Test 2 below pins both retentions so a future cleanup sweep cannot remove them by accident.'data'listener does not switch aReadableout of flowing mode (onlypause()does), so stdout keeps draining and the child never blocks on a full ~64 KB pipe. An earlier revision paired theoff()with an explicitstdout.resume()to document that; since the call is provably a no-op in every reachable state, it is now a comment instead.expect(child.stdout.isPaused()).toBe(false)in test 2 passes either way, which is the independent confirmation.stdoutChunksis not explicitly emptied. The unbounded-growth defect is fixed entirely by detaching the'data'handler: nothing appends to the array after the handshake. It is a local ofstartProcessreferenced only by the two now-detached handlers and the release closure, so it becomes unreachable the moment the method returns — an explicitstdoutChunks.length = 0would free nothing, and leaving it out keeps the premature-exit diagnostic trivially safe (test 7).startedflag is gone. WithonExitdetached at settle time it can no longer fire post-start, so the!startedguards were dead weight. Thefinallyruns in the microtask followingresolve(), strictly before the next I/O event, so no exit can slip through the gap.console.error("<name> exited with code <code>")line is dropped; it was the reported noise. A premature exit is still reported, via the captured-stdout diagnostic plus the rejection.successLogMessageline, and the resolvedport/urlfor both existing subclasses are unchanged.stop()is untouched.stop()'s unconditional 500 ms sleep,getResponse()'s missing timeout, handling astartMessagesplit across two stdout chunks, and killing the child on the failure path (afterAllstill callsstop()whenbeforeAllthrows).Collision check: ran
gh pr list --repo AmaadMartin/adk-js --state open --limit 100(100 open PRs) andgh pr diff <n> --name-onlyover every plausibly adjacent test-infrastructure PR (#359, #349, #343, #316, #314, #311, #308, #307, #306, #305, #304, #300, #299, #297, #296, #281, #276, #329). None of them touchestests/integration/test_case_utils.ts,tests/integration/test_api_server.ts,tests/cross_language/**or the Go backend. No overlap, so this branches frommainrather than stacking.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.
New file
tests/integration/test_case_utils_test.ts(9 cases, picked up by the existingintegrationvitest project — no config change). No existing test file was modified or deleted. It drivesBaseTestServerthrough a hermetic, cross-platformnode -echild spawned withprocess.execPath(no shell string, no POSIX-only binary, no literal newlines in the argv element).Proof the tests can fail. Every new case was run against mutated source; the mutation and its failure message:
startProcesshunk (git checkout main -- tests/integration/test_case_utils.ts)clears the start-up watchdog...→expected "clearTimeout" to be called with arguments: [ …(1) ];detaches the start-up listeners...andrejects and releases the handshake when the server never starts→expected 1 to be +0;does not log an exit message...→expected 'Fake exited with code null' not to match /exited with code/;ignores URLs printed after start-up→expected 49999 to be 41111; the127.0.0.1and[::1]banner rows →expected 19999 to be 41234. The two that (correctly) still pass are thelocalhostbanner row (unchanged behaviour) and the premature-exit guard.clearTimeout(startTimer)onlyclears the start-up watchdog once the server reports success→expected "clearTimeout" to be called with arguments: [ …(1) ]off(...)calls onlydetaches the start-up listeners...→expected 1 to be +0;does not log an exit message when the server is stopped cleanly→expected 'Fake Captured stdout before premature…' to be '';ignores URLs printed after start-up→expected 49999 to be 41111;rejects and releases the handshake when the server never starts→expected 1 to be +0SERVER_URL_REGEXback tolocalhostonlyhttp://127.0.0.1:41234andhttp://[::1]:41234rows →expected 19999 to be 41234stdoutChunksat the top ofonExit(destroy the diagnostic before it is built)rejects with the captured stdout when the server exits prematurely→expected 'Fake Captured stdout before premature…' to contain 'boot log line'Mutation 3 is why the clean-stop case asserts
loggedis''and not only that it does not match/exited with code/: dropping the detach moves the noise to the premature-exit diagnostic, which the narrower assertion missed. That strengthening is its own commit.Coverage.
vitest.config.tsonly instrumentscore/src,dev/srcandintegrations/src, so files undertests/are not measured and this change cannot move the thresholds (they are untouched). Every new branch is instead covered by a direct behavioural test: the release helper on the success path (cases 2, 3, 4), the timeout path (case 6) and the premature-exit path (case 7); the broadened regex on all three host forms (case 5); the watchdog clear (case 1). Every line of the release closure is mutation-pinned (rows 2 and 3 of the table above).Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
This boots the real
AdkTsApiServer(the ADK API server CLI) and tears it down inafterAll. Before/after teardown output, same command, same machine:Before (helper reverted to
main) — the run is green but teardown prints a crash-looking line:After — the
CLI exited with code ...line is gone:(On this Linux box the CLI installs a SIGINT handler and exits
0, so the observed code is0rather thannull; the spurious line is the same defect either way.)Cross-language run with a Go toolchain installed (go1.26.5), exercising
AdkGoServer— the subclass the regex fix targets:Green, the resolved URL still points at the port the Go server actually bound, and no
Go Server exited with code ...line at teardown.CI note. The
run-testsmatrix is green on all three legs of the pushed commit — ubuntu-latest (4m36s), windows-latest (9m38s) and macos-latest (7m35s) — and the new file passes on each (✓ integration tests/integration/test_case_utils_test.ts (9 tests)). An earlier commit on this branch saw macos-latest fail on twotests/integration/app_loader/app_loader_test.tscases withTest timed out in 40000ms; that file's failing cases exerciseAgentLoader.listApps()/AppFile.load()(esbuild compilation) and touch nothing in this diff, the same file fails the same way on macos-latest on an unrelated branch that modifies neithertest_case_utils.tsnorapp_loader, and that run was simply slow (501s total, 241s of it in transform). It has not recurred.Static checks on the pushed commit:
npm run build,npm run lintandnpm run format:checkall pass.npm run ts:checkis red onmaintoday for unrelated reasons (it is not part of.github/workflows/validation.yaml); neither of the two files in this diff contributes an error to it.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.