diff --git a/src/renderer/actions/projectActions.ts b/src/renderer/actions/projectActions.ts index 26e80de62..a950aae37 100644 --- a/src/renderer/actions/projectActions.ts +++ b/src/renderer/actions/projectActions.ts @@ -45,8 +45,24 @@ export function setProjectDisabled(projectId: string, disabled: boolean): void { store.setProjectDisabled(projectId, disabled); if (disabled) { + const wslDistro = project.location.kind === "wsl" ? project.location.distro : undefined; + const releaseWslDistro = + wslDistro && + !useAppStore + .getState() + .projects.some( + (candidate) => + !candidate.disabled && + candidate.location.kind === "wsl" && + candidate.location.distro === wslDistro, + ) + ? wslDistro + : undefined; void readBridge() - .gitUnwatchProject({ projectId }) + .gitUnwatchProject({ + projectId, + ...(releaseWslDistro ? { releaseWslDistro } : {}), + }) .catch(() => undefined); useGitStore.getState().clearStatus(projectId); diff --git a/src/renderer/state/projectKeys.test.ts b/src/renderer/state/projectKeys.test.ts index 165cc3bf1..ba9eba471 100644 --- a/src/renderer/state/projectKeys.test.ts +++ b/src/renderer/state/projectKeys.test.ts @@ -116,4 +116,32 @@ describe("projectKeys", () => { buildWslProjectDistrosKey(baseProjects), ); }); + + it("ignores disabled projects when building the WSL distro key", () => { + const projects: Project[] = [ + makeProject({ + id: "ubuntu-disabled", + name: "Ubuntu disabled", + disabled: true, + location: { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/disabled", + uncPath: "\\\\wsl$\\Ubuntu\\disabled", + }, + }), + makeProject({ + id: "debian-active", + name: "Debian active", + location: { + kind: "wsl", + distro: "Debian", + linuxPath: "/active", + uncPath: "\\\\wsl$\\Debian\\active", + }, + }), + ]; + + expect(buildWslProjectDistrosKey(projects)).toBe("Debian"); + }); }); diff --git a/src/renderer/state/projectKeys.ts b/src/renderer/state/projectKeys.ts index 7084bb393..9399942c0 100644 --- a/src/renderer/state/projectKeys.ts +++ b/src/renderer/state/projectKeys.ts @@ -22,7 +22,7 @@ export function buildWslProjectDistrosKey(projects: readonly Project[]): string return [ ...new Set( projects.flatMap((project) => - project.location.kind === "wsl" ? [project.location.distro] : [], + !project.disabled && project.location.kind === "wsl" ? [project.location.distro] : [], ), ), ] diff --git a/src/shared/contracts/git.ts b/src/shared/contracts/git.ts index 2797266d1..f1baa1850 100644 --- a/src/shared/contracts/git.ts +++ b/src/shared/contracts/git.ts @@ -630,6 +630,7 @@ export type GitWatchWorktreesPayload = z.infer; diff --git a/src/supervisor/ipcHandlers.ts b/src/supervisor/ipcHandlers.ts index 597f904bd..7ac418377 100644 --- a/src/supervisor/ipcHandlers.ts +++ b/src/supervisor/ipcHandlers.ts @@ -208,7 +208,12 @@ export function createSupervisorIpcHandlers(runtime: SupervisorRuntime): Supervi gitWatchWorktrees: async (payload) => { runtime.projectWatcher.watchWorktrees(payload.projectId, payload.worktreePaths); }, - gitUnwatchProject: (payload) => runtime.projectWatcher.unwatch(payload.projectId), + gitUnwatchProject: async (payload) => { + await runtime.projectWatcher.unwatch(payload.projectId); + if (payload.releaseWslDistro) { + runtime.releaseWslBridgeIfUnused(payload.releaseWslDistro); + } + }, relocateProject: (payload) => runtime.relocateProject(payload), searchProjectFiles: (payload) => fileIndex.searchProjectFiles(payload), listProjectTree: (payload) => projectTree.listProjectTree(payload), diff --git a/src/supervisor/supervisorRuntime.ts b/src/supervisor/supervisorRuntime.ts index 77bf22f4b..9af78fd29 100644 --- a/src/supervisor/supervisorRuntime.ts +++ b/src/supervisor/supervisorRuntime.ts @@ -676,6 +676,18 @@ export class SupervisorRuntime { return {}; } + releaseWslBridgeIfUnused(distro: string): void { + const hasLiveSession = [...this.sessions.values()].some( + (session) => + session.status !== "inactive" && + session.projectLocation.kind === "wsl" && + session.projectLocation.distro === distro, + ); + if (!hasLiveSession) { + this.wslHookBridge?.releaseBridge(distro); + } + } + dispose(): void { void this.disposeAsync(); } diff --git a/src/supervisor/wsl/bridge/index.test.ts b/src/supervisor/wsl/bridge/index.test.ts index 4bd2f1770..421768504 100644 --- a/src/supervisor/wsl/bridge/index.test.ts +++ b/src/supervisor/wsl/bridge/index.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentEventEnvelope } from "@/shared/contracts"; import { WslBridgeServer } from "./index"; @@ -54,14 +54,18 @@ function makeStubbedManager(opts: { helpersDir: string; onEvent: (envelope: AgentEventEnvelope) => void; onError?: (message: string, error?: unknown) => void; + onBridgeExit?: (distro: string) => void; bootPort?: number; child?: FakeChild; + childFactory?: () => FakeChild; + autoBoot?: (spawnCount: number) => boolean; spawnsRef?: { count: number }; onSpawn?: ( opts: Parameters[0]["spawn"]>>[0], ) => void; -}): { manager: WslBridgeServer; child: FakeChild } { +}): { manager: WslBridgeServer; child: FakeChild; children: FakeChild[] } { const child = opts.child ?? new FakeChild(); + const children: FakeChild[] = []; const bootPort = opts.bootPort ?? 54321; const managerOpts: ConstructorParameters[0] = { helpersDir: opts.helpersDir, @@ -75,21 +79,26 @@ function makeStubbedManager(opts: { }), deploy: () => ({ home: "/home/me", linuxBaseDir: "/home/me/.poracode" }), spawn: (childOpts) => { + const spawnedChild = opts.childFactory?.() ?? child; + children.push(spawnedChild); opts.onSpawn?.(childOpts); if (opts.spawnsRef) opts.spawnsRef.count += 1; // Emit boot AFTER spawn returns so attachLineSplitter has wired the // listener — without this the listener would miss the chunk. - setImmediate(() => { - child.stdout.emit( - "data", - Buffer.from(JSON.stringify({ type: "boot", port: bootPort }) + "\n"), - ); - }); - return child as never; + if (!opts.autoBoot || opts.autoBoot(children.length)) { + setImmediate(() => { + spawnedChild.stdout.emit( + "data", + Buffer.from(JSON.stringify({ type: "boot", port: bootPort }) + "\n"), + ); + }); + } + return spawnedChild as never; }, }; if (opts.onError) managerOpts.onError = opts.onError; - return { manager: new WslBridgeServer(managerOpts), child }; + if (opts.onBridgeExit) managerOpts.onBridgeExit = opts.onBridgeExit; + return { manager: new WslBridgeServer(managerOpts), child, children }; } function makeEnvelope(overrides: Partial = {}): AgentEventEnvelope { @@ -153,6 +162,46 @@ describe("WslBridgeServer", () => { await manager.dispose(); }); + it("releases a bridge without treating the intentional exit as a crash", async () => { + const helpersDir = makeHelpersDir(); + const onBridgeExit = vi.fn<(distro: string) => void>(); + const { manager, children } = makeStubbedManager({ + helpersDir, + onEvent: () => undefined, + onBridgeExit, + childFactory: () => new FakeChild(), + }); + + await manager.ensureBridge("Ubuntu"); + manager.releaseBridge("Ubuntu"); + children[0]!.emit("exit", 0, null); + await manager.ensureBridge("Ubuntu"); + + expect(children).toHaveLength(2); + expect(onBridgeExit).not.toHaveBeenCalled(); + await manager.dispose(); + }); + + it("releases a bridge that is still booting", async () => { + const helpersDir = makeHelpersDir(); + const { manager, children } = makeStubbedManager({ + helpersDir, + onEvent: () => undefined, + childFactory: () => new FakeChild(), + autoBoot: (spawnCount) => spawnCount > 1, + }); + + const firstBoot = manager.ensureBridge("Ubuntu"); + await vi.waitFor(() => expect(children).toHaveLength(1)); + manager.releaseBridge("Ubuntu"); + children[0]!.stdout.emit("data", Buffer.from('{"type":"boot","port":54001}\n')); + await firstBoot; + await manager.ensureBridge("Ubuntu"); + + expect(children).toHaveLength(2); + await manager.dispose(); + }); + it("forwards Browser MCP upstream env into the in-WSL bridge", async () => { const oldUrl = process.env.PORACODE_BROWSER_MCP_URL; const oldToken = process.env.PORACODE_BROWSER_MCP_TOKEN; diff --git a/src/supervisor/wsl/bridge/index.ts b/src/supervisor/wsl/bridge/index.ts index d9c357c77..4c89601ef 100644 --- a/src/supervisor/wsl/bridge/index.ts +++ b/src/supervisor/wsl/bridge/index.ts @@ -105,7 +105,7 @@ export interface WatchEvent { export class WslBridgeServer { private readonly bridges = new Map(); private readonly inFlight = new Map>(); - private readonly disposed = new WeakSet(); + private readonly disposed = new WeakSet(); private readonly bootTimeoutMs: number; private readonly watchListeners = new Map(); private isDisposed = false; @@ -158,7 +158,7 @@ export class WslBridgeServer { }); } this.bridges.delete(distro); - this.disposed.add(existing); + this.disposed.add(existing.child); try { terminateChildProcessTree(existing.child); } catch { @@ -195,7 +195,7 @@ export class WslBridgeServer { const state = this.bridges.get(distro); if (!state) continue; this.bridges.delete(distro); - this.disposed.add(state); + this.disposed.add(state.child); this.unregisterWatchListenersForDistro(distro); try { terminateChildProcessTree(state.child); @@ -205,6 +205,24 @@ export class WslBridgeServer { } } + /** Stop Poracode's bridge process without terminating the WSL distro itself. */ + releaseBridge(distro: string): void { + this.unregisterWatchListenersForDistro(distro); + const releaseStartedBridge = (): void => { + const state = this.bridges.get(distro); + if (!state) return; + this.bridges.delete(distro); + this.disposed.add(state.child); + try { + terminateChildProcessTree(state.child); + } catch { + // best effort + } + }; + void this.inFlight.get(distro)?.then(releaseStartedBridge); + releaseStartedBridge(); + } + /** * Spawn + wait-for-boot, with a one-shot retry when the booted bridge * reports a version different from the one we just staged. The retry @@ -415,7 +433,7 @@ export class WslBridgeServer { } if (!booted) { rejectBoot(new Error(`wsl hook bridge[${distro}] exited before boot`)); - } else if (!this.isDisposed) { + } else if (!this.isDisposed && !this.disposed.has(child)) { this.options.onBridgeExit?.(distro); } }; @@ -487,11 +505,12 @@ export class WslBridgeServer { actual: reportedVersion, }); } - this.bridges.set(distro, { + const bridgeState = { child, handle, ...(reportedVersion ? { version: reportedVersion } : {}), - }); + }; + this.bridges.set(distro, bridgeState); return handle; } }