diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 98b053290..8e83de5c3 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -39,6 +39,12 @@ const LITHE_GITIGNORE_ENTRIES: &[&str] = &["run/local.json", "toolchains/local.j pub struct RunProcessManager; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RunSessionKey { + window_label: String, + session_id: String, +} + struct RunningSession { pid: u32, stdin: Option, @@ -50,11 +56,18 @@ impl Default for RunProcessManager { } } -fn sessions() -> &'static Mutex> { - static SESSIONS: OnceLock>> = OnceLock::new(); +fn sessions() -> &'static Mutex> { + static SESSIONS: OnceLock>> = OnceLock::new(); SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) } +fn run_session_key(window_label: &str, session_id: &str) -> RunSessionKey { + RunSessionKey { + window_label: window_label.to_string(), + session_id: session_id.to_string(), + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WriteGeneratedArgs { @@ -148,6 +161,7 @@ pub struct ResolvedLaunch { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StartProcessArgs { + pub window_label: String, pub session_id: String, pub executable: String, pub arguments: Vec, @@ -261,7 +275,10 @@ pub fn run_resolve_launch(args: ResolveLaunchArgs) -> Result Result<(), String> { - stop_session(&args.session_id); + if args.window_label.trim().is_empty() { + return Err("A run process must be started from an active window.".into()); + } + stop_session(&args.window_label, &args.session_id); let mut command = command_for_executable(&args.executable, &args.arguments); command .current_dir(&args.working_directory) @@ -277,15 +294,33 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S let stdin = child.stdin.take(); let stdout = child.stdout.take(); let stderr = child.stderr.take(); + let session_key = run_session_key(&args.window_label, &args.session_id); sessions() .lock() .map_err(|_| "Run process state is unavailable".to_string())? - .insert(args.session_id.clone(), RunningSession { pid, stdin }); + .insert( + session_key, + RunningSession { + pid, + stdin, + }, + ); - let stdout_reader = spawn_output_reader(app.clone(), args.session_id.clone(), stdout); - let stderr_reader = spawn_output_reader(app.clone(), args.session_id.clone(), stderr); + let stdout_reader = spawn_output_reader( + app.clone(), + args.window_label.clone(), + args.session_id.clone(), + stdout, + ); + let stderr_reader = spawn_output_reader( + app.clone(), + args.window_label.clone(), + args.session_id.clone(), + stderr, + ); spawn_exit_waiter( app, + args.window_label, args.session_id, child, pid, @@ -296,18 +331,18 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S } #[tauri::command] -pub fn run_stop_process(session_id: String) -> Result<(), String> { - stop_session(&session_id); +pub fn run_stop_process(window_label: String, session_id: String) -> Result<(), String> { + stop_session(&window_label, &session_id); Ok(()) } #[tauri::command] -pub fn run_write_stdin(session_id: String, input: String) -> Result<(), String> { +pub fn run_write_stdin(window_label: String, session_id: String, input: String) -> Result<(), String> { let mut current = sessions() .lock() .map_err(|_| "Run process state is unavailable".to_string())?; let session = current - .get_mut(&session_id) + .get_mut(&run_session_key(&window_label, &session_id)) .ok_or_else(|| "The run process is no longer active.".to_string())?; let stdin = session .stdin @@ -1421,6 +1456,7 @@ fn runtime_version_parts(version: &str) -> Vec { fn spawn_output_reader( app: AppHandle, + window_label: String, session_id: String, stream: Option, ) -> thread::JoinHandle<()> { @@ -1434,7 +1470,8 @@ fn spawn_output_reader( if !pending.is_empty() { let chunk = decode_process_bytes(&pending); if !chunk.is_empty() { - let _ = app.emit( + let _ = app.emit_to( + &window_label, "run-output", json!({ "sessionId": session_id, "chunk": chunk }), ); @@ -1454,7 +1491,8 @@ fn spawn_output_reader( if chunk.is_empty() { continue; } - let _ = app.emit( + let _ = app.emit_to( + &window_label, "run-output", json!({ "sessionId": session_id, "chunk": chunk }), ); @@ -1467,6 +1505,7 @@ fn spawn_output_reader( fn spawn_exit_waiter( app: AppHandle, + window_label: String, session_id: String, mut child: Child, pid: u32, @@ -1481,10 +1520,11 @@ fn spawn_exit_waiter( .unwrap_or(-1); let _ = stdout_reader.join(); let _ = stderr_reader.join(); + let session_key = run_session_key(&window_label, &session_id); let stale = match sessions().lock() { - Ok(mut current) => match current.get(&session_id) { + Ok(mut current) => match current.get(&session_key) { Some(session) if session.pid == pid => { - current.remove(&session_id); + current.remove(&session_key); false } _ => true, @@ -1494,18 +1534,23 @@ fn spawn_exit_waiter( if stale { return; } - let _ = app.emit( + let _ = app.emit_to( + &window_label, "run-exit", json!({ "sessionId": session_id, "exitCode": exit_code }), ); }); } -fn stop_session(session_id: &str) { +fn stop_session(window_label: &str, session_id: &str) { let pid = sessions() .lock() .ok() - .and_then(|mut current| current.remove(session_id).map(|session| session.pid)); + .and_then(|mut current| { + current + .remove(&run_session_key(window_label, session_id)) + .map(|session| session.pid) + }); if let Some(pid) = pid { let mut command = Command::new("taskkill"); command.args(["/F", "/T", "/PID", &pid.to_string()]); @@ -1878,10 +1923,18 @@ mod tests { assert!(looks_like_real_utf8(b"[INFO] BUILD SUCCESS")); } + #[test] + fn run_session_keys_are_scoped_by_window_label() { + let left = run_session_key("project-a", "primary"); + let right = run_session_key("project-b", "primary"); + assert_ne!(left, right); + assert_eq!(left, run_session_key("project-a", "primary")); + } + #[test] fn stdin_write_rejects_an_inactive_session() { let session_id = format!("missing-{}", std::process::id()); - let error = run_write_stdin(session_id, "input\n".to_string()).unwrap_err(); + let error = run_write_stdin("main".into(), session_id, "input\n".to_string()).unwrap_err(); assert_eq!(error, "The run process is no longer active."); } diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts new file mode 100644 index 000000000..91a8b98a9 --- /dev/null +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const eventHandlers = new Map void>(); +const windowListen = mock(async (event: string, handler: (event: { payload: unknown }) => void) => { + eventHandlers.set(event, handler); + return () => { + eventHandlers.delete(event); + }; +}); +const globalListen = mock(async () => () => {}); +const appendOutput = mock(() => undefined); +const finishProcess = mock(() => undefined); +const releaseMavenSessionWorkspace = mock(() => undefined); + +mock.module("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ + label: "workspace-1", + listen: windowListen, + }), +})); +mock.module("@tauri-apps/api/event", () => ({ listen: globalListen })); +mock.module("../stores/maven.store", () => ({ + mavenStoreForSession: () => ({ + getState: () => ({ + actions: { appendOutput, finishProcess }, + }), + }), + releaseMavenSessionWorkspace, +})); + +const { ensureMavenProcessListeners } = await import("./use-maven-process-events"); + +describe("maven process event listeners", () => { + beforeEach(() => { + appendOutput.mockClear(); + finishProcess.mockClear(); + releaseMavenSessionWorkspace.mockClear(); + }); + + test("registers run-output and run-exit on the current webview window", async () => { + await ensureMavenProcessListeners(); + + expect(windowListen).toHaveBeenCalledTimes(2); + expect(windowListen).toHaveBeenCalledWith("run-output", expect.any(Function)); + expect(windowListen).toHaveBeenCalledWith("run-exit", expect.any(Function)); + expect(globalListen).not.toHaveBeenCalled(); + expect(eventHandlers.has("run-output")).toBe(true); + expect(eventHandlers.has("run-exit")).toBe(true); + }); + + test("ignores non-maven session output", () => { + const outputHandler = eventHandlers.get("run-output"); + expect(outputHandler).toBeDefined(); + + outputHandler?.({ + payload: { sessionId: "primary", chunk: "leaked\n" }, + }); + + expect(appendOutput).not.toHaveBeenCalled(); + }); + + test("routes maven session output through the maven store", () => { + const outputHandler = eventHandlers.get("run-output"); + expect(outputHandler).toBeDefined(); + + outputHandler?.({ + payload: { sessionId: "maven:task-1", chunk: "BUILD SUCCESS\n" }, + }); + + expect(appendOutput).toHaveBeenCalledWith("maven:task-1", "BUILD SUCCESS\n"); + }); +}); diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts index 252e3c3b1..fe21994f4 100644 --- a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts @@ -1,4 +1,5 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { mavenStoreForSession, releaseMavenSessionWorkspace } from "../stores/maven.store"; interface RunOutputEvent { @@ -15,8 +16,9 @@ let outputUnlisten: UnlistenFn | undefined; let exitUnlisten: UnlistenFn | undefined; export async function ensureMavenProcessListeners(): Promise { + const currentWindow = getCurrentWebviewWindow(); if (!outputUnlisten) { - outputUnlisten = await listen("run-output", (event) => { + outputUnlisten = await currentWindow.listen("run-output", (event) => { if (!event.payload.sessionId.startsWith("maven:")) return; mavenStoreForSession(event.payload.sessionId) .getState() @@ -24,7 +26,7 @@ export async function ensureMavenProcessListeners(): Promise { }); } if (!exitUnlisten) { - exitUnlisten = await listen("run-exit", (event) => { + exitUnlisten = await currentWindow.listen("run-exit", (event) => { const sessionId = event.payload.sessionId; if (!sessionId.startsWith("maven:")) return; mavenStoreForSession(sessionId) diff --git a/windows/tauri/src/features/run/api/run-host-api.test.ts b/windows/tauri/src/features/run/api/run-host-api.test.ts new file mode 100644 index 000000000..a026a0227 --- /dev/null +++ b/windows/tauri/src/features/run/api/run-host-api.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const invoke = mock(async () => undefined); +const getCurrentWebviewWindow = mock(() => ({ label: "project-window" })); + +mock.module("@/platform/tauri-core", () => ({ invoke })); +mock.module("@tauri-apps/api/webviewWindow", () => ({ getCurrentWebviewWindow })); + +const { startRunProcess, stopRunProcess, writeRunStdin } = await import("../api/run-host-api"); + +describe("run host API window scoping", () => { + beforeEach(() => { + invoke.mockClear(); + getCurrentWebviewWindow.mockClear(); + }); + + test("startRunProcess includes the current window label", async () => { + await startRunProcess({ + sessionId: "primary", + executable: "go.exe", + arguments: ["run", "."], + workingDirectory: "D:\\demo", + environment: {}, + }); + + expect(invoke).toHaveBeenCalledWith("run_start_process", { + args: { + sessionId: "primary", + executable: "go.exe", + arguments: ["run", "."], + workingDirectory: "D:\\demo", + environment: {}, + windowLabel: "project-window", + }, + }); + }); + + test("stop and stdin commands include the current window label", async () => { + await stopRunProcess("primary"); + await writeRunStdin("primary", "input\n"); + + expect(invoke).toHaveBeenNthCalledWith(1, "run_stop_process", { + windowLabel: "project-window", + sessionId: "primary", + }); + expect(invoke).toHaveBeenNthCalledWith(2, "run_write_stdin", { + windowLabel: "project-window", + sessionId: "primary", + input: "input\n", + }); + }); +}); diff --git a/windows/tauri/src/features/run/api/run-host-api.ts b/windows/tauri/src/features/run/api/run-host-api.ts index 0e587b8ee..bb686ddbc 100644 --- a/windows/tauri/src/features/run/api/run-host-api.ts +++ b/windows/tauri/src/features/run/api/run-host-api.ts @@ -1,4 +1,5 @@ import { invoke } from "@/platform/tauri-core"; +import { getRunWindowLabel } from "../utils/run-window-context"; import type { GenericRuntime, GlobalToolchain, @@ -27,7 +28,11 @@ export function writeRunDocuments( } export function writeRunStdin(sessionId: string, input: string) { - return invoke("run_write_stdin", { sessionId, input }); + return invoke("run_write_stdin", { + windowLabel: getRunWindowLabel(), + sessionId, + input, + }); } export function discoverRunToolchains(root: string, selected?: GlobalToolchain) { @@ -63,9 +68,17 @@ export function startRunProcess(args: { workingDirectory: string; environment: Record; }) { - return invoke("run_start_process", { args }); + return invoke("run_start_process", { + args: { + ...args, + windowLabel: getRunWindowLabel(), + }, + }); } export function stopRunProcess(sessionId: string) { - return invoke("run_stop_process", { sessionId }); + return invoke("run_stop_process", { + windowLabel: getRunWindowLabel(), + sessionId, + }); } diff --git a/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts new file mode 100644 index 000000000..430d4e784 --- /dev/null +++ b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const eventHandlers = new Map void>(); +const windowListen = mock(async (event: string, handler: (event: { payload: unknown }) => void) => { + eventHandlers.set(event, handler); + return () => { + eventHandlers.delete(event); + }; +}); +const globalListen = mock(async () => () => {}); +const appendOutput = mock(() => undefined); +const finishProcess = mock(() => undefined); +const releaseRunSessionWorkspace = mock(() => undefined); + +mock.module("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ + label: "project-window", + listen: windowListen, + }), +})); +mock.module("@tauri-apps/api/event", () => ({ listen: globalListen })); +mock.module("../stores/run.store", () => ({ + runStoreForSession: () => ({ + getState: () => ({ + actions: { appendOutput, finishProcess }, + }), + }), + releaseRunSessionWorkspace, +})); + +const { ensureRunProcessListeners } = await import("./use-run-process-events"); + +describe("run process event listeners", () => { + beforeEach(() => { + appendOutput.mockClear(); + finishProcess.mockClear(); + releaseRunSessionWorkspace.mockClear(); + }); + + test("registers run-output and run-exit on the current webview window", async () => { + await ensureRunProcessListeners(); + + expect(windowListen).toHaveBeenCalledTimes(2); + expect(windowListen).toHaveBeenCalledWith("run-output", expect.any(Function)); + expect(windowListen).toHaveBeenCalledWith("run-exit", expect.any(Function)); + expect(globalListen).not.toHaveBeenCalled(); + expect(eventHandlers.has("run-output")).toBe(true); + expect(eventHandlers.has("run-exit")).toBe(true); + }); + + test("does not register duplicate listeners on repeated setup", async () => { + const callsBefore = windowListen.mock.calls.length; + await ensureRunProcessListeners(); + expect(windowListen.mock.calls.length).toBe(callsBefore); + expect(globalListen).not.toHaveBeenCalled(); + }); + + test("routes output events through the run store for this window", () => { + const outputHandler = eventHandlers.get("run-output"); + expect(outputHandler).toBeDefined(); + + outputHandler?.({ + payload: { sessionId: "primary", chunk: "hello\n" }, + }); + + expect(appendOutput).toHaveBeenCalledWith("primary", "hello\n"); + }); +}); diff --git a/windows/tauri/src/features/run/hooks/use-run-process-events.ts b/windows/tauri/src/features/run/hooks/use-run-process-events.ts index 46bbeae65..d11ba1994 100644 --- a/windows/tauri/src/features/run/hooks/use-run-process-events.ts +++ b/windows/tauri/src/features/run/hooks/use-run-process-events.ts @@ -1,4 +1,5 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { releaseRunSessionWorkspace, runStoreForSession } from "../stores/run.store"; interface RunOutputEvent { @@ -15,15 +16,16 @@ let outputUnlisten: UnlistenFn | undefined; let exitUnlisten: UnlistenFn | undefined; export async function ensureRunProcessListeners(): Promise { + const currentWindow = getCurrentWebviewWindow(); if (!outputUnlisten) { - outputUnlisten = await listen("run-output", (event) => { + outputUnlisten = await currentWindow.listen("run-output", (event) => { runStoreForSession(event.payload.sessionId) .getState() .actions.appendOutput(event.payload.sessionId, event.payload.chunk); }); } if (!exitUnlisten) { - exitUnlisten = await listen("run-exit", (event) => { + exitUnlisten = await currentWindow.listen("run-exit", (event) => { const sessionId = event.payload.sessionId; runStoreForSession(sessionId).getState().actions.finishProcess(sessionId, event.payload.exitCode); releaseRunSessionWorkspace(sessionId); diff --git a/windows/tauri/src/features/run/utils/run-window-context.ts b/windows/tauri/src/features/run/utils/run-window-context.ts new file mode 100644 index 000000000..aaa3c44b5 --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-window-context.ts @@ -0,0 +1,5 @@ +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; + +export function getRunWindowLabel(): string { + return getCurrentWebviewWindow().label; +}