mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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.
This commit is contained in:
1 parent
653f2ff276
commit
0a47a685d9
13 files changed
+204
-24
No files matched your search
Generated
+1
@@ -4182,6 +4182,7 @@ dependencies = [
|
||||
"ide-language",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -43,6 +43,8 @@ pub(crate) struct Session {
|
||||
pub adapter_process: Mutex<Option<Child>>,
|
||||
/// Processes started for `runInTerminal`.
|
||||
pub debuggees: Mutex<Vec<Child>>,
|
||||
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
|
||||
pub program: Mutex<Option<ProgramTerminal>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
@@ -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<DebugManager>, s: &Arc<Session>, 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<dyn std::io::Write + Send>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
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<Session>, args: &Value) -> Result<Value, String> {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
let argv: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(Value::as_array)
|
||||
@@ -461,16 +484,25 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
let Some((program, rest)) = argv.split_first() else {
|
||||
return Err("runInTerminal without args".into());
|
||||
};
|
||||
let mut cmd = tokio::process::Command::new(program);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(rest)
|
||||
.envs(ide_core::process::shell_env())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
cmd.current_dir(cwd);
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
|
||||
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
|
||||
for (k, v) in ide_core::process::host_env_fixes() {
|
||||
match v {
|
||||
Some(v) => 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<Session>, args: &Value) -> Result<Value, S
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
let pid = child.id();
|
||||
if let Some(out) = child.stdout.take() {
|
||||
forward_output(s, out, "stdout");
|
||||
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
drop(pair.slave);
|
||||
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let pid = child.process_id();
|
||||
forward_terminal_output(s, reader);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
forward_output(s, err, "stderr");
|
||||
}
|
||||
s.debuggees.lock().push(child);
|
||||
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
|
||||
Ok(json!({ "processId": pid }))
|
||||
}
|
||||
|
||||
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
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<u8> = 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<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream: R, category: &'static str) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -381,6 +381,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
debug::debug_completions,
|
||||
debug::debug_resume,
|
||||
debug::debug_pause,
|
||||
debug::debug_send_input,
|
||||
debug::debug_hot_reload,
|
||||
debug::debug_hot_restart,
|
||||
debug::debug_step,
|
||||
|
||||
+8
-1
@@ -735,6 +735,8 @@ export const commands = {
|
||||
debugCompletions: (session: DebugSessionId, frameId: number | null, text: string, line: number, column: number) => typedError<DebugCompletion[], IdeError>(__TAURI_INVOKE("debug_completions", { session, frameId, text, line, column })),
|
||||
debugResume: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_resume", { session, threadId })),
|
||||
debugPause: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_pause", { session, threadId })),
|
||||
/** Types `text` into the session's program (a `runInTerminal` program). */
|
||||
debugSendInput: (session: DebugSessionId, text: string) => typedError<null, IdeError>(__TAURI_INVOKE("debug_send_input", { session, text })),
|
||||
debugHotReload: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_hot_reload", { session })),
|
||||
debugHotRestart: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_hot_restart", { session })),
|
||||
debugStep: (session: DebugSessionId, threadId: number, kind: StepKind) => typedError<null, IdeError>(__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`.
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)": "程序输入(暂停后可计算)"
|
||||
}
|
||||
Reference in new issue
Block a user