diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c7ff8f45c..8b966e2b5 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -103,6 +103,7 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { } import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' +import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy' import { assertWorktreeDoesNotContainRegisteredWorktree, canCleanupUnregisteredOrcaLeftoverDirectory, @@ -161,6 +162,9 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin // drop process-local caches before the same ID can point at a new workspace. store.removeWorktreeMeta(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) + // Why: drop this worktree's localhost label routes so they don't accumulate + // in the proxy's route maps for the rest of the session. + localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) // Why: release the removed worktree's PR-refresh aliases so coalesced queue // entries do not retain it for the rest of the session (memory creep). diff --git a/src/main/localhost-worktree-label-proxy.test.ts b/src/main/localhost-worktree-label-proxy.test.ts index 49a5e866e..7d4992098 100644 --- a/src/main/localhost-worktree-label-proxy.test.ts +++ b/src/main/localhost-worktree-label-proxy.test.ts @@ -124,4 +124,33 @@ describe('localhost worktree label proxy', () => { expect(result.status).toBe(404) }) + + it('drops a worktree label routes on unregisterWorktree, leaving others intact', async () => { + const port = await startUpstream((_request, response) => { + response.writeHead(200, { 'content-type': 'text/plain' }) + response.end('ok') + }) + const proxy = new LocalhostWorktreeLabelProxy() + const a = await proxy.registerRoute({ + targetUrl: `http://localhost:${port}/`, + projectName: 'Snap Studio', + worktreeName: 'feature-a', + worktreeId: 'wt-a' + }) + const b = await proxy.registerRoute({ + targetUrl: `http://localhost:${port}/`, + projectName: 'Snap Studio', + worktreeName: 'feature-b', + worktreeId: 'wt-b' + }) + + expect((await fetchThroughProxy(a.url)).status).toBe(200) + expect((await fetchThroughProxy(b.url)).status).toBe(200) + + proxy.unregisterWorktree('wt-a') + + // The removed worktree's label no longer routes; the other worktree is unaffected. + expect((await fetchThroughProxy(a.url)).status).toBe(404) + expect((await fetchThroughProxy(b.url)).status).toBe(200) + }) }) diff --git a/src/main/localhost-worktree-label-proxy.ts b/src/main/localhost-worktree-label-proxy.ts index b2f815270..acc1164d1 100644 --- a/src/main/localhost-worktree-label-proxy.ts +++ b/src/main/localhost-worktree-label-proxy.ts @@ -49,6 +49,18 @@ export class LocalhostWorktreeLabelProxy { } } + // Why: routes are added per (worktreeId, targetUrl) but were never removed, so + // labels for deleted worktrees accumulated in both maps for the whole session. + // Drop them when the worktree is torn down. The shared http.Server stays up. + unregisterWorktree(worktreeId: string): void { + for (const [label, route] of this.routes) { + if (route.worktreeId === worktreeId) { + this.routes.delete(label) + this.routeKeys.delete(route.routeKey) + } + } + } + private async ensureServer(): Promise { if (this.server && this.listenPort !== null) { return