From f6a015248838db083cdb47b55122ffbfaa98d6d0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:35:28 +0800 Subject: [PATCH] fix(dev): isolate Tauri development sessions per worktree --- package.json | 5 +- scripts/dev-session.test.ts | 95 +++++++++++++++++++++++++++++++++++++ scripts/dev-session.ts | 90 +++++++++++++++++++++++++++++++++++ scripts/dev-status.ts | 51 ++++++++++++++++++++ scripts/tauri.ts | 90 +++++++++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 3 ++ src-tauri/src/dev.rs | 24 ++++++++++ src-tauri/src/lib.rs | 7 ++- src-tauri/src/windows.rs | 1 - vite.config.ts | 2 +- 11 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 scripts/dev-session.test.ts create mode 100644 scripts/dev-session.ts create mode 100644 scripts/dev-status.ts create mode 100644 scripts/tauri.ts create mode 100644 src-tauri/src/dev.rs diff --git a/package.json b/package.json index 07f53c430..55e9c8955 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "tauri": "tauri", + "tauri": "node scripts/tauri.ts", "test": "vitest run", "test:watch": "vitest", "lint": "oxlint", @@ -19,7 +19,8 @@ "gen:bindings": "cargo test -p openidea --test export_bindings", "gen:theme-docs": "node scripts/theme/gen-docs.ts", "theme:import-intellij": "node scripts/theme/import-intellij-theme.ts", - "dev:app": "tauri dev --config src-tauri/tauri.dev.conf.json", + "dev:app": "node scripts/tauri.ts dev --config src-tauri/tauri.dev.conf.json", + "dev:status": "node scripts/dev-status.ts", "e2e": "playwright test", "perf": "node perf/run.ts", "perf:rust": "node perf/rust.ts" diff --git a/scripts/dev-session.test.ts b/scripts/dev-session.test.ts new file mode 100644 index 000000000..4020f66b4 --- /dev/null +++ b/scripts/dev-session.test.ts @@ -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((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + ), + ); +}); + +async function freePort(): Promise { + const server = createServer(); + await new Promise((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((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((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((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/" } }); + }); +}); diff --git a/scripts/dev-session.ts b/scripts/dev-session.ts new file mode 100644 index 000000000..5adbdfa7f --- /dev/null +++ b/scripts/dev-session.ts @@ -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 { + 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((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), + ]; +} diff --git a/scripts/dev-status.ts b/scripts/dev-status.ts new file mode 100644 index 000000000..48c741423 --- /dev/null +++ b/scripts/dev-status.ts @@ -0,0 +1,51 @@ +import { worktreeId, worktreeRoot } from "./dev-session.ts"; + +const identifier = worktreeId(worktreeRoot); + +function inspectBridge(port: number): Promise { + 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; +} diff --git a/scripts/tauri.ts b/scripts/tauri.ts new file mode 100644 index 000000000..c2105137c --- /dev/null +++ b/scripts/tauri.ts @@ -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 { + const child = spawn(process.execPath, [cli, ...args], { + cwd: worktreeRoot, + stdio: "inherit", + detached: process.platform !== "win32", + }); + let timer: ReturnType | 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((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 { + 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; +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f63ec5b81..d012e6533 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,6 +11,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +dunce.workspace = true [dependencies] tauri = { version = "2", features = [] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index edee35c0f..4cca5d5db 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,7 @@ fn main() { + let root = + dunce::canonicalize(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap()).expect("worktree root"); + println!("cargo:rustc-env=OPENIDEA_WORKTREE_ROOT={}", root.display()); // Windows (MSVC): link the Common-Controls v6 manifest into every binary of // this package, not only the app's resources, so test executables such as // `export_bindings` can load comctl32 and don't exit with diff --git a/src-tauri/src/dev.rs b/src-tauri/src/dev.rs new file mode 100644 index 000000000..593d7e4dd --- /dev/null +++ b/src-tauri/src/dev.rs @@ -0,0 +1,24 @@ +//! Development identity is tied to the compiled worktree, never the launch cwd. + +pub fn configure(mut context: tauri::Context) -> tauri::Context { + context.config_mut().identifier = identifier(env!("OPENIDEA_WORKTREE_ROOT")); + context +} + +fn identifier(root: &str) -> String { + // FNV-1a, shared with scripts/dev-session.ts. + let hash = + root.bytes().fold(0xcbf29ce484222325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)); + format!("ai.l0ng.openidea.dev.w{hash:016x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_matches_launcher_and_separates_worktrees() { + assert_eq!(identifier("/repo/idea"), "ai.l0ng.openidea.dev.w158e9c5954b7bf64"); + assert_ne!(identifier("/repo/idea"), identifier("/repo/idea-other")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9005f1bae..e714953c2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ //! adapter from IPC to a backend crate; no domain logic lives here. mod commands; +#[cfg(debug_assertions)] +mod dev; mod events; mod logging; mod menu; @@ -39,6 +41,8 @@ pub fn export_bindings(builder: &tauri_specta::Builder) { pub fn run() { ide_workspace::process::mark_start(); let context = tauri::generate_context!(); + #[cfg(debug_assertions)] + let context = dev::configure(context); logging::init(&context.config().identifier); let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier); @@ -58,8 +62,7 @@ pub fn run() { let app = tauri::Builder::default(); // Single instance first: a second launch focuses this one and opens its path argument. - // Release only, so a dev build can run beside an installed release (same identifier). - #[cfg(not(debug_assertions))] + // Debug builds have a separate identifier per worktree. let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance)); let app = app .plugin(tauri_plugin_opener::init()) diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 7fb0a1b48..7cfbe5b79 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -643,7 +643,6 @@ pub fn path_launch(path: PathBuf) -> Launch { } /// Second instance launched: focus this one and open its path argument. -#[cfg_attr(debug_assertions, allow(dead_code))] pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { match cli_path(&args, Path::new(&cwd)) { Some(path) => open_path(app, &path), diff --git a/vite.config.ts b/vite.config.ts index 8464cbc4f..8e276db72 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,7 +24,7 @@ export default defineConfig(() => ({ port: 1420, strictPort: true, host: host || false, - hmr: host ? { protocol: "ws", host, port: 1421 } : undefined, + hmr: host ? { protocol: "ws", host } : undefined, watch: { ignored: ["**/src-tauri/**", "**/crates/**", "**/target/**"] }, }, build: {