Fix: consolidate the three AgentLoader process-handler PRs onto one branch - #861
Open
AmaadMartin wants to merge 7 commits into
Open
Fix: consolidate the three AgentLoader process-handler PRs onto one branch#861AmaadMartin wants to merge 7 commits into
AmaadMartin wants to merge 7 commits into
Conversation
added 7 commits
August 9, 2026 04:21
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.
…lers The signal listeners called a bare `process.exit()`, so Ctrl-C on `adk web` reported success. A shell reports a process killed by signal `N` as `128 + N`, so `SIGINT` must exit `130`. Node passes the signal name to the listener, so one closure reads it and looks the number up in `os.constants.signals`, which differs per platform. This folds PR #703 onto the opt-in registration lifecycle of PR #511. #703 also removed the `uncaughtException` listener and added a removal closure; #511 already does both, so only the exit status is new here. The two new tests ask for the handlers explicitly, because the constructor no longer installs them.
…s exit A process 'exit' listener must be fully synchronous: Node terminates as soon as the last listener returns and drops any pending promise or queued I/O. The listener awaited disposeAll(), so every `adk web` and `adk api_server` run left its compiled-agent directory behind in the OS temp directory. AgentFile.disposeSync() and AgentLoader.disposeAllSync() do the same work with `fs.rmSync`, and the exit listener calls the sync path. disposeAllSync() also removes the process handlers, so it stays symmetric with disposeAll() and is safe for ordinary teardown; removing a listener during 'exit' emission is allowed. AdkApiServer.stop() now disposes the loader it built for itself; a loader supplied through ServerOptions still belongs to the caller. This folds PR #653 onto PR #511's opt-in registration lifecycle, so the constructor still registers nothing. Two existing tests change with it: `the exit listener disposes cached agents` now spies on disposeAllSync, because that is what the listener calls, and the file_utils stub in the server suite moves from the removed getTempDir to createTempDir.
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):
Or, if no issue exists, describe the change:
Problem: Three open PRs rewrite the same process-handler block in
dev/src/utils/agent_loader.ts, so whichever lands first makes the other two unmergeable. Each one alone leaves a real defect: #511 fixes the listener leak and the silentuncaughtExceptionexit, #703 fixes the exit status, #653 fixes the temp-directory leak. Onmainthe constructor installs five listeners that nothing removes, Ctrl-C onadk webexits0, and every run leaves anadk_agent_loader-*directory in the OS temp directory.Solution: This branch carries all three fixes on one trunk. #511 is the trunk because opt-in registration is the only one of the three that changes the shape of the block, so the other two reduce to a small delta on top of it. Commits 1-5 are #511 verbatim, rebased on
mainwith no conflicts. Commit 6 folds in #703's exit status and commit 7 folds in #653's synchronous cleanup.Collision check:
gh pr listplus a diff grep forprocess.on|process.exit|installProcessHandlers|disposeAllSync|uncaughtExceptionover every open PR that touchesagent_loader.ts(#830, #814, #793, #755, #731, #674, #662, #854). Only #511, #703 and #653 touch this block, so nothing else collides.Behaviour changes (all intended, all argued in the source PRs):
1with a stack, and Ctrl-C exits130. Both were0.AgentLoaderis not exported fromcore, so the blast radius is thedevpackage.AdkApiServer.stop()disposes the loader it built itself. A caller who injected a loader is unaffected.Conflict resolutions worth review:
onSignalclosure reads the signal name Node passes it, instead of Fix: exit non-zero when an AgentLoader process crashes or is interrupted #703's array of three[signal, closure]tuples. This keeps Fix: make AgentLoader process exit/signal handlers opt-in and removable #511's loop shape.disposeAllSync()also callsremoveProcessHandlers, which Fix: clean up compiled-agent temp directories synchronously on process exit #653 did not. Removing a listener while'exit'is being emitted is allowed, and the symmetry keepsdisposeAllSync()usable for ordinary teardown.listenerAddedBy,counts,afterInstall) and promoted it to module scope. Fix: clean up compiled-agent temp directories synchronously on process exit #653'strackLoaderProcessListeners().restore()only existed because the old constructor gave no way to remove listeners;disposeAll()does that now.Known residual leak, not fixed here:
adk webcan still leave one scratch directory when the debug UI compiles an agent, the server disposes that borrowedAgentFilethroughawait using, and a later request recompiles it.disposeSync()skips a file already marked disposed. The root cause is the borrowed-handle lifetime, which #830 fixes.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.
The one
unit:devfailure is pre-existing and unrelated:dev/test/cli/cli_create_test.ts > should handle Vertex AI selection with gcloud defaultsreads the developer's real gcloud config. It fails identically withdev/checked out frommain.npx tsc --noEmitreports no error in any file this PR touches. The errors it does report are pre-existing, incore/test/andtests/integration/.Mutation proofs. Every new test was run once against the unfixed line.
process.exit(128 + os.constants.signals[signal])->process.exit()exits SIGINT/SIGUSR1/SIGUSR2 with 128 plus the signal numberexpected "spy" to be called with arguments: [ 130 ]removeListener->removeAllListenerskeeps another loader listeners when disposeAll runs twiceexpected [ 0, 0, 0, 0, 1 ] to deeply equal [ 1, 1, 1, 1, 1 ]onExit->() => void this.disposeAll()cleans up synchronously from the exit listener,the exit listener disposes cached agentsexpected true to be false,expected "disposeAllSync" to be called at least onceremoveProcessHandlersfromdisposeAllSync()removes installed listeners on disposeAllSyncexpected [ 1, 1, 1, 1, 1 ] to deeply equal [ 0, 0, 0, 0, 1 ]fs.rmSyncfromdisposeSync()removes the compiled output directory synchronously on disposeSyncexpected true to be falseownsAgentLoader = falseshould dispose the agent loader it built itself when stoppedexpected [ 'agent-0' ] to deeply equal []stop()finally-> straight-lineshould release its own agent loader even when closing the server failsexpected "disposeAll" to be called 2 times, but got 1 timesExisting tests this PR edits. Both edits sit in the commit that changes the behaviour they pin.
the exit listener disposes cached agentsnow spies ondisposeAllSync, because that is what the listener calls. The behaviour it pins is unchanged.file_utilsstub moves fromgetTempDirtocreateTempDir.mainremovedgetTempDir, so the old stub was inert and the ownership test failed withexpected [] to have a length of 1.Tests from the superseded PRs that are not carried over. Each is a duplicate; the surviving test is named.
does not install an uncaughtException listener->never installs an uncaughtException listener.installs one exit listener and one per termination signal->installs exit and termination signal listeners on demand.removes every listener it installed on disposeAll->removes installed listeners on disposeAll.disposes the loader from the exit listener->the exit listener disposes cached agents.exits the process from the SIGINT handler, which emits the exit event->exits SIGINT with 128 plus the signal numberandcleans up synchronously from the exit listener.Manual End-to-End (E2E) Tests:
Measured on this branch, for both
adk webandadk api_server: exit130, scratch directory removed. Measured onmainwith the same script: exit0, scratch directory left behind.tests/integration/needs no edit. Theintegrationproject does not run in my sandbox: its fixtures runnpm install, which cannot reach the registry here. It fails the same way onmain.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.