Fix: stop the app_loader discovery test billing its fixture setup to the first it() (Part 1/2) - #506
Open
AmaadMartin wants to merge 5 commits into
Open
Fix: stop the app_loader discovery test billing its fixture setup to the first it() (Part 1/2)#506AmaadMartin wants to merge 5 commits into
AmaadMartin wants to merge 5 commits into
Conversation
This was referenced Aug 2, 2026
added 2 commits
August 3, 2026 21:42
The AgentLoader discovery suite charged its entire one-time setup to whichever it() ran first. listApps() in the first test drove preloadAgents(), which esbuild-bundles all four discovered entrypoints with the whole @google/adk graph inlined: 20511ms measured on an idle Linux workstation, against a 40000ms per-test budget. preloadAgents() holds no in-flight promise, so once the first case timed out the second started a duplicate set of bundles and timed out too, while the third found the cache warm and passed in 1ms -- the exact pass/fail pattern seen on the macos-latest and windows-latest legs. Warm the loader once in beforeAll and drop that hook's explicit 40000ms budget so it inherits the integration project's hookTimeout, which is where install-heavy setup belongs. The per-test budget stays at 40000ms and the assertions are unchanged, so the cases keep their value as a regression signal. The discovery fixture install is removed as well: unlike the app_* suites this fixture never spawns npm run start, and esbuild resolves @google/adk from the workspace root, so the install only added a node_modules tree for preloadAgents() to walk and delete again.
AmaadMartin
force-pushed
the
fix/app-loader-discovery-test-timeout
branch
from
August 4, 2026 04:45
d3bcac7 to
e42adeb
Compare
- Call preloadAgents() directly instead of routing through listAgents(). It is public and is what sets agentsAlreadyPreloaded, so the comment no longer has to explain the indirection. - Cut the two comment lines that narrated decisions not taken (why the hook passes no budget, why TEST_EXECUTION_TIMEOUT stays 40000). That reasoning belongs in the PR description and rots when either budget moves; the bundling cost and the missing in-flight guard stay. - Drop the optional chaining in afterAll. It guarded a failed npm install leaving loader unassigned, which was only reachable while the install ran ahead of the assignment. The constructor is now the first statement and only registers process handlers, so loader is always assigned. - Drop the afterAll budget too, for the same reason the beforeAll one went: the body is a single disposeAll() and the project hookTimeout already covers it.
This was referenced Aug 4, 2026
added 2 commits
August 4, 2026 12:03
…budget The three app_* hooks passed the file-local TEST_EXECUTION_TIMEOUT (40000) to beforeAll/afterAll, capping them below the integration project's 120000ms hookTimeout. Those hooks run `npm install` for a fixture whose file: links pull the full transitive tree; measured cold here the install needs ~70s, so the 40s cap made them fail outright rather than flake. Dropping the argument lets both hooks inherit the project budget, matching the discovery hooks and keeping a single source for the value. TEST_EXECUTION_TIMEOUT stays on the it() bodies, which are untouched. Also drop the discovery fixture's now-dead file: devDependencies (nothing installs it since the install was removed; package.json itself stays so the module-format lookup still resolves there), and set fail-fast: false on the validation matrix so one slow runner stops cancelling its siblings.
Both comments carried development history rather than guidance: the header comment described a budget as sized for an `npm install` this change deletes from the discovery hooks, and the warm-up comment replayed the incident and pinned a machine-specific measurement that rots. Keep only why preloadAgents() is hoisted. The CI matrix `fail-fast: false` change moves to its own stacked PR; it is a repo-wide policy change and does not belong in a test-timeout fix.
This was referenced Aug 4, 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 public issue tracks this. The failure is observed on the
macos-latestandwindows-latestlegs of thevalidationworkflow, on branches unrelated to the test file — so it predates and is independent of any one change.Problem:
tests/integration/app_loader/app_loader_test.tsintermittently fails withTest timed out in 40000ms, and always on the same two cases:should discover apps vs agents across directories and standalone filesshould load App from directory entrypoint and expose App and rootAgentwhile the third case in the same suite,
should synthesize App when loadApp() is called on BaseAgent file, passes.There are three separate defects behind that. This PR (Part 1/2) fixes the two in the test file; the third — the CI matrix cancelling its sibling legs — is #652, stacked on this branch, because it is a repo-wide workflow policy change and does not belong in a test-timeout fix.
(a) The test body did all the work; the assertions were free.
beforeAllonly rannpm installand constructed the loader;AgentLoaderdoes everything else lazily, so the first test body (loader.listApps()) is what drovepreloadAgents().preloadAgents()callsAgentFile.load()for each of the four discovered entrypoints (dev/src/utils/agent_loader.ts:514,:536), and eachload()is a fullesbuild.build()withbundle: true, minify: true— so every fixture inlines the whole@google/adkdependency graph. Measured on an idle Linux workstation: 20511 ms in the first test, 1 ms each in the other two. 100% of the suite's cost was billed to oneit(), against a 40000 ms budget.The cascade to the second case has a specific cause:
preloadAgents()guards re-entry with a plain boolean that is assigned only after itsawait Promise.all(...)resolves (dev/src/utils/agent_loader.ts:502), with no in-flight promise memo. When case 1 times out, Vitest fails the test but the pending preload keeps running with the flag stillfalse, so case 2 starts a second complete set of four bundles competing with the first and times out too. By case 3 the first preload has settled and everything is a cache hit — hence 1 ms and a pass.(b) The file-local 40 s constant was passed to the hooks, capping them below the project budget.
vitest.config.ts:79setshookTimeout: 120000on theintegrationproject precisely because these hooks are install-heavy. PassingTEST_EXECUTION_TIMEOUT(40000) as the second argument tobeforeAll/afterAlloverrode that downwards. The threeapp_*hooks are the acute case: they genuinely need their fixture install, and measured cold on this workstation that install takes ~70 s — 1.75× the cap that was attached to it. That is not a flake, it is a hook that cannot pass from a clean checkout (see the mutation proof below, which reproducesHook timed out in 40000mson demand).(c) One slow runner cancelled the other two.
.github/workflows/validation.yamlruns its three-OS matrix withoutfail-fast: false, so a single timing-out leg cancels its siblings and the run produces no usable signal from the OSes that were fine. Fixed in the stacked Part 2/2, not here — it is a one-line repo-wide policy change with its own cost tradeoff (a genuinely broken PR now burns all three runners), and it deserves to be accepted or rejected on its own merits rather than riding along with a test fix.Solution: move the one-time cost off the per-test budget and stop the file overriding the project's hook budget — rather than inflating any budget.
vitest.config.tsis not touched and no globaltestTimeoutis raised.beforeAllnow warms the loader with a singleawait loader.preloadAgents();, so everylistApps()/listAgents()/getAppFile()in the bodies below is a cache hit.Promise.all([listApps(), listAgents()]), which would duplicate all four bundles for exactly the re-entrancy reason above.TEST_EXECUTION_TIMEOUTargument so they inherit theintegrationproject'shookTimeout. The bug was this file overriding the project hook budget down to 40 s; the fix is to stop overriding it, not to override it with a different number (see the deviation note below).beforeAll. That is what makes the old teardown cascade unreachable: previouslynpm installran ahead of the assignment, so a slow install leftloaderundefined andafterAlldied withTypeError: Cannot read properties of undefined (reading 'disposeAll'), burying the real hook error. The constructor only registersprocess.onhandlers and cannot reject, soloaderis now always assigned by the timeafterAllruns.it()bodies and their assertions are unchanged, andTEST_EXECUTION_TIMEOUTstays40000on them. Warmed, the discovery bodies run in ~1 ms, so the budget still catches a future change that makes discovery expensive again.The discovery fixture
npm installis removed (and with it the matchingnode_modules/package-lock.jsonteardown, and the fixture's now-deaddevDependencies). Three things worth stating explicitly, since removing an install inside a timeout fix looks unrelated:discovery/package.jsondeclares noscripts. Unlike itsapp_ts/app_js/app_defaultsiblings — whose"start": "npx @google/adk-devtools run app.ts"genuinely needs a localnode_modules/.bin, which is why those three installs stay — nothing in this fixture ever spawns a subprocess; it is only read in-process byAgentLoader, whose esbuild pass resolves@google/adkby walking up to the workspace-rootnode_modules/@google/adk -> coresymlink. CI populatescore/distwithnpm run buildbeforenpm run test:coverage(.github/workflows/validation.yaml). The install was copy-paste from the siblingdescribe, and it was not free: it materialised a tree forpreloadAgents()toreaddir+statas a candidate agent directory and then recursively delete.devDependenciesgo with it. The twofile:links tocore/anddev/were only ever consumed by that install. Left behind they are dead metadata that implies an install which no longer happens — and there is no teardown left to clean up after anyone who runs one by hand.linkProjectNodeModules()(dev/src/utils/agent_loader.ts:616-641) now takes itsundefinedearly return for this fixture.getProjectNodeModulesDir()looks fornode_modulesbeside the nearestpackage.jsononly — that isdiscovery/package.json, which no longer has one — so the temp bundle directory gets nonode_modulessibling symlinked in. This is harmless under the defaultbundle: true(everything the entrypoints need is already inlined), and all six cases pass on all three CI legs, but it is a real change in which branch the integration test exercises and should be stated rather than discovered.Verification gate result: the removal is kept — with the fixture in fresh-checkout state (no
node_modules, nopackage-lock.json) all six cases pass.discovery/package.jsonitself is deliberately kept. It is load-bearing beyond dependency declaration:getTypeFromPackageJson()(dev/src/utils/agent_loader.ts:591-614) walks up from the entry file and stops at the firstpackage.json. Deleting it would reach the repo-rootpackage.jsonwith"type": "module", flipping the fixtures from CJS to ESM output and silently changing what the test exercises.Deviation from the plan, stated explicitly
The design called for a named
HOOK_TIMEOUT = 120000constant passed to the hooks, mirroringtests/integration/build_setup/build_setup_test.ts:15-24. I removed the hook arguments instead of replacing them, and this is the only substantive place I departed from the design. The design's postcondition — "nobeforeAll/afterAllin the file is capped below the project'shookTimeoutof 120 000 ms" — is met either way; the difference is where the number lives.The design's own justification for the value is that
120000"matchesINTEGRATION_HOOK_TIMEOUT_MSinvitest.config.ts" — i.e. the number the project already applies to this file (vitest.config.tsputstests/integration/**/*_test.tsin theintegrationproject and setshookTimeout: INTEGRATION_HOOK_TIMEOUT_MS). Passing it explicitly is a no-op that hardcodes a second copy of the number, free to drift from the config — which is the same failure shape as the bug being fixed, just pointed the other way. Dropping the argument gets the identical budget with one source of truth, and keeps all four hooks in the file consistent instead of leaving the discovery pair inheriting while theapp_*pair restates.The
build_setup_test.tsprecedent is weaker than it looks: it landed in google#549 at2026-07-30 20:01:22, thirty seconds before google#548 added the project-widehookTimeoutat20:01:52. The two were independent PRs merged back to back, so the duplication is an artifact of that collision rather than a considered idiom.A comment on
TEST_EXECUTION_TIMEOUTnow says what the constant is for and why the hooks do not take it, so the 40000 does not get re-attached to a hook by a future edit.Verified empirically rather than assumed, in both directions: inserting a temporary 45 s sleep into the discovery hook (on top of the ~20 s warm-up, so ~65 s total) passes, where the same hook on unmodified
mainreportsHook timed out in 40000ms; and theapp_*install hook completes a ~70 s cold install that fails at 40 s with the argument restored (mutation C below). The override is genuinely gone and the larger project budget applies.Two smaller departures, both narrowing the diff: the design suggested warming via
listAgents(), butpreloadAgents()is public and is literally the method that setsagentsAlreadyPreloaded, so calling it directly removes a layer of indirection the comment would otherwise have to explain; and the design's optionalloader?.disposeAll()is not included, because moving the constructor to the first statement ofbeforeAll(above) makes the undefined-loader case unreachable, andloaderis typed non-nullable.Collision check (open PRs on this fork)
Recorded as required;
gh pr list --limit 1000plusgh pr diff --name-onlyon everything adjacent (--limit 100silently truncates on this fork). Twelve open PRs touch this file. The closest:fix/app-loader-discovery-timeout-flakeand Fix: bill app_loader discovery fixture compilation to beforeAll instead of the first test #560fix/app-loader-integration-test-timeoutboth add the same warm-up tobeforeAll. This PR is the narrower form of the same fix: Fix: attribute cold AgentLoader discovery cost to beforeAll to stop macOS CI flake #260 also raises all four hooks to a localFIXTURE_SETUP_TIMEOUT = 180000(a third distinct copy of a budget the project config already sets), and Fix: bill app_loader discovery fixture compilation to beforeAll instead of the first test #560 additionally reflows all sixit()s and both CLI hooks. Either can supersede this one — the fix is the same idea and I make no claim to priority.feat/trim-integration-test-npm-installsdrops the same fixture install, but also deletesdiscovery/package.json, which flips the fixtures from CJS to ESM; it compensates with a newexpect(path.extname(...)).toBe('.mjs')assertion. This PR keeps thepackage.jsonand leaves what the suite exercises unchanged. These two conflict and only one should land.dev/src/instead of the test. They are complementary; if one lands the warm-up simply gets cheaper.Note on this branch's own history: an earlier revision of this PR additionally changed the discovery loader to
{compile: true, bundle: false}and cherry-picked thedev/src/utils/agent_loader.tsfix thatbundle: falsedepends on (owned by #275). That has been dropped. The production change belongs to #275, and touchingdev/src/widens the blast radius of what should be a CI-stability fix — the missing in-flight dedup inpreloadAgents()is real but is tracked separately. This PR touches no production source: 2 files, +9/-19 across the test and its fixture manifest. The workflow line lives in the stacked Part 2/2.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.
This change adds no production code — no line in
core/src,dev/src, orintegrations/srcis added or modified — so there is no new code to cover and no new unit test to write. The changed file is the test. The existing unit coverage of the code paths involved must keep passing untouched, and does:Per-test durations showing the cost has left the test bodies:
should discover apps vs agents across directories and standalone filesshould load App from directory entrypoint and expose App and rootAgentshould synthesize App when loadApp() is called on BaseAgent fileProof the change does something. This is a timing bug, so the mutations are the budgets themselves. Each was applied to the fixed tree, run, and reverted.
Mutation A — the warm-up. Note the design's suggested recipe (set
TEST_EXECUTION_TIMEOUTto2000on unfixed code) cannot produce the CI signature, because that same constant also budgeted thenpm installhooks, which fail first. Reproducing it needs a budget below the ~20 s preload but above zero. At10000, with only theawait loader.preloadAgents();line removed and everything else in place, the suite fails:Putting that single line back, with the budget still at
10000, turns it green:An earlier run of the same mutation reproduced the reported CI pattern exactly — two failures and a pass, with the third case surviving on a cache hit:
Reported honestly: whether that third case survives is machine-load dependent, which is the nature of the flake being fixed. What is stable across runs is that the mutation always fails and the fix always passes. The second case overrunning its own budget (11672 ms against 10000 ms) is the cascade itself — it re-entered
preloadAgents()and started a duplicate set of bundles._Mutation C — the
app\__hook budget.* This one needs no invented number: restore the removed}, TEST_EXECUTION_TIMEOUT);on the install hook and run one case from a cold fixture (nonode_modules, nopackage-lock.json):With the argument dropped again and the fixture reset to the same cold state, the identical command passes:
83.73 s total minus the 5.5 s test body puts the cold
npm installat ~70 s — which is why the 40 s cap failed outright rather than intermittently, and why the fix is to let the hook use the 120 s budgetvitest.config.tsalready defines for exactly this. Both hooks were left in the reverted (fixed) state;git diffconfirms it.Change (c), the workflow. A matrix cancellation cannot be reproduced locally, so it is verified structurally — the file parses and the strategy resolves to
{"fail-fast":false,"matrix":{"os":["ubuntu-latest","windows-latest","macos-latest"]}}, with the OS list and theenvblock byte-identical to before. Notenpm run format:checkglobs**/*.tsonly and this workflow file is not Prettier-formatted onmain; its existing quoting is therefore left exactly as-is rather than reflowed into the diff.Failure path exercised. On a genuinely cold checkout the unmodified file fails deterministically here, which is what motivated moving the loader construction to the first statement of the hook:
Neighbour suite.
tests/integration/agent_loader/agent_dirname_test.tsis unmodified and was checked as a guard against a fixture-resolution regression. It fails on this workstation withHook timed out in 40000mson its own fixture install — verified pre-existing:git stash-ing this change and re-running reproduces the identical failure on an unmodified tree. That file uses the same 40000-everywhere pattern and is tracked separately; it is deliberately not touched here.npx eslintandnpx prettier --checkare clean on the changed test file.npx tsc --noEmitreports 281 errors both with and without this change (all incore/test/**, none mentioning any file in this diff) — a pre-existing local state, confirmed by stashing the change and re-counting.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
All six cases pass; the three discovery cases report single-digit milliseconds, the one-time bundling cost is visible in the discovery
beforeAll, and the threeapp_*installs now complete inside the project hook budget instead of being cut off at 40 s. Afterwards,git status --porcelain tests/integration/app_loaderis empty apart from the intended edit — the suite no longer creates or deletesdiscovery/node_modules.To see the attribution fix directly, compare those per-test durations against the same command on
main.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.