From d5fe771eed3cc79f4469bb8f62e177d706297a86 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Fri, 30 Oct 2026 23:02:20 +0800 Subject: [PATCH] fix(lsp): allow long jdtls workspace builds and make Java launch resolution cancellable (#449) java/buildWorkspace builds the whole imported jdtls workspace, and its 5-minute cap failed launches in large multi-module projects. The budget is now 20 minutes, and the timeout error names it so a real stall can be told apart. Waiting that long needs a way out, so java_resolve_launch now takes a RequestId and is cancellable locally and over remote. The token reaches the jdtls ready and import waits, the requests (sending $/cancelRequest), and the external build. Stopping a run session during this phase cancels silently and does not launch. Starting a debug session passes a request id but has no stop path yet. Closes #373 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-host/src/ops/java.rs | 31 ++++- crates/ide-lsp/src/jdtls.rs | 121 +++++++++++++++---- src-tauri/src/commands/java.rs | 6 +- src/bindings.ts | 3 +- src/features/debugger/__tests__/java.test.ts | 2 +- src/features/debugger/java.ts | 4 +- src/features/run/resolveCancellation.test.ts | 82 +++++++++++++ src/features/run/store.ts | 35 +++++- 8 files changed, 245 insertions(+), 39 deletions(-) create mode 100644 src/features/run/resolveCancellation.test.ts diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index a43eca543..69b02708a 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -4,10 +4,11 @@ use crate::rpc::Dispatcher; use super::build::load; use ide_build::compile; -use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_core::{CancellationToken, IdeError, IdeResult, RequestId}; use ide_lsp::jdtls::JavaLaunchInfo; use std::path::PathBuf; +/// Cancellable with `cancel_request(request)`. /// Builds the workspace (when `build`), then resolves the runtime (or test) /// classpath and the JDK of the module at `module` (absolute, or relative to the project root). /// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first. @@ -17,17 +18,33 @@ pub async fn java_resolve_launch( module: PathBuf, test: bool, build: bool, + request: RequestId, +) -> IdeResult { + let cancel = host.begin_request(request); + let result = resolve_launch(host, window, module, test, build, &cancel).await; + host.end_request(request); + result +} + +async fn resolve_launch( + host: &Host, + window: &str, + module: PathBuf, + test: bool, + build: bool, + cancel: &CancellationToken, ) -> IdeResult { let project = host.project(window)?; let module = project.root.join(module); let settings = project.settings.effective(); // Let jdtls finish its build before the external compiler writes Kotlin/Scala classes. - let mut info = project.lsp.java_launch(&module, test, build).await?; + let mut info = project.lsp.java_launch(&module, test, build, cancel).await?; let built = if build && compile::has_non_java_sources(&module) { let (root, cache, module, settings) = (project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone()); + let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { - compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new()) + compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &cancel) }) .await .map_err(|e| IdeError::internal(e.to_string()))?? @@ -65,6 +82,9 @@ pub async fn java_resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))?; info.hints.extend(lombok.problem()); } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } Ok(info) } @@ -83,13 +103,14 @@ pub async fn java_build_workspace( ) -> IdeResult { let project = host.project(window)?; let module = project.root.join(module); - project.lsp.java_build(&module).await + project.lsp.java_build(&module, &CancellationToken::new()).await } /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { d.add("java_resolve_launch", |c| async move { - java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await + java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?) + .await }); d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await }); d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await }); diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 15279fbe5..ef883f9cd 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -8,8 +8,7 @@ use crate::LspManager; use crate::server::{Running, Server}; use ide_core::error::ErrorKind; -use ide_core::{IdeError, IdeResult, LanguageId}; -use ide_jsonrpc::RpcError; +use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use specta::Type; @@ -20,7 +19,9 @@ use std::time::{Duration, Instant}; const SERVER: &str = "jdtls"; /// First start imports the Maven / Gradle model, which can take minutes. const READY_TIMEOUT: Duration = Duration::from_secs(240); -const BUILD_TIMEOUT: Duration = Duration::from_secs(300); +/// A workspace build of a large multi-module project can take far longer than +/// an editor request; the user can always cancel the launch waiting on it. +const BUILD_TIMEOUT: Duration = Duration::from_secs(20 * 60); const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); /// How long to retry while the project is still being imported. const IMPORT_WAIT: Duration = Duration::from_secs(90); @@ -163,10 +164,19 @@ fn missing_bundle(e: &IdeError) -> bool { m.contains("no delegatecommandhandler") || m.contains("unsupported command") || m.contains("not supported") } +/// Cancel local waits without interrupting a shared server's startup or import. +async fn cancellable_wait(cancel: &CancellationToken, wait: impl std::future::Future) -> IdeResult { + tokio::select! { + biased; + _ = cancel.cancelled() => Err(IdeError::cancelled()), + result = wait => Ok(result), + } +} + impl LspManager { /// The jdtls instance serving `path` (a module directory or source file), /// started if needed and waited for. - async fn jdtls(&self, path: &Path) -> IdeResult<(Arc, Running)> { + async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc, Running)> { let java = LanguageId::new("java"); let server = self .servers_for(&java, path) @@ -174,7 +184,7 @@ impl LspManager { .find(|s| s.name == SERVER) .ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?; self.ensure_started(&server); - match server.wait_ready(READY_TIMEOUT).await { + match cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? { Some(run) => Ok((server, run)), None => { let status = server.status(); @@ -191,34 +201,64 @@ impl LspManager { } } - async fn jdtls_request(&self, path: &Path, method: &str, params: Value, timeout: Duration) -> IdeResult { - let (server, run) = self.jdtls(path).await?; - let fut = run.peer.request::(method, params, None); + async fn jdtls_request( + &self, + path: &Path, + method: &str, + params: Value, + timeout: Duration, + cancel: &CancellationToken, + ) -> IdeResult { + let (server, run) = self.jdtls(path, cancel).await?; + let fut = run.peer.request::(method, params, Some(cancel.clone())); match tokio::time::timeout(timeout, fut).await { Ok(r) => r.map_err(|e| crate::rpc_err(&server.name, method, e)), - Err(_) => Err(crate::rpc_err(&server.name, method, RpcError::Timeout)), + Err(_) => Err(IdeError::new( + ErrorKind::Protocol, + format!( + "{SERVER}: {method} timed out after {} s; retry, or check that jdtls is not stuck", + timeout.as_secs() + ), + )), } } /// `workspace/executeCommand` on the jdtls serving `path`. pub async fn jdtls_command(&self, path: &Path, command: &str, arguments: Vec) -> IdeResult { + self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await + } + + async fn jdtls_command_cancellable( + &self, + path: &Path, + command: &str, + arguments: Vec, + cancel: &CancellationToken, + ) -> IdeResult { self.jdtls_request( path, "workspace/executeCommand", json!({ "command": command, "arguments": arguments }), COMMAND_TIMEOUT, + cancel, ) .await } /// Like [`jdtls_command`](Self::jdtls_command), retried while the project is still importing. - async fn jdtls_project_command(&self, path: &Path, command: &str, arguments: Vec) -> IdeResult { + async fn jdtls_project_command( + &self, + path: &Path, + command: &str, + arguments: Vec, + cancel: &CancellationToken, + ) -> IdeResult { let deadline = Instant::now() + IMPORT_WAIT; loop { - match self.jdtls_command(path, command, arguments.clone()).await { + match self.jdtls_command_cancellable(path, command, arguments.clone(), cancel).await { Err(e) if not_imported(&e) && Instant::now() < deadline => { tracing::debug!("{command}: {e}; waiting for the project import"); - tokio::time::sleep(Duration::from_millis(1500)).await; + cancellable_wait(cancel, tokio::time::sleep(Duration::from_millis(1500))).await?; } r => return r, } @@ -245,43 +285,62 @@ impl LspManager { /// Incremental build of the jdtls workspace (`java/buildWorkspace`), so /// class files are up to date before a launch. - pub async fn java_build(&self, path: &Path) -> IdeResult { - let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT).await?; + pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult { + let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?; Ok(JavaBuildStatus::from_code(v.as_i64().unwrap_or(0))) } /// Runtime (`test == false`) or test classpath of the project containing `path`. - pub async fn java_classpath(&self, path: &Path, test: bool) -> IdeResult { + pub async fn java_classpath( + &self, + path: &Path, + test: bool, + cancel: &CancellationToken, + ) -> IdeResult { let uri = crate::uri::path_to_uri_string(path); let options = json!({ "scope": if test { "test" } else { "runtime" } }).to_string(); - let v = - self.jdtls_project_command(path, "java.project.getClasspaths", vec![json!(uri), json!(options)]).await?; + let v = self + .jdtls_project_command(path, "java.project.getClasspaths", vec![json!(uri), json!(options)], cancel) + .await?; Ok(parse_classpaths(&v)) } /// The JDK home jdtls uses for the project containing `path`. - pub async fn java_vm_location(&self, path: &Path) -> IdeResult> { + pub async fn java_vm_location(&self, path: &Path, cancel: &CancellationToken) -> IdeResult> { let uri = crate::uri::path_to_uri_string(path); let v = self .jdtls_project_command( path, "java.project.getSettings", vec![json!(uri), json!(["org.eclipse.jdt.ls.core.vm.location"])], + cancel, ) .await?; Ok(parse_vm_location(&v)) } /// Builds (when asked), then resolves the classpath and JDK of the module at `module`. - pub async fn java_launch(&self, module: &Path, test: bool, build: bool) -> IdeResult { - let build = if build { Some(self.java_build(module).await?) } else { None }; + pub async fn java_launch( + &self, + module: &Path, + test: bool, + build: bool, + cancel: &CancellationToken, + ) -> IdeResult { + let build = if build { Some(self.java_build(module, cancel).await?) } else { None }; + if build == Some(JavaBuildStatus::Cancelled) { + return Err(IdeError::cancelled()); + } let (errors, error_count) = match build { - Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => self.java_compile_errors(module).await, + Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => { + cancellable_wait(cancel, self.java_compile_errors(module)).await? + } _ => (Vec::new(), 0), }; - let cp = self.java_classpath(module, test).await?; - let java_home = match self.java_vm_location(module).await { + let cp = self.java_classpath(module, test, cancel).await?; + let java_home = match self.java_vm_location(module, cancel).await { Ok(home) => home, + Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), Err(e) => { tracing::debug!("java.project.getSettings: {e}"); None @@ -303,7 +362,7 @@ impl LspManager { /// Compile errors jdtls reports for its workspace (all modules it imported). async fn java_compile_errors(&self, module: &Path) -> (Vec, u32) { - let Ok((server, _)) = self.jdtls(module).await else { return (Vec::new(), 0) }; + let Ok((server, _)) = self.jdtls(module, &CancellationToken::new()).await else { return (Vec::new(), 0) }; let collect = || compile_errors(server.diagnostics_snapshot(), MAX_LAUNCH_ERRORS); let found = collect(); if found.1 > 0 { @@ -341,6 +400,20 @@ impl LspManager { mod tests { use super::*; + #[tokio::test] + async fn cancellation_ends_a_pending_wait() { + let cancel = CancellationToken::new(); + let waiting = cancellable_wait(&cancel, std::future::pending::<()>()); + let cancelling = async { + tokio::task::yield_now().await; + cancel.cancel(); + }; + let (result, ()) = tokio::time::timeout(Duration::from_secs(1), async { tokio::join!(waiting, cancelling) }) + .await + .expect("cancelled wait must finish"); + assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled); + } + #[test] fn launch_errors_are_sorted_and_capped() { let diag = |line: u32, severity, message: &str| lsp_types::Diagnostic { diff --git a/src-tauri/src/commands/java.rs b/src-tauri/src/commands/java.rs index c1794b33c..c27fa0dd3 100644 --- a/src-tauri/src/commands/java.rs +++ b/src-tauri/src/commands/java.rs @@ -1,13 +1,14 @@ //! IPC commands of `ide_host::ops::java` (the shared implementation). use crate::state::AppState; -use ide_core::IdeResult; +use ide_core::{IdeResult, RequestId}; #[allow(unused_imports)] pub use ide_host::ops::java::*; use ide_lsp::jdtls::JavaLaunchInfo; use std::path::PathBuf; use tauri::{State, Window}; +/// Cancellable with `cancel_request(request)`. /// Builds the workspace (when `build`), then resolves the runtime (or test) /// classpath and the JDK of the module at `module` (absolute, or relative to the project root). /// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first. @@ -19,8 +20,9 @@ pub async fn java_resolve_launch( module: PathBuf, test: bool, build: bool, + request: RequestId, ) -> IdeResult { - ide_host::ops::java::java_resolve_launch(&state.host, window.label(), module, test, build).await + ide_host::ops::java::java_resolve_launch(&state.host, window.label(), module, test, build, request).await } /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). diff --git a/src/bindings.ts b/src/bindings.ts index 425416d17..ac992e0c0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -606,11 +606,12 @@ export const commands = { */ buildRun: (kind: BuildKind, file: string | null, request: RequestId, clear: string[], onEvent: Channel) => typedError(__TAURI_INVOKE("build_run", { kind, file, request, clear, onEvent })), /** + * Cancellable with `cancel_request(request)`. * Builds the workspace (when `build`), then resolves the runtime (or test) * classpath and the JDK of the module at `module` (absolute, or relative to the project root). * A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first. */ - javaResolveLaunch: (module: string, test: boolean, build: boolean) => typedError(__TAURI_INVOKE("java_resolve_launch", { module, test, build })), + javaResolveLaunch: (module: string, test: boolean, build: boolean, request: RequestId) => typedError(__TAURI_INVOKE("java_resolve_launch", { module, test, build, request })), /** Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). */ javaDebugServer: (module: string) => typedError(__TAURI_INVOKE("java_debug_server", { module })), /** Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap). */ diff --git a/src/features/debugger/__tests__/java.test.ts b/src/features/debugger/__tests__/java.test.ts index 323fa32cd..253656261 100644 --- a/src/features/debugger/__tests__/java.test.ts +++ b/src/features/debugger/__tests__/java.test.ts @@ -67,7 +67,7 @@ describe("Java debugging (java-debug through jdtls)", () => { expect(isJavaDebuggable({ ...app, kind: "maven" })).toBe(false); const l = await javaLaunchConfig(app, "/p"); expect(invoked).toEqual([ - ["javaResolveLaunch", ["api", false, true]], + ["javaResolveLaunch", ["api", false, true, expect.any(Number)]], ["javaDebugServer", ["api"]], ]); expect(l).toMatchObject({ adapter: "java-debug", request: "launch", cwd: "/p/api", args: [], env: { A: "1" } }); diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index 2c73b0662..2614e0fa8 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -5,7 +5,7 @@ * JVMs started by Maven / Gradle with a JDWP agent are attached to. */ import { executeAction } from "@/platform/actions"; -import { call, commands, IpcError, type LaunchConfig, type RunConfig } from "@/platform/ipc"; +import { call, commands, IpcError, nextRequestId, type LaunchConfig, type RunConfig } from "@/platform/ipc"; import { formatCompileErrors, isJvmApplication, @@ -76,7 +76,7 @@ export async function javaLaunchConfig(rc: RunConfig, root: string): Promise 0) notifications().notify({ group: "run", diff --git a/src/features/run/resolveCancellation.test.ts b/src/features/run/resolveCancellation.test.ts new file mode 100644 index 000000000..ab16fa39d --- /dev/null +++ b/src/features/run/resolveCancellation.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { JavaLaunchInfo, RunConfig } from "@/platform/ipc"; + +const ipc = vi.hoisted(() => ({ + javaResolveLaunch: vi.fn(), + cancelRequest: vi.fn(async () => undefined), + runStart: vi.fn(), +})); +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + Channel: class { + onmessage: ((m: unknown) => void) | null = null; + }, +})); + +import { provideNotificationService } from "@/workbench/contributions"; +import { resetRuns, runtime, startRun, stopSession, useRun } from "./store"; + +const config: RunConfig = { + id: "application:Main", + name: "Main", + kind: "application", + command: "java", + args: ["-cp", "${classpath}", "Main"], + cwd: null, + env: {}, + detected: false, + isTest: false, + debugAdapter: null, +}; +const info: JavaLaunchInfo = { + build: "succeeded", + projectRoot: "/p", + classPaths: ["/p/target/classes"], + modulePaths: [], + javaHome: null, + javaExec: "java", + shortenCommandLine: null, + errors: [], + errorCount: 0, + hints: [], +}; +const notify = vi.fn(); +beforeEach(() => { + vi.clearAllMocks(); + provideNotificationService({ notify }); +}); +afterEach(() => resetRuns()); + +describe("Stop during Java launch resolution", () => { + it.each(["cancelled", "success"])("cancels the request and ignores a late %s response", async (response) => { + type Result = Awaited>; + let resolve!: (result: Result) => void; + const pending = new Promise((done) => { + resolve = done; + }); + ipc.javaResolveLaunch.mockReturnValue(pending); + const launch = startRun(config); + await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce()); + const session = useRun.getState().sessions[0]!; + expect(session.status).toBe("starting"); + const request = ipc.javaResolveLaunch.mock.calls[0]![3]; + expect(request).toEqual(expect.any(Number)); + await stopSession(session.id); + expect(ipc.cancelRequest).toHaveBeenCalledExactlyOnceWith(request); + resolve( + response === "cancelled" + ? { status: "error", error: { kind: "cancelled", message: "request cancelled" } } + : { status: "ok", data: info }, + ); + await launch; + expect(ipc.runStart).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + expect(runtime(session.id)!.console.lines.some((line) => line.kind === "stderr")).toBe(false); + expect(useRun.getState().sessions[0]).toMatchObject({ stopped: true, status: "finished", runId: null }); + expect(runtime(session.id)!.resolveRequest).toBeUndefined(); + }); +}); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 59010e7c2..04ff9ad14 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -9,6 +9,9 @@ import { call, commands, isTauri, + isCancelled, + nextRequestId, + type RequestId, type OutputChunk, type RunConfig, type RunId, @@ -68,6 +71,7 @@ export interface RunSession { } interface SessionRuntime { + resolveRequest?: { id: RequestId; cancelled: boolean }; console: ConsoleModel; tests: TestResults | null; parser: TestOutputParser | null; @@ -620,7 +624,10 @@ export async function startRun( const resolved = await resolveJvmApplication(sessionId, launch); if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null; if (!resolved) { - patchSession(sessionId, { status: "failed", endedAt: Date.now() }); + patchSession(sessionId, { + status: getSession(sessionId)?.stopped ? "finished" : "failed", + endedAt: Date.now(), + }); return getSession(sessionId) ?? null; } launch = resolved; @@ -690,8 +697,14 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom } if (!parsed.classpath) return config; say(`Building and resolving the classpath of ${parsed.mainClass}…\n`); + const rt = runtime(sessionId)!; + const request = { id: nextRequestId(), cancelled: false }; + rt.resolveRequest = request; try { - const info = await call(commands.javaResolveLaunch(config.cwd ?? "", parsed.classpath === "test", true)); + const info = await call( + commands.javaResolveLaunch(config.cwd ?? "", parsed.classpath === "test", true, request.id), + ); + if (request.cancelled) return null; if (info.build === "withErrors") say("Build completed with errors; launching with the last compiled classes.\n", "stderr"); if (info.build === "failed") say("Build failed; launching with the last compiled classes.\n", "stderr"); @@ -715,6 +728,10 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom return resolveJavaCommand(config, info, pathSeparator()); } catch (e) { const message = e instanceof Error ? e.message : String(e); + if (request.cancelled || isCancelled(e)) { + if (runtimes.get(sessionId) === rt && rt.resolveRequest === request) patchSession(sessionId, { stopped: true }); + return null; + } say(`Cannot resolve the classpath: ${message}\n`, "stderr"); // jdtls can't start without a newer JDK: "Download JDK 25". const actions = downloadJdkActions(message); @@ -726,6 +743,8 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom actions, }); return null; + } finally { + if (rt.resolveRequest === request) delete rt.resolveRequest; } } @@ -835,7 +854,15 @@ export function onRunExited(runId: RunId, code: number | null) { export async function stopSession(sessionId: string): Promise { const session = getSession(sessionId); - if (!session || session.runId === null || session.status !== "running") return; + if (!session) return; + const request = runtimes.get(sessionId)?.resolveRequest; + if (session.status === "starting" && request) { + request.cancelled = true; + patchSession(sessionId, { stopped: true }); + await commands.cancelRequest(request.id); + return; + } + if (session.runId === null || session.status !== "running") return; patchSession(sessionId, { stopped: true }); try { await call(commands.runStop(session.runId)); @@ -995,7 +1022,7 @@ export async function closeSessionTab(sessionId: string): Promise { export function closeSession(sessionId: string) { const session = getSession(sessionId); if (!session) return; - if (session.status === "running") void stopSession(sessionId); + if (session.status === "running" || session.status === "starting") void stopSession(sessionId); const rt = runtimes.get(sessionId); if (rt) cancelFlush(rt); runtimes.delete(sessionId);