fix(dev): isolate Tauri development sessions per worktree

Цей коміт міститься в:
l0ng-ai committed 2026-10-29 22:35:28 +08:00
1 джерело 234703c7e9
коміт f6a0152488
11 files changed
+363 -6

No files matched your search

+95
Переглянути файл
@@ -0,0 +1,95 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from "vitest";
import { createServer, type Server } from "node:net";
import { claimSession, preferredVitePort, withDevConfig, worktreeId } from "./dev-session.ts";
const servers: Server[] = [];
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
),
);
});
async function freePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing TCP address");
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
}
describe("development worktrees", () => {
it("keeps Vite origins stable and uses an available port when the preferred port is occupied", async () => {
const { createServer: createVite } = await import("vite");
const preferred = preferredVitePort(worktreeId("/repo/idea"));
expect(preferred).toBe(preferredVitePort(worktreeId("/repo/idea")));
expect(preferred).not.toBe(preferredVitePort(worktreeId("/repo/idea-other")));
const port = await freePort();
const blocker = createServer();
await new Promise<void>((resolve) => blocker.listen(port, "127.0.0.1", resolve));
servers.push(blocker);
const vite = await createVite({
configFile: false,
logLevel: "silent",
server: { host: "127.0.0.1", port, strictPort: false, watch: null },
});
try {
await vite.listen();
const address = vite.httpServer!.address();
expect(address && typeof address !== "string" && address.port).toBeGreaterThan(port);
expect(vite.resolvedUrls?.local[0]).not.toContain(`:${port}/`);
} finally {
await vite.close();
}
});
it("matches the Rust identity and separates paths, including Unicode", () => {
expect(worktreeId("/repo/idea")).toBe("ai.l0ng.openidea.dev.w158e9c5954b7bf64");
expect(worktreeId("/repo/idea")).not.toBe(worktreeId("/repo/idea-other"));
expect(worktreeId("/repo/项目")).not.toBe(worktreeId("/repo/项目2"));
});
it("allows exactly one launcher during concurrent starts and releases the lock", async () => {
const port = await freePort();
const session = { worktree: "/repo/idea", identifier: worktreeId("/repo/idea"), pid: process.pid };
const claims = await Promise.all(Array.from({ length: 8 }, () => claimSession(session, port)));
const owners = claims.filter((claim) => "server" in claim);
for (const owner of owners) servers.push(owner.server);
expect(owners).toHaveLength(1);
expect(claims.filter((claim) => "existing" in claim)).toHaveLength(7);
const server = servers.pop()!;
await new Promise<void>((resolve) => server.close(() => resolve()));
const restarted = await claimSession(session, port);
expect("server" in restarted).toBe(true);
if ("server" in restarted) servers.push(restarted.server);
});
it("keeps different worktrees independent even when their control ports collide", async () => {
const port = await freePort();
for (const root of ["/repo/a", "/repo/b"]) {
const claim = await claimSession({ worktree: root, identifier: worktreeId(root), pid: process.pid }, port);
expect("server" in claim).toBe(true);
if ("server" in claim) servers.push(claim.server);
}
const repeated = await claimSession(
{ worktree: "/repo/b", identifier: worktreeId("/repo/b"), pid: process.pid },
port,
);
expect("existing" in repeated && repeated.existing.worktree).toBe("/repo/b");
});
it("keeps runtime arguments intact and applies the actual Vite URL last", () => {
const args = withDevConfig(
["dev", "--config", "custom.json", "--", "--features", "test", "--", "/project"],
"http://localhost:54321/",
);
expect(args.slice(-5)).toEqual(["--", "--features", "test", "--", "/project"]);
expect(JSON.parse(args[4]!)).toEqual({ build: { beforeDevCommand: "", devUrl: "http://localhost:54321/" } });
});
});
+90
Переглянути файл
@@ -0,0 +1,90 @@
import { realpathSync } from "node:fs";
import { createConnection, createServer, type Server } from "node:net";
import { fileURLToPath } from "node:url";
export const worktreeRoot = realpathSync(fileURLToPath(new URL("..", import.meta.url)));
export function worktreeId(root: string): string {
let hash = 0xcbf29ce484222325n;
for (const byte of Buffer.from(root)) {
hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n);
}
return `ai.l0ng.openidea.dev.w${hash.toString(16).padStart(16, "0")}`;
}
export interface DevSession {
worktree: string;
identifier: string;
pid: number;
devUrl?: string;
}
export function preferredVitePort(identifier: string): number {
return 15000 + (Number.parseInt(identifier.slice(-8), 16) % 20000);
}
function readSession(port: number): Promise<DevSession | undefined> {
return new Promise((resolve) => {
const socket = createConnection({ host: "127.0.0.1", port });
let data = "";
const finish = (session?: DevSession) => {
socket.destroy();
resolve(session);
};
socket.setTimeout(1000, () => finish());
socket.on("error", () => finish());
socket.on("data", (chunk) => {
data += chunk.toString();
if (data.length > 16384) finish();
});
socket.on("end", () => {
try {
finish(JSON.parse(data));
} catch {
finish();
}
});
});
}
// A live OS listener is the lock: no stale PID files after crashes or PID reuse.
export async function claimSession(
session: DevSession,
firstPort = 43000 + (Number.parseInt(session.identifier.slice(-8), 16) % 10000),
): Promise<{ server: Server } | { existing: DevSession }> {
for (let offset = 0; offset < 100; offset++) {
const port = firstPort + offset;
const server = createServer((socket) => {
socket.on("error", () => {});
socket.end(JSON.stringify(session));
});
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.removeListener("error", reject);
resolve();
});
});
return { server };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
const existing = await readSession(port);
if (existing?.worktree === session.worktree && existing.identifier === session.identifier) {
return { existing };
}
}
}
throw new Error("No available development session port");
}
export function withDevConfig(args: string[], devUrl: string): string[] {
const separator = args.indexOf("--");
const index = separator < 0 ? args.length : separator;
return [
...args.slice(0, index),
"--config",
JSON.stringify({ build: { beforeDevCommand: "", devUrl } }),
...args.slice(index),
];
}
+51
Переглянути файл
@@ -0,0 +1,51 @@
import { worktreeId, worktreeRoot } from "./dev-session.ts";
const identifier = worktreeId(worktreeRoot);
function inspectBridge(port: number): Promise<unknown | undefined> {
return new Promise((resolve) => {
const socket = new WebSocket(`ws://127.0.0.1:${port}`);
let finished = false;
const finish = (state?: unknown) => {
if (finished) return;
finished = true;
clearTimeout(timer);
socket.close();
resolve(state);
};
const timer = setTimeout(() => finish(), 2000);
socket.addEventListener("error", () => finish());
socket.addEventListener("open", () => {
socket.send(
JSON.stringify({
id: "dev-status",
command: "invoke_tauri",
args: { command: "plugin:mcp-bridge|get_backend_state" },
}),
);
});
socket.addEventListener("message", (event) => {
try {
const response = JSON.parse(String(event.data));
if (response.id !== "dev-status") return;
const state = response.data;
finish(
response.success && state?.app?.identifier === identifier
? { port, appIdentifier: port, ...state }
: undefined,
);
} catch {
finish();
}
});
});
}
const bridges = (await Promise.all(Array.from({ length: 100 }, (_, i) => inspectBridge(9223 + i)))).filter(
(bridge) => bridge !== undefined,
);
console.log(JSON.stringify({ worktree: worktreeRoot, identifier, bridges }, null, 2));
if (bridges.length === 0) {
console.error("No MCP bridge found for this worktree. The app may still be compiling or not running.");
process.exitCode = 1;
}
+90
Переглянути файл
@@ -0,0 +1,90 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import { claimSession, preferredVitePort, withDevConfig, worktreeId, worktreeRoot } from "./dev-session.ts";
import type { DevSession } from "./dev-session.ts";
const require = createRequire(import.meta.url);
const cli = require.resolve("@tauri-apps/cli/tauri.js");
function stopChild(child: ChildProcess, signal: NodeJS.Signals) {
if (!child.pid) return;
if (process.platform === "win32") {
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
} else {
try {
process.kill(-child.pid, signal);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
}
}
async function runCli(args: string[]): Promise<number> {
const child = spawn(process.execPath, [cli, ...args], {
cwd: worktreeRoot,
stdio: "inherit",
detached: process.platform !== "win32",
});
let timer: ReturnType<typeof setTimeout> | undefined;
const stop = () => {
stopChild(child, "SIGTERM");
timer ??= setTimeout(() => stopChild(child, "SIGKILL"), 5000);
timer.unref();
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
try {
return await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
});
} finally {
if (timer) clearTimeout(timer);
stopChild(child, "SIGTERM");
process.off("SIGINT", stop);
process.off("SIGTERM", stop);
}
}
async function main(): Promise<number> {
const args = process.argv.slice(2);
if (args[0] !== "dev" || args.includes("--help") || args.includes("-h") || args.includes("--release")) {
return runCli(args);
}
const session: DevSession = {
worktree: worktreeRoot,
identifier: worktreeId(worktreeRoot),
pid: process.pid,
};
const claim = await claimSession(session);
if ("existing" in claim) {
console.log("Development session already running for this worktree:");
console.log(JSON.stringify(claim.existing, null, 2));
console.log("Use pnpm dev:status to find its MCP port.");
return 0;
}
let vite: import("vite").ViteDevServer | undefined;
try {
const { createServer } = await import("vite");
vite = await createServer({
root: worktreeRoot,
server: { port: preferredVitePort(session.identifier), strictPort: false },
});
await vite.listen();
session.devUrl = vite.resolvedUrls?.local[0] ?? vite.resolvedUrls?.network[0];
if (!session.devUrl) throw new Error("Vite did not expose a development URL");
console.log(JSON.stringify(session, null, 2));
console.log("Use pnpm dev:status after the app starts to find its MCP port.");
return await runCli(withDevConfig(args, session.devUrl));
} finally {
await vite?.close();
claim.server.close();
}
}
try {
process.exitCode = await main();
} catch (error) {
console.error(error);
process.exitCode = 1;
}