From f009ebf2a47fda060d4fb2310d96ac7c28cdcbee Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Tue, 1 Sep 2026 15:34:49 +0800 Subject: [PATCH 1/8] feat(windows): implement native DAP host adapter over shared debug core (#374) --- windows/tauri/src-tauri/Cargo.lock | 1 + windows/tauri/src-tauri/Cargo.toml | 1 + windows/tauri/src-tauri/src/debug.rs | 973 ++++++++++++++++++ windows/tauri/src-tauri/src/main.rs | 6 + windows/tauri/src-tauri/src/run.rs | 6 +- .../tauri/src/config/backend-capabilities.ts | 2 +- .../components/debugger-variables-panel.tsx | 9 +- .../debugger/components/debugger-view.tsx | 4 +- .../components/debugger-watch-panel.tsx | 9 +- .../services/debug-adapter-events.test.ts | 182 ++++ .../debugger/services/debug-adapter-events.ts | 256 +++-- .../services/debug-adapter-service.ts | 21 +- .../debugger/stores/debugger.store.ts | 14 +- .../features/debugger/types/debugger.types.ts | 5 + windows/tauri/src/platform/tauri-core.ts | 3 + 15 files changed, 1344 insertions(+), 148 deletions(-) create mode 100644 windows/tauri/src-tauri/src/debug.rs create mode 100644 windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 554812551..7e4e259d6 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2612,6 +2612,7 @@ dependencies = [ name = "lithe-windows" version = "0.3.0" dependencies = [ + "base64 0.22.1", "chrono", "keyring", "lithe-core", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index 9ebac1fb5..11214c71d 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -9,6 +9,7 @@ license = "Apache-2.0" tauri-build = { version = "2", features = [] } [dependencies] +base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } lithe-core = { path = "../../../rust/lithe-core" } lithe-project = { path = "../crates/project" } diff --git a/windows/tauri/src-tauri/src/debug.rs b/windows/tauri/src-tauri/src/debug.rs new file mode 100644 index 000000000..28fe7c073 --- /dev/null +++ b/windows/tauri/src-tauri/src/debug.rs @@ -0,0 +1,973 @@ +//! Windows Debug Adapter host: adapter process and stdio transport. +//! +//! DAP framing, request correlation, breakpoint sets, and the session state +//! machine stay in `lithe-core` under the `debug.*` contract. This module owns +//! only the adapter executable, its stdin/stdout/stderr pipes, session +//! lifecycle, and the Tauri events projected to the React debugger. +//! +//! Byte flow: React invokes `debug_*` commands, the host reduces them through +//! `lithe_core::execute_json`, writes the returned base64 `outboundFrames` to +//! the adapter stdin, and feeds adapter stdout chunks back through +//! `debug.receive`. Core-generated normalized events are emitted to React as +//! `debugger_message`, adapter stderr as `debugger_output`, and process exit +//! as `debugger_session_ended`. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread; +use tauri::{AppHandle, Emitter}; + +use crate::run::{apply_creation_flags, decode_process_bytes, incomplete_suffix_len}; + +const CORE_TIMEOUT_MILLISECONDS: u64 = 30_000; +const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; + +static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); +static OPERATION_COUNTER: AtomicU64 = AtomicU64::new(1); + +/// Managed marker for the Tauri state container. The session registry itself +/// is process-wide so reader and exit-waiter threads can reach it without an +/// `AppHandle`, mirroring the existing `run.rs` process manager. +pub struct DebugAdapterManager; + +impl Default for DebugAdapterManager { + fn default() -> Self { + Self + } +} + +struct AdapterSession { + pid: u32, + /// Shared so adapter stdout readers can write Core-produced frames while + /// Tauri commands write request frames; the mutex prevents interleaving. + stdin: Arc>, +} + +fn sessions() -> &'static Mutex> { + static SESSIONS: OnceLock>> = OnceLock::new(); + SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugAdapterLaunch { + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub env: HashMap, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugSessionInfo { + pub id: String, + pub command: String, + pub args: Vec, + pub cwd: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugSendRequest { + pub session_id: String, + pub command: String, + #[serde(default)] + pub arguments: Value, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugCommandResult { + pub session_id: String, + pub operation_id: String, +} + +/// Starts one Debug Adapter executable and returns its stable session id. +#[tauri::command] +pub async fn debug_start_session( + app: AppHandle, + launch: DebugAdapterLaunch, +) -> Result { + let command = launch.command.trim().to_string(); + if command.is_empty() { + return Err("The debug adapter command cannot be empty.".to_string()); + } + + let mut child_command = Command::new(&command); + child_command + .args(&launch.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(cwd) = launch.cwd.as_deref().filter(|cwd| !cwd.trim().is_empty()) { + child_command.current_dir(cwd); + } + if !launch.env.is_empty() { + child_command.envs(&launch.env); + } + apply_creation_flags(&mut child_command); + + let mut child = child_command + .spawn() + .map_err(|error| format!("Unable to start debug adapter: {error}"))?; + let pid = child.id(); + let Some(stdin) = child.stdin.take() else { + let _ = child.kill(); + return Err("The debug adapter did not expose an input pipe.".to_string()); + }; + let stdin = Arc::new(Mutex::new(stdin)); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + let session_id = format!( + "windows-debug-{}-{}", + std::process::id(), + SESSION_COUNTER.fetch_add(1, Ordering::Relaxed) + ); + let root_path = launch + .cwd + .as_deref() + .filter(|cwd| !cwd.trim().is_empty()) + .unwrap_or("."); + + let update = execute_core_async( + "debug.createSession".to_string(), + json!({ + "sessionId": session_id, + "adapterId": adapter_identifier(&command), + "rootPath": root_path, + "supportsRunInTerminalRequest": false, + }), + ) + .await + .map_err(|error| { + let _ = child.kill(); + error + })?; + + if let Err(error) = write_outbound_frames(&stdin, &update) { + let _ = child.kill(); + let _ = execute_core_async( + "debug.destroySession".to_string(), + json!({ "sessionId": session_id }), + ) + .await; + return Err(error); + } + emit_update_events(&app, &session_id, &update); + + { + let mut current = sessions().lock().map_err(|_| { + let _ = child.kill(); + let _ = execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })); + "Debug session state is unavailable.".to_string() + })?; + current.insert( + session_id.clone(), + AdapterSession { + pid, + stdin: stdin.clone(), + }, + ); + } + + let stdout_reader = spawn_stdout_reader(app.clone(), session_id.clone(), pid, stdin, stdout); + let stderr_reader = spawn_stderr_reader(app.clone(), session_id.clone(), stderr); + spawn_exit_waiter( + app, + session_id.clone(), + child, + pid, + stdout_reader, + stderr_reader, + ); + + Ok(DebugSessionInfo { + id: session_id, + command, + args: launch.args, + cwd: launch.cwd, + }) +} + +/// Temporary compatibility facade for the existing React request surface. +/// +/// Every request is translated onto the shared `debug.*` contract and reduced +/// by Rust Core; this function keeps no DAP sequence or adapter state. +#[tauri::command] +pub async fn debug_send_request( + app: AppHandle, + request: DebugSendRequest, +) -> Result { + let (pid, stdin) = { + let current = sessions() + .lock() + .map_err(|_| "Debug session state is unavailable.".to_string())?; + let session = current + .get(&request.session_id) + .ok_or_else(|| "The debug session is no longer active.".to_string())?; + (session.pid, session.stdin.clone()) + }; + let operation_id = format!( + "debug-op-{}", + OPERATION_COUNTER.fetch_add(1, Ordering::Relaxed) + ); + let (core_command, payload) = translate_request( + &request.session_id, + &request.command, + &request.arguments, + &operation_id, + )?; + let update = execute_core_async(core_command, payload).await?; + if let Err(error) = write_outbound_frames(&stdin, &update) { + fail_session(&app, &request.session_id, pid, &error); + return Err(error); + } + emit_update_events(&app, &request.session_id, &update); + Ok(DebugCommandResult { + session_id: request.session_id, + operation_id, + }) +} + +/// Stops one adapter session idempotently: graceful DAP disconnect, process +/// tree kill, shared-session destroy, and a single `session-ended` event. +#[tauri::command] +pub async fn debug_stop_session(app: AppHandle, session_id: String) -> Result<(), String> { + stop_session(&app, &session_id).await +} + +async fn stop_session(app: &AppHandle, session_id: &str) -> Result<(), String> { + let removed = { + let mut current = sessions() + .lock() + .map_err(|_| "Debug session state is unavailable.".to_string())?; + current + .remove(session_id) + .map(|session| (session.pid, session.stdin)) + }; + let Some((pid, stdin)) = removed else { + // Repeated or late stops are no-ops and never touch a newer session. + return Ok(()); + }; + + match execute_core_async( + "debug.disconnect".to_string(), + json!({ "sessionId": session_id }), + ) + .await + { + Ok(update) => { + if let Err(error) = write_outbound_frames(&stdin, &update) { + kill_adapter_process(pid); + let _ = execute_core_async( + "debug.destroySession".to_string(), + json!({ "sessionId": session_id }), + ) + .await; + return Err(error); + } + emit_update_events(app, session_id, &update); + } + Err(_) => { + // The shared session already ended (for example the adapter exited + // first); only the native process still needs to be reaped. + } + } + + kill_adapter_process(pid); + let _ = execute_core_async( + "debug.destroySession".to_string(), + json!({ "sessionId": session_id }), + ) + .await; + let _ = app.emit( + "debugger_session_ended", + json!({ "sessionId": session_id, "reason": "stopped" }), + ); + Ok(()) +} + +/// Kills every live adapter during application exit so no child process or +/// reader task outlives the shell. +pub fn shutdown() { + let sessions_to_stop = { + let Ok(mut current) = sessions().lock() else { + return; + }; + current + .drain() + .map(|(session_id, session)| (session_id, session.pid)) + .collect::>() + }; + for (session_id, pid) in sessions_to_stop { + kill_adapter_process(pid); + let _ = execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })); + } +} + +/// Maps the legacy React request surface onto the shared `debug.*` contract. +fn translate_request( + session_id: &str, + command: &str, + arguments: &Value, + operation_id: &str, +) -> Result<(String, Value), String> { + let arguments = arguments.as_object().cloned().unwrap_or_default(); + let (core_command, payload) = match command { + "launch" => { + let request_kind = arguments + .get("request") + .and_then(Value::as_str) + .unwrap_or("launch"); + if !matches!(request_kind, "launch" | "attach") { + return Err(format!("Unsupported debug launch request: {request_kind}")); + } + let name = arguments + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .unwrap_or("Launch"); + ( + "debug.launch".to_string(), + json!({ + "sessionId": session_id, + "operationId": operation_id, + "configuration": { + "name": name, + "request": request_kind, + "arguments": arguments, + } + }), + ) + } + "setBreakpoints" => { + let source_path = arguments + .get("source") + .and_then(Value::as_object) + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| "setBreakpoints requires a source path.".to_string())?; + let breakpoints = arguments + .get("breakpoints") + .cloned() + .unwrap_or_else(|| json!([])); + ( + "debug.setBreakpoints".to_string(), + json!({ + "sessionId": session_id, + "sourcePath": source_path, + "breakpoints": breakpoints, + }), + ) + } + "threads" | "stackTrace" | "scopes" | "variables" | "evaluate" => ( + "debug.inspect".to_string(), + inspect_payload(session_id, operation_id, command, &arguments)?, + ), + "continue" | "pause" | "next" | "stepIn" | "stepOut" | "stepBack" => ( + "debug.execute".to_string(), + json!({ + "sessionId": session_id, + "operationId": operation_id, + "command": command, + "threadId": arguments.get("threadId").cloned().unwrap_or(Value::Null), + "singleThread": arguments.get("singleThread").cloned().unwrap_or(json!(false)), + }), + ), + "setVariable" => ( + "debug.setVariable".to_string(), + json!({ + "sessionId": session_id, + "operationId": operation_id, + "variablesReference": arguments + .get("variablesReference") + .cloned() + .ok_or_else(|| "setVariable requires variablesReference.".to_string())?, + "name": arguments + .get("name") + .cloned() + .ok_or_else(|| "setVariable requires a variable name.".to_string())?, + "value": arguments + .get("value") + .cloned() + .ok_or_else(|| "setVariable requires a value.".to_string())?, + }), + ), + _ => return Err(format!("Unsupported debug adapter request: {command}")), + }; + Ok((core_command, payload)) +} + +/// Builds a `debug.inspect` payload from the requested normalized kind. +fn inspect_payload( + session_id: &str, + operation_id: &str, + kind: &str, + arguments: &Map, +) -> Result { + let mut payload = Map::new(); + payload.insert("sessionId".into(), json!(session_id)); + payload.insert("operationId".into(), json!(operation_id)); + payload.insert("kind".into(), json!(kind)); + match kind { + "threads" => {} + "stackTrace" => { + payload.insert("threadId".into(), required_argument(arguments, "threadId")?); + } + "scopes" => { + payload.insert("frameId".into(), required_argument(arguments, "frameId")?); + } + "variables" => { + payload.insert( + "variablesReference".into(), + required_argument(arguments, "variablesReference")?, + ); + if let Some(filter) = arguments.get("filter").and_then(Value::as_str) { + if !matches!(filter, "named" | "indexed") { + return Err(format!("Unsupported debug variable filter: {filter}")); + } + payload.insert("variableFilter".into(), json!(filter)); + } + for field in ["start", "count"] { + if let Some(value) = arguments.get(field) { + payload.insert(field.into(), value.clone()); + } + } + } + "evaluate" => { + payload.insert( + "expression".into(), + required_argument(arguments, "expression")?, + ); + if let Some(frame_id) = arguments.get("frameId") { + payload.insert("frameId".into(), frame_id.clone()); + } + } + _ => return Err(format!("Unsupported debug inspection request: {kind}")), + } + Ok(Value::Object(payload)) +} + +fn required_argument(arguments: &Map, field: &str) -> Result { + arguments + .get(field) + .cloned() + .ok_or_else(|| format!("Debug {field} is required.")) +} + +/// Reduces one shared `debug.*` request and returns the serialized update. +fn execute_core_sync(command: &str, payload: Value) -> Result { + let operation_id = format!("core-{}", OPERATION_COUNTER.fetch_add(1, Ordering::Relaxed)); + let request = json!({ + "id": operation_id, + "operationId": operation_id, + "timeoutMilliseconds": CORE_TIMEOUT_MILLISECONDS, + "command": command, + "payload": payload, + }) + .to_string(); + let response = lithe_core::execute_json(&request); + let envelope: Value = serde_json::from_str(&response) + .map_err(|error| format!("The shared debug core returned invalid JSON: {error}"))?; + if envelope.get("ok").and_then(Value::as_bool) == Some(true) { + return envelope + .get("data") + .cloned() + .ok_or_else(|| "The shared debug core returned an empty update.".to_string()); + } + let error = envelope.get("error").unwrap_or(&Value::Null); + Err(error + .get("message") + .and_then(Value::as_str) + .unwrap_or("The shared debug core operation failed") + .to_string()) +} + +async fn execute_core_async(command: String, payload: Value) -> Result { + tauri::async_runtime::spawn_blocking(move || execute_core_sync(&command, payload)) + .await + .map_err(|error| format!("Shared debug core task failed: {error}"))? +} + +/// Decodes Core's ordered base64 frames and writes them without interleaving. +fn write_outbound_frames(stdin: &Mutex, update: &Value) -> Result<(), String> { + let frames = update + .get("outboundFrames") + .and_then(Value::as_array) + .ok_or_else(|| "The shared debug core returned an invalid session update.".to_string())?; + let mut writer = stdin + .lock() + .map_err(|_| "The debug adapter input pipe is unavailable.".to_string())?; + for frame in frames { + let encoded = frame.as_str().ok_or_else(|| { + "The shared debug core returned an invalid outbound frame.".to_string() + })?; + let bytes = BASE64.decode(encoded).map_err(|error| { + format!("The shared debug core returned invalid frame data: {error}") + })?; + if bytes.len() > MAX_FRAME_BYTES { + return Err("The shared debug core produced an oversized frame.".to_string()); + } + writer + .write_all(&bytes) + .map_err(|error| format!("Could not write to debug adapter input: {error}"))?; + } + writer + .flush() + .map_err(|error| format!("Could not flush debug adapter input: {error}")) +} + +/// Projects Core's normalized events onto the existing React event surface. +fn emit_update_events(app: &AppHandle, session_id: &str, update: &Value) { + let Some(events) = update.get("events").and_then(Value::as_array) else { + return; + }; + for event in events { + let _ = app.emit( + "debugger_message", + json!({ "sessionId": session_id, "message": event }), + ); + } +} + +/// Feeds adapter stdout bytes to `debug.receive` and writes resulting frames. +fn spawn_stdout_reader( + app: AppHandle, + session_id: String, + pid: u32, + stdin: Arc>, + stdout: Option, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let Some(mut stream) = stdout else { + return; + }; + let mut buffer = [0_u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(count) => { + if !is_current_session(&session_id, pid) { + break; + } + let data_base64 = BASE64.encode(&buffer[..count]); + match execute_core_sync( + "debug.receive", + json!({ + "sessionId": session_id, + "dataBase64": data_base64, + }), + ) { + Ok(update) => { + if !is_current_session(&session_id, pid) { + break; + } + if let Err(error) = write_outbound_frames(&stdin, &update) { + fail_session(&app, &session_id, pid, &error); + break; + } + emit_update_events(&app, &session_id, &update); + } + Err(error) => { + if is_current_session(&session_id, pid) { + fail_session( + &app, + &session_id, + pid, + &format!("Debug adapter protocol error: {error}"), + ); + } + break; + } + } + } + Err(_) => break, + } + } + }) +} + +/// Projects adapter stderr onto the debug console output event. +fn spawn_stderr_reader( + app: AppHandle, + session_id: String, + stderr: Option, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let Some(mut stream) = stderr else { + return; + }; + let mut buffer = [0_u8; 4096]; + let mut pending = Vec::new(); + loop { + match stream.read(&mut buffer) { + Ok(0) => { + if !pending.is_empty() { + emit_stderr_chunk(&app, &session_id, &pending); + } + break; + } + Ok(count) => { + pending.extend_from_slice(&buffer[..count]); + let keep = incomplete_suffix_len(&pending); + let ready = pending.len().saturating_sub(keep); + if ready == 0 { + continue; + } + emit_stderr_chunk(&app, &session_id, &pending[..ready]); + pending.drain(..ready); + } + Err(_) => break, + } + } + }) +} + +fn emit_stderr_chunk(app: &AppHandle, session_id: &str, bytes: &[u8]) { + let text = decode_process_bytes(bytes); + if text.is_empty() { + return; + } + let _ = app.emit( + "debugger_output", + json!({ "sessionId": session_id, "stream": "stderr", "data": text }), + ); +} + +/// Reaps an exited adapter, destroys its shared session, and emits the ended +/// event only when this process generation is still the registered one. +fn spawn_exit_waiter( + app: AppHandle, + session_id: String, + mut child: Child, + pid: u32, + stdout_reader: thread::JoinHandle<()>, + stderr_reader: thread::JoinHandle<()>, +) { + thread::spawn(move || { + let exit_code = child + .wait() + .ok() + .and_then(|status| status.code()) + .unwrap_or(-1); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + if !remove_session(&session_id, pid) { + return; + } + let _ = execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })); + let _ = app.emit( + "debugger_session_ended", + json!({ + "sessionId": session_id, + "reason": "exited", + "exitCode": exit_code, + }), + ); + }); +} + +/// Removes the session only when its pid still matches, so a late event from +/// an old adapter can never clean up or end a newer session. +fn remove_session(session_id: &str, pid: u32) -> bool { + match sessions().lock() { + Ok(mut current) => match current.get(session_id) { + Some(session) if session.pid == pid => { + current.remove(session_id); + true + } + _ => false, + }, + Err(_) => false, + } +} + +fn is_current_session(session_id: &str, pid: u32) -> bool { + sessions().lock().ok().is_some_and(|current| { + current + .get(session_id) + .is_some_and(|session| session.pid == pid) + }) +} + +/// Tears down a failed session: process tree kill, shared-session destroy, +/// console error, and a single `failed` end event. +fn fail_session(app: &AppHandle, session_id: &str, pid: u32, message: &str) { + if !remove_session(session_id, pid) { + return; + } + kill_adapter_process(pid); + let _ = execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })); + let _ = app.emit( + "debugger_output", + json!({ "sessionId": session_id, "stream": "stderr", "data": format!("{message}\n") }), + ); + let _ = app.emit( + "debugger_session_ended", + json!({ "sessionId": session_id, "reason": "failed" }), + ); +} + +fn kill_adapter_process(pid: u32) { + if pid == 0 { + return; + } + let mut command = Command::new("taskkill"); + command.args(["/F", "/T", "/PID", &pid.to_string()]); + apply_creation_flags(&mut command); + let _ = command.output(); +} + +fn adapter_identifier(command: &str) -> String { + Path::new(command) + .file_stem() + .and_then(|stem| stem.to_str()) + .filter(|stem| !stem.trim().is_empty()) + .unwrap_or("custom") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn translates_launch_into_shared_launch_configuration() { + let (command, payload) = translate_request( + "session-1", + "launch", + &json!({ + "name": "Demo", + "request": "launch", + "type": "node", + "program": "C:/work/main.js", + }), + "op-1", + ) + .unwrap(); + assert_eq!(command, "debug.launch"); + assert_eq!(payload["sessionId"], "session-1"); + assert_eq!(payload["operationId"], "op-1"); + assert_eq!(payload["configuration"]["name"], "Demo"); + assert_eq!(payload["configuration"]["request"], "launch"); + assert_eq!( + payload["configuration"]["arguments"]["program"], + "C:/work/main.js" + ); + } + + #[test] + fn translates_attach_requests_without_changing_the_payload() { + let (_, payload) = translate_request( + "session-1", + "launch", + &json!({ "request": "attach", "port": 9229 }), + "op-2", + ) + .unwrap(); + assert_eq!(payload["configuration"]["request"], "attach"); + assert_eq!(payload["configuration"]["arguments"]["port"], 9229); + } + + #[test] + fn translates_set_breakpoints_into_the_core_contract() { + let (command, payload) = translate_request( + "session-1", + "setBreakpoints", + &json!({ + "source": { "path": "C:/work/main.js" }, + "breakpoints": [{ "line": 3, "enabled": true }, { "line": 7 }], + }), + "op-3", + ) + .unwrap(); + assert_eq!(command, "debug.setBreakpoints"); + assert_eq!(payload["sourcePath"], "C:/work/main.js"); + assert_eq!(payload["breakpoints"][0]["line"], 3); + assert_eq!(payload["breakpoints"][1]["line"], 7); + } + + #[test] + fn translates_inspection_requests_onto_debug_inspect() { + let (command, payload) = + translate_request("session-1", "stackTrace", &json!({ "threadId": 4 }), "op-4") + .unwrap(); + assert_eq!(command, "debug.inspect"); + assert_eq!(payload["kind"], "stackTrace"); + assert_eq!(payload["threadId"], 4); + + let (_, variables) = translate_request( + "session-1", + "variables", + &json!({ "variablesReference": 9, "start": 0, "count": 50 }), + "op-5", + ) + .unwrap(); + assert_eq!(variables["kind"], "variables"); + assert_eq!(variables["variablesReference"], 9); + assert_eq!(variables["start"], 0); + assert_eq!(variables["count"], 50); + } + + #[test] + fn translates_execution_controls_onto_debug_execute() { + let (command, payload) = + translate_request("session-1", "stepIn", &json!({ "threadId": 7 }), "op-6").unwrap(); + assert_eq!(command, "debug.execute"); + assert_eq!(payload["command"], "stepIn"); + assert_eq!(payload["threadId"], 7); + assert_eq!(payload["singleThread"], false); + } + + #[test] + fn rejects_unsupported_requests_and_missing_arguments() { + let error = translate_request("session-1", "restart", &json!({}), "op-7").unwrap_err(); + assert!(error.contains("Unsupported debug adapter request: restart")); + + let error = translate_request("session-1", "stackTrace", &json!({}), "op-8").unwrap_err(); + assert!(error.contains("threadId is required")); + + let error = translate_request( + "session-1", + "setBreakpoints", + &json!({ "breakpoints": [] }), + "op-9", + ) + .unwrap_err(); + assert!(error.contains("source path")); + } + + #[test] + fn writes_decoded_frames_in_order_without_mangling() { + let first = "Content-Length: 5\r\n\r\nhello"; + let second = "Content-Length: 5\r\n\r\nworld"; + let sink = Mutex::new(Vec::new()); + let update = json!({ + "sessionId": "session-1", + "state": "initializing", + "outboundFrames": [BASE64.encode(first), BASE64.encode(second)], + "events": [], + }); + write_outbound_frames(&sink, &update).unwrap(); + let bytes = sink.into_inner().unwrap(); + assert_eq!( + String::from_utf8(bytes).unwrap(), + format!("{first}{second}") + ); + } + + #[test] + fn rejects_updates_without_outbound_frames() { + let sink = Mutex::new(Vec::new()); + let error = write_outbound_frames(&sink, &json!({ "sessionId": "session-1" })).unwrap_err(); + assert!(error.contains("invalid session update")); + } + + #[test] + fn derives_adapter_identifier_from_the_executable_name() { + assert_eq!(adapter_identifier(r"C:\tools\bun.exe"), "bun"); + assert_eq!(adapter_identifier("python"), "python"); + assert_eq!(adapter_identifier(""), "custom"); + } + + #[test] + fn facade_launch_reaches_the_adapter_through_the_shared_state_machine() { + let session_id = format!("facade-session-{}", std::process::id()); + let created = execute_core_sync( + "debug.createSession", + json!({ + "sessionId": session_id, + "adapterId": "fake-adapter", + "rootPath": ".", + "supportsRunInTerminalRequest": false, + }), + ) + .unwrap(); + assert_eq!(created["state"], "initializing"); + assert_eq!(created["outboundFrames"].as_array().unwrap().len(), 1); + + let (_, launch_payload) = translate_request( + &session_id, + "launch", + &json!({ "name": "Demo", "request": "launch", "program": "C:/work/main.js" }), + "facade-op-1", + ) + .unwrap(); + let launched = execute_core_sync("debug.launch", launch_payload).unwrap(); + assert!(launched["outboundFrames"].as_array().unwrap().is_empty()); + + // The fake adapter answers initialize and announces readiness; Core + // then emits the queued launch and closes the configuration handshake. + let initialize_response = json!({ + "seq": 101, + "type": "response", + "request_seq": 1, + "success": true, + "command": "initialize", + "body": { "supportsConfigurationDoneRequest": true } + }); + let initialized_event = json!({ "seq": 102, "type": "event", "event": "initialized" }); + let mut adapter_bytes = dap_frame(&initialize_response); + adapter_bytes.extend(dap_frame(&initialized_event)); + + let received = execute_core_sync( + "debug.receive", + json!({ + "sessionId": session_id, + "dataBase64": BASE64.encode(&adapter_bytes), + }), + ) + .unwrap(); + let commands = decode_frame_commands(received["outboundFrames"].as_array().unwrap()); + assert_eq!( + commands, + vec!["launch", "setExceptionBreakpoints", "configurationDone"] + ); + let event_types = received["events"] + .as_array() + .unwrap() + .iter() + .filter_map(|event| event["type"].as_str()) + .collect::>(); + assert!(event_types.contains(&"initialized")); + assert!(event_types.contains(&"capabilities")); + + execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })).unwrap(); + } + + fn dap_frame(message: &Value) -> Vec { + let body = serde_json::to_vec(message).unwrap(); + let mut frame = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + frame.extend(body); + frame + } + + fn decode_frame_commands(frames: &[Value]) -> Vec { + frames + .iter() + .filter_map(|frame| { + let bytes = BASE64.decode(frame.as_str()?).ok()?; + let text = String::from_utf8(bytes).ok()?; + let body_start = text.find("\r\n\r\n")? + 4; + let message: Value = serde_json::from_str(&text[body_start..]).ok()?; + message + .get("command") + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect() + } +} diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 41a43c60d..6e5e2d029 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod core; +mod debug; mod file_events; mod host; mod logging; @@ -63,6 +64,7 @@ fn main() { )); app.manage(host::FileClipboard::default()); app.manage(run::RunProcessManager::default()); + app.manage(debug::DebugAdapterManager::default()); run::cleanup_legacy_appdata(app.handle()); if let Some(window) = app.get_webview_window("main") { host::apply_window_taskbar_icon(&window); @@ -72,6 +74,9 @@ fn main() { .invoke_handler(tauri::generate_handler![ core::core_execute, core::core_cancel, + debug::debug_start_session, + debug::debug_send_request, + debug::debug_stop_session, platform::platform_invoke, memory::get_application_memory_usage, terminal::begin_frontend_terminal_session, @@ -135,6 +140,7 @@ fn main() { application.run(|app, event| { if matches!(event, tauri::RunEvent::Exit) { + debug::shutdown(); if let Some(manager) = app.try_state::>() { manager.shutdown(); } diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 98b053290..0b5ca2f62 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -1280,7 +1280,7 @@ fn quote_windows_arg(argument: &str) -> String { quoted } -fn apply_creation_flags(command: &mut Command) { +pub(crate) fn apply_creation_flags(command: &mut Command) { #[cfg(windows)] { use std::os::windows::process::CommandExt; @@ -1289,7 +1289,7 @@ fn apply_creation_flags(command: &mut Command) { let _ = command; } -fn decode_process_bytes(bytes: &[u8]) -> String { +pub(crate) fn decode_process_bytes(bytes: &[u8]) -> String { if bytes.is_empty() { return String::new(); } @@ -1316,7 +1316,7 @@ fn looks_like_real_utf8(bytes: &[u8]) -> bool { }) } -fn incomplete_suffix_len(bytes: &[u8]) -> usize { +pub(crate) fn incomplete_suffix_len(bytes: &[u8]) -> usize { match bytes.last() { Some(&byte) if byte >= 0x81 => 1, _ => 0, diff --git a/windows/tauri/src/config/backend-capabilities.ts b/windows/tauri/src/config/backend-capabilities.ts index c7f36961b..bb34dd1c8 100644 --- a/windows/tauri/src/config/backend-capabilities.ts +++ b/windows/tauri/src/config/backend-capabilities.ts @@ -3,7 +3,7 @@ export const BACKEND_UNAVAILABLE_TOOLTIP = "待开发"; export const backendCapabilities = { agent: false, database: false, - debugger: false, + debugger: true, docker: false, extensions: false, git: true, diff --git a/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx b/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx index 23540f193..eea202082 100644 --- a/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx @@ -13,7 +13,7 @@ interface DebugVariablesPanelProps { selectedFrameId: number | null; scopes: DebugScope[]; variablesByReference: Record; - pendingRequests: Record; + pendingRequests: Record; } export function DebugVariablesPanel({ @@ -56,10 +56,13 @@ export function DebugVariablesPanel({ if (!shouldLoadChildren) return; try { - const seq = await sendDebugAdapterRequest(activeSessionId, "variables", { + const result = await sendDebugAdapterRequest(activeSessionId, "variables", { + variablesReference, + }); + debuggerActions.registerAdapterRequest(result.operationId, { + command: "variables", variablesReference, }); - debuggerActions.registerAdapterRequest(seq, { command: "variables", variablesReference }); } catch { setExpandedVariableReferences((current) => { const next = new Set(current); diff --git a/windows/tauri/src/features/debugger/components/debugger-view.tsx b/windows/tauri/src/features/debugger/components/debugger-view.tsx index 74ec13bfc..aede615b7 100644 --- a/windows/tauri/src/features/debugger/components/debugger-view.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-view.tsx @@ -298,8 +298,8 @@ export default function DebuggerView() { if (activeSession?.id) { try { - const seq = await sendDebugAdapterRequest(activeSession.id, "scopes", { frameId }); - debuggerActions.registerAdapterRequest(seq, { command: "scopes", frameId }); + const result = await sendDebugAdapterRequest(activeSession.id, "scopes", { frameId }); + debuggerActions.registerAdapterRequest(result.operationId, { command: "scopes", frameId }); } catch { // Some adapters may not allow scope requests after the session moves on. } diff --git a/windows/tauri/src/features/debugger/components/debugger-watch-panel.tsx b/windows/tauri/src/features/debugger/components/debugger-watch-panel.tsx index 68c0bd0c8..4da98e383 100644 --- a/windows/tauri/src/features/debugger/components/debugger-watch-panel.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-watch-panel.tsx @@ -16,7 +16,7 @@ interface DebugWatchPanelProps { activeSessionId?: string; selectedFrameId: number | null; isPaused: boolean; - pendingRequests: Record; + pendingRequests: Record; } export function DebugWatchPanel({ @@ -46,12 +46,15 @@ export function DebugWatchPanel({ if (!activeSessionId || !isPaused) return; try { - const seq = await sendDebugAdapterRequest(activeSessionId, "evaluate", { + const result = await sendDebugAdapterRequest(activeSessionId, "evaluate", { expression, frameId: selectedFrameId ?? undefined, context: "watch", }); - debuggerActions.registerAdapterRequest(seq, { command: "evaluate", expressionId }); + debuggerActions.registerAdapterRequest(result.operationId, { + command: "evaluate", + expressionId, + }); } catch (error) { debuggerActions.setWatchResult({ expressionId, diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts new file mode 100644 index 000000000..f74495a49 --- /dev/null +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, expect, mock, test } from "bun:test"; +import type { DebugProtocolMessage } from "../types/debugger.types"; + +let operationCounter = 0; +const sendDebugAdapterRequest = mock( + async (): Promise<{ sessionId: string; operationId: string }> => { + operationCounter += 1; + return { sessionId: "session-1", operationId: `debug-op-${operationCounter}` }; + }, +); +const subscribeDebuggerEvents = mock(async () => () => {}); + +type DebuggerEventHandlers = { + onMessage?: (payload: DebugProtocolMessage) => void; + onOutput?: (payload: unknown) => void; + onSessionEnded?: (payload: unknown) => void; +}; + +let capturedHandlers: DebuggerEventHandlers = {}; + +mock.module("@/features/debugger/services/debug-adapter-service", () => ({ + sendDebugAdapterRequest, + subscribeDebuggerEvents: async (handlers: DebuggerEventHandlers) => { + capturedHandlers = handlers; + return () => {}; + }, +})); + +const { initializeDebuggerEventBridge } = await import("./debug-adapter-events"); +const { useDebuggerStore } = await import("../stores/debugger.store"); + +const flush = async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +const emitMessage = (sessionId: string, message: unknown) => { + capturedHandlers.onMessage?.({ sessionId, message }); +}; + +beforeEach(() => { + operationCounter = 0; + sendDebugAdapterRequest.mockClear(); + useDebuggerStore.setState({ + pendingRequests: {}, + threads: [], + stackFrames: [], + scopes: [], + variablesByReference: {}, + watchResults: {}, + adapterOutput: [], + stoppedState: null, + activeSession: null, + }); +}); + +test("stopped events pause the session and cascade into stack trace requests", async () => { + await initializeDebuggerEventBridge(); + useDebuggerStore.getState().actions.startSession({ + id: "session-1", + name: "Demo", + configId: "generated-bun", + command: "bun", + startedAt: 1, + status: "running", + }); + + emitMessage("session-1", { type: "stopped", reason: "breakpoint", threadId: 3 }); + await flush(); + + const state = useDebuggerStore.getState(); + expect(state.activeSession?.status).toBe("paused"); + expect(state.stoppedState).toEqual({ + reason: "breakpoint", + threadId: 3, + description: undefined, + }); + expect(sendDebugAdapterRequest).toHaveBeenCalledWith("session-1", "stackTrace", { + threadId: 3, + }); + expect(state.pendingRequests["debug-op-1"]).toEqual({ + command: "stackTrace", + threadId: 3, + }); +}); + +test("normalized thread results fill the thread list and cascade further", async () => { + await initializeDebuggerEventBridge(); + useDebuggerStore.getState().actions.registerAdapterRequest("threads-op", { + command: "threads", + }); + + emitMessage("session-1", { + type: "operationCompleted", + operationId: "threads-op", + result: { kind: "threads", threads: [{ id: 1, name: "main" }] }, + }); + await flush(); + + const state = useDebuggerStore.getState(); + expect(state.threads).toEqual([{ id: 1, name: "main" }]); + expect(state.pendingRequests["threads-op"]).toBeUndefined(); + expect(sendDebugAdapterRequest).toHaveBeenCalledWith("session-1", "stackTrace", { + threadId: 1, + }); + expect(state.pendingRequests["debug-op-1"]).toEqual({ + command: "stackTrace", + threadId: 1, + }); +}); + +test("evaluate results land in watch results for the correlated expression", async () => { + await initializeDebuggerEventBridge(); + useDebuggerStore.getState().actions.registerAdapterRequest("debug-op-1", { + command: "evaluate", + expressionId: "watch-1", + }); + + emitMessage("session-1", { + type: "operationCompleted", + operationId: "debug-op-1", + result: { + kind: "evaluate", + variable: { name: "", value: "42", type: "number", variablesReference: 0 }, + }, + }); + await flush(); + + const result = useDebuggerStore.getState().watchResults["watch-1"]; + expect(result?.value).toBe("42"); + expect(result?.type).toBe("number"); + expect(result?.error).toBeUndefined(); +}); + +test("failed evaluate operations surface an actionable watch error", async () => { + await initializeDebuggerEventBridge(); + useDebuggerStore.getState().actions.registerAdapterRequest("debug-op-1", { + command: "evaluate", + expressionId: "watch-2", + }); + + emitMessage("session-1", { + type: "operationFailed", + operationId: "debug-op-1", + command: "evaluate", + code: "adapterRejected", + message: "Expression could not be evaluated.", + }); + await flush(); + + const result = useDebuggerStore.getState().watchResults["watch-2"]; + expect(result?.error).toBe("Expression could not be evaluated."); + expect(useDebuggerStore.getState().pendingRequests).toEqual({}); +}); + +test("normalized output events reach the debug console", async () => { + await initializeDebuggerEventBridge(); + + emitMessage("session-1", { type: "output", category: "stderr", output: "boom" }); + await flush(); + + expect(useDebuggerStore.getState().adapterOutput).toEqual([ + { sessionId: "session-1", stream: "stderr", data: "boom" }, + ]); +}); + +test("terminated events end only the matching session", async () => { + await initializeDebuggerEventBridge(); + useDebuggerStore.getState().actions.startSession({ + id: "session-1", + name: "Demo", + configId: "generated-bun", + command: "bun", + startedAt: 1, + status: "running", + }); + + emitMessage("session-1", { type: "terminated", exitCode: 0 }); + await flush(); + + expect(useDebuggerStore.getState().activeSession?.status).toBe("idle"); +}); diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts index 8c2c95134..f3bb3d3c8 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts @@ -5,6 +5,7 @@ import { import { useDebuggerStore } from "@/features/debugger/stores/debugger.store"; import type { DebugProtocolMessage, + DebugRequestContext, DebugScope, DebugStackFrame, DebugThread, @@ -36,157 +37,185 @@ export function initializeDebuggerEventBridge(): Promise { return pendingSubscription; } +// Rust Core emits normalized events only: no DAP frames, request sequences, or +// response correlation exist in the React layer. async function handleDebugProtocolMessage(payload: DebugProtocolMessage) { - const message = asRecord(payload.message); - if (!message) return; - - if (message.type === "event") { - await handleDebugEvent(payload.sessionId, message); - return; + const event = asRecord(payload.message); + if (!event) return; + + switch (event.type) { + case "stateChanged": + handleStateChanged(event); + return; + case "stopped": + await handleStopped(payload.sessionId, event); + return; + case "continued": + handleContinued(); + return; + case "terminated": + useDebuggerStore.getState().actions.recordSessionEnded({ + sessionId: payload.sessionId, + reason: "terminated", + }); + return; + case "output": + handleOutput(payload.sessionId, event); + return; + case "operationCompleted": + await handleOperationCompleted(payload.sessionId, event); + return; + case "operationFailed": + handleOperationFailed(event); + return; } +} - if (message.type === "response") { - await handleDebugResponse(payload.sessionId, message); +function handleStateChanged(event: Record) { + const state = typeof event.state === "string" ? event.state : ""; + if (state === "paused") { + useDebuggerStore.getState().actions.setSessionStatus("paused"); + } else if (state === "running") { + useDebuggerStore.getState().actions.setSessionStatus("running"); } } -async function handleDebugEvent(sessionId: string, message: Record) { - const event = typeof message.event === "string" ? message.event : ""; - const body = asRecord(message.body); +async function handleStopped(sessionId: string, event: Record) { + const threadId = typeof event.threadId === "number" ? event.threadId : undefined; const actions = useDebuggerStore.getState().actions; + actions.setSessionStatus("paused"); + actions.setStoppedState({ + reason: typeof event.reason === "string" ? event.reason : "stopped", + threadId, + description: typeof event.description === "string" ? event.description : undefined, + }); - if (event === "stopped") { - const threadId = typeof body?.threadId === "number" ? body.threadId : undefined; - actions.setSessionStatus("paused"); - actions.setStoppedState({ - reason: typeof body?.reason === "string" ? body.reason : "stopped", - threadId, - description: typeof body?.description === "string" ? body.description : undefined, - }); - - if (typeof threadId === "number") { - await requestStackTrace(sessionId, threadId); - } else { - await requestThreads(sessionId); - } - return; + if (typeof threadId === "number") { + await requestStackTrace(sessionId, threadId); + } else { + await requestThreads(sessionId); } +} - if (event === "continued") { - actions.setSessionStatus("running"); - actions.setStoppedState(null); - return; - } +function handleContinued() { + const actions = useDebuggerStore.getState().actions; + actions.setSessionStatus("running"); + actions.setStoppedState(null); +} - if (event === "terminated" || event === "exited") { - actions.recordSessionEnded({ - sessionId, - reason: event, - }); - } +function handleOutput(sessionId: string, event: Record) { + const output = typeof event.output === "string" ? event.output : ""; + if (!output) return; + useDebuggerStore.getState().actions.recordAdapterOutput({ + sessionId, + stream: typeof event.category === "string" ? event.category : "stdout", + data: output, + }); } -async function handleDebugResponse(sessionId: string, message: Record) { - const requestSeq = typeof message.request_seq === "number" ? message.request_seq : null; - const command = typeof message.command === "string" ? message.command : ""; - const body = asRecord(message.body); +async function handleOperationCompleted(sessionId: string, event: Record) { + const operationId = typeof event.operationId === "string" ? event.operationId : ""; + const result = asRecord(event.result); + const kind = typeof result?.kind === "string" ? result.kind : ""; const store = useDebuggerStore.getState(); - const context = requestSeq ? store.pendingRequests[requestSeq] : undefined; - - if (requestSeq) { - store.actions.clearAdapterRequest(requestSeq); + const context = operationId ? store.pendingRequests[operationId] : undefined; + if (operationId) { + store.actions.clearAdapterRequest(operationId); } - if (message.success === false) { - if (context?.command === "evaluate") { - store.actions.setWatchResult({ - expressionId: context.expressionId, - value: "", - variablesReference: 0, - error: - typeof message.message === "string" ? message.message : "Could not evaluate expression.", - evaluatedAt: Date.now(), - }); + switch (kind) { + case "threads": { + const threads = toThreads(result?.threads); + store.actions.setThreads(threads); + const firstThreadId = threads[0]?.id; + if (typeof firstThreadId === "number") { + await requestStackTrace(sessionId, firstThreadId); + } + return; } - return; - } - - if (command === "threads") { - const threads = toThreads(body?.threads); - store.actions.setThreads(threads); - const firstThreadId = threads[0]?.id; - if (typeof firstThreadId === "number") { - await requestStackTrace(sessionId, firstThreadId); + case "stackTrace": { + const frames = toStackFrames(result?.stackFrames); + store.actions.setStackFrames(frames); + const firstFrameId = frames[0]?.id; + if (typeof firstFrameId === "number") { + await requestScopes(sessionId, firstFrameId); + } + return; } - return; - } - - if (command === "stackTrace") { - const frames = toStackFrames(body?.stackFrames); - store.actions.setStackFrames(frames); - const firstFrameId = frames[0]?.id; - if (typeof firstFrameId === "number") { - await requestScopes(sessionId, firstFrameId); + case "scopes": { + const scopes = toScopes(result?.scopes); + store.actions.setScopes(scopes); + await Promise.all( + scopes + .filter((scope) => scope.variablesReference > 0) + .map((scope) => requestVariables(sessionId, scope.variablesReference)), + ); + return; + } + case "variables": { + if (context?.command === "variables") { + store.actions.setVariables(context.variablesReference, toVariables(result?.variables)); + } + return; + } + case "evaluate": { + if (context?.command === "evaluate") { + const variable = asRecord(result?.variable); + store.actions.setWatchResult({ + expressionId: context.expressionId, + value: typeof variable?.value === "string" ? variable.value : "", + type: typeof variable?.type === "string" ? variable.type : undefined, + variablesReference: + typeof variable?.variablesReference === "number" ? variable.variablesReference : 0, + evaluatedAt: Date.now(), + }); + } + return; } - return; - } - - if (command === "scopes") { - const scopes = toScopes(body?.scopes); - store.actions.setScopes(scopes); - await Promise.all( - scopes - .filter((scope) => scope.variablesReference > 0) - .map((scope) => requestVariables(sessionId, scope.variablesReference)), - ); - return; } +} - if (command === "variables" && context?.command === "variables") { - store.actions.setVariables(context.variablesReference, toVariables(body?.variables)); - return; +function handleOperationFailed(event: Record) { + const operationId = typeof event.operationId === "string" ? event.operationId : ""; + const store = useDebuggerStore.getState(); + const context = operationId ? store.pendingRequests[operationId] : undefined; + if (operationId) { + store.actions.clearAdapterRequest(operationId); } - - if (command === "evaluate" && context?.command === "evaluate") { + if (context?.command === "evaluate") { store.actions.setWatchResult({ expressionId: context.expressionId, - value: typeof body?.result === "string" ? body.result : "", - type: typeof body?.type === "string" ? body.type : undefined, - variablesReference: - typeof body?.variablesReference === "number" ? body.variablesReference : 0, + value: "", + variablesReference: 0, + error: typeof event.message === "string" ? event.message : "Could not evaluate expression.", evaluatedAt: Date.now(), }); } } async function requestThreads(sessionId: string) { - const seq = await sendDebugAdapterRequest(sessionId, "threads"); - useDebuggerStore.getState().actions.registerAdapterRequest(seq, { command: "threads" }); + const result = await sendDebugAdapterRequest(sessionId, "threads"); + registerContext(result.operationId, { command: "threads" }); } async function requestStackTrace(sessionId: string, threadId: number) { - const seq = await sendDebugAdapterRequest(sessionId, "stackTrace", { - threadId, - startFrame: 0, - levels: 50, - }); - useDebuggerStore.getState().actions.registerAdapterRequest(seq, { - command: "stackTrace", - threadId, - }); + const result = await sendDebugAdapterRequest(sessionId, "stackTrace", { threadId }); + registerContext(result.operationId, { command: "stackTrace", threadId }); } async function requestScopes(sessionId: string, frameId: number) { - const seq = await sendDebugAdapterRequest(sessionId, "scopes", { frameId }); - useDebuggerStore.getState().actions.registerAdapterRequest(seq, { command: "scopes", frameId }); + const result = await sendDebugAdapterRequest(sessionId, "scopes", { frameId }); + registerContext(result.operationId, { command: "scopes", frameId }); } async function requestVariables(sessionId: string, variablesReference: number) { - const seq = await sendDebugAdapterRequest(sessionId, "variables", { variablesReference }); - useDebuggerStore.getState().actions.registerAdapterRequest(seq, { - command: "variables", - variablesReference, - }); + const result = await sendDebugAdapterRequest(sessionId, "variables", { variablesReference }); + registerContext(result.operationId, { command: "variables", variablesReference }); +} + +function registerContext(operationId: string, context: DebugRequestContext) { + if (!operationId) return; + useDebuggerStore.getState().actions.registerAdapterRequest(operationId, context); } function asRecord(value: unknown): Record | null { @@ -215,11 +244,10 @@ function toStackFrames(value: unknown): DebugStackFrame[] { .map((item): DebugStackFrame | null => { const frame = asRecord(item); if (!frame || typeof frame.id !== "number") return null; - const source = asRecord(frame.source); return { id: frame.id, name: typeof frame.name === "string" ? frame.name : `Frame ${frame.id}`, - sourcePath: typeof source?.path === "string" ? source.path : undefined, + sourcePath: typeof frame.sourcePath === "string" ? frame.sourcePath : undefined, line: typeof frame.line === "number" ? frame.line : 0, column: typeof frame.column === "number" ? frame.column : 0, }; diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts index 3e2967e7d..c6d7c8e49 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts @@ -4,6 +4,7 @@ import type { DebugAdapterLaunch, DebugAdapterSessionInfo, DebugBreakpoint, + DebugCommandResult, DebugLaunchConfig, DebugProcessOutput, DebugProtocolMessage, @@ -26,8 +27,8 @@ export async function sendDebugAdapterRequest( sessionId: string, command: string, argumentsPayload?: unknown, -): Promise { - return await invoke("debug_send_request", { +): Promise { + return await invoke("debug_send_request", { sessionId, command, arguments: argumentsPayload, @@ -53,18 +54,8 @@ export async function startDebugLaunchSession( env: config.env, }); - await sendDebugAdapterRequest(session.id, "initialize", { - adapterID: config.type ?? config.runtime, - pathFormat: "path", - linesStartAt1: true, - columnsStartAt1: true, - supportsVariableType: true, - supportsVariablePaging: true, - supportsRunInTerminalRequest: true, - }); - - await syncDebugBreakpoints(session.id, breakpoints); - + // Rust Core owns DAP initialization and the configurationDone handshake; + // the host facade only queues the launch and breakpoint sets. await sendDebugAdapterRequest(session.id, config.request ?? "launch", { name: config.name, type: config.type ?? config.runtime, @@ -75,7 +66,7 @@ export async function startDebugLaunchSession( env: config.env ?? {}, }); - await sendDebugAdapterRequest(session.id, "configurationDone"); + await syncDebugBreakpoints(session.id, breakpoints); return session; } diff --git a/windows/tauri/src/features/debugger/stores/debugger.store.ts b/windows/tauri/src/features/debugger/stores/debugger.store.ts index b1a0b54cc..dc2e1d0b5 100644 --- a/windows/tauri/src/features/debugger/stores/debugger.store.ts +++ b/windows/tauri/src/features/debugger/stores/debugger.store.ts @@ -38,7 +38,7 @@ interface DebuggerState { scopes: DebugScope[]; variablesByReference: Record; stoppedState: DebugStoppedState | null; - pendingRequests: Record; + pendingRequests: Record; actions: { hydrate: () => void; setWorkspaceConfigs: (configs: DebugLaunchConfig[]) => void; @@ -59,8 +59,8 @@ interface DebuggerState { recordAdapterMessage: (message: DebugProtocolMessage) => void; recordAdapterOutput: (output: DebugProcessOutput) => void; recordSessionEnded: (event: DebugSessionEnded) => void; - registerAdapterRequest: (seq: number, context: DebugRequestContext) => void; - clearAdapterRequest: (seq: number) => void; + registerAdapterRequest: (operationId: string, context: DebugRequestContext) => void; + clearAdapterRequest: (operationId: string) => void; setThreads: (threads: DebugThread[]) => void; setStackFrames: (frames: DebugStackFrame[]) => void; selectStackFrame: (frameId: number | null) => void; @@ -349,19 +349,19 @@ export const useDebuggerStore = createSelectors( })); }, - registerAdapterRequest: (seq, context) => { + registerAdapterRequest: (operationId, context) => { set((state) => ({ pendingRequests: { ...state.pendingRequests, - [seq]: context, + [operationId]: context, }, })); }, - clearAdapterRequest: (seq) => { + clearAdapterRequest: (operationId) => { set((state) => { const nextPendingRequests = { ...state.pendingRequests }; - delete nextPendingRequests[seq]; + delete nextPendingRequests[operationId]; return { pendingRequests: nextPendingRequests }; }); }, diff --git a/windows/tauri/src/features/debugger/types/debugger.types.ts b/windows/tauri/src/features/debugger/types/debugger.types.ts index 037072034..100626e67 100644 --- a/windows/tauri/src/features/debugger/types/debugger.types.ts +++ b/windows/tauri/src/features/debugger/types/debugger.types.ts @@ -56,6 +56,11 @@ export interface DebugAdapterSessionInfo { cwd?: string; } +export interface DebugCommandResult { + sessionId: string; + operationId: string; +} + export interface DebugProtocolMessage { sessionId: string; message: unknown; diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index e74d192e2..5718ba110 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -26,6 +26,9 @@ const nativeCommands = new Set([ "core_execute", "create_app_window", "create_terminal", + "debug_send_request", + "debug_start_session", + "debug_stop_session", "get_secure_secret", "frontend_trace", "get_application_memory_usage", From c9392fe2da2f4f00b298e549b4e9453c60b2f9ad Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Tue, 1 Sep 2026 17:17:15 +0800 Subject: [PATCH 2/8] fix(windows): address DAP host review feedback (attach, lifecycle, failed state, stale events, timer-free tests) --- windows/tauri/src-tauri/src/debug.rs | 198 +++++++++++++++++- windows/tauri/src-tauri/src/main.rs | 1 + .../debugger/components/debugger-view.tsx | 6 +- .../services/debug-adapter-events.test.ts | 93 +++++--- .../debugger/services/debug-adapter-events.ts | 31 ++- .../services/debug-adapter-service.ts | 4 +- .../features/debugger/types/debugger.types.ts | 2 + .../file-system/stores/file-system.store.ts | 5 + windows/tauri/src/platform/tauri-core.ts | 1 + 9 files changed, 299 insertions(+), 42 deletions(-) diff --git a/windows/tauri/src-tauri/src/debug.rs b/windows/tauri/src-tauri/src/debug.rs index 28fe7c073..5e36db3ba 100644 --- a/windows/tauri/src-tauri/src/debug.rs +++ b/windows/tauri/src-tauri/src/debug.rs @@ -46,6 +46,9 @@ impl Default for DebugAdapterManager { struct AdapterSession { pid: u32, + /// Workspace root that owns this session, used to reap adapters when the + /// owning project closes without touching sessions of other projects. + workspace: String, /// Shared so adapter stdout readers can write Core-produced frames while /// Tauri commands write request frames; the mutex prevents interleaving. stdin: Arc>, @@ -66,6 +69,8 @@ pub struct DebugAdapterLaunch { pub cwd: Option, #[serde(default)] pub env: HashMap, + #[serde(default)] + pub workspace_path: Option, } #[derive(Debug, Serialize)] @@ -140,6 +145,13 @@ pub async fn debug_start_session( .as_deref() .filter(|cwd| !cwd.trim().is_empty()) .unwrap_or("."); + let workspace = launch + .workspace_path + .as_deref() + .map(str::trim) + .filter(|workspace| !workspace.is_empty()) + .unwrap_or("") + .to_string(); let update = execute_core_async( "debug.createSession".to_string(), @@ -166,6 +178,24 @@ pub async fn debug_start_session( return Err(error); } emit_update_events(&app, &session_id, &update); + if update_state_failed(&update) { + let message = session_failure_message(&update); + let _ = child.kill(); + let _ = execute_core_async( + "debug.destroySession".to_string(), + json!({ "sessionId": session_id }), + ) + .await; + let _ = app.emit( + "debugger_output", + json!({ "sessionId": session_id, "stream": "stderr", "data": format!("{message}\n") }), + ); + let _ = app.emit( + "debugger_session_ended", + json!({ "sessionId": session_id, "reason": "failed" }), + ); + return Err(message); + } { let mut current = sessions().lock().map_err(|_| { @@ -177,6 +207,7 @@ pub async fn debug_start_session( session_id.clone(), AdapterSession { pid, + workspace, stdin: stdin.clone(), }, ); @@ -228,13 +259,27 @@ pub async fn debug_send_request( &request.command, &request.arguments, &operation_id, - )?; - let update = execute_core_async(core_command, payload).await?; + ) + .map_err(|error| { + // The facade owns the adapter process; an unmapped request must not + // leave a live adapter or shared session behind. + fail_session(&app, &request.session_id, pid, &error); + error + })?; + let update = execute_core_async(core_command, payload).await.map_err(|error| { + fail_session(&app, &request.session_id, pid, &error); + error + })?; if let Err(error) = write_outbound_frames(&stdin, &update) { fail_session(&app, &request.session_id, pid, &error); return Err(error); } emit_update_events(&app, &request.session_id, &update); + if update_state_failed(&update) { + let message = session_failure_message(&update); + fail_session(&app, &request.session_id, pid, &message); + return Err(message); + } Ok(DebugCommandResult { session_id: request.session_id, operation_id, @@ -248,6 +293,19 @@ pub async fn debug_stop_session(app: AppHandle, session_id: String) -> Result<() stop_session(&app, &session_id).await } +/// Stops every adapter session owned by one workspace. Project close and +/// workspace switches call this so adapters never outlive their project. +#[tauri::command] +pub async fn debug_stop_workspace_sessions( + app: AppHandle, + workspace_path: String, +) -> Result<(), String> { + for session_id in matching_workspace_sessions(&workspace_path) { + stop_session(&app, &session_id).await?; + } + Ok(()) +} + async fn stop_session(app: &AppHandle, session_id: &str) -> Result<(), String> { let removed = { let mut current = sessions() @@ -326,11 +384,11 @@ fn translate_request( ) -> Result<(String, Value), String> { let arguments = arguments.as_object().cloned().unwrap_or_default(); let (core_command, payload) = match command { - "launch" => { + "launch" | "attach" => { let request_kind = arguments .get("request") .and_then(Value::as_str) - .unwrap_or("launch"); + .unwrap_or(command); if !matches!(request_kind, "launch" | "attach") { return Err(format!("Unsupported debug launch request: {request_kind}")); } @@ -468,6 +526,54 @@ fn required_argument(arguments: &Map, field: &str) -> Result bool { + update.get("state").and_then(Value::as_str) == Some("failed") +} + +/// Derives an actionable message from the failed update's events. +fn session_failure_message(update: &Value) -> String { + if let Some(events) = update.get("events").and_then(Value::as_array) { + for event in events { + if event.get("type").and_then(Value::as_str) == Some("operationFailed") { + if let Some(message) = event.get("message").and_then(Value::as_str) { + if !message.trim().is_empty() { + return message.to_string(); + } + } + } + } + } + "The debug session failed to start or continue.".to_string() +} + +/// Returns the ids of sessions owned by the given workspace path. +fn matching_workspace_sessions(workspace_path: &str) -> Vec { + let wanted = normalize_workspace(workspace_path); + if wanted.is_empty() { + return Vec::new(); + } + let Ok(current) = sessions().lock() else { + return Vec::new(); + }; + current + .iter() + .filter(|(_, session)| session_owned_by(&session.workspace, &wanted)) + .map(|(session_id, _)| session_id.clone()) + .collect() +} + +fn session_owned_by(session_workspace: &str, normalized_wanted: &str) -> bool { + normalize_workspace(session_workspace) == normalized_wanted +} + +fn normalize_workspace(workspace_path: &str) -> String { + workspace_path + .trim() + .trim_end_matches(['/', '\\']) + .replace('\\', "/") + .to_lowercase() +} + /// Reduces one shared `debug.*` request and returns the serialized update. fn execute_core_sync(command: &str, payload: Value) -> Result { let operation_id = format!("core-{}", OPERATION_COUNTER.fetch_add(1, Ordering::Relaxed)); @@ -558,7 +664,19 @@ fn spawn_stdout_reader( let mut buffer = [0_u8; 4096]; loop { match stream.read(&mut buffer) { - Ok(0) => break, + Ok(0) => { + if is_current_session(&session_id, pid) { + fail_session( + &app, + &session_id, + pid, + &format!( + "Debug adapter {pid} (session {session_id}) closed its output stream unexpectedly." + ), + ); + } + break; + } Ok(count) => { if !is_current_session(&session_id, pid) { break; @@ -594,7 +712,19 @@ fn spawn_stdout_reader( } } } - Err(_) => break, + Err(error) => { + if is_current_session(&session_id, pid) { + fail_session( + &app, + &session_id, + pid, + &format!( + "Debug adapter {pid} (session {session_id}) output read failed: {error}" + ), + ); + } + break; + } } } }) @@ -782,6 +912,62 @@ mod tests { assert_eq!(payload["configuration"]["arguments"]["port"], 9229); } + #[test] + fn facade_accepts_attach_as_the_request_command() { + let (command, payload) = translate_request( + "session-1", + "attach", + &json!({ "name": "Attach", "port": 9229, "type": "node" }), + "op-attach", + ) + .unwrap(); + assert_eq!(command, "debug.launch"); + assert_eq!(payload["configuration"]["request"], "attach"); + assert_eq!(payload["configuration"]["arguments"]["port"], 9229); + assert_eq!(payload["configuration"]["name"], "Attach"); + } + + #[test] + fn workspace_ownership_matching_ignores_trailing_separators_and_case() { + assert!(session_owned_by( + "C:/work/project-a", + &normalize_workspace("C:\\Work\\Project-A\\") + )); + assert!(!session_owned_by( + "C:/work/project-b", + &normalize_workspace("C:/work/project-a") + )); + assert!(!session_owned_by("", &normalize_workspace("C:/work/project-a"))); + } + + #[test] + fn failed_updates_expose_the_adapter_message() { + let update = json!({ + "state": "failed", + "events": [ + { + "type": "operationFailed", + "operationId": "op-1", + "command": "launch", + "code": "adapterRejected", + "message": "The adapter rejected the launch configuration.", + } + ] + }); + assert!(update_state_failed(&update)); + assert_eq!( + session_failure_message(&update), + "The adapter rejected the launch configuration." + ); + + let running = json!({ "state": "running", "events": [] }); + assert!(!update_state_failed(&running)); + assert_eq!( + session_failure_message(&running), + "The debug session failed to start or continue." + ); + } + #[test] fn translates_set_breakpoints_into_the_core_contract() { let (command, payload) = translate_request( diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 6e5e2d029..9e4e211ca 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -77,6 +77,7 @@ fn main() { debug::debug_start_session, debug::debug_send_request, debug::debug_stop_session, + debug::debug_stop_workspace_sessions, platform::platform_invoke, memory::get_application_memory_usage, terminal::begin_frontend_terminal_session, diff --git a/windows/tauri/src/features/debugger/components/debugger-view.tsx b/windows/tauri/src/features/debugger/components/debugger-view.tsx index aede615b7..fa4be280f 100644 --- a/windows/tauri/src/features/debugger/components/debugger-view.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-view.tsx @@ -223,7 +223,11 @@ export default function DebuggerView() { setStartError(null); if (resolvedSelectedConfig.adapterCommand) { try { - const adapterSession = await startDebugLaunchSession(resolvedSelectedConfig, breakpoints); + const adapterSession = await startDebugLaunchSession( + resolvedSelectedConfig, + breakpoints, + rootFolderPath, + ); debuggerActions.startSession({ id: adapterSession.id, name: resolvedSelectedConfig.name, diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts index f74495a49..123f9ee29 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts @@ -8,10 +8,11 @@ const sendDebugAdapterRequest = mock( return { sessionId: "session-1", operationId: `debug-op-${operationCounter}` }; }, ); +const stopDebugAdapterSession = mock(async (_sessionId: string): Promise => {}); const subscribeDebuggerEvents = mock(async () => () => {}); type DebuggerEventHandlers = { - onMessage?: (payload: DebugProtocolMessage) => void; + onMessage?: (payload: DebugProtocolMessage) => void | Promise; onOutput?: (payload: unknown) => void; onSessionEnded?: (payload: unknown) => void; }; @@ -20,6 +21,7 @@ let capturedHandlers: DebuggerEventHandlers = {}; mock.module("@/features/debugger/services/debug-adapter-service", () => ({ sendDebugAdapterRequest, + stopDebugAdapterSession, subscribeDebuggerEvents: async (handlers: DebuggerEventHandlers) => { capturedHandlers = handlers; return () => {}; @@ -29,18 +31,15 @@ mock.module("@/features/debugger/services/debug-adapter-service", () => ({ const { initializeDebuggerEventBridge } = await import("./debug-adapter-events"); const { useDebuggerStore } = await import("../stores/debugger.store"); -const flush = async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); -}; - -const emitMessage = (sessionId: string, message: unknown) => { +// The bridge returns the awaited event-handling promise, so tests await the +// full cascade without real timers (write-stable-tests gate). +const emitMessage = (sessionId: string, message: unknown) => capturedHandlers.onMessage?.({ sessionId, message }); -}; beforeEach(() => { operationCounter = 0; sendDebugAdapterRequest.mockClear(); + stopDebugAdapterSession.mockClear(); useDebuggerStore.setState({ pendingRequests: {}, threads: [], @@ -54,19 +53,22 @@ beforeEach(() => { }); }); -test("stopped events pause the session and cascade into stack trace requests", async () => { - await initializeDebuggerEventBridge(); +const startSession = (id: string) => { useDebuggerStore.getState().actions.startSession({ - id: "session-1", + id, name: "Demo", configId: "generated-bun", command: "bun", startedAt: 1, status: "running", }); +}; + +test("stopped events pause the session and cascade into stack trace requests", async () => { + await initializeDebuggerEventBridge(); + startSession("session-1"); - emitMessage("session-1", { type: "stopped", reason: "breakpoint", threadId: 3 }); - await flush(); + await emitMessage("session-1", { type: "stopped", reason: "breakpoint", threadId: 3 }); const state = useDebuggerStore.getState(); expect(state.activeSession?.status).toBe("paused"); @@ -86,16 +88,16 @@ test("stopped events pause the session and cascade into stack trace requests", a test("normalized thread results fill the thread list and cascade further", async () => { await initializeDebuggerEventBridge(); + startSession("session-1"); useDebuggerStore.getState().actions.registerAdapterRequest("threads-op", { command: "threads", }); - emitMessage("session-1", { + await emitMessage("session-1", { type: "operationCompleted", operationId: "threads-op", result: { kind: "threads", threads: [{ id: 1, name: "main" }] }, }); - await flush(); const state = useDebuggerStore.getState(); expect(state.threads).toEqual([{ id: 1, name: "main" }]); @@ -111,12 +113,13 @@ test("normalized thread results fill the thread list and cascade further", async test("evaluate results land in watch results for the correlated expression", async () => { await initializeDebuggerEventBridge(); + startSession("session-1"); useDebuggerStore.getState().actions.registerAdapterRequest("debug-op-1", { command: "evaluate", expressionId: "watch-1", }); - emitMessage("session-1", { + await emitMessage("session-1", { type: "operationCompleted", operationId: "debug-op-1", result: { @@ -124,7 +127,6 @@ test("evaluate results land in watch results for the correlated expression", asy variable: { name: "", value: "42", type: "number", variablesReference: 0 }, }, }); - await flush(); const result = useDebuggerStore.getState().watchResults["watch-1"]; expect(result?.value).toBe("42"); @@ -134,19 +136,19 @@ test("evaluate results land in watch results for the correlated expression", asy test("failed evaluate operations surface an actionable watch error", async () => { await initializeDebuggerEventBridge(); + startSession("session-1"); useDebuggerStore.getState().actions.registerAdapterRequest("debug-op-1", { command: "evaluate", expressionId: "watch-2", }); - emitMessage("session-1", { + await emitMessage("session-1", { type: "operationFailed", operationId: "debug-op-1", command: "evaluate", code: "adapterRejected", message: "Expression could not be evaluated.", }); - await flush(); const result = useDebuggerStore.getState().watchResults["watch-2"]; expect(result?.error).toBe("Expression could not be evaluated."); @@ -156,8 +158,7 @@ test("failed evaluate operations surface an actionable watch error", async () => test("normalized output events reach the debug console", async () => { await initializeDebuggerEventBridge(); - emitMessage("session-1", { type: "output", category: "stderr", output: "boom" }); - await flush(); + await emitMessage("session-1", { type: "output", category: "stderr", output: "boom" }); expect(useDebuggerStore.getState().adapterOutput).toEqual([ { sessionId: "session-1", stream: "stderr", data: "boom" }, @@ -166,17 +167,47 @@ test("normalized output events reach the debug console", async () => { test("terminated events end only the matching session", async () => { await initializeDebuggerEventBridge(); - useDebuggerStore.getState().actions.startSession({ - id: "session-1", - name: "Demo", - configId: "generated-bun", - command: "bun", - startedAt: 1, - status: "running", - }); + startSession("session-1"); - emitMessage("session-1", { type: "terminated", exitCode: 0 }); - await flush(); + await emitMessage("session-1", { type: "terminated", exitCode: 0 }); expect(useDebuggerStore.getState().activeSession?.status).toBe("idle"); }); + +test("stale events from a previous session cannot modify a restarted session", async () => { + await initializeDebuggerEventBridge(); + startSession("session-1"); + await emitMessage("session-1", { type: "stopped", reason: "breakpoint", threadId: 3 }); + expect(useDebuggerStore.getState().activeSession?.status).toBe("paused"); + + startSession("session-2"); + const requestCountAfterRestart = sendDebugAdapterRequest.mock.calls.length; + + await emitMessage("session-1", { + type: "operationCompleted", + operationId: "stale-stack-op", + result: { kind: "stackTrace", stackFrames: [{ id: 7, name: "old", line: 1, column: 1 }] }, + }); + await emitMessage("session-1", { type: "stopped", reason: "breakpoint", threadId: 99 }); + + const state = useDebuggerStore.getState(); + expect(state.activeSession?.id).toBe("session-2"); + expect(state.activeSession?.status).toBe("running"); + expect(state.stoppedState).toBeNull(); + expect(state.stackFrames).toEqual([]); + expect(sendDebugAdapterRequest.mock.calls.length).toBe(requestCountAfterRestart); +}); + +test("failed states end the session and request native teardown", async () => { + await initializeDebuggerEventBridge(); + startSession("session-1"); + + await emitMessage("session-1", { type: "stateChanged", state: "failed" }); + + const state = useDebuggerStore.getState(); + expect(state.activeSession?.status).toBe("idle"); + expect(state.adapterOutput).toEqual([ + { sessionId: "session-1", stream: "stderr", data: "The debug session failed.\n" }, + ]); + expect(stopDebugAdapterSession).toHaveBeenCalledWith("session-1"); +}); diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts index f3bb3d3c8..996fe4f12 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts @@ -1,5 +1,6 @@ import { sendDebugAdapterRequest, + stopDebugAdapterSession, subscribeDebuggerEvents, } from "@/features/debugger/services/debug-adapter-service"; import { useDebuggerStore } from "@/features/debugger/stores/debugger.store"; @@ -22,7 +23,7 @@ export function initializeDebuggerEventBridge(): Promise { pendingSubscription = subscribeDebuggerEvents({ onMessage: (message) => { useDebuggerStore.getState().actions.recordAdapterMessage(message); - void handleDebugProtocolMessage(message); + return handleDebugProtocolMessage(message); }, onOutput: (output) => useDebuggerStore.getState().actions.recordAdapterOutput(output), onSessionEnded: (event) => useDebuggerStore.getState().actions.recordSessionEnded(event), @@ -43,9 +44,16 @@ async function handleDebugProtocolMessage(payload: DebugProtocolMessage) { const event = asRecord(payload.message); if (!event) return; + // Stale state and result events from a stopped session must not mutate the + // store after a fast Stop/Restart; per-session log output is still kept. + const activeSessionId = useDebuggerStore.getState().activeSession?.id; + if (event.type !== "output" && payload.sessionId !== activeSessionId) { + return; + } + switch (event.type) { case "stateChanged": - handleStateChanged(event); + handleStateChanged(payload.sessionId, event); return; case "stopped": await handleStopped(payload.sessionId, event); @@ -71,12 +79,29 @@ async function handleDebugProtocolMessage(payload: DebugProtocolMessage) { } } -function handleStateChanged(event: Record) { +function handleStateChanged(sessionId: string, event: Record) { const state = typeof event.state === "string" ? event.state : ""; if (state === "paused") { useDebuggerStore.getState().actions.setSessionStatus("paused"); } else if (state === "running") { useDebuggerStore.getState().actions.setSessionStatus("running"); + } else if (state === "failed") { + const message = + typeof event.message === "string" && event.message + ? event.message + : "The debug session failed."; + useDebuggerStore.getState().actions.recordAdapterOutput({ + sessionId, + stream: "stderr", + data: `${message}\n`, + }); + useDebuggerStore.getState().actions.recordSessionEnded({ + sessionId, + reason: "failed", + }); + void stopDebugAdapterSession(sessionId).catch((error) => { + console.error("Failed to stop the debug session after a failure:", error); + }); } } diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts index c6d7c8e49..e13004e5c 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts @@ -12,7 +12,7 @@ import type { } from "@/features/debugger/types/debugger.types"; interface DebuggerEventHandlers { - onMessage?: (payload: DebugProtocolMessage) => void; + onMessage?: (payload: DebugProtocolMessage) => void | Promise; onOutput?: (payload: DebugProcessOutput) => void; onSessionEnded?: (payload: DebugSessionEnded) => void; } @@ -42,6 +42,7 @@ export async function stopDebugAdapterSession(sessionId: string): Promise export async function startDebugLaunchSession( config: DebugLaunchConfig, breakpoints: DebugBreakpoint[], + workspacePath?: string, ): Promise { if (!config.adapterCommand) { throw new Error("Debug configuration is missing adapterCommand"); @@ -52,6 +53,7 @@ export async function startDebugLaunchSession( args: config.adapterArgs ?? [], cwd: config.cwd, env: config.env, + workspacePath, }); // Rust Core owns DAP initialization and the configurationDone handshake; diff --git a/windows/tauri/src/features/debugger/types/debugger.types.ts b/windows/tauri/src/features/debugger/types/debugger.types.ts index 100626e67..1b30c0078 100644 --- a/windows/tauri/src/features/debugger/types/debugger.types.ts +++ b/windows/tauri/src/features/debugger/types/debugger.types.ts @@ -47,6 +47,8 @@ export interface DebugAdapterLaunch { args?: string[]; cwd?: string; env?: Record; + /** Project root that owns this session; used to reap adapters on close. */ + workspacePath?: string; } export interface DebugAdapterSessionInfo { diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index c051d9418..0ade99e95 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -3085,6 +3085,11 @@ const createFileSystemStore = (workspaceId: string): StoreApi { + console.error("Failed to stop debug sessions for the project:", error); + }); const terminalSessions = useTerminalStore.getStore(projectId).getState().sessions; await Promise.all( [...terminalSessions.values()].map(async (session) => { diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index 5718ba110..f68a74763 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -29,6 +29,7 @@ const nativeCommands = new Set([ "debug_send_request", "debug_start_session", "debug_stop_session", + "debug_stop_workspace_sessions", "get_secure_secret", "frontend_trace", "get_application_memory_usage", From e0dbe428f66a980551c840494087c5bbdef0ab29 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Wed, 2 Sep 2026 13:21:54 +0800 Subject: [PATCH 3/8] fix(windows): tighten debug adapter lifecycle --- .github/workflows/ci-windows.yml | 1 + windows/tauri/src-tauri/src/debug.rs | 38 ++++++++++++------- .../components/debugger-variables-panel.tsx | 12 +++--- .../debugger/components/debugger-view.tsx | 6 ++- .../components/debugger-watch-panel.tsx | 12 +++--- .../services/debug-adapter-events.test.ts | 4 +- .../debugger/services/debug-adapter-events.ts | 29 ++++++++++---- .../services/debug-adapter-service.ts | 13 ++++++- 8 files changed, 74 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index b21c52d96..72d7f4d25 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -236,6 +236,7 @@ jobs: bun test src/features/editor/stores/editor-app.store.test.ts src/features/editor/services/document-external-change-workflow.test.ts src/features/editor/services/document-save-lifecycle.test.ts bun test src/features/editor/lsp/language-server-navigation.test.ts src/features/editor/lsp/java-navigation-marker-loader.test.ts src/features/editor/engines/monaco/definition-link-scheduler.test.ts src/features/editor/engines/monaco/java-implementation-markers.test.ts bun test src/features/editor/lsp/java-workspace-language-server.test.ts src/features/editor/lsp/java-workspace-change-scheduler.test.ts + bun test src/features/debugger/services/debug-adapter-events.test.ts bun test src/features/run bun test src/features/file-explorer/lib/java-clipboard-class.test.ts src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts diff --git a/windows/tauri/src-tauri/src/debug.rs b/windows/tauri/src-tauri/src/debug.rs index 5e36db3ba..693ea6b71 100644 --- a/windows/tauri/src-tauri/src/debug.rs +++ b/windows/tauri/src-tauri/src/debug.rs @@ -89,6 +89,8 @@ pub struct DebugSendRequest { pub command: String, #[serde(default)] pub arguments: Value, + #[serde(default)] + pub operation_id: Option, } #[derive(Debug, Serialize)] @@ -250,10 +252,12 @@ pub async fn debug_send_request( .ok_or_else(|| "The debug session is no longer active.".to_string())?; (session.pid, session.stdin.clone()) }; - let operation_id = format!( - "debug-op-{}", - OPERATION_COUNTER.fetch_add(1, Ordering::Relaxed) - ); + let operation_id = request.operation_id.unwrap_or_else(|| { + format!( + "debug-op-{}", + OPERATION_COUNTER.fetch_add(1, Ordering::Relaxed) + ) + }); let (core_command, payload) = translate_request( &request.session_id, &request.command, @@ -300,8 +304,14 @@ pub async fn debug_stop_workspace_sessions( app: AppHandle, workspace_path: String, ) -> Result<(), String> { + let mut failures = Vec::new(); for session_id in matching_workspace_sessions(&workspace_path) { - stop_session(&app, &session_id).await?; + if let Err(error) = stop_session(&app, &session_id).await { + failures.push(error); + } + } + if let Some(error) = failures.into_iter().next() { + return Err(error); } Ok(()) } @@ -665,15 +675,12 @@ fn spawn_stdout_reader( loop { match stream.read(&mut buffer) { Ok(0) => { + // EOF is not itself a failure: a normally exiting adapter + // closes stdout before the exit waiter can publish its + // exit code. Terminate a still-live process so a broken + // adapter cannot leave the session registered forever. if is_current_session(&session_id, pid) { - fail_session( - &app, - &session_id, - pid, - &format!( - "Debug adapter {pid} (session {session_id}) closed its output stream unexpectedly." - ), - ); + kill_adapter_process(pid); } break; } @@ -693,6 +700,11 @@ fn spawn_stdout_reader( if !is_current_session(&session_id, pid) { break; } + if update_state_failed(&update) { + let message = session_failure_message(&update); + fail_session(&app, &session_id, pid, &message); + break; + } if let Err(error) = write_outbound_frames(&stdin, &update) { fail_session(&app, &session_id, pid, &error); break; diff --git a/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx b/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx index eea202082..c5f365f27 100644 --- a/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { Spinner } from "@/ui/spinner"; import { useTranslation } from "@/i18n/locale-provider"; import { cn } from "@/utils/cn"; -import { sendDebugAdapterRequest } from "../services/debug-adapter-service"; +import { createDebugOperationId, sendDebugAdapterRequest } from "../services/debug-adapter-service"; import { useDebuggerStore } from "../stores/debugger.store"; import type { DebugRequestContext, DebugScope, DebugVariable } from "../types/debugger.types"; import { DebugEmptyState } from "./debugger-panels"; @@ -56,13 +56,11 @@ export function DebugVariablesPanel({ if (!shouldLoadChildren) return; try { - const result = await sendDebugAdapterRequest(activeSessionId, "variables", { + const operationId = createDebugOperationId(); + debuggerActions.registerAdapterRequest(operationId, { command: "variables", variablesReference }); + await sendDebugAdapterRequest(activeSessionId, "variables", { variablesReference, - }); - debuggerActions.registerAdapterRequest(result.operationId, { - command: "variables", - variablesReference, - }); + }, operationId); } catch { setExpandedVariableReferences((current) => { const next = new Set(current); diff --git a/windows/tauri/src/features/debugger/components/debugger-view.tsx b/windows/tauri/src/features/debugger/components/debugger-view.tsx index fa4be280f..6433e0c70 100644 --- a/windows/tauri/src/features/debugger/components/debugger-view.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-view.tsx @@ -22,6 +22,7 @@ import { useTranslation } from "@/i18n/locale-provider"; import { cn } from "@/utils/cn"; import { joinPath } from "@/utils/path-helpers"; import { + createDebugOperationId, sendDebugAdapterRequest, startDebugLaunchSession, stopDebugAdapterSession, @@ -302,8 +303,9 @@ export default function DebuggerView() { if (activeSession?.id) { try { - const result = await sendDebugAdapterRequest(activeSession.id, "scopes", { frameId }); - debuggerActions.registerAdapterRequest(result.operationId, { command: "scopes", frameId }); + const operationId = createDebugOperationId(); + debuggerActions.registerAdapterRequest(operationId, { command: "scopes", frameId }); + await sendDebugAdapterRequest(activeSession.id, "scopes", { frameId }, operationId); } catch { // Some adapters may not allow scope requests after the session moves on. } diff --git a/windows/tauri/src/features/debugger/components/debugger-watch-panel.tsx b/windows/tauri/src/features/debugger/components/debugger-watch-panel.tsx index 4da98e383..6b9a542b7 100644 --- a/windows/tauri/src/features/debugger/components/debugger-watch-panel.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-watch-panel.tsx @@ -4,7 +4,7 @@ import { TrashIcon as Trash, } from "@/ui/icons"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { sendDebugAdapterRequest } from "../services/debug-adapter-service"; +import { createDebugOperationId, sendDebugAdapterRequest } from "../services/debug-adapter-service"; import { useDebuggerStore } from "../stores/debugger.store"; import type { DebugRequestContext } from "../types/debugger.types"; import { Button } from "@/ui/button"; @@ -46,15 +46,13 @@ export function DebugWatchPanel({ if (!activeSessionId || !isPaused) return; try { - const result = await sendDebugAdapterRequest(activeSessionId, "evaluate", { + const operationId = createDebugOperationId(); + debuggerActions.registerAdapterRequest(operationId, { command: "evaluate", expressionId }); + await sendDebugAdapterRequest(activeSessionId, "evaluate", { expression, frameId: selectedFrameId ?? undefined, context: "watch", - }); - debuggerActions.registerAdapterRequest(result.operationId, { - command: "evaluate", - expressionId, - }); + }, operationId); } catch (error) { debuggerActions.setWatchResult({ expressionId, diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts index 123f9ee29..28d102ee9 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts @@ -79,7 +79,7 @@ test("stopped events pause the session and cascade into stack trace requests", a }); expect(sendDebugAdapterRequest).toHaveBeenCalledWith("session-1", "stackTrace", { threadId: 3, - }); + }, "debug-op-1"); expect(state.pendingRequests["debug-op-1"]).toEqual({ command: "stackTrace", threadId: 3, @@ -104,7 +104,7 @@ test("normalized thread results fill the thread list and cascade further", async expect(state.pendingRequests["threads-op"]).toBeUndefined(); expect(sendDebugAdapterRequest).toHaveBeenCalledWith("session-1", "stackTrace", { threadId: 1, - }); + }, "debug-op-1"); expect(state.pendingRequests["debug-op-1"]).toEqual({ command: "stackTrace", threadId: 1, diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts index 996fe4f12..38192b966 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts @@ -219,23 +219,36 @@ function handleOperationFailed(event: Record) { } async function requestThreads(sessionId: string) { - const result = await sendDebugAdapterRequest(sessionId, "threads"); - registerContext(result.operationId, { command: "threads" }); + const operationId = createOperationId(); + registerContext(operationId, { command: "threads" }); + await sendDebugAdapterRequest(sessionId, "threads", undefined, operationId); } async function requestStackTrace(sessionId: string, threadId: number) { - const result = await sendDebugAdapterRequest(sessionId, "stackTrace", { threadId }); - registerContext(result.operationId, { command: "stackTrace", threadId }); + const operationId = createOperationId(); + registerContext(operationId, { command: "stackTrace", threadId }); + await sendDebugAdapterRequest(sessionId, "stackTrace", { threadId }, operationId); } async function requestScopes(sessionId: string, frameId: number) { - const result = await sendDebugAdapterRequest(sessionId, "scopes", { frameId }); - registerContext(result.operationId, { command: "scopes", frameId }); + const operationId = createOperationId(); + registerContext(operationId, { command: "scopes", frameId }); + await sendDebugAdapterRequest(sessionId, "scopes", { frameId }, operationId); } async function requestVariables(sessionId: string, variablesReference: number) { - const result = await sendDebugAdapterRequest(sessionId, "variables", { variablesReference }); - registerContext(result.operationId, { command: "variables", variablesReference }); + const operationId = createOperationId(); + registerContext(operationId, { command: "variables", variablesReference }); + await sendDebugAdapterRequest(sessionId, "variables", { variablesReference }, operationId); +} + +let operationCounter = 0; +function createOperationId(): string { + if (Object.keys(useDebuggerStore.getState().pendingRequests).length === 0) { + operationCounter = 0; + } + operationCounter += 1; + return `debug-op-${operationCounter}`; } function registerContext(operationId: string, context: DebugRequestContext) { diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts index e13004e5c..8577a8476 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts @@ -27,14 +27,22 @@ export async function sendDebugAdapterRequest( sessionId: string, command: string, argumentsPayload?: unknown, + operationId?: string, ): Promise { return await invoke("debug_send_request", { sessionId, command, arguments: argumentsPayload, + operationId, }); } +let operationCounter = 0; +export function createDebugOperationId(): string { + operationCounter += 1; + return `ui-debug-op-${operationCounter}`; +} + export async function stopDebugAdapterSession(sessionId: string): Promise { await invoke("debug_stop_session", { sessionId }); } @@ -48,10 +56,11 @@ export async function startDebugLaunchSession( throw new Error("Debug configuration is missing adapterCommand"); } + const effectiveCwd = config.cwd ?? workspacePath; const session = await startDebugAdapterSession({ command: config.adapterCommand, args: config.adapterArgs ?? [], - cwd: config.cwd, + cwd: effectiveCwd, env: config.env, workspacePath, }); @@ -63,7 +72,7 @@ export async function startDebugLaunchSession( type: config.type ?? config.runtime, request: config.request ?? "launch", program: config.program, - cwd: config.cwd, + cwd: effectiveCwd, args: config.args ?? [], env: config.env ?? {}, }); From 033988f8fca131e675fc398c220c60a8c59ce907 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Wed, 2 Sep 2026 14:04:19 +0800 Subject: [PATCH 4/8] style(windows): format debug host --- windows/tauri/src-tauri/src/debug.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/windows/tauri/src-tauri/src/debug.rs b/windows/tauri/src-tauri/src/debug.rs index 693ea6b71..1959ae448 100644 --- a/windows/tauri/src-tauri/src/debug.rs +++ b/windows/tauri/src-tauri/src/debug.rs @@ -270,10 +270,12 @@ pub async fn debug_send_request( fail_session(&app, &request.session_id, pid, &error); error })?; - let update = execute_core_async(core_command, payload).await.map_err(|error| { - fail_session(&app, &request.session_id, pid, &error); - error - })?; + let update = execute_core_async(core_command, payload) + .await + .map_err(|error| { + fail_session(&app, &request.session_id, pid, &error); + error + })?; if let Err(error) = write_outbound_frames(&stdin, &update) { fail_session(&app, &request.session_id, pid, &error); return Err(error); @@ -949,7 +951,10 @@ mod tests { "C:/work/project-b", &normalize_workspace("C:/work/project-a") )); - assert!(!session_owned_by("", &normalize_workspace("C:/work/project-a"))); + assert!(!session_owned_by( + "", + &normalize_workspace("C:/work/project-a") + )); } #[test] From f10058c759e450d98b53e09c572fa0d7b6ad695f Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Thu, 3 Sep 2026 09:10:55 +0800 Subject: [PATCH 5/8] fix(windows): preserve startup debug events --- windows/tauri/src-tauri/src/debug.rs | 5 +++++ .../debugger/components/debugger-view.tsx | 22 ++++++++++--------- .../services/debug-adapter-service.ts | 2 ++ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/windows/tauri/src-tauri/src/debug.rs b/windows/tauri/src-tauri/src/debug.rs index 1959ae448..4d1b434b7 100644 --- a/windows/tauri/src-tauri/src/debug.rs +++ b/windows/tauri/src-tauri/src/debug.rs @@ -346,6 +346,10 @@ async fn stop_session(app: &AppHandle, session_id: &str) -> Result<(), String> { json!({ "sessionId": session_id }), ) .await; + let _ = app.emit( + "debugger_session_ended", + json!({ "sessionId": session_id, "reason": "failed" }), + ); return Err(error); } emit_update_events(app, session_id, &update); @@ -704,6 +708,7 @@ fn spawn_stdout_reader( } if update_state_failed(&update) { let message = session_failure_message(&update); + emit_update_events(&app, &session_id, &update); fail_session(&app, &session_id, pid, &message); break; } diff --git a/windows/tauri/src/features/debugger/components/debugger-view.tsx b/windows/tauri/src/features/debugger/components/debugger-view.tsx index 6433e0c70..dfde98bcb 100644 --- a/windows/tauri/src/features/debugger/components/debugger-view.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-view.tsx @@ -224,20 +224,22 @@ export default function DebuggerView() { setStartError(null); if (resolvedSelectedConfig.adapterCommand) { try { - const adapterSession = await startDebugLaunchSession( + await startDebugLaunchSession( resolvedSelectedConfig, breakpoints, rootFolderPath, + (session) => { + debuggerActions.startSession({ + id: session.id, + name: resolvedSelectedConfig.name, + configId: resolvedSelectedConfig.id, + command: [session.command, ...session.args].join(" "), + cwd: session.cwd, + startedAt: Date.now(), + status: "running", + }); + }, ); - debuggerActions.startSession({ - id: adapterSession.id, - name: resolvedSelectedConfig.name, - configId: resolvedSelectedConfig.id, - command: [adapterSession.command, ...adapterSession.args].join(" "), - cwd: adapterSession.cwd, - startedAt: Date.now(), - status: "running", - }); } catch (error) { setStartError(error instanceof Error ? error.message : String(error)); } diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts index 8577a8476..ff5e13b50 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts @@ -51,6 +51,7 @@ export async function startDebugLaunchSession( config: DebugLaunchConfig, breakpoints: DebugBreakpoint[], workspacePath?: string, + onSessionStarted?: (session: DebugAdapterSessionInfo) => void, ): Promise { if (!config.adapterCommand) { throw new Error("Debug configuration is missing adapterCommand"); @@ -64,6 +65,7 @@ export async function startDebugLaunchSession( env: config.env, workspacePath, }); + onSessionStarted?.(session); // Rust Core owns DAP initialization and the configurationDone handshake; // the host facade only queues the launch and breakpoint sets. From 5bf3292e30ec2dc29f152f3c95b3ff1548841828 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Thu, 3 Sep 2026 14:38:19 +0800 Subject: [PATCH 6/8] fix(windows): harden debug adapter lifecycle --- windows/tauri/src-tauri/Cargo.toml | 8 + .../src-tauri/src/bin/fake_dap_adapter.rs | 128 +++++ windows/tauri/src-tauri/src/debug.rs | 540 +++++++++++++++++- windows/tauri/src-tauri/src/main.rs | 1 + .../services/debug-adapter-events.test.ts | 3 + .../debugger/services/debug-adapter-events.ts | 11 +- .../services/debug-adapter-service.ts | 35 +- windows/tauri/src/platform/tauri-core.ts | 1 + 8 files changed, 684 insertions(+), 43 deletions(-) create mode 100644 windows/tauri/src-tauri/src/bin/fake_dap_adapter.rs diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index 11214c71d..dc14515d2 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -5,6 +5,14 @@ description = "Lithe Windows desktop application" edition = "2021" license = "Apache-2.0" +[features] +test-support = [] + +[[bin]] +name = "fake-dap-adapter" +path = "src/bin/fake_dap_adapter.rs" +required-features = ["test-support"] + [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/windows/tauri/src-tauri/src/bin/fake_dap_adapter.rs b/windows/tauri/src-tauri/src/bin/fake_dap_adapter.rs new file mode 100644 index 000000000..f277bfbcc --- /dev/null +++ b/windows/tauri/src-tauri/src/bin/fake_dap_adapter.rs @@ -0,0 +1,128 @@ +//! Deterministic DAP adapter fixture used by Windows process lifecycle tests. + +use serde_json::Value; +use std::io::{self, Read, Write}; + +fn frame(payload: &str) -> Vec { + format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload).into_bytes() +} + +fn read_frame(input: &mut impl Read) -> Option { + let mut bytes = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + if input.read(&mut byte).ok()? == 0 { + return None; + } + bytes.push(byte[0]); + if bytes.ends_with(b"\r\n\r\n") { + let header = String::from_utf8(bytes).ok()?; + let length = header + .lines() + .find_map(|line| line.strip_prefix("Content-Length: "))? + .parse::() + .ok()?; + let mut body = vec![0_u8; length]; + input.read_exact(&mut body).ok()?; + return serde_json::from_slice(&body).ok(); + } + } +} + +fn write_frame(output: &mut impl Write, payload: &str, fragmented: bool) { + let bytes = frame(payload); + if fragmented { + let midpoint = bytes.len() / 2; + let _ = output.write_all(&bytes[..midpoint]); + let _ = output.flush(); + let _ = output.write_all(&bytes[midpoint..]); + } else { + let _ = output.write_all(&bytes); + } + let _ = output.flush(); +} + +fn main() { + let mode = std::env::args() + .nth(1) + .unwrap_or_else(|| "normal".to_string()); + eprintln!("fake-dap-adapter:{mode}"); + if mode == "eof-live" { + return; + } + + let mut stdin = io::stdin().lock(); + let mut stdout = io::stdout().lock(); + let Some(initialize) = read_frame(&mut stdin) else { + let payload = if mode == "reject-initialize" { + r#"{"seq":1,"type":"response","request_seq":1,"success":false,"command":"initialize","message":"rejected"}"# + } else { + r#"{"seq":1,"type":"event","event":"terminated"}"# + }; + write_frame(&mut stdout, payload, mode == "fragmented"); + return; + }; + let initialize_seq = initialize["seq"].as_i64().unwrap_or(1); + if mode == "reject-initialize" { + write_frame( + &mut stdout, + &format!( + r#"{{"seq":1,"type":"response","request_seq":{initialize_seq},"success":false,"command":"initialize","message":"rejected"}}"# + ), + false, + ); + return; + } + write_frame( + &mut stdout, + &format!( + r#"{{"seq":1,"type":"response","request_seq":{initialize_seq},"success":true,"command":"initialize","body":{{"supportsConfigurationDoneRequest":true}}}}"# + ), + mode == "fragmented", + ); + write_frame( + &mut stdout, + r#"{"seq":2,"type":"event","event":"initialized"}"#, + mode == "fragmented", + ); + let Some(launch) = read_frame(&mut stdin) else { + return; + }; + let launch_seq = launch["seq"].as_i64().unwrap_or(2); + if mode == "reject-launch" { + write_frame( + &mut stdout, + &format!( + r#"{{"seq":3,"type":"response","request_seq":{launch_seq},"success":false,"command":"launch","message":"rejected"}}"# + ), + false, + ); + return; + } + write_frame( + &mut stdout, + &format!( + r#"{{"seq":3,"type":"response","request_seq":{launch_seq},"success":true,"command":"launch"}}"# + ), + mode == "fragmented", + ); + if mode == "hold" { + while read_frame(&mut stdin).is_some() {} + return; + } + let _ = read_frame(&mut stdin); + if mode == "startup-stopped" { + write_frame( + &mut stdout, + r#"{"seq":4,"type":"event","event":"stopped","reason":"entry","body":{"threadId":1}}"#, + false, + ); + while read_frame(&mut stdin).is_some() {} + return; + } + write_frame( + &mut stdout, + r#"{"seq":4,"type":"event","event":"terminated"}"#, + mode == "fragmented", + ); +} diff --git a/windows/tauri/src-tauri/src/debug.rs b/windows/tauri/src-tauri/src/debug.rs index 4d1b434b7..64e9772f7 100644 --- a/windows/tauri/src-tauri/src/debug.rs +++ b/windows/tauri/src-tauri/src/debug.rs @@ -29,6 +29,17 @@ use crate::run::{apply_creation_flags, decode_process_bytes, incomplete_suffix_l const CORE_TIMEOUT_MILLISECONDS: u64 = 30_000; const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; +const MAX_BUFFERED_STARTUP_EVENTS: usize = 128; + +trait DebugEventSink: Clone + Send + Sync + 'static { + fn emit_event(&self, name: &str, payload: Value); +} + +impl DebugEventSink for AppHandle { + fn emit_event(&self, name: &str, payload: Value) { + let _ = self.emit(name, payload); + } +} static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); static OPERATION_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -52,6 +63,14 @@ struct AdapterSession { /// Shared so adapter stdout readers can write Core-produced frames while /// Tauri commands write request frames; the mutex prevents interleaving. stdin: Arc>, + startup_gate: Arc>, +} + +struct StartupGate { + /// Whether the frontend has installed this session as active. + ready: bool, + /// Normalized protocol events received before `ready` in wire order. + buffered_events: Vec, } fn sessions() -> &'static Mutex> { @@ -105,6 +124,13 @@ pub struct DebugCommandResult { pub async fn debug_start_session( app: AppHandle, launch: DebugAdapterLaunch, +) -> Result { + start_debug_adapter_session(app, launch).await +} + +async fn start_debug_adapter_session( + app: S, + launch: DebugAdapterLaunch, ) -> Result { let command = launch.command.trim().to_string(); if command.is_empty() { @@ -154,6 +180,10 @@ pub async fn debug_start_session( .filter(|workspace| !workspace.is_empty()) .unwrap_or("") .to_string(); + let startup_gate = Arc::new(Mutex::new(StartupGate { + ready: false, + buffered_events: Vec::new(), + })); let update = execute_core_async( "debug.createSession".to_string(), @@ -179,20 +209,20 @@ pub async fn debug_start_session( .await; return Err(error); } - emit_update_events(&app, &session_id, &update); if update_state_failed(&update) { let message = session_failure_message(&update); + emit_update_events(&app, &session_id, &update); let _ = child.kill(); let _ = execute_core_async( "debug.destroySession".to_string(), json!({ "sessionId": session_id }), ) .await; - let _ = app.emit( + app.emit_event( "debugger_output", json!({ "sessionId": session_id, "stream": "stderr", "data": format!("{message}\n") }), ); - let _ = app.emit( + app.emit_event( "debugger_session_ended", json!({ "sessionId": session_id, "reason": "failed" }), ); @@ -211,11 +241,23 @@ pub async fn debug_start_session( pid, workspace, stdin: stdin.clone(), + startup_gate: startup_gate.clone(), }, ); } - let stdout_reader = spawn_stdout_reader(app.clone(), session_id.clone(), pid, stdin, stdout); + if let Err(error) = emit_or_buffer_update_events(&app, &session_id, &startup_gate, &update) { + fail_session(&app, &session_id, pid, &error); + return Err(error); + } + let stdout_reader = spawn_stdout_reader( + app.clone(), + session_id.clone(), + pid, + stdin, + startup_gate, + stdout, + ); let stderr_reader = spawn_stderr_reader(app.clone(), session_id.clone(), stderr); spawn_exit_waiter( app, @@ -234,6 +276,35 @@ pub async fn debug_start_session( }) } +/// Releases normalized startup events after the frontend registers the session. +#[tauri::command] +pub fn debug_session_ready(app: AppHandle, session_id: String) -> Result<(), String> { + mark_session_ready(&app, &session_id) +} + +fn mark_session_ready(app: &S, session_id: &str) -> Result<(), String> { + let gate = sessions() + .lock() + .map_err(|_| "Debug session state is unavailable.".to_string())? + .get(session_id) + .map(|session| session.startup_gate.clone()) + .ok_or_else(|| "The debug session is no longer active.".to_string())?; + let buffered_events = { + let mut gate = gate + .lock() + .map_err(|_| "Debug session startup state is unavailable.".to_string())?; + if gate.ready { + return Ok(()); + } + gate.ready = true; + std::mem::take(&mut gate.buffered_events) + }; + for payload in buffered_events { + app.emit_event("debugger_message", payload); + } + Ok(()) +} + /// Temporary compatibility facade for the existing React request surface. /// /// Every request is translated onto the shared `debug.*` contract and reduced @@ -242,6 +313,13 @@ pub async fn debug_start_session( pub async fn debug_send_request( app: AppHandle, request: DebugSendRequest, +) -> Result { + send_debug_request(app, request).await +} + +async fn send_debug_request( + app: S, + request: DebugSendRequest, ) -> Result { let (pid, stdin) = { let current = sessions() @@ -318,7 +396,7 @@ pub async fn debug_stop_workspace_sessions( Ok(()) } -async fn stop_session(app: &AppHandle, session_id: &str) -> Result<(), String> { +async fn stop_session(app: &S, session_id: &str) -> Result<(), String> { let removed = { let mut current = sessions() .lock() @@ -346,7 +424,7 @@ async fn stop_session(app: &AppHandle, session_id: &str) -> Result<(), String> { json!({ "sessionId": session_id }), ) .await; - let _ = app.emit( + app.emit_event( "debugger_session_ended", json!({ "sessionId": session_id, "reason": "failed" }), ); @@ -366,7 +444,7 @@ async fn stop_session(app: &AppHandle, session_id: &str) -> Result<(), String> { json!({ "sessionId": session_id }), ) .await; - let _ = app.emit( + app.emit_event( "debugger_session_ended", json!({ "sessionId": session_id, "reason": "stopped" }), ); @@ -653,24 +731,57 @@ fn write_outbound_frames(stdin: &Mutex, update: &Value) -> Result<( } /// Projects Core's normalized events onto the existing React event surface. -fn emit_update_events(app: &AppHandle, session_id: &str, update: &Value) { +fn emit_update_events(app: &S, session_id: &str, update: &Value) { let Some(events) = update.get("events").and_then(Value::as_array) else { return; }; for event in events { - let _ = app.emit( + app.emit_event( "debugger_message", json!({ "sessionId": session_id, "message": event }), ); } } +fn emit_or_buffer_update_events( + app: &S, + session_id: &str, + startup_gate: &Arc>, + update: &Value, +) -> Result<(), String> { + let Some(events) = update.get("events").and_then(Value::as_array) else { + return Ok(()); + }; + let payloads = events + .iter() + .map(|event| json!({ "sessionId": session_id, "message": event })) + .collect::>(); + let payloads = { + let mut gate = startup_gate + .lock() + .map_err(|_| "Debug session startup state is unavailable.".to_string())?; + if !gate.ready { + if gate.buffered_events.len() + payloads.len() > MAX_BUFFERED_STARTUP_EVENTS { + return Err("Debug adapter produced too many startup events.".to_string()); + } + gate.buffered_events.extend(payloads); + return Ok(()); + } + payloads + }; + for payload in payloads { + app.emit_event("debugger_message", payload); + } + Ok(()) +} + /// Feeds adapter stdout bytes to `debug.receive` and writes resulting frames. -fn spawn_stdout_reader( - app: AppHandle, +fn spawn_stdout_reader( + app: S, session_id: String, pid: u32, stdin: Arc>, + startup_gate: Arc>, stdout: Option, ) -> thread::JoinHandle<()> { thread::spawn(move || { @@ -716,7 +827,15 @@ fn spawn_stdout_reader( fail_session(&app, &session_id, pid, &error); break; } - emit_update_events(&app, &session_id, &update); + if let Err(error) = emit_or_buffer_update_events( + &app, + &session_id, + &startup_gate, + &update, + ) { + fail_session(&app, &session_id, pid, &error); + break; + } } Err(error) => { if is_current_session(&session_id, pid) { @@ -750,8 +869,8 @@ fn spawn_stdout_reader( } /// Projects adapter stderr onto the debug console output event. -fn spawn_stderr_reader( - app: AppHandle, +fn spawn_stderr_reader( + app: S, session_id: String, stderr: Option, ) -> thread::JoinHandle<()> { @@ -785,12 +904,12 @@ fn spawn_stderr_reader( }) } -fn emit_stderr_chunk(app: &AppHandle, session_id: &str, bytes: &[u8]) { +fn emit_stderr_chunk(app: &S, session_id: &str, bytes: &[u8]) { let text = decode_process_bytes(bytes); if text.is_empty() { return; } - let _ = app.emit( + app.emit_event( "debugger_output", json!({ "sessionId": session_id, "stream": "stderr", "data": text }), ); @@ -798,8 +917,8 @@ fn emit_stderr_chunk(app: &AppHandle, session_id: &str, bytes: &[u8]) { /// Reaps an exited adapter, destroys its shared session, and emits the ended /// event only when this process generation is still the registered one. -fn spawn_exit_waiter( - app: AppHandle, +fn spawn_exit_waiter( + app: S, session_id: String, mut child: Child, pid: u32, @@ -818,7 +937,7 @@ fn spawn_exit_waiter( return; } let _ = execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })); - let _ = app.emit( + app.emit_event( "debugger_session_ended", json!({ "sessionId": session_id, @@ -854,17 +973,17 @@ fn is_current_session(session_id: &str, pid: u32) -> bool { /// Tears down a failed session: process tree kill, shared-session destroy, /// console error, and a single `failed` end event. -fn fail_session(app: &AppHandle, session_id: &str, pid: u32, message: &str) { +fn fail_session(app: &S, session_id: &str, pid: u32, message: &str) { if !remove_session(session_id, pid) { return; } kill_adapter_process(pid); let _ = execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })); - let _ = app.emit( + app.emit_event( "debugger_output", json!({ "sessionId": session_id, "stream": "stderr", "data": format!("{message}\n") }), ); - let _ = app.emit( + app.emit_event( "debugger_session_ended", json!({ "sessionId": session_id, "reason": "failed" }), ); @@ -892,6 +1011,65 @@ fn adapter_identifier(command: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::process::Stdio; + use std::sync::mpsc; + use std::time::Duration; + + #[cfg(feature = "test-support")] + #[derive(Clone)] + struct RecordingSink { + events: mpsc::Sender<(String, Value)>, + } + + #[cfg(feature = "test-support")] + impl DebugEventSink for RecordingSink { + fn emit_event(&self, name: &str, payload: Value) { + self.events + .send((name.to_string(), payload)) + .expect("recording sink receiver should remain active"); + } + } + + #[cfg(feature = "test-support")] + fn fake_adapter_path() -> std::path::PathBuf { + let test_binary = std::env::current_exe().expect("test binary path"); + test_binary + .parent() + .and_then(Path::parent) + .expect("target directory") + .join("fake-dap-adapter.exe") + } + + #[cfg(all(windows, feature = "test-support"))] + fn start_launched_fake_session( + sink: &RecordingSink, + mode: &str, + operation_id: &str, + ) -> DebugSessionInfo { + let session = tauri::async_runtime::block_on(start_debug_adapter_session( + sink.clone(), + DebugAdapterLaunch { + command: fake_adapter_path().to_string_lossy().into_owned(), + args: vec![mode.to_string()], + cwd: None, + env: HashMap::new(), + workspace_path: None, + }, + )) + .expect("host should start the fake adapter"); + tauri::async_runtime::block_on(send_debug_request( + sink.clone(), + DebugSendRequest { + session_id: session.id.clone(), + command: "launch".to_string(), + arguments: json!({ "request": "launch", "name": "Demo" }), + operation_id: Some(operation_id.to_string()), + }, + )) + .expect("host should queue launch"); + mark_session_ready(sink, &session.id).expect("startup gate should open"); + session + } #[test] fn translates_launch_into_shared_launch_configuration() { @@ -990,6 +1168,43 @@ mod tests { ); } + #[cfg(all(windows, feature = "test-support"))] + #[test] + fn startup_events_are_buffered_until_session_ready() { + let gate = Arc::new(Mutex::new(StartupGate { + ready: false, + buffered_events: Vec::new(), + })); + let (events, received) = mpsc::channel(); + let sink = RecordingSink { events }; + let update = json!({ + "events": [ + { "type": "stateChanged", "state": "paused" }, + { "type": "stopped", "reason": "entry" } + ] + }); + + emit_or_buffer_update_events(&sink, "session-1", &gate, &update).unwrap(); + assert!(received.try_recv().is_err()); + assert_eq!(gate.lock().unwrap().buffered_events.len(), 2); + + gate.lock().unwrap().ready = true; + emit_or_buffer_update_events( + &sink, + "session-1", + &gate, + &json!({ "events": [{ "type": "initialized" }] }), + ) + .unwrap(); + assert_eq!( + received + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .1["message"]["type"], + "initialized" + ); + } + #[test] fn translates_set_breakpoints_into_the_core_contract() { let (command, payload) = translate_request( @@ -1077,6 +1292,266 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn fake_adapter_process_round_trips_stdio_with_bounded_cleanup() { + let mut child = Command::new("cmd.exe") + .args(["/D", "/Q", "/C", "more"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("fake adapter process should start"); + let mut stdin = child.stdin.take().expect("fake adapter stdin"); + let stdout = child.stdout.take().expect("fake adapter stdout"); + let stderr = child.stderr.take().expect("fake adapter stderr"); + let (output_tx, output_rx) = mpsc::channel(); + thread::spawn(move || { + let mut stdout = stdout; + let mut bytes = Vec::new(); + let _ = stdout.read_to_end(&mut bytes); + let _ = output_tx.send(bytes); + }); + thread::spawn(move || { + let mut stderr = stderr; + let mut bytes = Vec::new(); + let _ = stderr.read_to_end(&mut bytes); + }); + + let frame = b"Content-Length: 4\r\n\r\nTEST"; + stdin.write_all(frame).expect("fake adapter stdin write"); + drop(stdin); + let (wait_tx, wait_rx) = mpsc::channel(); + thread::spawn(move || { + let _ = wait_tx.send(child.wait()); + }); + let status = wait_rx + .recv_timeout(Duration::from_secs(3)) + .expect("fake adapter must exit within watchdog deadline") + .expect("fake adapter wait should succeed"); + let echoed = output_rx + .recv_timeout(Duration::from_secs(3)) + .expect("stdout reader must finish within watchdog deadline"); + assert!(status.success()); + assert_eq!(echoed, [frame.as_slice(), b"\r\n"].concat()); + } + + #[cfg(all(windows, feature = "test-support"))] + #[test] + fn fake_dap_adapter_emits_fragmented_protocol_and_stderr() { + let adapter = fake_adapter_path(); + let output = Command::new(adapter) + .arg("fragmented") + .output() + .expect("fake DAP adapter should start"); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("fake-dap-adapter:fragmented")); + assert!(String::from_utf8_lossy(&output.stdout).contains("Content-Length:")); + assert!(String::from_utf8_lossy(&output.stdout).contains("terminated")); + } + + #[cfg(all(windows, feature = "test-support"))] + #[test] + fn fake_dap_adapter_rejection_and_eof_modes_are_deterministic() { + let adapter = fake_adapter_path(); + for (mode, expected) in [("reject-initialize", "initialize")] { + let output = Command::new(&adapter) + .arg(mode) + .output() + .expect("fake DAP adapter should start"); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains(r#""success":false"#)); + assert!(String::from_utf8_lossy(&output.stdout).contains(expected)); + } + let output = Command::new(adapter) + .arg("eof-live") + .output() + .expect("fake DAP adapter should start"); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("eof-live")); + } + + #[cfg(all(windows, feature = "test-support"))] + #[test] + fn fake_dap_adapter_runs_through_stdout_reader_and_exit_waiter() { + let adapter = fake_adapter_path(); + let mut child = Command::new(adapter) + .arg("eof-live") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("fake DAP adapter should start"); + let pid = child.id(); + let stdin = Arc::new(Mutex::new(child.stdin.take().expect("adapter stdin"))); + let session_id = format!("fake-host-session-{}", pid); + execute_core_sync( + "debug.createSession", + json!({ + "sessionId": session_id, + "adapterId": "fake-dap-adapter", + "rootPath": ".", + "supportsRunInTerminalRequest": false, + }), + ) + .expect("Core session should be created"); + sessions().lock().unwrap().insert( + session_id.clone(), + AdapterSession { + pid, + workspace: String::new(), + stdin: stdin.clone(), + startup_gate: Arc::new(Mutex::new(StartupGate { + ready: true, + buffered_events: Vec::new(), + })), + }, + ); + let (events, received) = mpsc::channel(); + let sink = RecordingSink { events }; + let stdout_reader = spawn_stdout_reader( + sink.clone(), + session_id.clone(), + pid, + stdin, + Arc::new(Mutex::new(StartupGate { + ready: true, + buffered_events: Vec::new(), + })), + child.stdout.take(), + ); + let stderr_reader = + spawn_stderr_reader(sink.clone(), session_id.clone(), child.stderr.take()); + spawn_exit_waiter( + sink, + session_id.clone(), + child, + pid, + stdout_reader, + stderr_reader, + ); + let deadline = std::time::Instant::now() + Duration::from_secs(3); + let mut saw_stderr = false; + let ended = loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let event = received + .recv_timeout(remaining) + .expect("exit waiter should publish a bounded completion event"); + if event.0 == "debugger_output" { + saw_stderr = true; + continue; + } + break event; + }; + assert!(saw_stderr); + assert_eq!(ended.0, "debugger_session_ended"); + assert_eq!(ended.1["sessionId"], session_id); + assert_eq!(ended.1["reason"], "exited"); + assert!(!is_current_session(&session_id, pid)); + } + + #[cfg(all(windows, feature = "test-support"))] + #[test] + fn fake_dap_adapter_initialize_rejection_triggers_host_teardown() { + let adapter = fake_adapter_path(); + let (events, received) = mpsc::channel(); + let sink = RecordingSink { events }; + let launch = DebugAdapterLaunch { + command: adapter.to_string_lossy().into_owned(), + args: vec!["reject-initialize".to_string()], + cwd: None, + env: HashMap::new(), + workspace_path: None, + }; + let session = tauri::async_runtime::block_on(start_debug_adapter_session(sink, launch)) + .expect("host should return session before reader receives rejection"); + let deadline = std::time::Instant::now() + Duration::from_secs(3); + let mut observed = Vec::new(); + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let Ok((name, payload)) = received.recv_timeout(remaining) else { + break; + }; + observed.push(format!("{name}:{}", payload)); + if name == "debugger_session_ended" { + assert_eq!(payload["sessionId"], session.id); + assert_eq!(payload["reason"], "failed"); + assert!(!sessions().lock().unwrap().contains_key(&session.id)); + return; + } + } + panic!("host rejection teardown was not observed; events={observed:?}"); + } + + #[cfg(all(windows, feature = "test-support"))] + #[test] + fn repeated_stop_only_ends_the_selected_session() { + let (events, _received) = mpsc::channel(); + let sink = RecordingSink { events }; + let first = start_launched_fake_session(&sink, "hold", "hold-launch-1"); + let second = start_launched_fake_session(&sink, "hold", "hold-launch-2"); + + tauri::async_runtime::block_on(stop_session(&sink, &first.id)) + .expect("first session should stop"); + tauri::async_runtime::block_on(stop_session(&sink, &first.id)) + .expect("repeated stop should be idempotent"); + assert!(!sessions().lock().unwrap().contains_key(&first.id)); + let second_pid = sessions().lock().unwrap()[&second.id].pid; + assert!(is_current_session(&second.id, second_pid)); + + tauri::async_runtime::block_on(stop_session(&sink, &second.id)) + .expect("second session should stop"); + assert!(!sessions().lock().unwrap().contains_key(&second.id)); + } + + #[cfg(all(windows, feature = "test-support"))] + #[test] + fn normal_adapter_exit_reaches_host_exit_waiter_once() { + let adapter = fake_adapter_path(); + let (events, received) = mpsc::channel(); + let sink = RecordingSink { events }; + let session = tauri::async_runtime::block_on(start_debug_adapter_session( + sink.clone(), + DebugAdapterLaunch { + command: adapter.to_string_lossy().into_owned(), + args: Vec::new(), + cwd: None, + env: HashMap::new(), + workspace_path: None, + }, + )) + .expect("host should start the fake adapter"); + tauri::async_runtime::block_on(send_debug_request( + sink.clone(), + DebugSendRequest { + session_id: session.id.clone(), + command: "launch".to_string(), + arguments: json!({ "request": "launch", "name": "Demo" }), + operation_id: Some("normal-launch".to_string()), + }, + )) + .expect("host should queue launch"); + mark_session_ready(&sink, &session.id).expect("startup gate should open"); + + let deadline = std::time::Instant::now() + Duration::from_secs(3); + let mut ended = Vec::new(); + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let Ok((name, payload)) = received.recv_timeout(remaining) else { + break; + }; + if name == "debugger_session_ended" { + ended.push(payload); + break; + } + } + assert_eq!(ended.len(), 1); + assert_eq!(ended[0]["reason"], "exited"); + assert!(received.recv_timeout(Duration::from_millis(100)).is_err()); + assert!(!sessions().lock().unwrap().contains_key(&session.id)); + } + #[test] fn rejects_updates_without_outbound_frames() { let sink = Mutex::new(Vec::new()); @@ -1084,6 +1559,27 @@ mod tests { assert!(error.contains("invalid session update")); } + #[test] + fn reports_adapter_stdin_write_failure() { + struct FailingWriter; + impl Write for FailingWriter { + fn write(&mut self, _buffer: &[u8]) -> std::io::Result { + Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let stdin = Mutex::new(FailingWriter); + let update = json!({ + "outboundFrames": [BASE64.encode(b"Content-Length: 0\r\n\r\n")] + }); + let error = write_outbound_frames(&stdin, &update).unwrap_err(); + assert!(error.contains("Could not write to debug adapter input")); + } + #[test] fn derives_adapter_identifier_from_the_executable_name() { assert_eq!(adapter_identifier(r"C:\tools\bun.exe"), "bun"); diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 9e4e211ca..0eb3b64b5 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -75,6 +75,7 @@ fn main() { core::core_execute, core::core_cancel, debug::debug_start_session, + debug::debug_session_ready, debug::debug_send_request, debug::debug_stop_session, debug::debug_stop_workspace_sessions, diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts index 28d102ee9..89f5e8d8f 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts @@ -48,6 +48,7 @@ beforeEach(() => { variablesByReference: {}, watchResults: {}, adapterOutput: [], + endedSessions: [], stoppedState: null, activeSession: null, }); @@ -172,6 +173,7 @@ test("terminated events end only the matching session", async () => { await emitMessage("session-1", { type: "terminated", exitCode: 0 }); expect(useDebuggerStore.getState().activeSession?.status).toBe("idle"); + expect(useDebuggerStore.getState().endedSessions).toEqual([]); }); test("stale events from a previous session cannot modify a restarted session", async () => { @@ -209,5 +211,6 @@ test("failed states end the session and request native teardown", async () => { expect(state.adapterOutput).toEqual([ { sessionId: "session-1", stream: "stderr", data: "The debug session failed.\n" }, ]); + expect(state.endedSessions).toEqual([]); expect(stopDebugAdapterSession).toHaveBeenCalledWith("session-1"); }); diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts index 38192b966..34945dfa7 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts @@ -62,10 +62,8 @@ async function handleDebugProtocolMessage(payload: DebugProtocolMessage) { handleContinued(); return; case "terminated": - useDebuggerStore.getState().actions.recordSessionEnded({ - sessionId: payload.sessionId, - reason: "terminated", - }); + useDebuggerStore.getState().actions.setSessionStatus("idle"); + useDebuggerStore.getState().actions.setStoppedState(null); return; case "output": handleOutput(payload.sessionId, event); @@ -95,10 +93,7 @@ function handleStateChanged(sessionId: string, event: Record) { stream: "stderr", data: `${message}\n`, }); - useDebuggerStore.getState().actions.recordSessionEnded({ - sessionId, - reason: "failed", - }); + useDebuggerStore.getState().actions.setSessionStatus("idle"); void stopDebugAdapterSession(sessionId).catch((error) => { console.error("Failed to stop the debug session after a failure:", error); }); diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts index ff5e13b50..54db4c95c 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-service.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-service.ts @@ -47,6 +47,10 @@ export async function stopDebugAdapterSession(sessionId: string): Promise await invoke("debug_stop_session", { sessionId }); } +export async function markDebugSessionReady(sessionId: string): Promise { + await invoke("debug_session_ready", { sessionId }); +} + export async function startDebugLaunchSession( config: DebugLaunchConfig, breakpoints: DebugBreakpoint[], @@ -67,19 +71,24 @@ export async function startDebugLaunchSession( }); onSessionStarted?.(session); - // Rust Core owns DAP initialization and the configurationDone handshake; - // the host facade only queues the launch and breakpoint sets. - await sendDebugAdapterRequest(session.id, config.request ?? "launch", { - name: config.name, - type: config.type ?? config.runtime, - request: config.request ?? "launch", - program: config.program, - cwd: effectiveCwd, - args: config.args ?? [], - env: config.env ?? {}, - }); - - await syncDebugBreakpoints(session.id, breakpoints); + try { + // Rust Core owns DAP initialization and the configurationDone handshake; + // the host facade only queues the launch and breakpoint sets. + await sendDebugAdapterRequest(session.id, config.request ?? "launch", { + name: config.name, + type: config.type ?? config.runtime, + request: config.request ?? "launch", + program: config.program, + cwd: effectiveCwd, + args: config.args ?? [], + env: config.env ?? {}, + }); + await markDebugSessionReady(session.id); + await syncDebugBreakpoints(session.id, breakpoints); + } catch (error) { + await stopDebugAdapterSession(session.id).catch(() => {}); + throw error; + } return session; } diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index f68a74763..4c9ca9e0c 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -28,6 +28,7 @@ const nativeCommands = new Set([ "create_terminal", "debug_send_request", "debug_start_session", + "debug_session_ready", "debug_stop_session", "debug_stop_workspace_sessions", "get_secure_secret", From e6995eb6c1d0e38e325f1f1eaeab400db182174e Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Thu, 3 Sep 2026 15:50:33 +0800 Subject: [PATCH 7/8] feat(windows): show debugger threads panel --- .../debugger/components/debugger-panels.tsx | 37 ++++++++++++++++++- .../debugger/components/debugger-view.tsx | 13 +++++++ windows/tauri/src/i18n/locale.ts | 4 ++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/windows/tauri/src/features/debugger/components/debugger-panels.tsx b/windows/tauri/src/features/debugger/components/debugger-panels.tsx index b0ab0d654..51d10f287 100644 --- a/windows/tauri/src/features/debugger/components/debugger-panels.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-panels.tsx @@ -16,7 +16,7 @@ import { useTranslation } from "@/i18n/locale-provider"; import { ScrollArea } from "@/ui/scroll-area"; import { cn } from "@/utils/cn"; import { getBaseName } from "@/utils/path-helpers"; -import type { DebugBreakpoint, DebugStackFrame } from "../types/debugger.types"; +import type { DebugBreakpoint, DebugStackFrame, DebugThread } from "../types/debugger.types"; const debugSectionVariants = cva( "flex min-h-0 flex-col overflow-hidden rounded-xl border border-border/70 bg-surface/30", @@ -140,6 +140,41 @@ export function DebugStackFrames({ ); } +export function DebugThreads({ + threads, + selectedThreadId, + onSelect, +}: { + threads: DebugThread[]; + selectedThreadId?: number; + onSelect: (threadId: number) => void; +}) { + const { t } = useTranslation(); + if (threads.length === 0) { + return {t("debugger.threadsEmpty")}; + } + + return ( +
+ {threads.map((thread) => ( + + ))} +
+ ); +} + export function DebugBreakpointsList({ breakpoints, onOpen, diff --git a/windows/tauri/src/features/debugger/components/debugger-view.tsx b/windows/tauri/src/features/debugger/components/debugger-view.tsx index dfde98bcb..0d6a34fef 100644 --- a/windows/tauri/src/features/debugger/components/debugger-view.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-view.tsx @@ -41,6 +41,7 @@ import { DebugSection, DebugSessionStatusIcon, DebugStackFrames, + DebugThreads, } from "./debugger-panels"; import { DebugWatchPanel } from "./debugger-watch-panel"; import { DebugVariablesPanel } from "./debugger-variables-panel"; @@ -483,6 +484,18 @@ export default function DebuggerView() { /> + + { + if (activeSession?.id) { + debuggerActions.setStoppedState({ reason: stoppedState?.reason ?? "pause", threadId }); + } + }} + /> + + Date: Thu, 3 Sep 2026 16:11:49 +0800 Subject: [PATCH 8/8] feat(windows): add debugger panel toolbar --- .../debugger/components/debugger-view.tsx | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/windows/tauri/src/features/debugger/components/debugger-view.tsx b/windows/tauri/src/features/debugger/components/debugger-view.tsx index 0d6a34fef..4ce81d37f 100644 --- a/windows/tauri/src/features/debugger/components/debugger-view.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-view.tsx @@ -1,5 +1,8 @@ import { + ArrowDownIcon as StepIntoIcon, + ArrowUpIcon as StepOutIcon, BugIcon as Bug, + ArrowBendDownLeftIcon as StepOverIcon, FolderOpenIcon as FolderOpen, ListBulletsIcon as ListBullets, PauseIcon as Pause, @@ -331,6 +334,69 @@ export default function DebuggerView() {
{t("debugger.runAndDebug")}
+
+ + + + + + + +
{activeSession ? : null}