diff --git a/crates/ide-host/src/ops/run/container.rs b/crates/ide-host/src/ops/run/container.rs index e8616befb..4dfcc285a 100644 --- a/crates/ide-host/src/ops/run/container.rs +++ b/crates/ide-host/src/ops/run/container.rs @@ -6,19 +6,32 @@ use ide_core::remote_settings::{RunTarget, RunTargetKind}; use ide_core::{IdeError, IdeResult}; use ide_docker::DockerEndpoint; use ide_remote::docker::{ - ContainerRun, compose_container_root, container_config, container_name, default_container_root, + ContainerDebug, ContainerRun, DEBUG_BOOTSTRAP, DEBUG_BOOTSTRAP_NAME, compose_container_root, container_config, + container_name, default_container_root, }; use ide_run::RunConfig; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +/// Set by the Debug action on a configuration that runs on a container target: the +/// program starts under debugpy and waits for the debugger. +const DEBUG_PARAM: &str = "containerDebug"; + /// `config` rewritten to run in a new container of `target`'s image or service. pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult { if config.kind == "dotnet" || config.kind == "dotnet-test" { return Err(IdeError::invalid(".NET Project requires the local machine")); } + let debug = config.extras.params.get(DEBUG_PARAM).is_some_and(|v| v == "true"); + if debug && config.debug_adapter.as_deref() != Some("debugpy") { + return Err(IdeError::invalid(format!( + "'{}' can't be debugged in a container: only Python run configurations can", + config.name + ))); + } let endpoint = endpoint(&host.data_dir, &target)?; let root = root.to_path_buf(); + let data_dir = host.data_dir.clone(); host.blocking(move || { if !ide_docker::endpoint_is_local(&endpoint)? { return Err(IdeError::invalid(format!( @@ -39,6 +52,7 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: }; let file_env = env_files(&root, &config)?; let name = container_name(&config.id, &unique()); + let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None }; container_config( &config, &ContainerRun { @@ -50,12 +64,45 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: file_env: &file_env, name: &name, user: user(&root, &target), + debug: debugging.as_ref().map(|d| ContainerDebug { + port: d.port, + bootstrap_dir: &d.bootstrap_dir, + debugpy: d.debugpy.as_deref(), + }), }, ) }) .await } +/// What a debug run mounts and publishes. +struct Debugging { + port: u16, + bootstrap_dir: PathBuf, + debugpy: Option, +} + +impl Debugging { + fn prepare(data_dir: &Path) -> IdeResult { + // The bootstrap is (re)written on each run, so it matches this version of the IDE. + let bootstrap_dir = data_dir.join("container-debug"); + std::fs::create_dir_all(&bootstrap_dir)?; + std::fs::write(bootstrap_dir.join(DEBUG_BOOTSTRAP_NAME), DEBUG_BOOTSTRAP)?; + // The managed install the local debugger falls back to (`ide_dap::adapter::prepare_debugpy`). + let tools = ide_dap::adapter::tools_root().unwrap_or_else(|| ide_tools::tools_dir(data_dir)); + let lib = ide_tools::python_lib_dir(&tools, ide_dap::adapter::DEBUGPY_TOOL); + let debugpy = lib.join("debugpy").join("__init__.py").is_file().then_some(lib); + Ok(Self { port: free_port()?, bootstrap_dir, debugpy }) + } +} + +/// A port nobody listens on here: debugpy listens on the same port in the container, +/// published on the loopback interface. +fn free_port() -> IdeResult { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; + Ok(listener.local_addr()?.port()) +} + /// The target's Docker connection (Services tool window), by id; none = the CLI's daemon. fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult { let id = target.docker_connection.trim(); diff --git a/crates/ide-host/tests/docker_run_targets.rs b/crates/ide-host/tests/docker_run_targets.rs index 4671ea27e..ac8931369 100644 --- a/crates/ide-host/tests/docker_run_targets.rs +++ b/crates/ide-host/tests/docker_run_targets.rs @@ -117,6 +117,41 @@ async fn docker_targets_run_through_the_docker_cli() { assert!(out.contains("DATA=/workspaces/proj/data"), "{out}"); assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line"); + // Debug: the program starts under the bootstrap, which waits for the debugger on a + // port published on the loopback interface, with the IDE's debugpy mounted. + let lib = host.data_dir.join("tools/debugpy/lib"); + std::fs::create_dir_all(lib.join("debugpy")).unwrap(); + std::fs::write(lib.join("debugpy/__init__.py"), "").unwrap(); + let mut debug = config("py", &root); + debug.debug_adapter = Some("debugpy".into()); + debug.extras.params.insert("containerDebug".into(), "true".into()); + let out = output_of(&host, debug.clone()).await; + let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect(); + let publish = args[args.iter().position(|a| *a == "--publish").unwrap() + 1]; + let port = publish.strip_prefix("127.0.0.1:").and_then(|p| p.split_once(':')).unwrap(); + assert_eq!(port.0, port.1, "{out}"); + let bootstrap = host.data_dir.join("container-debug"); + assert!(args.contains(&format!("{}:/opt/openidea/debug:ro", bootstrap.display()).as_str()), "{out}"); + assert!(args.contains(&format!("{}:/opt/openidea/debugpy-lib:ro", lib.display()).as_str()), "{out}"); + assert_eq!( + std::fs::read_to_string(bootstrap.join("openidea_debug.py")).unwrap(), + ide_remote::docker::DEBUG_BOOTSTRAP + ); + assert!( + args.ends_with(&[ + "python:3.12", + "python3", + "/opt/openidea/debug/openidea_debug.py", + port.0, + "/workspaces/proj", + "/workspaces/proj/main.py" + ]), + "{out}" + ); + debug.debug_adapter = Some("delve".into()); + let err = ops::run::start(&host, "main", debug, Arc::new(|_| {})).await.unwrap_err(); + assert!(err.message.contains("only Python"), "{}", err.message); + let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err(); assert!(err.message.contains("another machine"), "{}", err.message); } diff --git a/crates/ide-host/tests/docker_run_targets_real.rs b/crates/ide-host/tests/docker_run_targets_real.rs index 16b7a1c66..d76201446 100644 --- a/crates/ide-host/tests/docker_run_targets_real.rs +++ b/crates/ide-host/tests/docker_run_targets_real.rs @@ -157,3 +157,150 @@ async fn runs_in_docker_and_compose_containers() { assert_eq!(containers("label=openidea.run=sleep"), "", "{out}"); Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap(); } + +/// A debug adapter connection: just enough DAP to drive debugpy. +struct Dap { + reader: std::io::BufReader, + writer: std::net::TcpStream, + seq: i64, +} + +impl Dap { + fn connect(address: &str) -> Self { + let stream = std::net::TcpStream::connect(address).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(60))).unwrap(); + Self { reader: std::io::BufReader::new(stream.try_clone().unwrap()), writer: stream, seq: 0 } + } + + fn send(&mut self, command: &str, arguments: serde_json::Value) { + use std::io::Write; + self.seq += 1; + let body = serde_json::json!({"seq": self.seq, "type": "request", "command": command, "arguments": arguments}) + .to_string(); + write!(self.writer, "Content-Length: {}\r\n\r\n{body}", body.len()).unwrap(); + } + + fn read(&mut self) -> serde_json::Value { + use std::io::{BufRead, Read}; + let mut length = 0; + loop { + let mut line = String::new(); + assert!(self.reader.read_line(&mut line).unwrap() > 0, "the adapter closed the connection"); + let line = line.trim(); + if line.is_empty() { + break; + } + if let Some(n) = line.strip_prefix("Content-Length:") { + length = n.trim().parse().unwrap(); + } + } + let mut body = vec![0; length]; + self.reader.read_exact(&mut body).unwrap(); + serde_json::from_slice(&body).unwrap() + } + + /// Reads until a message of `kind` (`"response"` / `"event"`) named `name`. + fn wait(&mut self, kind: &str, name: &str) -> serde_json::Value { + loop { + let m = self.read(); + let key = if kind == "event" { "event" } else { "command" }; + if m["type"] == kind && m[key] == name { + return m; + } + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a Docker daemon, OPENIDEA_DOCKER_TEST_DIR, and pip to fetch debugpy"] +async fn debugs_python_in_a_container() { + let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR")); + // A project of its own: the other test may run at the same time. + let root = dunce::canonicalize(root).unwrap().join("debug"); + std::fs::create_dir_all(&root).unwrap(); + let name = "debug"; + let main = root.join("main.py"); + std::fs::write(&main, "import os\nvalue = os.environ['GREETING']\nprint('value=' + value)\n").unwrap(); + // The IDE's state must be where the daemon can mount it (the bootstrap, the debugpy copy). + let state = root.join(".state"); + let host = host(&state); + // The IDE's copy, installed like the Tools page does (`ide_tools` `PipTarget`): the image has no debugpy. + let lib = host.data_dir.join("tools/debugpy/lib"); + let pip = Command::new("python3") + .args(["-m", "pip", "install", "--quiet", "--no-deps", "--target"]) + .arg(&lib) + .args(["--only-binary=:all:", "--platform", "any", "--implementation", "py", "debugpy"]) + .status() + .unwrap(); + assert!(pip.success()); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let targets = + serde_json::json!([{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" }]); + ops::project::set_settings( + &host, + "main", + ide_workspace::settings::SettingsScope::Project, + "runTargets", + &targets.to_string(), + ) + .unwrap(); + + let mut debug = config("debug-main", "py", &root, &[&main.to_string_lossy()]); + debug.debug_adapter = Some("debugpy".into()); + debug.extras.params.insert("containerDebug".into(), "true".into()); + let (_, out) = start(&host, debug).await; + let mut ready = None; + for _ in 0..1200 { + ready = out + .lock() + .lines() + .find_map(|l| l.trim().strip_prefix(ide_remote::docker::DEBUG_READY).map(|rest| rest.to_owned())); + if ready.is_some() || out.lock().contains("Process finished") { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let ready = ready.unwrap_or_else(|| panic!("{}", out.lock())); + let (port, folder) = ready.split_once(", project folder ").unwrap(); + assert_eq!(folder, format!("/workspaces/{name}")); + let address = format!("{}:{port}", std::env::var("OPENIDEA_DOCKER_TEST_HOST").unwrap_or("127.0.0.1".into())); + + // Attach with the project mapped, stop at a breakpoint set on the local file, continue. + let local = main.to_string_lossy().into_owned(); + let root_str = root.to_string_lossy().into_owned(); + let folder = folder.to_owned(); + let stopped_at = tokio::task::spawn_blocking(move || { + let mut dap = Dap::connect(&address); + dap.send( + "initialize", + serde_json::json!({"adapterID": "debugpy", "pathFormat": "path", "linesStartAt1": true}), + ); + dap.wait("response", "initialize"); + dap.send( + "attach", + serde_json::json!({"justMyCode": true, "pathMappings": [{"localRoot": root_str, "remoteRoot": folder}]}), + ); + dap.wait("event", "initialized"); + dap.send("setBreakpoints", serde_json::json!({"source": {"path": local}, "breakpoints": [{"line": 3}]})); + let set = dap.wait("response", "setBreakpoints"); + assert_eq!(set["body"]["breakpoints"][0]["verified"], true, "{set}"); + dap.send("configurationDone", serde_json::json!({})); + let stopped = dap.wait("event", "stopped"); + let thread = stopped["body"]["threadId"].clone(); + dap.send("stackTrace", serde_json::json!({"threadId": thread})); + let frame = dap.wait("response", "stackTrace")["body"]["stackFrames"][0].clone(); + dap.send("continue", serde_json::json!({"threadId": thread})); + dap.wait("response", "continue"); + (stopped["body"]["reason"].clone(), frame["source"]["path"].clone(), frame["line"].clone()) + }) + .await + .unwrap(); + assert_eq!(stopped_at.0, "breakpoint"); + assert_eq!(stopped_at.1, main.to_string_lossy().as_ref(), "the container's path maps back to the project"); + assert_eq!(stopped_at.2, 3); + let out = finished(&out, 60).await; + assert!(out.contains("value=hello") && out.contains("exit code 0"), "{out}"); + assert_eq!(containers("label=openidea.run=debug-main"), "", "removed when the run ends"); + std::fs::remove_dir_all(&root).unwrap(); +} diff --git a/crates/ide-remote/src/docker.rs b/crates/ide-remote/src/docker.rs index f59dc8cd0..b372d49e5 100644 --- a/crates/ide-remote/src/docker.rs +++ b/crates/ide-remote/src/docker.rs @@ -28,8 +28,31 @@ pub struct ContainerRun<'a> { pub name: &'a str, /// `--user uid:gid`; `None` keeps the image's user. pub user: Option<(u32, u32)>, + /// Runs the Python program under debugpy, for the debugger to attach. + pub debug: Option>, } +/// A debug run: the program starts under [`DEBUG_BOOTSTRAP`], which waits for the +/// debugger on `port` (published on this machine's loopback interface only). +pub struct ContainerDebug<'a> { + pub port: u16, + /// Folder on this machine holding [`DEBUG_BOOTSTRAP`] as [`DEBUG_BOOTSTRAP_NAME`]. + pub bootstrap_dir: &'a Path, + /// The IDE's debugpy (the folder that contains the `debugpy` package), used when + /// the image has none. + pub debugpy: Option<&'a Path>, +} + +/// The script that starts a debugged program in the container: it prints a line +/// starting with [`DEBUG_READY`] once debugpy listens, then runs the program. +pub const DEBUG_BOOTSTRAP: &str = include_str!("openidea_debug.py"); +pub const DEBUG_BOOTSTRAP_NAME: &str = "openidea_debug.py"; +/// Where the bootstrap folder and the IDE's debugpy are mounted (read-only). +const DEBUG_DIR: &str = "/opt/openidea/debug"; +const DEBUGPY_DIR: &str = "/opt/openidea/debugpy-lib"; +/// Start of the line the bootstrap prints when the debugger can attach. +pub const DEBUG_READY: &str = "debugpy is waiting for the debugger on port "; + /// The container folder of the project when a Docker target sets none. pub fn default_container_root(root: &Path) -> String { let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); @@ -108,6 +131,14 @@ pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult IdeResult target.service.trim().to_owned(), _ => target.image.trim().to_owned(), }); - match command.shell_line { - Some(line) => args.extend(["sh".into(), "-c".into(), line]), - None => { + match (command.shell_line, &run.debug) { + (Some(_), Some(_)) => { + return Err(IdeError::invalid(format!( + "'{}' is a shell command line: only a Python script, module or console script can be debugged in a container. Set Command to the Python executable and put the script or -m module in Arguments", + config.name + ))); + } + (Some(line), None) => args.extend(["sh".into(), "-c".into(), line]), + (None, Some(debug)) => { + args.extend(debug_command(&command.program, &command.args, target, debug.port, run.container_root)?) + } + (None, None) => { args.push(command.program); args.extend(command.args); } @@ -138,6 +178,78 @@ pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult bool { + let name = program.rsplit('/').next().unwrap_or(program); + name == "python" || name == "python2" || name.starts_with("python3") +} + +/// The container's command line that runs `program args` under [`DEBUG_BOOTSTRAP`]: +/// `python [options] (script | -m module | -c code) args`. +/// A console script (`pytest`) runs as a module of the interpreter next to it, or of the +/// target's; `uv run …` and the like keep the manager in front. +fn debug_command( + program: &str, + args: &[String], + target: &RunTarget, + port: u16, + folder: &str, +) -> IdeResult> { + let tool_module = |tool: &str, rest: &[String]| -> Vec { + let module = tool.rsplit('/').next().unwrap_or(tool).to_owned(); + [vec!["-m".to_owned(), module], rest.to_vec()].concat() + }; + let (mut line, rest): (Vec, Vec) = if is_python(program) { + (vec![program.to_owned()], args.to_vec()) + } else if PYTHON_RUNNERS.contains(&program.rsplit('/').next().unwrap_or(program)) + && args.first().is_some_and(|a| a == "run") + && args.len() > 1 + { + if args[1].starts_with('-') { + return Err(IdeError::invalid( + "container debugging does not support runner options before the executable: use `uv run python script.py` or `uv run pytest` with options after the script or module", + )); + } + if is_python(&args[1]) { + (vec![program.to_owned(), "run".into(), args[1].clone()], args[2..].to_vec()) + } else { + (vec![program.to_owned(), "run".into(), "python".into()], tool_module(&args[1], &args[2..])) + } + } else { + let python = match program.rfind('/') { + Some(i) => format!("{}python", &program[..=i]), + None if !target.python.trim().is_empty() => target.python.trim().to_owned(), + None => "python3".to_owned(), + }; + (vec![python], tool_module(program, args)) + }; + // Interpreter options stay before the bootstrap (`-u`, `-X dev`). + let mut i = 0; + while let Some(a) = rest.get(i).filter(|a| a.starts_with('-') && *a != "-m" && *a != "-c") { + line.push(a.clone()); + if matches!(a.as_str(), "-W" | "-X") + && let Some(value) = rest.get(i + 1) + { + line.push(value.clone()); + i += 1; + } + i += 1; + } + let rest = &rest[i..]; + match rest.first().map(String::as_str) { + None => return Err(IdeError::invalid("nothing to debug: the command names no Python script or module")), + Some("-m" | "-c") if rest.len() < 2 => { + return Err(IdeError::invalid(format!("nothing to debug: `{}` has no argument", rest[0]))); + } + _ => {} + } + line.extend([format!("{DEBUG_DIR}/{DEBUG_BOOTSTRAP_NAME}"), port.to_string(), folder.to_owned()]); + line.extend(rest.iter().cloned()); + Ok(line) +} + /// `word` for this machine's shell (the stop command runs there): the docker CLI found /// outside `PATH` (Docker.app's bundle) may contain spaces. fn shell_word(word: &str) -> String { @@ -229,6 +341,7 @@ mod tests { file_env: files, name: "openidea-app-1", user: Some((501, 20)), + debug: None, } } @@ -316,6 +429,78 @@ mod tests { assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service")); } + /// The container's command line of `c` debugged on `target` (after the image). + #[cfg(unix)] + fn debugged(target: &RunTarget, c: &RunConfig) -> IdeResult> { + let none = BTreeMap::new(); + let mut r = run(target, &none, &none); + r.debug = Some(ContainerDebug { + port: 41234, + bootstrap_dir: Path::new("/data/container-debug"), + debugpy: Some(Path::new("/data/tools/debugpy/lib")), + }); + let out = container_config(c, &r)?; + Ok(out.args) + } + + #[cfg(unix)] + #[test] + fn debug_runs_the_program_under_the_bootstrap() { + let target = RunTarget { kind: RunTargetKind::Docker, image: "python:3.12".into(), ..Default::default() }; + let boot = "/opt/openidea/debug/openidea_debug.py 41234 /workspaces/proj"; + assert!(DEBUG_BOOTSTRAP.contains(&format!("\"{DEBUG_READY}%d, project folder %s\""))); + let line = |c: RunConfig| { + let args = debugged(&target, &c).unwrap(); + let image = args.iter().position(|a| a == "python:3.12").unwrap(); + args[image + 1..].join(" ") + }; + let args = debugged(&target, &config("python3", &["main.py"])).unwrap().join(" "); + assert!( + args.contains( + "--user 501:20 --publish 127.0.0.1:41234:41234 \ + --volume /data/container-debug:/opt/openidea/debug:ro \ + --volume /data/tools/debugpy/lib:/opt/openidea/debugpy-lib:ro python:3.12" + ), + "{args}" + ); + assert_eq!( + line(config("/work/proj/.venv/bin/python", &["-u", "-X", "dev", "/work/proj/main.py", "a"])), + format!("python3 -u -X dev {boot} /workspaces/proj/main.py a"), + "interpreter options stay with the interpreter" + ); + assert_eq!(line(config("python3", &["-m", "pytest", "-x"])), format!("python3 {boot} -m pytest -x")); + assert_eq!(line(config("python3", &["-c", "print(1)", "a"])), format!("python3 {boot} -c print(1) a")); + assert_eq!( + line(config("/work/proj/.venv/bin/pytest", &["tests"])), + format!("/workspaces/proj/.venv/bin/python {boot} -m pytest tests"), + "a console script runs as a module of the interpreter next to it" + ); + assert_eq!(line(config("uv", &["run", "python", "main.py"])), format!("uv run python {boot} main.py")); + assert_eq!(line(config("uv", &["run", "pytest", "-x"])), format!("uv run python {boot} -m pytest -x")); + let custom = RunTarget { python: "/usr/local/bin/python".into(), ..target.clone() }; + let args = debugged(&custom, &config("uvicorn", &["app:app"])).unwrap(); + assert!(args.join(" ").ends_with(&format!("python:3.12 /usr/local/bin/python {boot} -m uvicorn app:app"))); + + for (command, args, error) in [ + ("python3", &["-u"][..], "nothing to debug"), + ("python3", &["-m"][..], "`-m` has no argument"), + ("uv", &["run", "--no-sync", "python", "main.py"][..], "use `uv run python"), + ("uv", &["run", "--project", "sub", "python", "main.py"][..], "use `uv run python"), + ("python manage.py runserver", &[][..], "shell command line"), + ] { + let err = debugged(&target, &config(command, args)).unwrap_err(); + assert!(err.message.contains(error), "{command}: {}", err.message); + } + + // Without the IDE's copy only the bootstrap is mounted (the image brings debugpy). + let none = BTreeMap::new(); + let mut r = run(&target, &none, &none); + r.debug = + Some(ContainerDebug { port: 41234, bootstrap_dir: Path::new("/data/container-debug"), debugpy: None }); + let args = container_config(&config("python3", &["main.py"]), &r).unwrap().args.join(" "); + assert!(args.contains("openidea/debug:ro python:3.12") && !args.contains("debugpy-lib"), "{args}"); + } + #[cfg(unix)] #[test] fn compose_container_root_follows_the_project_bind_mount() { diff --git a/crates/ide-remote/src/openidea_debug.py b/crates/ide-remote/src/openidea_debug.py new file mode 100644 index 000000000..a8df53895 --- /dev/null +++ b/crates/ide-remote/src/openidea_debug.py @@ -0,0 +1,52 @@ +"""Debugs a run configuration in a container, for OpenIDEA's Docker and Compose run targets. + + openidea_debug.py PORT PROJECT_FOLDER (SCRIPT | -m MODULE | -c CODE) [ARGS...] + +debugpy listens on PORT (published on the host's loopback interface) and the IDE +attaches once the line below is printed. The image's debugpy is used when it has +one, else the IDE's copy mounted read-only next to this script. +""" + +import os +import runpy +import sys + +IDE_COPY = "/opt/openidea/debugpy-lib" + + +def main(): + del sys.path[0] # This script's folder, not the program's. + port, folder, argv = int(sys.argv[1]), sys.argv[2], sys.argv[3:] + try: + import debugpy + except ImportError: + sys.path.append(IDE_COPY) + try: + import debugpy + except ImportError: + sys.exit( + "debugpy is not installed in the image. Install OpenIDEA's copy (Settings > Tools) " + "or add debugpy to the image." + ) + debugpy.listen(("0.0.0.0", port)) + print( + "debugpy is waiting for the debugger on port %d, project folder %s" % (port, folder), + file=sys.stderr, + flush=True, + ) + debugpy.wait_for_client() + if argv[0] == "-m": + sys.argv = argv[1:] + sys.path.insert(0, os.getcwd()) + runpy.run_module(argv[1], run_name="__main__", alter_sys=True) + elif argv[0] == "-c": + sys.argv = ["-c"] + argv[2:] + sys.path.insert(0, "") + exec(compile(argv[1], "", "exec"), {"__name__": "__main__"}) + else: + sys.argv = argv + sys.path.insert(0, os.path.dirname(os.path.abspath(argv[0]))) + runpy.run_path(argv[0], run_name="__main__") + + +main() diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index 47c828b66..679715b4f 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -5,6 +5,7 @@ const ipc = vi.hoisted(() => { const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); return { ok, + runStop: vi.fn(() => ok(null)), debugStep: vi.fn(() => ok(null)), debugResume: vi.fn(() => ok(null)), debugForceStepOver: vi.fn(() => ok(null)), @@ -420,3 +421,20 @@ describe("output class breakpoint restrictions", () => { expect(ipc.debugRunTo).not.toHaveBeenCalled(); }); }); + +it("stops the waiting Python container if attaching fails", async () => { + useProject.setState({ project: { root: "/p" } as never }); + ipc.debugStart.mockResolvedValueOnce({ + status: "error", + error: { kind: "internal", message: "connection closed" }, + } as never); + await executeAction("debug.attach", { + name: "container", + adapter: "debugpy", + hostName: "127.0.0.1", + port: 41234, + remoteRoot: "/app", + process: 9, + }); + expect(ipc.runStop).toHaveBeenCalledWith(9); +}); diff --git a/src/features/debugger/__tests__/remote.test.ts b/src/features/debugger/__tests__/remote.test.ts index 71e1c18db..9aaedc75b 100644 --- a/src/features/debugger/__tests__/remote.test.ts +++ b/src/features/debugger/__tests__/remote.test.ts @@ -33,6 +33,8 @@ const { phpRemoteType, jdwpAgentArgs, xdebugCommand, + isPythonAttachTarget, + pythonAttachConfig, remoteAttachConfig, remoteJvmTarget, remoteWaitingNote, @@ -130,6 +132,18 @@ describe("remote attach requests", () => { expect(plain).toEqual({ debugServer: "localhost:2345", mode: "remote" }); }); + it("a Python program in a container: the project root maps onto its folder there", () => { + const target = { name: "app", adapter: "debugpy" as const, hostName: "127.0.0.1", port: 41234, remoteRoot: "/app" }; + expect(isPythonAttachTarget({ ...target, process: 3 })).toBe(true); + expect(isPythonAttachTarget({ name: "app", module: "", hostName: "h", port: 5005 })).toBe(false); + const l = pythonAttachConfig(target, "/p"); + expect(l).toMatchObject({ name: "app", adapter: "debugpy", request: "attach", cwd: "/p" }); + expect(JSON.parse(l.extraJson!)).toMatchObject({ + debugServer: "127.0.0.1:41234", + pathMappings: [{ localRoot: "/p", remoteRoot: "/app" }], + }); + }); + it("Python remote connects to debugpy --listen with pathMappings", () => { const l = remoteAttachConfig( configOf(pythonRemoteType, { host: "::1", localRoot: "svc", remoteRoot: "/app" }), diff --git a/src/features/debugger/__tests__/runOrigin.test.ts b/src/features/debugger/__tests__/runOrigin.test.ts index ab4a1a44f..023f2fb3b 100644 --- a/src/features/debugger/__tests__/runOrigin.test.ts +++ b/src/features/debugger/__tests__/runOrigin.test.ts @@ -75,6 +75,35 @@ it("stops shared-process debugger roots without stopping their process until req expect(ipc.runStop).toHaveBeenCalledWith(7); }); +it("stops associated processes through Run before disconnecting the debugger", async () => { + const order: string[] = []; + const stop = vi.fn((_process: unknown) => { + order.push("run"); + }); + const dispose = registerActions([ + { + id: "run.stopProcess", + title: "Stop", + run: ({ arg }) => { + stop(arg); + }, + }, + ]); + ipc.debugStop.mockImplementationOnce(async () => { + order.push("debug"); + return { status: "ok", data: null }; + }); + try { + adoptSession(1, { runConfig: config, process: 7 }); + await stopSession(1); + expect(stop).toHaveBeenCalledWith(7); + expect(ipc.runStop).not.toHaveBeenCalled(); + expect(order).toEqual(["run", "debug"]); + } finally { + dispose(); + } +}); + it("pins Show in Debug to the configured session and clears summaries with the project", () => { adoptSession(1, { runConfig: config }); adoptSession(2, { runConfig: { ...config, id: "other" } }); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 354b7328d..33f9b073f 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -31,7 +31,14 @@ import { toggleBreakpointSmart } from "./breakpoints/toggle"; import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java"; import { reloadChangedClasses } from "./hotswap"; import { debugUnsupportedReason, launchConfigFor } from "./launch"; -import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote"; +import { + isPythonAttachTarget, + isRemoteConfig, + pythonAttachConfig, + REMOTE_JVM, + remoteAttachConfig, + remoteJvmTarget, +} from "./remote"; import { activeSession, resumeExecution, @@ -45,6 +52,7 @@ import { startSession, startPreparedSession, stopSession, + stopAssociatedProcess, useDebug, type DebugSession, } from "./state"; @@ -384,6 +392,8 @@ export function registerDebugActions(): () => void { title: "Attach Debugger", category: "Debug", hidden: true, + // Also debugpy to a Python program in a run target's container + // (arg: { name, adapter: "debugpy", hostName, port, remoteRoot, process }). description: "Attach the Java debugger to a JVM listening for JDWP (arg: { name, module, hostName, port, process })", run: ({ arg }) => @@ -391,13 +401,18 @@ export function registerDebugActions(): () => void { tx("Cannot attach the debugger"), (async () => { const root = useProject.getState().project?.root; - if (!root || !isJavaAttachTarget(arg)) return; - const launch = await javaAttachConfig(arg, root); - await startSession(launch, { + if (!root) return; + let launch: LaunchConfig; + if (isPythonAttachTarget(arg)) launch = pythonAttachConfig(arg, root); + else if (isJavaAttachTarget(arg)) launch = await javaAttachConfig(arg, root); + else return; + const session = await startSession(launch, { process: (arg.process ?? null) as RunId | null, runConfig: arg.runConfig, rerunnable: false, }); + if (session == null && isPythonAttachTarget(arg) && arg.process != null) + await stopAssociatedProcess(arg.process); })(), ), }, diff --git a/src/features/debugger/remote.ts b/src/features/debugger/remote.ts index 5a9908952..405c13452 100644 --- a/src/features/debugger/remote.ts +++ b/src/features/debugger/remote.ts @@ -318,7 +318,7 @@ export function serverAddress(host: string, port: number): string { } function requestBase( - rc: RunConfig, + rc: Pick, adapter: string, root: string, request: "attach" | "launch", @@ -336,6 +336,44 @@ function requestBase( }; } +/** Attach request for a process listening with `debugpy --listen`. */ +function debugpyAttach( + name: string, + root: string, + host: string, + port: number, + map: { local: string; remote: string } | null, +): LaunchConfig { + return requestBase({ name }, "debugpy", root, "attach", { + debugServer: serverAddress(host, port), + justMyCode: workflowSettings().debugger.skipLibraryCode, + ...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}), + }); +} + +/** A Python program debugged in a run target's container, waiting for the debugger (Run's `debugInContainer`). */ +export interface PythonAttachTarget { + name: string; + adapter: "debugpy"; + hostName: string; + port: number; + /** The project folder in the container. */ + remoteRoot: string; +} + +export function isPythonAttachTarget( + arg: unknown, +): arg is PythonAttachTarget & { process?: number | null; runConfig?: RunConfig } { + if (!arg || typeof arg !== "object") return false; + const a = arg as Partial; + return a.adapter === "debugpy" && typeof a.port === "number" && typeof a.remoteRoot === "string"; +} + +/** Attach request of a `PythonAttachTarget`: the project root maps onto the container's folder. */ +export function pythonAttachConfig(target: PythonAttachTarget, root: string): LaunchConfig { + return debugpyAttach(target.name, root, target.hostName, target.port, { local: root, remote: target.remoteRoot }); +} + /** * The request of a Go / Python / Node.js / PHP remote configuration. Delve's headless * server and `debugpy --listen` speak DAP themselves (`debugServer` connects there); @@ -356,11 +394,7 @@ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig | ...(map ? { substitutePath: [{ from: map.local, to: map.remote }] } : {}), }); case PYTHON_REMOTE: - return requestBase(rc, "debugpy", root, "attach", { - debugServer: serverAddress(host, portOf(p, 5678)), - justMyCode: skip, - ...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}), - }); + return debugpyAttach(rc.name, root, host, portOf(p, 5678), map); case GODOT_ATTACH: return requestBase(rc, "godot", root, "attach", { debugServer: serverAddress(host, portOf(p, 6006)), diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 877e3e30d..9ecccad7a 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -4,7 +4,7 @@ */ import { tx } from "@/platform/i18n"; import { workflowSettings } from "@/platform/workflowSettings"; -import { executeAction } from "@/platform/actions"; +import { executeAction, getAction } from "@/platform/actions"; import { create } from "zustand"; import { AnsiStream, appendSpans, type Span } from "@/ui/ansi"; import { setContext } from "@/platform/context"; @@ -25,7 +25,7 @@ import { } from "@/platform/ipc"; import { confirmTerminate } from "@/workbench/confirmTerminate"; import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions"; -import { startedPort } from "@/features/run/serverPort"; +import { startedPort } from "@/platform/serverPort"; import { breakpointsOf, consumeTemporary, flushBreakpoints, refreshStates, resetStatuses } from "./breakpoints/store"; import { interpolateLog, logParts, suspends } from "./breakpoints/model"; import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special"; @@ -653,6 +653,12 @@ export async function startSession( } } +/** Stops an associated Run process through its owner, which records the user's stop intent. */ +export async function stopAssociatedProcess(process: RunId): Promise { + if (getAction("run.stopProcess")) await executeAction("run.stopProcess", process); + else await call(commands.runStop(process)).catch(() => undefined); +} + export async function stopSession(id: DebugSessionId, options: { stopProcess?: boolean } = {}) { const s = useDebug.getState().sessions[id]; if (!s) return; @@ -670,6 +676,8 @@ export async function stopSession(id: DebugSessionId, options: { stopProcess?: b const target = useDebug.getState().sessions[sid]; if (target && isLive(target)) patch(sid, { status: "stopping" }); } + const stoppingProcess = + options.stopProcess !== false && root.process != null ? stopAssociatedProcess(root.process) : null; try { await call(commands.debugStop(root.id)); } catch (e) { @@ -677,9 +685,7 @@ export async function stopSession(id: DebugSessionId, options: { stopProcess?: b console.warn("debugStop failed", e); markTerminated(root.id); } - // Detaching leaves an attached JVM running: stop the process it belongs to, like IDEA. - if (options.stopProcess !== false && root.process != null) - await call(commands.runStop(root.process)).catch(() => undefined); + await stoppingProcess; } /** diff --git a/src/features/run/containerDebug.test.ts b/src/features/run/containerDebug.test.ts new file mode 100644 index 000000000..8836c243d --- /dev/null +++ b/src/features/run/containerDebug.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import type { RunConfig } from "@/platform/ipc"; +import { containerDebugLaunch, debugpyReady } from "./containerDebug"; + +describe("container debugging", () => { + it("reads the port and the project folder from the bootstrap's line", () => { + expect( + debugpyReady("debugpy is waiting for the debugger on port 41234, project folder /workspaces/my app\r"), + ).toEqual({ + port: 41234, + remoteRoot: "/workspaces/my app", + }); + expect(debugpyReady("waiting for the debugger on port 41234")).toBeNull(); + }); + + it("asks the backend for a debug run, keeping the configuration's parameters", () => { + const config = { id: "a", params: { module: "app" } } as unknown as RunConfig; + expect(containerDebugLaunch(config).params).toEqual({ module: "app", containerDebug: "true" }); + expect(config.params).toEqual({ module: "app" }); + }); +}); diff --git a/src/features/run/containerDebug.ts b/src/features/run/containerDebug.ts new file mode 100644 index 000000000..85c298e2b --- /dev/null +++ b/src/features/run/containerDebug.ts @@ -0,0 +1,92 @@ +/** + * Debugging Python on Docker / Compose run targets: the run starts the program in a new + * container under debugpy (the bootstrap of `ide_remote::docker`), which prints its port + * once it listens; the debugger then attaches like a Python Remote Debug configuration, + * with the project folder mapped. Output stays in the Run tab; stopping the debugger + * stops the container. + */ +import { executeAction } from "@/platform/actions"; +import type { RunConfig } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { notifications } from "@/workbench/contributions"; +import { isLive, startRun, stopSession, useRun } from "./store"; + +/** The bootstrap's line (`ide_remote::docker::DEBUG_READY`). */ +const READY = /^debugpy is waiting for the debugger on port (\d+), project folder (.+)$/; + +interface Ready { + port: number; + remoteRoot: string; +} + +/** Where the debugger attaches, from an output line of a container debug run. */ +export function debugpyReady(line: string): Ready | null { + const m = READY.exec(line.trim()); + return m ? { port: Number(m[1]), remoteRoot: m[2]! } : null; +} + +/** `config` as the backend runs it for debugging: under debugpy, waiting for the debugger. */ +export function containerDebugLaunch(config: RunConfig): RunConfig { + return { ...config, params: { ...config.params, containerDebug: "true" } }; +} + +/** Runs `config` in its container under debugpy and attaches the debugger. */ +export async function debugInContainer(config: RunConfig): Promise { + let sessionId: string | null = null; + let ready: Ready | null = null; + let done = false; + const pending: { timer?: ReturnType; unsubscribe?: () => void } = {}; + const finish = () => { + done = true; + clearTimeout(pending.timer); + pending.unsubscribe?.(); + }; + const fail = (body: string) => { + finish(); + notifications().notify({ group: "run", level: "error", title: tx("Cannot start container debugging"), body }); + }; + const attach = ({ port, remoteRoot }: Ready) => { + if (done) return; + finish(); + const runId = useRun.getState().sessions.find((s) => s.id === sessionId)?.runId ?? null; + void executeAction("debug.attach", { + name: config.name, + adapter: "debugpy", + hostName: "127.0.0.1", + port, + remoteRoot, + process: runId, + runConfig: config, + }); + }; + const session = await startRun(config, undefined, undefined, { + launch: containerDebugLaunch(config), + onLine: (line) => { + if (done) return; + const parsed = debugpyReady(line); + if (!parsed) return; + ready = parsed; + if (sessionId) attach(ready); + }, + }); + if (!session) return; + sessionId = session.id; + const check = () => { + const current = useRun.getState().sessions.find((s) => s.id === sessionId); + if (!current || current.stopped || current.status === "failed") finish(); + else if (!isLive(current)) + fail(tx("The container exited before debugpy was ready. Check the Run console for errors.")); + }; + pending.unsubscribe = useRun.subscribe(check); + check(); + if (done) return; + pending.timer = setTimeout(() => { + fail( + tx( + "debugpy did not become ready within 120 seconds. Check the Run console and the container's Python interpreter.", + ), + ); + void stopSession(session.id); + }, 120_000); + if (ready) attach(ready); +} diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 93f271d6b..4a4caec19 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -5,7 +5,7 @@ import { registerEditorContribution } from "@/editor/api"; import { executeAction, getAction, registerActions } from "@/platform/actions"; import { getContext, setContext } from "@/platform/context"; -import { onIdeEvent, type RunConfig } from "@/platform/ipc"; +import { call, commands, onIdeEvent, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { onWorkflowSettingsChange } from "@/platform/workflowSettings"; import { showContextMenu, lazyComponent, lazyWhen, type MenuItem } from "@/ui"; @@ -46,7 +46,8 @@ import { cleanupRemovedProject, recordRun, runServiceProvider } from "./services import { liveServiceSession, serviceSessions } from "./serviceSessions"; import { runBeforeLaunch } from "./beforeLaunch"; import { compoundChildren } from "./configs"; -import { remoteDebugBlocked } from "./remote"; +import { containerDebuggable, remoteDebugBlocked } from "./remote"; +import { debugInContainer } from "./containerDebug"; import { isDebugOnly, registerBuiltinTypes } from "./types"; import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate"; import { npmGutter } from "./npmGutter"; @@ -116,6 +117,11 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) { if (remoteDebugBlocked(config)) return; if (!(await prepareLaunch(config, chain))) return; recordRun(config.id); + // Python on a Docker / Compose target: debugpy in the container, the debugger attaches. + if (containerDebuggable(config) && getAction("debug.attach")) { + await debugInContainer(config); + return; + } // Remote JVM Debug, Go Remote…: the debugger attaches, nothing is launched here. if (isDebugOnly(config) && getAction("debug.startConfig")) { await executeAction("debug.startConfig", config); @@ -438,6 +444,18 @@ export function activate(): () => void { when: "projectOpen", run: ({ arg }) => stopAction(arg), }, + { + id: "run.stopProcess", + title: "Stop", + category: "Run", + hidden: true, + run: async ({ arg }) => { + if (typeof arg !== "number") return; + const session = useRun.getState().sessions.find((s) => s.runId === arg); + if (session) await stopSession(session.id); + else await call(commands.runStop(arg)); + }, + }, { id: "run.rerun", title: "Rerun", diff --git a/src/features/run/remote.test.tsx b/src/features/run/remote.test.tsx index 91a447b03..52dcc896a 100644 --- a/src/features/run/remote.test.tsx +++ b/src/features/run/remote.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; const ipc = vi.hoisted(() => ({ runStart: vi.fn(), + runStop: vi.fn(() => Promise.resolve({ status: "ok" as const, data: null })), runSaveConfigs: vi.fn((_configs: unknown) => Promise.resolve({ status: "ok" as const, data: null })), runConfigs: vi.fn(() => Promise.resolve({ status: "ok" as const, data: [] as unknown[] })), sshTrustHost: vi.fn((_id: string) => Promise.resolve({ status: "ok" as const, data: null })), @@ -59,7 +60,7 @@ import { withTarget, type RemedyDeps, } from "./remote"; -import { resetRuns, saveUserConfigs, startRun, useRun } from "./store"; +import { onRunExited, resetRuns, saveUserConfigs, startRun, useRun } from "./store"; import { newConfigOfType } from "./types"; import { readRan } from "./services"; @@ -181,6 +182,138 @@ describe("remote debugging", () => { ); }); + describe("in containers", () => { + const container: RunTargetValid = { ...DEFAULT_RUN_TARGET, kind: "docker", id: "py", name: "Python", image: "x" }; + const attach = vi.fn(); + let unregisterAttach: () => void; + beforeEach(() => { + useProject.setState({ settings: { runTargets: [box, container], ssh: { configs: [] } } as never }); + unregisterAttach = registerActions([{ id: "debug.attach", title: "Attach", run: ({ arg }) => attach(arg) }]); + attach.mockClear(); + }); + afterEach(() => unregisterAttach()); + + it("stops a container that never announces debugpy", async () => { + vi.useFakeTimers(); + try { + const py = cfg("app", { target: "py", debugAdapter: "debugpy" }); + useRun.setState({ configs: [py], loaded: true }); + ipc.runStart.mockResolvedValueOnce(ok); + await executeAction("run.debugConfig", py.id); + await vi.advanceTimersByTimeAsync(120_000); + expect(ipc.runStop).toHaveBeenCalledWith(3); + expect(attach).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Cannot start container debugging", + body: expect.stringContaining("120 seconds"), + }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("reports an early container exit and cancels the readiness timeout", async () => { + vi.useFakeTimers(); + try { + const py = cfg("app", { target: "py", debugAdapter: "debugpy" }); + useRun.setState({ configs: [py], loaded: true }); + ipc.runStart.mockResolvedValueOnce(ok); + await executeAction("run.debugConfig", py.id); + onRunExited(3, 1); + await vi.advanceTimersByTimeAsync(120_000); + expect(attach).not.toHaveBeenCalled(); + expect(ipc.runStop).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + title: "Cannot start container debugging", + body: expect.stringContaining("exited before debugpy"), + }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("cleans up the readiness wait when the user stops the run", async () => { + vi.useFakeTimers(); + try { + const py = cfg("app", { target: "py", debugAdapter: "debugpy" }); + useRun.setState({ configs: [py], loaded: true }); + ipc.runStart.mockResolvedValueOnce(ok); + await executeAction("run.debugConfig", py.id); + useRun.setState((s) => ({ sessions: s.sessions.map((s) => ({ ...s, stopped: true })) })); + await vi.advanceTimersByTimeAsync(120_000); + expect(attach).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("records debugger-requested stops before a signalled container exit", async () => { + vi.useFakeTimers(); + try { + provideToolWindowService({ show: vi.fn(), isVisible: () => false } as unknown as ToolWindowService); + const py = cfg("app", { target: "py", debugAdapter: "debugpy" }); + ipc.runStart.mockResolvedValueOnce(ok); + await startRun(py); + await executeAction("run.stopProcess", 3); + expect(useRun.getState().sessions[0]?.stopped).toBe(true); + expect(ipc.runStop).toHaveBeenCalledWith(3); + onRunExited(3, 143); + await vi.advanceTimersByTimeAsync(100); + expect(notify).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("runs Python under debugpy and attaches once it listens", async () => { + const py = cfg("app", { target: "py", debugAdapter: "debugpy" }); + useRun.setState({ configs: [py], loaded: true }); + ipc.runStart.mockResolvedValueOnce(ok); + await executeAction("run.debugConfig", py.id); + expect(startConfig).not.toHaveBeenCalled(); + expect(ipc.runStart).toHaveBeenCalledWith( + expect.objectContaining({ id: "app", params: { containerDebug: "true" } }), + expect.anything(), + ); + const channel = ipc.runStart.mock.calls.at(-1)![1] as { onmessage: (m: unknown) => void }; + channel.onmessage({ + kind: "stderr", + text: "debugpy is waiting for the debugger on port 41234, project folder /app\n", + }); + await vi.waitFor(() => expect(attach).toHaveBeenCalled()); + channel.onmessage({ + kind: "stderr", + text: "debugpy is waiting for the debugger on port 41234, project folder /app\n", + }); + expect(attach).toHaveBeenCalledTimes(1); + expect(attach).toHaveBeenCalledWith( + expect.objectContaining({ + adapter: "debugpy", + hostName: "127.0.0.1", + port: 41234, + remoteRoot: "/app", + process: 3, + }), + ); + expect(notify).not.toHaveBeenCalled(); + }); + + it("refuses other debuggers", async () => { + const go = cfg("app", { target: "py", debugAdapter: "delve" }); + useRun.setState({ configs: [go], loaded: true }); + await executeAction("run.debugConfig", go.id); + expect(ipc.runStart).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ title: "Only Python can be debugged in containers", level: "warning" }), + ); + }); + }); + it("still debugs local configurations", async () => { const local = cfg("app", { debugAdapter: "debugpy" }); useRun.setState({ configs: [local], loaded: true }); diff --git a/src/features/run/remote.ts b/src/features/run/remote.ts index bc2d64ffa..513b4e17f 100644 --- a/src/features/run/remote.ts +++ b/src/features/run/remote.ts @@ -51,18 +51,34 @@ export function withTarget(config: RunConfig, target: string): RunConfig { return target ? { ...rest, target } : rest; } +/** A Python configuration on a Docker / Compose target: debugged in its container (`debugInContainer`). */ +export function containerDebuggable( + config: RunConfig, + targets: readonly RunTargetValid[] = workflowSettings().runTargets, +): boolean { + const target = targetOf(config, targets); + return !!target && inContainer(target) && config.debugAdapter === "debugpy"; +} + /** - * Debugging on run targets isn't supported: tells so (offering Run) and returns - * true when `config` runs on a target. + * Debugging on run targets isn't supported, except Python in containers: tells so + * (offering Run) and returns true when `config` can't be debugged where it runs. */ -export function remoteDebugBlocked(config: RunConfig): boolean { - if (!config.target) return false; - const target = targetName(config) ?? config.target; +export function remoteDebugBlocked( + config: RunConfig, + targets: readonly RunTargetValid[] = workflowSettings().runTargets, +): boolean { + if (!config.target || containerDebuggable(config, targets)) return false; + const target = targetName(config, targets) ?? config.target; + const container = targetOf(config, targets); notifications().notify({ group: "run", level: "warning", key: "run.remoteDebug", - title: "Debugging on run targets is not supported", + title: + container && inContainer(container) + ? "Only Python can be debugged in containers" + : "Debugging on run targets is not supported", body: txTemplate( `'${config.name}' runs on ${target}. Run it, or choose “Local machine” under “Run on” to debug it.`, ), diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index 5465c3bc8..4d5b2e518 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -18,7 +18,7 @@ import { sessionDetail, sessionStatus, } from "./services"; -import { startedPort } from "./serverPort"; +import { startedPort } from "@/platform/serverPort"; import { useRun, type RunSession } from "./store"; import type { DebugServiceSession } from "@/workbench/contributions"; diff --git a/src/features/run/store.ts b/src/features/run/store.ts index d3a19f2e2..73f583e62 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -54,7 +54,7 @@ import { TerminalOutput } from "./terminalOutput"; import { typeFor } from "./types"; import { loadRunTemplates } from "./runTemplates"; import { recordRun } from "./services"; -import { startedPort } from "./serverPort"; +import { startedPort } from "@/platform/serverPort"; import { tomcatBrowserUrl } from "./tomcat"; import { gradleDelegation, gradleTestLaunch } from "./gradleRun"; import { openUrl } from "./navigate"; diff --git a/src/features/settings/pages/runTargets.tsx b/src/features/settings/pages/runTargets.tsx index 0ffe5f190..a19603a53 100644 --- a/src/features/settings/pages/runTargets.tsx +++ b/src/features/settings/pages/runTargets.tsx @@ -553,7 +553,7 @@ function RunTargetsEditor() { {" "} { - "Docker connections come from the Services tool window. Each run gets a new container, removed when it ends; debugging, coverage and profiling stay on this machine." + "Docker connections come from the Services tool window. Each run gets a new container, removed when it ends. Python configurations can be debugged there; coverage and profiling stay on this machine." } diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 706aed631..e72e108db 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -190,6 +190,11 @@ "Cannot choose a directory": "Cannot choose a directory", "Open in HTTP Client": "Open in HTTP Client", "Cannot generate the HTTP request": "Cannot generate the HTTP request", + "Cannot start container debugging": "Cannot start container debugging", + "The container exited before debugpy was ready. Check the Run console for errors.": "The container exited before debugpy was ready. Check the Run console for errors.", + "debugpy did not become ready within 120 seconds. Check the Run console and the container's Python interpreter.": "debugpy did not become ready within 120 seconds. Check the Run console and the container's Python interpreter.", + "Only Python can be debugged in containers": "Only Python can be debugged in containers", + "Docker connections come from the Services tool window. Each run gets a new container, removed when it ends. Python configurations can be debugged there; coverage and profiling stay on this machine.": "Docker connections come from the Services tool window. Each run gets a new container, removed when it ends. Python configurations can be debugged there; coverage and profiling stay on this machine.", "Use Project JDK": "Use Project JDK", "JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence." } diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 2ee95ca92..baf317bfe 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -6296,6 +6296,5 @@ "Empty: where the service mounts the project.": "비워 두면 서비스가 프로젝트를 마운트하는 위치를 사용합니다.", "The project is mounted here; paths and the working directory are mapped into it.": "프로젝트가 여기에 마운트되며, 경로와 작업 디렉터리가 이 위치로 매핑됩니다.", "Published ports": "게시된 포트", - "Run as the image's user, not the project folder's owner (Linux)": "프로젝트 폴더 소유자가 아닌 이미지의 사용자로 실행(Linux)", - "Docker connections come from the Services tool window. Each run gets a new container, removed when it ends; debugging, coverage and profiling stay on this machine.": "Docker 연결은 서비스 도구 창에서 가져옵니다. 실행할 때마다 새 컨테이너가 만들어지고 실행이 끝나면 제거됩니다. 디버깅, 커버리지 및 프로파일링은 이 컴퓨터에서 실행됩니다." + "Run as the image's user, not the project folder's owner (Linux)": "프로젝트 폴더 소유자가 아닌 이미지의 사용자로 실행(Linux)" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2f94525d7..7b713571d 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6470,7 +6470,8 @@ "The project is mounted here; paths and the working directory are mapped into it.": "项目挂载在此处;路径和工作目录会映射到其中。", "Published ports": "发布的端口", "Run as the image's user, not the project folder's owner (Linux)": "以镜像的用户运行,而不是项目文件夹的所有者(Linux)", - "Docker connections come from the Services tool window. Each run gets a new container, removed when it ends; debugging, coverage and profiling stay on this machine.": "Docker 连接来自“服务”工具窗口。每次运行都会使用一个新容器,运行结束后删除;调试、覆盖率和性能分析仍在本机进行。", + "Docker connections come from the Services tool window. Each run gets a new container, removed when it ends. Python configurations can be debugged there; coverage and profiling stay on this machine.": "Docker 连接来自“服务”工具窗口。每次运行都会使用一个新容器,运行结束后删除。Python 配置可以在容器中调试;覆盖率和性能分析仍在本机进行。", + "Only Python can be debugged in containers": "容器中只能调试 Python", "Service name, e.g. ORCLPDB1": "服务名,例如 ORCLPDB1", "Current schema:": "当前模式:", "HR (ALTER SESSION SET CURRENT_SCHEMA)": "HR(ALTER SESSION SET CURRENT_SCHEMA)", @@ -6567,6 +6568,9 @@ "Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}", "Cannot choose a directory": "无法选择目录", "Open in HTTP Client": "在 HTTP 客户端中打开", + "Cannot start container debugging": "无法启动容器调试", + "The container exited before debugpy was ready. Check the Run console for errors.": "容器在 debugpy 就绪前已退出。请检查运行控制台中的错误。", + "debugpy did not become ready within 120 seconds. Check the Run console and the container's Python interpreter.": "debugpy 未在 120 秒内就绪。请检查运行控制台和容器的 Python 解释器。", "Use Project JDK": "使用项目 SDK", "JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "用于 Maven 同步、构建、目标和测试的 JDK。运行配置中显式设置的 JAVA_HOME 优先。" } diff --git a/src/features/run/serverPort.ts b/src/platform/serverPort.ts similarity index 100% rename from src/features/run/serverPort.ts rename to src/platform/serverPort.ts