дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(dev): isolate Tauri development sessions per worktree
Цей коміт міститься в:
1 джерело
234703c7e9
коміт
f6a0152488
11 files changed
+363
-6
No files matched your search
+3
-2
@@ -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"
|
||||
|
||||
@@ -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/" } });
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
dunce.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Development identity is tied to the compiled worktree, never the launch cwd.
|
||||
|
||||
pub fn configure(mut context: tauri::Context<tauri::Wry>) -> tauri::Context<tauri::Wry> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<tauri::Wry>) {
|
||||
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())
|
||||
|
||||
@@ -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<String>, cwd: String) {
|
||||
match cli_path(&args, Path::new(&cwd)) {
|
||||
Some(path) => open_path(app, &path),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Заблокувати користувача