[BUG] VS Code extension spawns a new claude --resume process on every session-tab activation and never stops the previous one
What's Wrong?
The extension starts a new claude --resume=<sessionId> process every time a session tab
becomes active. It does not check whether a process for that session already exists, and
it does not stop the previous one. Processes accumulate for the lifetime of the window.
The duplicate-prevention guard exists but is keyed on channelId, which is freshly
randomised on every launch — so it can never match. There is no sessionId-based liveness
check anywhere in the launch path.
On this machine that reached 42 live claude processes across 15 conversations, holding
10.18 GB — of which 6.52 GB is redundant (27 surplus processes). Eight of the fifteen
sessions had four processes each.
Two consequences beyond the memory:
- Background work started by the superseded process is orphaned. When a tab respawns,
the webview moves its channel to the new process. Any background shell job, subagent,
or monitor the old process started keeps running, but the agent can no longer collect its
result. I hit this with a monitoring script that stayed parented to the abandoned process
and whose completion was never reported.
- Under a two-window condition it forks the transcript and loses history. Filed
separately as #83719, since the severity and reproduction differ.
The duplicates do not make API calls. That is probably the first thing to wonder about,
so I checked it four independent ways: CPU time (0.4–2.0 s over 11 minutes — idle — against
7.0 s for a process actually working), no forked assistant branches in the transcript, all
request IDs unique, and a flat request rate across spawn waves. Token consumption is not
multiplied.
Steps to Reproduce
- Open the Claude Code panel with at least two session tabs.
- From tab A, switch to another tab, then switch back to A.
ps -eo command | grep -c 'claude .*--resume=<A's sessionId>'
The count goes up by one every time you return to A. Nothing exits. Repeat and it grows
without bound. 100% reproducible.
Measured here (extension log, JST):
09:55:31.902 new_conversation_tab
09:55:32.098 launch_claude channelId=b8suysvgszo (no resume)
09:55:32.202 Spawning Claude with SDK query function -> PID 2393
09:55:35.189 get_session_request 46234561-… (switched back)
09:55:35.246 launch_claude channelId=8qubmed6jdp resume="46234561-…"
09:55:35.289 Spawning Claude with SDK query function -> PID 2445
38 → 40 processes, zero exits. Across a full day of logs:
launch_claude = 12 Closing Claude on channel = 0
launch_claude = 10 Closing Claude on channel = 0
Channel already exists = 0 (the guard never fires)
The only Closing Claude on channel lines all day matched moments when I manuallySIGTERM-ed processes — never a new spawn.
Root cause
Names below are from the minified bundle, beautified locally for reading.
1. The duplicate guard is keyed on channelId, not sessionId — and channelId is
freshly randomised per launch, so the guard can never match.
// extension host
async launchClaude(e, t, r, n, i) { // e = channelId, t = resume (sessionId)
if (this.channels.has(e) || this.pendingChannelInputs.has(e)) {
this.logger.error(`Channel already exists: ${e}`); return
}
// webview caller
async launchClaude() {
if (this.claudeChannelId) return …; // per-instance memo only
let e = Math.random().toString(36).slice(2); // new channelId every time
…
let i = t.launchClaude(e, this.sessionId.value, …);
}
this.claudeChannelId lives on the session object instance, so it is lost on webview
reload, panel recreation, or whenever doListSessions() rebuilds those instances. Nothing
consults process state or a session lock.
2. Nothing closes the previous process at spawn time. closeChannel() runs only on an
explicit close_channel from the webview, on panel dispose, or on extension-host exit.sessionPanels is keyed by sessionId, but on collision it overwrites the entry instead of
terminating the previous process:
for (let [f, m] of this.sessionPanels) if (m === e && f !== u) this.sessionPanels.delete(f);
if (this.sessionPanels.set(u, e), e.active) this.activeSessionId = u
3. The trigger is an unconditional reactive effect on active-session change:
uo(() => {
let n = this.activeSession.value;
if (n) { … n.preloadConnection() … } // -> launchClaude()
})
4. deserializeWebviewPanel passes undefined for the sessionId —setupPanel(g, undefined, undefined, E) — so restored panels are never registered insessionPanels. After a VS Code restart the sessionId-level bookkeeping is absent entirely,
which is when I saw the largest batch of duplicates appear.
There is also a bulk-terminate path (Sb + process.on("exit", …)) that SIGTERMs every
spawned child, but it only runs when the extension host itself exits. It is the last safety
net, not per-session cleanup.
Still present in the current release. Reproduced on 2.1.220; in 2.1.221 all four markers
above are byte-identical.
Ruled out
Checked in code and against logs, none of these are the trigger: extension-host
crash/restart (exthost.log shows one Extension host with pid … started, no restarts),
sleep/wake (pmset -g log events don't line up with the spawn times), network reconnect
(network.log is 0 bytes), window focus (onDidChangeWindowState does not appear in the
bundle), auth-token refresh (no matching interval constant), and setInterval health checks
(the four sites are generic polling, voice keepalive, and silence detection).
Measuring memory
Sum RSS across these processes and you get 17.35 GB, but that double-counts shared pages.
Measured with footprint (physical footprint) instead:
15 sessions, 42 processes
processes per session: 4 -> 8 sessions, 3 -> 1, 2 -> 1, 1 -> 5
sum of RSS : 17.35 GB (inflated ~69%)
sum of footprint : 10.18 GB (actual)
surplus (27 procs): 6.52 GB
per process : 110 MB min / 258 MB median / 341 MB max
Some duplicates in this snapshot were induced by my own reproduction testing; the steady
state before testing was 39 processes across 10 conversations.
Suggested direction (untested)
A pointer rather than a fix — I have not validated any of this beyond reading the bundle:
- Key the in-flight registry on
sessionIdrather thanchannelId, and consult it before
spawning.
- In the extension host's
launchClaude(), reverse-look-upthis.channelsby sessionId;
if a live process exists, reuse it, or closeChannel() it before spawning a replacement.
- Pass the sessionId from
deserializeWebviewPanel()intosetupPanel()so restored panels
are tracked.
- Pair the active-session effect's
preloadConnection()with closing the channel of the
session being deactivated.
Detection, if useful
~/.claude/sessions/<pid>.json records {pid, sessionId, cwd, procStart, …} per process.
Because the file is per-PID, up to four live PIDs claimed the same sessionId here. Tooling
that resolves sessionId → process sees only one of them and reports the situation as
healthy, which is why this went unnoticed for days. Counting entries in that directory bysessionId is a reliable detector.
Environment
| | |
|---|---|
| Extension | anthropic.claude-code 2.1.220 (reproduced); 2.1.221 byte-identical on all relevant paths |
| VS Code | 1.131.0, commit e4c7e7b1d6d060162f4aa7f8225271b67ce1df75, arm64 |
| OS | macOS, Darwin 25.5.0, arm64 |
| CLI | Not installed separately — only the extension-bundled binary was running |
Logs
--debug-to-stderr output lands in:
~/Library/Application Support/Code/logs/<launch>/window<N>/exthost/Anthropic.claude-code/Claude VSCode.log
It rotates at 5 MB × 7, which on a busy day is a few hours. All timestamps above come from
these files; happy to supply excerpts.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗