microsoft / microsoft/vscode

Git extension retains ~1.2GB of refs in the Agents window: duplicate Repository creation + undisposed listeners

Open
#327,438 0 comments 0 reactions 1 assignee Claimed by @lszomoru View on GitHub
Dominant language
TypeScript
Stars
193k
Forks
42.4k
PR merge metrics
PR metrics pending

Description

Found while analyzing an extension host heap snapshot from the Agents window. The EH was at **2.6 GB self-size**, and **1,239 MB (47%)** of it was git ref data retained by the git extension.

## Summary

`Model.openRepositories` held only **28** repositories, but **137 `Repository` objects** were still reachable. Every repository that has ever been opened is retained, along with its full ref array (~25,600 refs each in this workspace), because of three separate undisposed listeners.

Each retained `Repository` holds `_refs`, and each `GitHistoryProvider._historyItemRefs` rebuilds them with a fresh `ThemeIcon` per ref, giving **2.9M `ThemeIcon` instances** in the snapshot.

The 137 objects cover only **43 distinct repository roots** (`/Users/roblou/code/vscode` alone appears 35 times). That ratio is not a duplicate-creation bug: `Model.openRepository()` is decorated with `@sequentialize`, which serializes all calls, so concurrent callers cannot race past the `getRepositoryExact(repositoryRoot)` guard at `model.ts:620`. It is ordinary open/close churn over a long session as worktrees come and go. Retention is the entire problem.

These leaks all predate the Agents window. They were previously invisible because a normal window opens a handful of repositories and never closes them, so retaining a few dead `Repository` objects costs nothing. The Agents window churns worktrees constantly, and this workspace has an unusually large ref count, which is what makes it add up to 1.2 GB.

## The three retainers

### a) `ProgressManager` never disposes its constructor listeners

`extensions/git/src/repository.ts:360`. The constructor registers:

```ts
const onDidChange = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git', Uri.file(this.repository.root)));
onDidChange(_ => this.updateEnablement());
...
this.repository.onDidChangeOperations(() => { ... });
```

Both return values are discarded. The class does have a `dispose()`, but it only disposes the progress-notification disposable, not these two. The global `workspace.onDidChangeConfiguration` emitter therefore pins every `Repository` ever created. **137 instances in the heap.**

This is likely the single biggest contributor. The class was clean when it was introduced in 2017 (135b261f884); the two listeners were added later in 7ffa9219ae5 (2018) and d904014287c (2022) by people extending the constructor without noticing there was no store to register into.

### b) `GitHubBranchProtectionProviderManager` has no close handling

`extensions/github/src/branchProtection.ts:81-82` adds a provider per `onDidOpenRepository` into a single `DisposableStore` that is only disposed when the whole feature is disabled. There is **no `onDidCloseRepository` handling**. Result: **137 `GitHubBranchProtectionProvider`**, each holding an `ApiRepository` -> `Repository`. This in turn pins `Model.branchProtectionProviders` (`model.ts:246`), whose entries are only removed when a provider unregisters.

The code also only stores the *registration* disposable returned by `registerBranchProtectionProvider`, so the `GitHubBranchProtectionProvider` itself is never disposed and its `octokitService.onDidChangeSessions` listener leaks too.

### c) `ApiRepository` identity makes `DisposableMap` cleanup a no-op

`extensions/git/src/api/api1.ts:415-420`:

```ts
get onDidOpenRepository(): Event {
return mapEvent(this.#model.onDidOpenRepository, r => new ApiRepository(r));
}
get onDidCloseRepository(): Event {
return mapEvent(this.#model.onDidCloseRepository, r => new ApiRepository(r));
}
```

A **fresh wrapper is allocated per event**, and `get repositories` / `getRepository` do the same. Any consumer keying a `Map`/`DisposableMap` by the API repository object will insert on open and then fail to find the key on close.

Confirmed victim: `GitCommitMessageServiceImpl._repositoryDisposables` (`extensions/copilot/src/extension/prompt/vscode-node/gitCommitMessageServiceImpl.ts:35`), where `deleteAndDispose(repository)` never matches. **910 `ApiRepository` objects** in the heap.

This is an API shape bug that silently breaks any extension doing the same thing, so it is worth fixing centrally by caching the `ApiRepository` per underlying `Repository`.

## Amplifiers

- **Sliced strings.** `parseRefs` (`extensions/git/src/git.ts:1280`) regex-execs over the entire multi-MB `for-each-ref` stdout, so every ref name/commit is a V8 *sliced string* pinning its up-to-30 MB parent. **7.3M slices, 140 MB.**
- **ThemeIcon churn.** `historyProvider.ts:104-109` allocates a `new ThemeIcon(...)` per ref. **2.9M instances, 55 MB.**
- **No ref filtering.** `Repository.getRefs()` (`repository.ts:2915`) requests *all* refs with no pattern. `git for-each-ref` in this workspace returns **28,586** refs: 23,158 remotes, 2,094 heads, 1,688 `refs/sessions/*`, 1,139 `refs/agents/*`, 387 tags, 115 `refs/copilot/*`. The agents tooling accumulates `refs/agents/*` and `refs/sessions/*` without pruning.
- **Worktrees.** 80 of the 137 are `kind: worktree`, and 69 share `commonPath=/Users/roblou/code/vscode/.git`, so the ref data is up to 137x identical. Sharing ref state across repositories with the same `commonPath` would be a large win, though it is an architectural change.

## Pending stdout buffers (related, same extension)

Separately, `system / JSArrayBufferData` accounted for **583 MB** across 5,655 buffers, all traced to the `const buffers: Buffer[] = []` accumulation in `exec` (`extensions/git/src/git.ts:231-244`), held by pending promise reactions. Chunks up to 2.6 MB each. This is presumably in-flight `for-each-ref` output from the retained repositories, so fixing the retention should mostly resolve it, but it may be worth capping or streaming.

## Status

#327491 fixes (a) and (b):

- `ProgressManager` now keeps and disposes its two constructor subscriptions.
- `GitHubBranchProtectionProviderManager` now tracks providers in a `Map` keyed by repository root, handles `onDidCloseRepository`, and disposes the `GitHubBranchProtectionProvider` itself.
- `DisposableStore` in the github extension now disposes anything added after it has been disposed, so the async `repository.status().then(...)` registration in the provider constructor cannot outlive the provider.

Left for you, since they need a judgement call: (c) the `ApiRepository` identity issue, plus the `parseRefs` sliced strings and the per-ref `ThemeIcon` allocation.

## Effort estimate

| Fix | Effort | Status |
|---|---|---|
| `ProgressManager` disposal | trivial | done in #327491 |
| Branch protection close handling | trivial | done in #327491 |
| `ApiRepository` identity caching | easy, but needs care re: API compat | open |
| Flatten / line-parse in `parseRefs` | easy-medium | open |
| Share ref data across worktrees by `commonPath` | medium-hard, architectural | open |

(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.