Skip to content

Fix: select the client-label context store at runtime so the web build drops node:async_hooks - #660

Open
AmaadMartin wants to merge 3 commits into
mainfrom
fix/client-labels-runtime-async-context-store
Open

Fix: select the client-label context store at runtime so the web build drops node:async_hooks#660
AmaadMartin wants to merge 3 commits into
mainfrom
fix/client-labels-runtime-async-context-store

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Related: Fix: remove orphaned fragment from BasePlugin.onToolErrorCallback JSDoc #611
    Related: Fix: keep temp: state readable for the whole invocation in DatabaseSessionService (stacked on #132) #607

(Deliberately not Closes: #611 — that issue also covers the winston half, which is a separate change, and #614 also claims to close it.)

  1. Or, if no issue exists, describe the change:

Problem: core/src/utils/client_labels.ts statically imported a Node builtin:

import {AsyncLocalStorage} from 'node:async_hooks';

That module is reachable from the browser entry point (core/src/index_web.ts./common.jscore/src/common.ts:282). The published web build is transpile-only — core/package.json runs "build": "tsc --emitDeclarationOnly && node ./build.js" with no --bundle, so core/build.js takes the entryPoints: ['./src/**/*.ts'] + outdir branch and passes every module specifier through verbatim. The specifier therefore survived into core/dist/web/utils/client_labels.js:8, and no browser bundler can resolve it, even though core/package.json advertises "browser": "./dist/web/index_web.js".

A shim already existed at core/src/utils/async_hooks_shim.ts, but it was wired only through esbuild's buildOptions.alias, gated on platform === 'browser' && bundle (core/build.js:53). That gate is load-bearing: esbuild fails outright with Cannot use "alias" without "bundle", so the alias never applied to the shipped (non-bundled) web build.

Solution: select the store at runtime instead of at build time.

  • core/src/utils/client_labels.ts no longer names any node: module. It declares a minimal internal ClientLabelStore interface (run + getStore), defaults to the existing synchronous shim, and exposes an internal setClientLabelStore() seam. The shim is imported under the alias SingleSlotClientLabelStore because its exported class is literally named AsyncLocalStorage but is not one — importing it under its own name at that site invites exactly the confusion this bug is about.
  • New core/src/utils/client_labels_node.ts installs Node's real AsyncLocalStorage. It is now the only module in core/src that names node:async_hooks, and nothing reachable from index_web.ts imports it.
  • core/src/index.ts (the Node entry point) calls installNodeClientLabelStore() at module-evaluation time, before any user code.
  • core/src/utils/async_hooks_shim.ts gains a class doc comment only. It was dead code before this change and is now the live browser fallback, so it says plainly that it is a synchronous single-slot stand-in, that the value does not survive an await, and that it must never become the Node implementation. Behaviour, export name and file name are unchanged.
  • core/build.js loses the five-line if (platform === 'browser' && bundle) alias block, which contained the node:async_hooks entry and nothing else. That alias never fired. packages: 'external' (core/build.js:49) externalizes the bare node:async_hooks specifier before the alias is consulted, so even a genuinely browser-reachable import was never substituted. Measured by injecting exactly such an import into a module reachable from index_web.ts and running npm run build:bundle: with the alias present, the specifier survives verbatim as import {AsyncLocalStorage} from 'node:async_hooks'; at dist/web/index.js:23. The block therefore gave the false impression that browser bundles were protected from node:async_hooks when they were not — and after the runtime seam nothing browser-reachable imports it in any case. Removing it changes no build output: dist/web is identical with and without it, and both npm run build and npm run build:bundle still succeed.

Node's AsyncLocalStorage<string> is structurally assignable to ClientLabelStore, so there is no cast, no any, and no suppression anywhere in this diff.

No public API change. core/src/common.ts:282 keeps its existing export list (getClientLabels, runWithClientLabel); ClientLabelStore, setClientLabelStore and installNodeClientLabelStore stay internal. No signature changes, no new dependency — package.json and package-lock.json are untouched.

Corrected acceptance criteria (please read — the obvious one is not achievable)

The natural criterion, cd core && npm run build && grep -rn 'node:async_hooks' dist/web returning 0 hits, cannot be met by any source-only change, and this was verified rather than argued:

  1. In transpile-only mode core/build.js emits every file in core/src into core/dist/web, reachable from index_web.js or not. A whole-tree grep therefore hits unreachable files too — dist/web already ships unreachable node:fs, node:path, node:net, node:dns/promises, node:crypto and node:os specifiers today for the same reason.
  2. There is no non-obfuscated way to obtain Node's AsyncLocalStorage without naming the module. Checked with esbuild: import type is fully erased, but 'node:' + 'async_hooks' is constant-folded straight back to "node:async_hooks", and a dynamic import('node:async_hooks') keeps the literal too. Splitting the specifier to dodge a grep would be obfuscation, not a fix, and was not attempted.

Measured against these criteria instead (base 693a1d79, core v1.5.0):

criterion before after
node:async_hooks resolve errors bundling dist/web/index_web.js for the browser 1 0
grep -c 'node:async_hooks' dist/web/utils/client_labels.js 1 0
files under dist/web still containing the literal utils/client_labels.js only utils/client_labels_node.js (unreachable, Node-only)
npx esbuild src/utils/client_labels.ts --bundle --platform=browser 1 error 0 errors

The last residual hit closes only when the web build stops emitting unreachable modules — a build-system change (#614, or a follow-up), not this PR.

One honest correction to the expected numbers. The total error count bundling dist/web/index_web.js stays at 346, it does not drop to 345. The node:async_hooks error disappears (1 → 0), but async_hooks_shim.js becomes newly reachable from the browser graph and carries the pre-existing createRequire preamble that core/build.js:73-76 prepends to every emitted file, so Could not resolve "module" goes 147 → 148. That banner defect is pre-existing, unrelated, and separately tracked; this PR does not make it worse in kind, only in count by one, and fixing it means touching core/build.js, which is out of scope here.

Measured taint reduction (source-level, esbuild --bundle --platform=browser, error counts)

module before after this PR remaining cause
core/src/models/base_llm.ts 1 0
core/src/utils/client_labels.ts 1 0
core/src/agents/llm_agent.ts 21 20 winston
core/src/runner/runner.ts 21 20 winston
core/src/models/registry.ts 21 20 winston
core/src/tools/agent_tool.ts 21 20 winston

The residual 20 are winston (util, os, fs, path, …) reached through core/src/utils/logger.ts. Dropping winston is a separate in-flight change; only once both land do llm_agent.ts and runner.ts reach zero. This PR alone does not make the web build bundle — it removes one of the two causes, and it is what unblocks the web-specific barrel work in #612 (of the 22 Node-tainted re-exports in core/src/common.ts, 15 were tainted solely by this single import, including LlmAgent, Runner, BaseLlm, GoogleLlm, the model registry, AgentTool and SequentialAgent).

Relationship to #614

Open PR #614 ("Fix: make the web build bundle and run in a browser") also references #611 and solves the same symptom a different way: it makes the web target always bundle, which is what makes esbuild's alias legal so the existing shim finally applies. Confirmed still OPEN at the time of writing.

  • The two changes overlap on one file, core/build.js, and are otherwise disjoint. Fix: honour ADK_DISABLE_GEMINI_MODEL_ID_CHECK in GoogleSearchTool #614 touches core/build.js, core/src/common.ts, core/src/index.ts (export block only), core/src/utils/winston_shim.ts and tests/integration/build_setup/web_build_test.ts. This PR touches core/src/utils/client_labels.ts, core/src/utils/client_labels_node.ts, core/src/utils/async_hooks_shim.ts, core/src/index.ts (one import + one call, no exports), core/build.js (deleting the five-line dead alias block) and core/test/utils/*.
  • Merge-order note for whoever sequences these. Fix: honour ADK_DISABLE_GEMINI_MODEL_ID_CHECK in GoogleSearchTool #614's premise is that making the web target always bundle activates the alias so the shim is substituted for client_labels.ts's import. The measurement above shows the alias does not actually work under packages: 'external', so that premise does not hold as stated; and once this PR lands, client_labels.ts has no such import to substitute. If Fix: honour ADK_DISABLE_GEMINI_MODEL_ID_CHECK in GoogleSearchTool #614 lands after this one its core/build.js hunk needs a trivial rebase around the deleted block, and its alias-based mechanism is redundant rather than conflicting.
  • They are complementary, not redundant. Fix: honour ADK_DISABLE_GEMINI_MODEL_ID_CHECK in GoogleSearchTool #614 fixes the artifact via a build-time alias; the source module stays browser-hostile and re-breaks the moment the build stops bundling. This PR fixes the source, so client_labels.ts is browser-safe under any build strategy.
  • No second test file was added under tests/integration/build_setup/Fix: honour ADK_DISABLE_GEMINI_MODEL_ID_CHECK in GoogleSearchTool #614 owns web_build_test.ts there. The regression coverage lives in core/test/utils/ instead.

Deliberate deviation from the agreed plan. The plan for this change said to leave core/build.js alone and queue the alias removal as a follow-up, to avoid colliding with #614. A complexity review flagged the leftover block; on measuring it I agreed and removed it. It is not merely dead — it never functioned, and a non-functional guard is worse than no guard because it reads as protection. The deletion is five lines, contains only the node:async_hooks entry, and provably changes no build output.

Collision check. Searched all 557 open PRs on the fork (gh pr list --limit 1000, plus --search async_hooks / client_labels / 611). No open PR touches core/src/utils/client_labels.ts or core/src/utils/async_hooks_shim.ts. The nearest neighbours were checked by file list and are disjoint: the winston-removal PR (logger.ts, env_aware_utils.ts, file_utils.ts, artifacts, code executors), the package-exports PR (core/package.json, core/test/package_exports_test.ts), and the google_llm.ts labels-cast PR. Branched from main, not stacked.

Known, accepted limitation

Because the install is wired through core/src/index.ts, any in-repo code path that reaches client_labels.ts without index.ts having been evaluated gets the synchronous fallback rather than AsyncLocalStorage. This is measured, not assumed. In practice every entry into the package goes through index.ts: core/package.json exports exposes only "." (no subpath exports, so an external consumer cannot reach client_labels.js any other way), the vitest @google/adk alias resolves to core/src/index.ts, and the dev and integrations workspaces were checked for deep imports of core internals — there are none. Behaviour for Node consumers is unchanged; browser behaviour improves from "cannot load at all" to "loads, with single-slot label context". Reaching real AsyncLocalStorage from client_labels.ts itself is impossible without naming node:async_hooks there, which is the whole defect.

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.

11 new tests across three files. Test placement follows the source layout, with each behaviour pinned once:

  • core/test/utils/async_hooks_shim_test.ts (new, 5 tests). The shim had zero tests and is now the live browser fallback: initial empty store, value visible inside run plus return-value pass-through, nesting/restore, restore-on-throw (the finally), and an explicit assertion of the documented limitation that the value does not survive an await, so nobody later mistakes it for an AsyncLocalStorage equivalent.
  • core/test/utils/client_labels_node_test.ts (new, 2 tests). Calls installNodeClientLabelStore() explicitly, then asserts the label survives an await and that concurrent invocations stay isolated. This is the only coverage of core/src/utils/client_labels_node.ts.
  • core/test/utils/client_labels_test.ts (4 tests added; all 17 existing tests untouched). A concurrency invariant guard and a nesting/restore case through the public API, a fake-ClientLabelStore test for the new setter seam, and an esbuild browser-bundle regression test.

The concurrency case deliberately appears in two files, and they are not duplicates: the one in client_labels_node_test.ts installs the store itself and pins the store, while the one in client_labels_test.ts relies only on importing @google/adk and therefore pins the wiring in index.ts. Mutation M2 below demonstrates the difference — removing the index.ts call fails one and not the other.

Commands run (targeted, on the exact commit pushed):

npx vitest run --project unit:core core/test/utils/client_labels_test.ts \
  core/test/utils/client_labels_node_test.ts core/test/utils/async_hooks_shim_test.ts
#  3 files, 28 tests, all passing

npx vitest run --project unit:core
#  170 files, 2372 tests, all passing  (includes the pre-existing
#  "should propagate label across async hops" test, which is the proof that
#  Node still gets real AsyncLocalStorage)

npx tsc --noEmit -p core/tsconfig.json     # clean
npx eslint 'core/src/**/*.ts'              # clean
npx prettier --check "core/**/*.ts"        # clean

npx vitest run --project unit:dev --project unit:integrations reports 2 failures — integrations/test/version_test.ts (expected '1.5.0' to be '1.3.0', a stale hardcoded version assertion) and dev/test/cli/cli_create_test.ts gcloud-defaults. Both were reproduced on the unmodified base commit with core/src reverted, so they are pre-existing and unrelated; they are not touched here.

Coverage. async_hooks_shim.ts and client_labels_node.ts are at 100% of statements, branches, functions and lines. client_labels.ts is at 98.3% statements / 93.33% branches; the single uncovered line is the pre-existing parseUserAgent(window.navigator.userAgent) browser-only branch at line 71, which this change does not touch and which cannot execute under the node test environment.

Proof the tests can fail. Every new test was run against mutated source and observed to fail. Each mutation was reverted immediately (git checkout --) and the tree verified clean.

# mutation test(s) that failed observed message
M1 restore the node:async_hooks import as the default store in client_labels.ts (i.e. the bug) browser-bundle test Build failed with 1 error: core/src/utils/client_labels.ts:8:32: ERROR: Could not resolve "node:async_hooks"
M2 delete installNodeClientLabelStore(); from index.ts concurrency test in client_labels_test.ts (+ the pre-existing async-hops test); client_labels_node_test.ts still passed expected [ undefined, undefined ] to deeply equal [ 'task-a', 'task-b' ]
M3 make the shim the Node implementation (client_labels_node.ts imports from ./async_hooks_shim.js) — the thing the invariant forbids both client_labels_node_test.ts tests expected undefined to be 'task-a'; expected [ undefined, undefined ] to deeply equal [ 'task-a', 'task-b' ]
M4 drop the shim's try/finally restore shim nesting, restore-on-throw, and await-limitation tests expected [ 'outer', 'inner', 'inner', 'inner' ] to deeply equal [ Array(4) ]
M5a shim run never stores (this.store = previous) shim "exposes the store inside run" expected undefined to be 'a'
M5b shim getStore never reports empty shim "has no store before any run" expected 'seeded' to be undefined
M6 make setClientLabelStore a no-op seam test expected [] to deeply equal [ 'routed-label' ]
M7 runWithClientLabel bypasses the store (return callback()) nesting/restore test expected [ undefined, undefined, …(2) ] to deeply equal [ Array(4) ]

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

npm ci
npx tsc --noEmit -p core/tsconfig.json
cd core && npm run build

# the file named in the bug is clean
grep -c 'node:async_hooks' dist/web/utils/client_labels.js                # 0

# the reachable browser graph is clean (this is the criterion that matters)
npx esbuild dist/web/index_web.js --bundle --platform=browser \
  --outfile=/dev/null --log-limit=0 2>&1 | grep -c 'Could not resolve "node:async_hooks"'
# 0   (1 on main)

# the only residual hit is the unreachable Node-only module
grep -rln 'node:async_hooks' dist/web                                     # utils/client_labels_node.js

# a previously-tainted module is now clean
npx esbuild dist/web/models/base_llm.js --bundle --platform=browser --outfile=/dev/null
# 0 errors (1 on main)

# source-level
npx esbuild src/utils/client_labels.ts --bundle --platform=browser --outfile=/dev/null
# no errors

# the bundled build still succeeds with the dead alias block removed
cd .. && npm run build:bundle --workspace=core
grep -c 'node:async_hooks' core/dist/web/index.js                         # 0

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.

Amaad Martin added 3 commits August 4, 2026 14:54
client_labels.ts statically imported node:async_hooks, and the module is
reachable from the browser entry point index_web.ts. The web build is
transpile-only, so the specifier survived verbatim into dist/web and no
browser bundler could resolve it.

Default the store to the existing synchronous single-slot shim and add an
internal setter seam. index.ts, the Node entry point, installs a real
AsyncLocalStorage through a new client_labels_node.ts, which is now the
only module in core/src that names node:async_hooks and is unreachable
from index_web.ts.
Adds shim coverage (it is now the live browser fallback), a Node-store
test for AsyncLocalStorage semantics, and, in client_labels_test.ts, a
concurrency invariant guard, a nesting/restore case, a fake-store test
for the new setter seam, and an esbuild browser-bundle regression test
that fails while client_labels.ts names node:async_hooks.
…edupe the invariant

The build.js browser alias for node:async_hooks never fires: packages:
'external' (build.js:49) externalizes the bare specifier before the
alias is consulted. Verified by injecting a browser-reachable
node:async_hooks import and building with --bundle -- the specifier
survives verbatim into dist/web/index.js with the alias present. It
therefore gave a false impression that browser bundles were protected
from node:async_hooks when they were not, and after the runtime store
seam nothing browser-reachable imports it at all. Removing it changes
no build output: dist/web is byte-identical either way.

Also collapses the browser-graph invariant to a single statement on
client_labels_node.ts, the module that owns it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant