Skip to content

fix(components): close deploy-validation Scopes to stop deployLifecycle listener leak - #1465

Merged
kriszyp merged 2 commits into
mainfrom
kris/deploy-lifecycle-scope-leak
Jul 10, 2026
Merged

fix(components): close deploy-validation Scopes to stop deployLifecycle listener leak#1465
kriszyp merged 2 commits into
mainfrom
kris/deploy-lifecycle-scope-leak

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 23, 2026

Copy link
Copy Markdown
Member

Fixes #1462

Summary

deploy_component's pre-flight validation load (components/operations.js, deployComponent) loads the freshly-prepared component on a worker — with pseudoResources.isWorker = true — purely to surface load-time errors early. Because isWorker is set, loadComponent creates real Scopes, each of which registers deploy:start / deploy:end listeners on the shared deployLifecycle emitter (components/Scope.ts:138-139). These throwaway validation Scopes were never closed — Scope.close() only ran via the per-Scope worker-SHUTDOWN handler — so their listeners accumulated on the worker across deploys, eventually tripping MaxListenersExceededWarning (11 deploy:start listeners). This is the leak in #1462.

The invariant the rest of the system relies on is stated in components/scopeShutdown.ts: "scopes are created once at load and closed once at shutdown, so nothing accumulates." The validation load violated it.

Changes

  • components/componentLoader.ts — added a collectScopes?: Set<Scope> option to loadComponent. When provided, each Scope created during the load is added to the set instead of being registered for worker-shutdown auto-close; the caller then owns closing them. Threaded through the sub-component recursion so packaged sub-components are collected too.
  • components/operations.js (deployComponent) — the validation load now collects its Scopes and closes them in a finally once validation completes, so their deployLifecycle listeners are removed immediately. The load+close is wrapped in trackScopeClose(...) so a concurrent worker shutdown still waits for these Scopes to dispose (a plugin may start a native runtime in handleApplication) before realExit. Also dropped the stale trailing positional args to loadComponent (the 4th+ args were already ignored by the current (dir, resources, origin, options) signature).
  • unitTests/components/componentLoader.test.js — a characterization test (each unclosed validation load leaks exactly one deploy:start listener) and a regression test (with collectScopes closed after each load, the listener count stays at baseline across 12 loads — past the default maxListeners of 10).

Where to look

  • Shutdown safety (operations.js). Skipping the per-Scope SHUTDOWN auto-close for collected Scopes would otherwise drop them from the worker's whenScopesClosed() wait. The trackScopeClose(validation) wrapping restores that guarantee — worth a look that this is the right mechanism. (Raised by the Codex cross-model review and addressed here.)
  • This is distinct from harper-pro#460/ci(claude): bump actions/checkout v4.3.1 → v6.0.2 (Node 24) #461 (replicator node-update watcher accumulation) and from the in-flight startOnMainThread-once core fix (branch kris/start-on-main-thread-once). No overlap: that fix gates main-thread init; this fixes worker-side validation-load Scope cleanup.

Local validation: unitTests/components/componentLoader.test.js and unitTests/components/Scope.test.js pass; the deploy integration suite is left to CI.

🤖 Generated by an LLM (Claude Opus 4.8).

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@kriszyp
kriszyp requested a review from kylebernhardy June 23, 2026 22:00
@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp marked this pull request as ready for review June 25, 2026 04:28
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@kriszyp
kriszyp requested review from cb1kenobi and removed request for kylebernhardy July 8, 2026 16:45
Comment thread components/operations.js Outdated
try {
for (let i = 0; i < loads; i++) {
componentLoader.loadedPaths.clear(); // a fresh deploy re-validates past the path-load guard
await componentLoader.loadComponent(componentDir, { isWorker: true, set: sinon.stub() }, 'test-origin');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): sinon.stub() here (and on line 381) can be replaced with a plain () => {} — the stub's return value is never asserted. Per AGENTS.md, new tests in unitTests/components/ should avoid adding new sinon uses.

Suggested change
await componentLoader.loadComponent(componentDir, { isWorker: true, set: sinon.stub() }, 'test-origin');
await componentLoader.loadComponent(componentDir, { isWorker: true, set: () => {} }, 'test-origin');

for (let i = 0; i < loads; i++) {
componentLoader.loadedPaths.clear();
const collectScopes = new Set();
await componentLoader.loadComponent(componentDir, { isWorker: true, set: sinon.stub() }, 'test-origin', {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): same as line 349 — sinon.stub() can be () => {} since the call is never asserted.

Suggested change
await componentLoader.loadComponent(componentDir, { isWorker: true, set: sinon.stub() }, 'test-origin', {
await componentLoader.loadComponent(componentDir, { isWorker: true, set: () => {} }, 'test-origin', {

kriszyp and others added 2 commits July 10, 2026 15:23
…le listener leak

The deploy_component pre-flight validation load creates Scopes (isWorker)
that register deploy:start/deploy:end listeners on the shared deployLifecycle
emitter but were never closed, so the listeners accumulated on the worker
across deploys and eventually tripped MaxListenersExceededWarning (#1462).

loadComponent gains a collectScopes option: when provided, created Scopes are
added to the set instead of being registered for worker-shutdown auto-close,
and the deploy validation closes them once validation completes. The load+close
is tracked via trackScopeClose so a concurrent worker shutdown still waits for
native-runtime disposal before realExit.

Fixes #1462

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/deploy-lifecycle-scope-leak branch from 69acdd3 to 6ea910f Compare July 10, 2026 21:23
@kriszyp
kriszyp merged commit 8eceabc into main Jul 10, 2026
93 of 94 checks passed
@kriszyp
kriszyp deleted the kris/deploy-lifecycle-scope-leak branch July 10, 2026 21:55
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.

DeployLifecycle listeners leak across deploys (MaxListenersExceededWarning) — Scope not removing deploy:start/deploy:end on reload

2 participants