Git extension retains ~1.2GB of refs in the Agents window: duplicate Repository creation + undisposed listeners
- 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
Assessment
This issue has not been assessed yet.