Fix: make AgentLoader process exit/signal handlers opt-in and removable - #511
Open
AmaadMartin wants to merge 5 commits into
Open
Fix: make AgentLoader process exit/signal handlers opt-in and removable#511AmaadMartin wants to merge 5 commits into
AmaadMartin wants to merge 5 commits into
Conversation
added 5 commits
August 2, 2026 01:24
The AgentLoader constructor unconditionally registered five process listeners, so merely constructing a loader (directly or via AdkApiServer) mutated global process state. The uncaughtException listener converted every crash in the process into a silent exit 0 with no stack trace, and none of the listeners were ever removed, so each loader leaked one listener per event for the process lifetime. Registration now happens only through an explicit installProcessHandlers(), called from the CLI entrypoints that own the process, and disposeAll() removes what it installed. No uncaughtException listener is installed at all, so Node's default crash reporting is restored.
Adds a process-handlers suite to the agent loader tests that pins the constructor registering nothing, the opt-in install adding exactly one listener per event, the absence of any uncaughtException listener, idempotency, removal on disposeAll and reinstall afterwards. Server, CLI and deploy tests cover the opt-in flag and the CLI call sites. The loader tests capture the listener the loader installed by diffing process.listeners() and invoke it directly, rather than emitting the event, so Vitest's and Tinypool's own listeners are left alone.
…ader Taking the baseline after construction meant the test only pinned the install path; a constructor that registered an uncaughtException listener still passed. Snapshotting first pins the invariant across the loader's whole lifecycle.
vi.mocked keeps the mock metadata typed, so the new assertions do not need an `as unknown as Mock` escape hatch to reach .mock.calls.
The ProcessHandler record type and the array of them were a generic listener registry serving two listener shapes, with the bookkeeping spread over five sites. Closing over the two listeners and storing one removal closure drops the interface, the private removal method and the per-signal closure allocation, and makes the installed/not-installed state the presence of that closure. Behaviour is unchanged: install is still idempotent, disposeAll still removes exactly this instance's listeners, and reinstall still works.
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
Link to an existing issue (if applicable):
No existing public issue; found during a review of
AgentLoaderlifecycle handling.Or, if no issue exists, describe the change:
Problem: The
AgentLoaderconstructor unconditionally registered five process listeners (dev/src/utils/agent_loader.ts), so merely constructing a loader mutated global process state. Two defects followed.The
uncaughtExceptionlistener swallowed every crash in the process. Its handler ignored the error argument and calledprocess.exit()with no code, i.e. exit status 0 and no stack trace. Once a singleAgentLoaderexisted anywhere in the process, any unhandled exception — including one thrown by code unrelated to agent loading — became a silent, successful-looking exit. Registering the listener also suppressed Node's default crash reporting process-wide. In a Vitest worker this is actively dangerous: a later unhandled error tears the worker down instead of failing the test.The listeners leaked.
disposeAll()closed the file watcher and disposed the cachedAgentFiles but left all five listeners attached, so every loader ever constructed added one listener per event for the process lifetime.AdkApiServerconstructs a loader whenever the caller does not inject one, so the leak was reachable without ever namingAgentLoader. The existing loader test file already builds eight loaders in one worker, against Node's defaultdefaultMaxListenersof 10.Solution: Registration is now opt-in and removable.
process.listenerCount(e)is unchanged for everyeafternew AgentLoader(...).installProcessHandlers()wiresexit,SIGINT,SIGUSR1andSIGUSR2to the loader's cleanup. It is idempotent (a second call while installed is a no-op) and is only called from entrypoints that own the process:adk deploy cloud_run,adk deploy agent_engine, andAdkApiServerbehind a new opt-ininstallProcessHandlersserver option thatadk web/adk api_serverpass.uncaughtExceptionlistener is installed at all (grep -rn "uncaughtException" dev/srcnow returns nothing). An uncaught exception keeps Node's default behaviour: stack trace printed, non-zero exit. Cleanup parity is retained because Node still emitsexiton that path.disposeAll()removes every listener the loader installed, so install → dispose is listener-count neutral and a later install re-installs.Why an explicit method rather than a constructor flag: the loader's teardown entry point is already
disposeAll(), which every consumer calls, so the install/remove pair hangs off the existing lifecycle instead of adding a second one. Bookkeeping is a single privateremoveProcessHandlers?: () => voidclosure over the two listeners rather than a registry of{event, handler}records — the presence of that closure is the installed/not-installed state, which is what makes the guard and the teardown one line each, and there is no public uninstall method becausedisposeAll()is the single teardown door.Behavioural change for embedders (intended, and the point of the fix): an embedder of
AdkApiServerthat relied on the server's internal loader installing signal handlers loses that side effect unless it passesinstallProcessHandlers: true. An embedded server must not callprocess.exit()on its host's behalf.adkCLI behaviour is unchanged —web,api_server,deploy cloud_runanddeploy agent_engineall opt in, so theirexit/SIGINT/SIGUSR1/SIGUSR2behaviour is what it is today. The single deliberate difference is that an uncaught exception now crashes loudly with a non-zero exit code instead of exiting0silently.Collision check (required before implementation):
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000(408 open PRs) was filtered for agent-loader / process-handler / signal / listener keywords, and the diffs of the 14 plausibly adjacent PRs (#480, #457, #455, #375, #365, #328, #309, #285, #276, #275, #264, #260, #257, #247) were grepped forprocess.on|installProcessHandlers|uncaughtException|removeListener|SIGINT|SIGUSR|exitHandler. No open PR touches the process-handler block. Several touchdev/src/utils/agent_loader.tsin unrelated regions (esbuild options, logging, discovery performance); since none implements this change, this PR branches frommainrather than stacking.Scope note: the
exitlistener performs best-effort cleanup (void this.disposeAll()) exactly as before — Node cannot await inside anexitlistener, so its async tail does not complete. That pre-existing limitation, makingSIGINTawait cleanup before exiting, and havingAdkApiServer.stop()dispose the loader are deliberately out of scope here and left unchanged.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 tests (all additions; no existing test was rewritten, weakened, skipped or deleted):
dev/test/utils/agent_loader_test.ts— adescribe('process handlers')block: constructor registers nothing (3 loaders, counts unchanged); install adds exactly one listener to each of the four events; never installs anuncaughtExceptionlistener; idempotent;disposeAll()removes them; reinstall after dispose works;disposeAll()on a never-installed loader is a no-op; the capturedexitlistener disposes cached agents; the capturedSIGINTlistener callsprocess.exit(). These tests neverprocess.emit— that would fire Vitest's and Tinypool's own listeners and can take the worker down — they diffprocess.listeners(event)to capture this loader's listener and invoke it directly, and every test disposes in afinallyso a failure cannot leak listeners into the rest of the worker.dev/test/server/adk_api_server_test.ts— the loader'sinstallProcessHandlersis not called by default and is called exactly once when opted in; both also assert the realSIGINTlistener count, so the flag is proven to reach process state rather than just the spy.dev/test/cli/cli_test.ts—webandapi_serverboth passinstallProcessHandlers: true.dev/test/cli/cli_deploy_{cloud_run,agent_engine}_test.ts— each deploy path installs handlers on its loader. TheAgentLoadermock implementations in these two files gained aninstallProcessHandlers: vi.fn()member (4 sites); without it the deploy functions throwTypeError: agentLoader.installProcessHandlers is not a function. That is a mock-shape fix, not an assertion change — no existing assertion was touched.Coverage: 100% of new lines and branches. Verified from the v8 JSON report over the five suites above:
dev/src/utils/agent_loader.tslines 369 and 386–406 (the idempotency guard, both listener bodies, the install and removal loops, and the closure clearing itself) and 489,dev/src/server/adk_api_server.ts157–158 (both branches),dev/src/cli/cli.ts242 and 289, and the deploy call sites are all absent from the uncovered sets. No coverage-tool suppression was added anywhere.Proof each new test can fail. Every new test was run against mutated source and observed to fail:
process.on(...)calls to the constructordoes not register process listeners in the constructor,never installs an uncaughtException listener,disposeAll is a no-op…expected [ 3, 3, 3, 3, 4 ] to deeply equal [ +0, +0, +0, +0, 1 ]/expected 6 to be 5process.on('uncaughtException', …)insideinstallProcessHandlers()installs exit and termination signal listeners on demand,never installs an uncaughtException listener,is idempotent,removes installed listeners on disposeAll,can reinstall after disposeAllexpected [ 1, 1, 1, 1, 2 ] to deeply equal [ 1, 1, 1, 1, 1 ]this.removeProcessHandlers?.();fromdisposeAll()removes installed listeners on disposeAll,can reinstall after disposeAllexpected [ 4, 4, 4, 4, 1 ] to deeply equal [ 3, 3, 3, 3, 1 ]is idempotentexpected [ 2, 2, 2, 2, 1 ] to deeply equal [ 1, 1, 1, 1, 1 ]exit)removes installed listeners on disposeAll,can reinstall after disposeAllexpected [ +0, 4, 4, 4, 1 ] to deeply equal [ +0, 3, 3, 3, 1 ]can reinstall after disposeAllexpected [ +0, +0, +0, +0, 1 ] to deeply equal [ 1, 1, 1, 1, 1 ]onExitno longer disposesthe exit listener disposes cached agentsexpected "disposeAll" to be called at least onceonSignalno longer exitsa termination signal listener exits the processexpected [Function] to throw an errorinstallProcessHandlers: truefrom thewebaction (and separately fromapi_server)should opt the server into process handlersexpected undefined to be trueinstalls agent loader process handlers when opted inexpected "installProcessHandlers" to be called once, but got 0 timesexpected "installProcessHandlers" to be called once, but got 0 times(and the default case)agentLoader.installProcessHandlers()from both deploy pathsinstalls process handlers on the agent loaderexpected "spy" to be called once, but got 0 timesThe idempotency guard mutation is worth calling out: the combination test (
is idempotent) is what pins it — line coverage ofinstallProcessHandlersalone is satisfied without ever calling it twice.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Run
npm run build -w devfirst, then:repro1.mjs: importAgentLoaderfromdev/dist/esm/utils/agent_loader.js,new AgentLoader(process.cwd()), thensetTimeout(() => { throw new Error('this should crash loudly'); }, 10).mainthis prints nothing and exits0.MaxListenersExceededWarning. Onmainthis is 20 per event plus the warning.node dev/dist/esm/cli_entrypoint.js web dev/samples --port 0→For local testing, access at http://localhost:42399.;SIGINT→web: terminated on SIGINT (exit 0). Same forapi_server(http://localhost:40635,terminated on SIGINT (exit 0)).new AdkApiServer({agentsDir})→embedded -> SIGINT:0 exit:0;new AdkApiServer({agentsDir, installProcessHandlers: true})→opted in -> SIGINT:1 exit:1.Regression gate — integration suite, unmodified:
npx vitest run --project integration tests/integration/app_loader/app_loader_test.ts→Test Files 1 passed (1) Tests 6 passed (6). This suite is only green once its fixturenode_modulesexist; from cold it fails inbeforeAllatexecAsync('npm install')withHook timed out in 40000ms(a cold install of the fixture measured 1m26s here) before any loader code runs. That timeout is pre-existing and environmental, not caused by this change.Pre-existing failures on this tree, unrelated to this change (both reproduced on unmodified
fork/mainin a detached worktree):dev/test/cli/cli_create_test.ts > should handle Vertex AI selection with gcloud defaults(depends on the local gcloud default project) and thee2eproject (requires live Gemini API credentials).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.
No
@ts-expect-error,@ts-ignore,eslint-disable,as any,as never,as unknown asor coverage-tool suppression was added by this branch (git diff main -U0 | grep -E '^\+.*(@ts-expect-error|…)'returns nothing).TERMINATION_SIGNALSis declaredas constso its members satisfy theprocess.on/removeListenersignal overloads without referencing theNodeJSglobal namespace, which the repo'sno-undeflint rule rejects. The new CLI/deploy assertions read mock metadata throughvi.mocked(...)rather than theas unknown as Mockcast used elsewhere in those files.