From 0a47a685d950e67167220bc068de6ff20ab4e31c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:02:42 +0800 Subject: [PATCH] fix(debugger): type input for the program in the debug console (#1614) A program started through runInTerminal had stdin from /dev/null, and CodeLLDB's launcher only hands the program a terminal it can read from when it runs on one. runInTerminal now starts the command on a pseudo-terminal, forwards its output to the console (CRLF as LF, without CodeLLDB's clear-screen request) and reports that the program takes input; while it runs, the console's input line goes to it, while paused it evaluates as before. debugpy and js-debug programs started in a terminal get the same. --- Cargo.lock | 1 + crates/ide-core/src/event.rs | 3 + crates/ide-dap/Cargo.toml | 1 + crates/ide-dap/src/lib.rs | 10 +++ crates/ide-dap/src/session.rs | 105 ++++++++++++++++++---- crates/ide-dap/src/tests.rs | 34 +++++++ crates/ide-host/src/ops/debug.rs | 8 ++ src-tauri/src/commands/debug.rs | 7 ++ src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 9 +- src/features/debugger/state.ts | 15 ++++ src/features/debugger/ui/DebugConsole.tsx | 31 ++++++- src/platform/locales/text/zh.json | 3 +- 13 files changed, 204 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 276aeda0..2f09cd5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4182,6 +4182,7 @@ dependencies = [ "ide-language", "memchr", "parking_lot", + "portable-pty", "serde", "serde_json", "specta", diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 6cef110c..298547f8 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -229,6 +229,9 @@ pub enum DebugEvent { thread_id: i64, started: bool, }, + /// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it + /// in the debug console reaches its input. + ProgramInput, BreakpointsChanged { path: crate::IpcPath, }, diff --git a/crates/ide-dap/Cargo.toml b/crates/ide-dap/Cargo.toml index a902900b..9cb2f2a6 100644 --- a/crates/ide-dap/Cargo.toml +++ b/crates/ide-dap/Cargo.toml @@ -16,6 +16,7 @@ specta.workspace = true tokio = { workspace = true, features = ["net"] } parking_lot.workspace = true memchr = "2" +portable-pty.workspace = true tracing.workspace = true [dev-dependencies] diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 5d301237..8428ac8d 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -1266,6 +1266,16 @@ impl DebugManager { Ok(()) } + /// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]). + pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> { + let session = self.session(s)?; + let mut program = session.program.lock(); + let program = program + .as_mut() + .ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?; + program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}"))) + } + /// Flutter's DAP adapter: injects the changed sources into the running app. /// Not part of the DAP standard; adapters that do not know the request reject it. pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> { diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index f7bc69c3..e7356fbb 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -43,6 +43,8 @@ pub(crate) struct Session { pub adapter_process: Mutex>, /// Processes started for `runInTerminal`. pub debuggees: Mutex>, + /// The terminal a `runInTerminal` program runs in: typed input goes to it. + pub program: Mutex>, /// Set once `configurationDone` was sent; breakpoint updates go out live after that. pub configured: AtomicBool, pub initialized: watch::Sender, @@ -90,6 +92,7 @@ impl Session { caps: Mutex::new(Value::Null), adapter_process: Mutex::new(child), debuggees: Mutex::new(Vec::new()), + program: Mutex::new(None), configured: AtomicBool::new(false), initialized: watch::channel(false).0, terminated: AtomicBool::new(false), @@ -266,6 +269,9 @@ impl Session { for mut c in std::mem::take(&mut *self.debuggees.lock()) { let _ = c.start_kill(); } + if let Some(mut program) = self.program.lock().take() { + let _ = program.child.kill(); + } } } @@ -451,8 +457,25 @@ fn on_reverse_request(mgr: &Arc, s: &Arc, seq: i64, comma } } -/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console. +/// A `runInTerminal` program and the pseudo-terminal it runs in. +pub(crate) struct ProgramTerminal { + writer: Box, + child: Box, + _master: Box, +} + +impl ProgramTerminal { + pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> { + self.writer.write_all(text.as_bytes())?; + self.writer.flush() + } +} + +/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB +/// starts a launcher that gives the program its terminal, and the program reads input only from +/// a terminal), forwards its output to the console, and keeps the terminal for typed input. pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result { + use portable_pty::{CommandBuilder, PtySize, native_pty_system}; let argv: Vec = args .get("args") .and_then(Value::as_array) @@ -461,16 +484,25 @@ pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result cmd.env(k, v), + None => cmd.env_remove(k), + } + } + for (k, v) in ide_core::process::shell_env() { + cmd.env(k, v); + } + cmd.env("TERM", "xterm-256color"); + match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) { + Some(cwd) => cmd.cwd(cwd), + None => cmd.cwd(&s.spec.cwd), } if let Some(env) = args.get("env").and_then(Value::as_object) { for (k, v) in env { @@ -480,18 +512,53 @@ pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result) { + let session = s.id; + let sink = s.owner.sink.clone(); + let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || { + let mut buf = vec![0u8; 8192]; + let mut pending: Vec = Vec::new(); + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => pending.extend_from_slice(&buf[..n]), + } + // Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk. + let mut valid = match std::str::from_utf8(&pending) { + Ok(_) => pending.len(), + Err(e) if e.error_len().is_none() => e.valid_up_to(), + Err(_) => pending.len(), + }; + if valid > 0 && pending[valid - 1] == b'\r' { + valid -= 1; + } + let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n"); + pending.drain(..valid); + if !output.is_empty() { + sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } }); + } + } + if !pending.is_empty() { + let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n"); + sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } }); + } + }); +} + fn forward_output(s: &Session, mut stream: R, category: &'static str) { let session = s.id; let sink = s.owner.sink.clone(); diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index e1efd710..e86cb6fc 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -468,6 +468,40 @@ async fn run_in_terminal_spawns_debuggee() { assert!(response["body"]["processId"].as_u64().unwrap() > 0); } +#[cfg(unix)] +#[tokio::test] +async fn run_in_terminal_program_reads_typed_input() { + let h = harness(Script { + // CodeLLDB's launcher asks to clear the terminal; the console keeps its output. + run_in_terminal: Some(vec![ + "sh".into(), + "-c".into(), + "[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(), + "--clear-screen".into(), + ]), + ..Script::default() + }); + let session = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await; + h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await; + h.manager.send_input(session, "Bob\n").unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await; + let output: String = h + .events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => { + Some(output.clone()) + } + _ => None, + }) + .collect(); + assert!(!output.contains('\r'), "{output:?}"); + h.manager.stop(session).await.unwrap(); + assert!(h.manager.send_input(session, "x\n").is_err()); +} + #[test] fn launch_arguments_merge_and_defaults() { let mut c = config(); diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 82722354..8cf3a44d 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -162,6 +162,11 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th host.debugger.pause(session, thread_id).await } +/// Types `text` into the session's program (a `runInTerminal` program). +pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> { + host.debugger.send_input(session, &text) +} + pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> { host.debugger.hot_reload(session).await } @@ -381,6 +386,9 @@ pub(crate) fn register(d: &mut Dispatcher) { "debug_pause", |c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await }, ); + d.add("debug_send_input", |c| async move { + debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await + }); d.add("debug_step", |c| async move { debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await }); diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index 52230064..4e7080c9 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -92,6 +92,13 @@ pub async fn debug_resume(state: State<'_, AppState>, session: DebugSessionId, t ide_host::ops::debug::debug_resume(&state.host, "", session, thread_id).await } +/// Types `text` into the session's program (a `runInTerminal` program). +#[tauri::command] +#[specta::specta] +pub async fn debug_send_input(state: State<'_, AppState>, session: DebugSessionId, text: String) -> IdeResult<()> { + ide_host::ops::debug::debug_send_input(&state.host, "", session, text).await +} + #[tauri::command] #[specta::specta] pub async fn debug_pause(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f4f3dba7..d5645df7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -381,6 +381,7 @@ pub fn builder() -> tauri_specta::Builder { debug::debug_completions, debug::debug_resume, debug::debug_pause, + debug::debug_send_input, debug::debug_hot_reload, debug::debug_hot_restart, debug::debug_step, diff --git a/src/bindings.ts b/src/bindings.ts index ae6e9dee..1c0e231b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -735,6 +735,8 @@ export const commands = { debugCompletions: (session: DebugSessionId, frameId: number | null, text: string, line: number, column: number) => typedError(__TAURI_INVOKE("debug_completions", { session, frameId, text, line, column })), debugResume: (session: DebugSessionId, threadId: number) => typedError(__TAURI_INVOKE("debug_resume", { session, threadId })), debugPause: (session: DebugSessionId, threadId: number) => typedError(__TAURI_INVOKE("debug_pause", { session, threadId })), + /** Types `text` into the session's program (a `runInTerminal` program). */ + debugSendInput: (session: DebugSessionId, text: string) => typedError(__TAURI_INVOKE("debug_send_input", { session, text })), debugHotReload: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_hot_reload", { session })), debugHotRestart: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_hot_restart", { session })), debugStep: (session: DebugSessionId, threadId: number, kind: StepKind) => typedError(__TAURI_INVOKE("debug_step", { session, threadId, kind })), @@ -2855,7 +2857,12 @@ export type DebugEvent = * First event of every session (only build output of a root session's * `cargo` step can precede it). `parent` is set for adapter-started child sessions. */ -{ kind: "started"; name: string; adapter: string; parent: DebugSessionId | null } | { kind: "initialized" } | { kind: "stopped"; threadId: number; reason: string; description: string | null; allThreadsStopped: boolean } | { kind: "continued"; threadId: number; allThreadsContinued: boolean } | { kind: "output"; category: string; output: string } | { kind: "thread"; threadId: number; started: boolean } | { kind: "breakpointsChanged"; path: IpcPath } | { kind: "terminated" } | { kind: "exited"; code: number } | +{ kind: "started"; name: string; adapter: string; parent: DebugSessionId | null } | { kind: "initialized" } | { kind: "stopped"; threadId: number; reason: string; description: string | null; allThreadsStopped: boolean } | { kind: "continued"; threadId: number; allThreadsContinued: boolean } | { kind: "output"; category: string; output: string } | { kind: "thread"; threadId: number; started: boolean } | +/** + * The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it + * in the debug console reaches its input. + */ +{ kind: "programInput" } | { kind: "breakpointsChanged"; path: IpcPath } | { kind: "terminated" } | { kind: "exited"; code: number } | /** * java-debug's `hotcodereplace` event: `change_type` is `BUILD_COMPLETE` (classes * changed and can be reloaded), `STARTING`, `END`, `WARNING` or `ERROR`. diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 1bcbd3dc..c77fe0fc 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -45,6 +45,8 @@ export interface DebugSession { parent: DebugSessionId | null; /** Launch configuration (root sessions), used by Rerun. */ launch: LaunchConfig | null; + /** The program runs in a terminal the IDE gave it: the console types into it while it runs. */ + programInput?: boolean; /** Process the session is attached to (a Run tab's build tool); stopped with the session. */ process: RunId | null; status: SessionStatus; @@ -944,6 +946,16 @@ export async function setVariableValue( return result; } +/** Whether the console types into the session's program (it runs, in a terminal the IDE gave it). */ +export function readsProgramInput(s: DebugSession): boolean { + return !!s.programInput && s.status === "running"; +} + +/** Types `text` into the session's program; its terminal echoes it to the console. */ +export async function sendProgramInput(session: DebugSession, text: string): Promise { + await call(commands.debugSendInput(session.id, text)); +} + export async function evaluate( expression: string, context: "repl" | "watch" | "hover" | "clipboard", @@ -1022,6 +1034,9 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { case "thread": if (s.status === "paused") void loadThreads(id); break; + case "programInput": + patch(id, { programInput: true }); + break; case "breakpointsChanged": void refreshStates(event.path); break; diff --git a/src/features/debugger/ui/DebugConsole.tsx b/src/features/debugger/ui/DebugConsole.tsx index f791a053..21f95a2f 100644 --- a/src/features/debugger/ui/DebugConsole.tsx +++ b/src/features/debugger/ui/DebugConsole.tsx @@ -16,7 +16,16 @@ import { type FindOptions, type FindResult, } from "@/ui/consoleFind"; -import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state"; +import { + appendConsole, + clearConsole, + evaluate, + isLive, + readsProgramInput, + sendProgramInput, + useDebug, + type DebugSession, +} from "../state"; import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory"; import { useExpressionCompletion } from "./ExpressionCompletion"; import styles from "./DebugToolWindow.module.css"; @@ -245,6 +254,16 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe }, [findOpen, current, virtualizer]); const submit = async () => { + if (readsProgramInput(session)) { + // Like the Run console: the line as typed, empty or not, goes to the program. + setInput(""); + try { + await sendProgramInput(session, `${input}\n`); + } catch (err) { + appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`); + } + return; + } const e = input.trim(); if (!e) return; pushHistory("console", e); @@ -466,10 +485,16 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe value={input} onChange={(e) => { setInput(e.target.value); - completion.onInput(); + if (!readsProgramInput(session)) completion.onInput(); }} onKeyDown={onKeyDown} - placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"} + placeholder={ + !isLive(session) + ? "Session finished" + : readsProgramInput(session) + ? "Input for the program (pause to evaluate)" + : "Evaluate in the debug console" + } disabled={!isLive(session)} aria-label={tx("Debug console input")} spellCheck={false} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d24ee23e..80a9620a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6299,5 +6299,6 @@ "Idle": "空闲", "Composer dependencies are not installed": "未安装 Composer 依赖", "Installed Composer packages differ from composer.lock": "已安装的 Composer 包与 composer.lock 不一致", - "Composer Install": "安装 Composer 依赖" + "Composer Install": "安装 Composer 依赖", + "Input for the program (pause to evaluate)": "程序输入(暂停后可计算)" }