microsoft / microsoft/vscode

Listener leak: AgentHostSessionAdapter subscribes to shared rootState.onDidChange per session

Open
#324,316 0 comments 0 reactions 1 assignee Claimed by @sandy081 View on GitHub
agents-window
Dominant language
TypeScript
Stars
193k
Forks
42.4k
PR merge metrics
PR metrics pending

Description

tl;dr: a listener leak warning is being triggered because every session indepently listens to root state updates, just to figure out whether the agent supports multi-chat. Can we streamline this to avoid attaching hundreds of listeners?

---

### Problem

The Agents window logs a `potential listener LEAK detected` warning originating from `AgentHostSessionAdapter`:

```
potential listener LEAK detected, popular: Error
at FromEventObservable.onFirstObserverAdded (.../observableFromEvent.js)
at Derived._computeFn (.../baseAgentHostSessionsProvider.js) // capabilities derived
at new AgentHostSessionAdapter (.../baseAgentHostSessionsProvider.js) // autorun
at LocalAgentHostSessionsProvider.createAdapter (...)
at LocalAgentHostSessionsProvider._refreshSessions (...)
```

The Agents window sets a global emitter leak-warning threshold of 175 (`src/vs/sessions/browser/workbench.ts`). The warning fires once a single `Emitter` accumulates ≥175 live listeners.

### Root cause

Each `AgentHostSessionAdapter` subscribes **once per session** to the connection's single shared root-state emitter (`RootStateSubscription._onDidChange`, reached via `connection.rootState.onDidChange`):

```ts
// src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts (~L650)
const connection = this._options.getConnection();
const rootStateObs = connection
? observableFromEvent(this, connection.rootState.onDidChange, () => connection.rootState.value)
: constObservable(undefined);
this.capabilities = derivedOpts({ owner: this, ... }, reader => {
const agentCapabilities = agentCapabilitiesForProvider(rootStateObs.read(reader), this.agentProvider);
return { supportsMultipleChats: ..., supportsFork: ..., supportsRename: true, supportsDelete: true };
});

// permanently observes `capabilities` (and thus rootStateObs) for the adapter's lifetime
this._register(autorun(reader => {
this.capabilities.read(reader);
...
}));
```

All adapters from a provider share **one** connection (`getConnection: () => this.connection`), so this is an **O(number of live session adapters)** fan-out onto a single shared emitter. Every other `rootState.onDidChange` subscriber in the codebase is a singleton (chat contribution, session-list contribution, terminal/prompt contributions, the providers themselves) — the adapter is the only per-session subscriber, which is why the leak monitor names this stack as the most frequent one. Once a user has ~175+ agent-host session adapters live, the shared `rootState.onDidChange` crosses the threshold and warns. At threshold² (30,625) the emitter would start *refusing* new listeners.

### Regression

This was introduced in "sessions: support Multi-Chat in the Claude agent-host harness" (#323625, commit b10844efce8944e714285044e08994903a0124d1, 2026-07-01).

Before that commit, `capabilities` was a **static object** snapshotted once at construction:

```ts
this.capabilities = { supportsMultipleChats: logicalSessionType === CopilotCLISessionType.id, supportsRename: true, supportsDelete: true };
```

The commit converted it into a reactive `derived` backed by a per-adapter `observableFromEvent` on `connection.rootState.onDidChange`, plus a `this._register(autorun(...))` that keeps it permanently observed — introducing the per-session subscription.

### Is it an unbounded leak?

No — the adapter extends `Disposable`, the autorun is `this._register`-ed, and `_refreshSessions` disposes adapters for removed sessions, which tears down the autorun → the derived loses its observer → `FromEventObservable` unsubscribes. So the listener count tracks the number of *currently live* session adapters rather than growing forever. But it's a real design smell that trips the warning as session counts grow and needlessly replicates one shared root state into N identical subscriptions.

### Suggested fix

Hoist a **single shared root-state observable** to the provider (or connection) level and hand it to every adapter, so the shared emitter gets exactly one listener instead of N. There's already a helper for this: `observableFromSubscription(owner, sub)` in `src/vs/platform/agentHost/common/state/agentSubscription.ts`. The `capabilities` derived only depends on root state + `agentProvider` + `_kind`, so a shared root-state observable is a drop-in.

Caveat: the shared observable must be scoped to the current connection and rebuilt when the connection is replaced (the remote provider swaps connections and disposes per-connection state), so it shouldn't be owned unconditionally by the long-lived provider store.

(Written by Copilot)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.