Fix: allocate integration test-server ports from the OS and attach child output to start-up failures - #546
Open
AmaadMartin wants to merge 5 commits into
Open
Fix: allocate integration test-server ports from the OS and attach child output to start-up failures#546AmaadMartin wants to merge 5 commits into
AmaadMartin wants to merge 5 commits into
Conversation
added 3 commits
August 2, 2026 15:16
…ild output to start-up failures The windows-latest validation leg failed intermittently from two unrelated causes. BaseTestServer guessed its port as 40000 + random(10000) and passed the guess to the spawned CLI as an explicit --port. Nothing checked the guess was bindable, so it could already be held by another concurrent vitest worker or fall inside a TCP range Windows reserves for Hyper-V/WinNAT; a failed bind is fatal to the child, which is what 'CLI exited prematurely with code 1' was. reserveFreePort() now binds port 0, reads the assignment back and releases it before the child is spawned, so the port is one the OS just confirmed free. The rejected Error carried only the exit code: stdout went to a separate console.error and stderr was logged per chunk and dropped. Both streams are now accumulated and embedded in the rejection, along with the terminating signal, so a CI log line is enough to diagnose the failure. The handshake matches the banner against the accumulated buffer rather than a single chunk, rejects on 'close' rather than 'exit' so the capture is complete, and routes every settle path through one settle() that clears the timer and detaches its listeners. stop() now waits for the child's 'close' event with a bounded SIGKILL escalation instead of sleeping a fixed 500 ms while the child may still hold its port. The shell case in unsafe_local_code_executor_test.ts spawns a real PowerShell host on Windows but inherited Vitest's 5000 ms default, which is not a process-start budget. It gets one named per-test constant; no project-level or file-level timeout was raised.
…harness Adds the two cases the first pass left uncovered: an explicitly requested port must be used verbatim rather than re-reserved, and the post-handshake drain test now floods stderr as well as stdout, so the retention gate is exercised on both streams.
The cross-language Go suite spawns `go run .`, which leaves the built binary running as a grandchild holding the inherited stdio pipes. 'close' fires only once those pipes are released, so it can outlive the process stop() is trying to reap: killing the wrapper emitted 'exit' but never 'close', and the afterAll hook hung until its 60s budget expired. 'exit' is the event that means the child is gone, and it is guaranteed after the SIGKILL escalation. The informational listener moves back to 'exit' for the same reason, so a mid-test crash of such a server is still reported. The start-up handshake keeps rejecting on 'close', where waiting for the pipes to drain is the point -- that is what guarantees the captured output in the message is complete. Adds a regression test whose grandchild outlives the assertion window, so it cannot pass by merely outlasting the pipe holder.
added 2 commits
August 2, 2026 15:49
Three new cases encoded POSIX signal semantics and failed on windows-latest. Windows reports the signal a child was *asked* to terminate with, but a self-termination (process.kill(process.pid, ...)) surfaces only as exit code 1, so the signal-naming case now kills from the test. It waits for the harness to reach its spawn closure first, since startProcess awaits the port reservation before the child handle exists -- the reason that case self-signalled at all. Windows emulates SIGINT as unconditional termination, so a child cannot ignore it and the SIGKILL escalation never arms; the escalation case now expects the signal each platform actually produces, and still pins that stop() returns with the child reaped. Windows tears the grandchild down with its parent, so teardown reaped a pid that no longer existed. It now tolerates ESRCH specifically -- that is the outcome the test wants -- and rethrows anything else.
…bstractions Simplifications from the complexity review. The banner-port-adoption path is dead once the port is reserved before the spawn: every subclass hands the child that exact port (--port and TEST_API_SERVER_PORT, or PORT), and neither child can bind anything else -- AdkApiServer rejects on EADDRINUSE rather than rebinding and the Go server log.Fatalf's -- so the parsed port could only ever equal the port we already had. The Go banner could not match the regex at all, since it prints 127.0.0.1 and the pattern requires localhost. The regex, the parse block and the two tests covering that behaviour go with it. reserveFreePort hand-rolled a promise wrapper around listen(); node:events once() already does exactly that and rejects on 'error' by design, and it was imported for stop() already. formatStream/formatCapturedOutput were two functions and two doc comments for one string; excerpt() keeps the part worth naming. successLogMessage restated serverName at every call site and carried nothing the callee lacked. It was also the root cause of the go_server.ts workaround: the argument was evaluated before the port was reserved, so interpolating this.url rendered :0. Logging after the await from serverName + this.url drops the parameter and restores the URL the Go server had lost. TerminationSignal was a one-use alias; its single use now names the derived type inline. Not NodeJS.Signals -- that identifier trips eslint no-undef in this config, which is why the alias existed.
This was referenced Aug 3, 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
No existing issue. This targets the intermittently red
windows-latestleg of therun-testsmatrix in.github/workflows/validation.yaml(os: [ubuntu-latest, windows-latest, macos-latest]).Problem: two unrelated defects make the
windows-latestleg fail with no code change to blame, so contributors cannot tell a real regression from CI noise.Cause 1 — the integration harness guesses its port.
BaseTestServerpicked its port as40000 + Math.floor(Math.random() * 10000)and passed that guess to the spawned CLI as an explicit--port. Nothing checked the guess was bindable.AdkTsApiServeralways lands in that branch — it callssuper('localhost', params.port || 0), so bothport: 0and an omitted port collapsed to the guess. A failed bind is fatal to the child:AdkApiServer.start()rejects from itsserver.on('error')handler, theapi_server/webcommand logs and callsprocess.exit(1), and the harness reportsCLI exited prematurely with code 1. Windows is where a guessed port is most likely to be unusable — the OS reserves TCP blocks for Hyper-V/WinNAT (netsh int ipv4 show excludedportrange protocol=tcp) and several vitest workers draw independently from the same 10000-port range concurrently. Corroboration inside a single failing file:webui_test.tsruns the same assertions twice viadescribe.each, once against the CLI-spawned server (guessed port) and once against the in-processAdkApiServerwithport: 0, which asks the OS for a port — and only the first flakes.Compounding it, the failure was undiagnosable. The rejected
Errorcarried only the exit code; the child's stdout went to a separateconsole.error, and its stderr was echoed per chunk and never accumulated at all, so in an interleaved CI log the reason never reached the error the reporter shows.Cause 2 — a real-interpreter test on a 5000 ms budget.
'should execute shell code and return stdout'runs real code throughUnsafeLocalCodeExecutor, which on Windows spawnspowershellwith-NoLogo -NoProfile -ExecutionPolicy Bypass -File script.ps1. The file lives in theunit:corevitest project, which sets notestTimeoutand therefore inherits Vitest's 5000 ms default — unlike theintegrationproject, which sets 60000 ms explicitly. A PowerShell cold start under V8 coverage instrumentation (npm run test:coverage) on a loaded shared runner is marginal against 5000 ms.Solution:
Cause 1 — remove the guess, do not retry it. New
reserveFreePort(host)bindsport: 0, reads the assignment back offserver.address()and releases the probe, so the port handed to the child is one the OS just confirmed free. It runs beforespawnProcess()because both subclasses readthis.portfrom inside that closure (--portandTEST_API_SERVER_PORT), andmulti_hop_remote_agent.tsreadsTEST_API_SERVER_PORTat child start-up to build its own agent-card URL — so passing--port 0and reading the banner afterwards would not be sufficient. There is deliberately no retry loop around server start-up: the allocation is fixed so the start does not fail.static getRandomPort()is deleted (grep confirms no callers outside the class) andurlbecomes a getter so it can never disagree withport.The handshake now accumulates both streams and embeds them in every rejection, along with the terminating signal. It matches the start message against the accumulated buffer rather than a single chunk, so a split write cannot lose the handshake; it rejects on
'close'rather than'exit', because'close'fires only after the stdio pipes are drained, which is what guarantees the captured output is complete; and every settle path (resolve, error, premature close, timeout) goes through onesettle()that clears the timer and detaches its listeners, so no timer outlives the handshake. The two'data'listeners stay attached for the life of the child so the pipes keep draining — a chatty server would otherwise block on a full 64 KB pipe buffer — and only retention is gated, by acapturingflag.stop()waits for the child's'exit'event with a boundedSIGKILLescalation instead of sleeping a fixed 500 ms while the child may still hold its port. The subscription is taken before the kill so a fast exit cannot be missed, and an already-exited child returns early because the event would never fire again for it.'exit'rather than'close'here is load-bearing, and the first push of this PR got it wrong: the cross-language suite spawnsgo run ., which leaves the built binary running as a grandchild holding the inherited stdio pipes.'close'fires only once those pipes are released, so it can outlive the processstop()is trying to reap — killing the wrapper emitted'exit'but never'close', andts_a2a_go_test.ts'safterAllhung until its 60 s budget expired (Error: Hook timed out in 60000ms).'exit'means the child itself is gone and is guaranteed after the SIGKILL escalation. The start-up handshake still rejects on'close', where waiting for the pipes to drain is exactly the point.One line in
go_server.tsfollows:successLogMessageinterpolatedthis.urleagerly, i.e. before the port is allocated, so it now reads'Test Go Server started'.Cause 2 — one per-test budget. A single named, doc-commented constant
REAL_INTERPRETER_TIMEOUT_MS = 40000applied as the third argument to that oneit(). Neither the project-level nor the file-level timeout was raised, and the mocked-spawncases in the nesteddescribe('spawn arguments')keep the 5000 ms default. The value sits aboveUnsafeLocalCodeExecutor's own defaulttimeoutSeconds ?? 30on purpose, so a genuinely stuck interpreter reports the executor's own timeout message instead of an opaque Vitest timeout.Simplifications from the complexity review (a fifth commit,
2696ca3b):http://localhost:<n>out of stdout and adopt it) is now unreachable, so it is deleted along with its regex and its two tests. Once the port is reserved before the spawn, every subclass hands the child that exact port, and neither child can bind anything else:AdkApiServerrejects onEADDRINUSErather than rebinding (adk_api_server.ts:975-982) and the Go serverlog.Fatalfs (go_backend/server.go:25-27). The Go banner could not match the pattern in any case — it prints127.0.0.1and the regex requiredlocalhost. The parsed port could only ever equal the port we already had.reserveFreePortno longer hand-rolls a promise aroundlisten();node:events'once()does exactly that and rejects on'error'by design, and it was already imported forstop().formatStream/formatCapturedOutputwere two functions and two doc comments for one string;excerpt()keeps only the part worth naming.successLogMessageis gone. It restatedserverNameat every call site, and it was the root cause of thego_server.tschange in the first place: the argument was evaluated before the port was reserved, so interpolatingthis.urlrendered:0, which my first pass worked around by deleting the URL from the string. Logging after the await fromserverName+this.urlremoves the parameter and restores the URL the Go server had lost. This does touchtest_api_server.ts, which the plan asked to leave alone — the plan's reason was that it already readsthis.portlazily, which is still true; removing a parameter it passes is unavoidable and is called out here rather than left silent.TerminationSignalalias is inlined at its single use. Note it is inlined asChildProcessWithoutNullStreams['signalCode'], notNodeJS.Signals | null: the bareNodeJSidentifier fails this repo'sno-undeflint rule, which is why the alias existed at all.Scope: test-infrastructure only. No file under
core/src,dev/srcorintegrations/srcchanges, andcoverage.includeis limited to those three trees, so the coverage thresholds are untouched by construction.package.json/package-lock.jsonare not touched.Collision check (recorded as required):
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000returned 445 open PRs; I then rangh pr diff --name-onlyover all 445 rather than filtering by title, and found 27 that touch these files. Substantive overlaps: #218 (fix/flaky-install-bound-integration-suites) adds areserveFreePortwith the same signature plus thego_server.tsline; #545 adds the same allocation asgetFreePortwith a bounded capture; #375 reworks the same handshake for listener cleanup; #224, #498, #254 and #61 all give the shell test a larger budget. None of them is merged, all six are still open, and none changesstop(). I did not branch from or stack on any of them, since stacking would mean picking one of three mutually-conflicting unmerged rewrites of the same region as a base. Whoever triages this should dedupe against #218/#545 for cause 1 and #224/#498 for cause 2 — only one of these should land.CI on this branch (fork, commit
14b2475d):run-tests (windows-latest)pass (9m28s),run-tests (ubuntu-latest)pass,run-tests (macos-latest)pass,run-tests(cross-language) pass,check-licensepass.Two rounds of red on the way there, both caused by this change and both fixed rather than worked around, which is worth recording because each was a genuine defect the local Linux run could not have caught:
stop()waiting on'close'hung the cross-languageafterAll(see above). Fixed by reaping on'exit'; the diagnosis was then reproduced and re-verified locally.windows-latest. Windows reports the signal a child was asked to terminate with but surfaces a self-termination only as exit code 1, so the signal-naming case now kills from the test; Windows emulates SIGINT as unconditional termination so the SIGKILL escalation never arms there, and that case now expects the signal each platform actually produces while still pinning thatstop()returns with the child reaped; and Windows tears the grandchild down with its parent, so teardown now toleratesESRCHspecifically and rethrows anything else. No suite is skipped and no timeout was widened to accommodate them.Size note: the diff is ~594 insertions, but ~400 of those are the new test file that proves the fix, and the production-side change is 169/-52 lines in one harness file plus 30/-13 and 1/-1. I did not split it into a stack because separating the tests from the code they pin would make both halves unreviewable, and a stacked part would get no CI on the fork (the workflow triggers on
pull_request: branches: [main]).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(18 cases). It does not depend on a built CLI — a localScriptedTestServersubclass spawnsprocess.execPath -e <script>, which is portable and needs no shell quoting. It records the child handle andthis.portfrom inside its own spawn closure, so nothing reaches into aprotectedmember.Those 3 failures are pre-existing and unrelated —
should materialize input files…,should return only new files…,should infer correct mimeType…fail identically on a clean checkout of the base commit (verified viagit stash:3 failed | 15 passedboth with and without this change). They areFileContentEncodingstring-literal mismatches in test fixtures, in code this PR does not touch. The shell case this PR does touch passes.Coverage of the new harness code, measured with a throwaway config (these files sit outside
coverage.include, so the repo gate is unaffected): 97.50% branch ontest_case_utils.ts, with exactly one uncovered branch —address === null || typeof address === 'string'inreserveFreePort. That is a type-narrowing guard forServer.address()'sAddressInfo | string | nullsignature; thestringform is only produced by IPC servers, which this never is, so it is unreachable for a TCP listen. I kept it rather than delete it or replace it with a non-null assertion: removing a guard to make a number go up is the wrong trade. Every other new branch is executed, including the!capturingretention gate on both streams,parsedPort > 0in both directions, the signal-present and signal-absent close paths, the empty-capture path, tail truncation, and all fourstop()paths.Prove each test can fail — each mutation was applied to the fixed code, the test run, and the mutation reverted:
surfaces both captured streams when the child exits prematurelyAssertionError: expected [Function] to throw error including 'STDERR-REASON' but got 'Scripted exited prematurely with code…'reserveFreePortcall to afterspawnProcess()reserves the port before the child is spawnedAssertionError: expected 0 to be greater than 0completes the handshake when the banner is split across writesAssertionError: promise rejected "Error: Timeout waiting for scripted to st…" instead of resolvingawait new Promise(r => setTimeout(r, 500))instop()returns only once the child has actually exitedAssertionError: expected false to be trueREAL_INTERPRETER_TIMEOUT_MS = 1should execute shell code and return stdoutError: Test timed out in 1ms.'close'instead of'exit'instop()returns when a grandchild still holds the inherited stdio pipesError: Test timed out in 60000ms.Mutation 6 also reproduces end to end: with
'close',npx vitest run --project cross-language tests/cross_language/a2a/ts_go/ts_a2a_go_test.tsfails locally with the sameError: Hook timed out in 60000msthat CI reported, and passes with'exit'. The regression test's grandchild is kept alive for 60 s and reaped in teardown, so the case cannot pass by simply outlasting the pipe holder — an earlier version of it used a 3 s grandchild and passed against the bug.Mutation 4 does fail on Linux here — the case scripts a child that installs a
SIGINThandler and delays its exit by 800 ms, which outlasts the old 500 ms sleep, so the old code returns while the child is still alive.Limitation stated plainly: mutation 5 proves only that the budget is wired to that test. The Windows PowerShell slowness that motivates cause 2 cannot be reproduced on a Linux dev machine, so there is no local proof that 40000 ms is the right number — only that the previous 5000 ms was not a process-start budget and that the new value is above the executor's own 30 s deadline.
Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Run after
npm install && npm run build(the harness spawnsdev/dist/esm/cli_entrypoint.js, which only exists after a build).Occupy a port:
node -e "require('node:net').createServer().listen(41234,'localhost',()=>console.log('held'))"In another shell, point the real CLI at it:
node dev/dist/esm/cli_entrypoint.js api_server tests/integration/adk_web/agent --port 41234Observed:
[ADK CLI] Error starting API server: Port 41234 is already in use, exit code1. This is the child-side text the harness previously discarded.Confirm the harness now attaches it: with the port still held, construct
new AdkTsApiServer({agentsDir: 'tests/integration/adk_web/agent', port: 41234})and awaitstart().Observed: rejects with a message containing both
Port 41234 is already in useandexited prematurely with code 1. Before this change the same scenario produced onlyCLI exited prematurely with code 1. (Run as a scratch test and deleted afterwards; it is not part of the diff, because it depends on binding a fixed port.)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.