Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/renderer/actions/projectActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions src/renderer/state/projectKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
2 changes: 1 addition & 1 deletion src/renderer/state/projectKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] : [],
),
),
]
Expand Down
1 change: 1 addition & 0 deletions src/shared/contracts/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ export type GitWatchWorktreesPayload = z.infer<typeof gitWatchWorktreesPayloadSc

export const gitUnwatchProjectPayloadSchema = z.object({
projectId: z.string().min(1),
releaseWslDistro: z.string().min(1).optional(),
});
export type GitUnwatchProjectPayload = z.infer<typeof gitUnwatchProjectPayloadSchema>;

Expand Down
7 changes: 6 additions & 1 deletion src/supervisor/ipcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
12 changes: 12 additions & 0 deletions src/supervisor/supervisorRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
69 changes: 59 additions & 10 deletions src/supervisor/wsl/bridge/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<NonNullable<ConstructorParameters<typeof WslBridgeServer>[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<typeof WslBridgeServer>[0] = {
helpersDir: opts.helpersDir,
Expand All @@ -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> = {}): AgentEventEnvelope {
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 25 additions & 6 deletions src/supervisor/wsl/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export interface WatchEvent {
export class WslBridgeServer {
private readonly bridges = new Map<string, BridgeState>();
private readonly inFlight = new Map<string, Promise<BridgeHandle | undefined>>();
private readonly disposed = new WeakSet<BridgeState>();
private readonly disposed = new WeakSet<ChildProcess>();
private readonly bootTimeoutMs: number;
private readonly watchListeners = new Map<string, WatchListenerEntry>();
private isDisposed = false;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -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;
}
}
Expand Down