feat(run): debug Python on Docker and Compose run targets (#1757)

Refs #1083 (step A2). Debug now works on Python configurations whose run target is a Docker image or Compose service: the run starts the program in a new container under debugpy (the image's own, else the IDE's managed copy mounted read-only), publishes the debug port on 127.0.0.1 only, and the debugger attaches like a Python Remote Debug configuration with the project folder mapped. Console scripts (pytest) run as modules and `uv run` keeps the manager in front; shell command lines are refused. Container debug startup is bounded (error when the container exits early or debugpy isn't ready in 120 s) and stopping the debugger stops the container.

Co-authored-by: andreabasile <[email protected]>
This commit is contained in:
Andrea Basileandandreabasile authored and GitHub committed 2026-11-08 00:21:42 +08:00
1 parent c3671d782c
commit daad91777f
23 files changed
+904 -34

No files matched your search

+48 -1
View File
@@ -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<RunConfig> {
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<PathBuf>,
}
impl Debugging {
fn prepare(data_dir: &Path) -> IdeResult<Self> {
// 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<u16> {
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<DockerEndpoint> {
let id = target.docker_connection.trim();
@@ -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);
}
@@ -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<std::net::TcpStream>,
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();
}
+188 -3
View File
@@ -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<ContainerDebug<'a>>,
}
/// 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<Run
if let Some((uid, gid)) = run.user {
args.extend(["--user".into(), format!("{uid}:{gid}")]);
}
if let Some(debug) = &run.debug {
let port = debug.port;
args.extend(["--publish".into(), format!("127.0.0.1:{port}:{port}")]);
args.extend(["--volume".into(), format!("{}:{DEBUG_DIR}:ro", debug.bootstrap_dir.to_string_lossy())]);
if let Some(lib) = debug.debugpy {
args.extend(["--volume".into(), format!("{}:{DEBUGPY_DIR}:ro", lib.to_string_lossy())]);
}
}
let options = target.run_options.trim();
if !options.is_empty() {
args.extend(ide_run::import::split_args(options));
@@ -116,9 +147,18 @@ pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult<Run
RunTargetKind::Compose => 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<Run
Ok(out)
}
/// Project managers whose `run` executes in the project's environment (`uv run python …`).
const PYTHON_RUNNERS: [&str; 6] = ["uv", "poetry", "pdm", "pipenv", "hatch", "rye"];
fn is_python(program: &str) -> 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] <bootstrap> <port> <folder> (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<Vec<String>> {
let tool_module = |tool: &str, rest: &[String]| -> Vec<String> {
let module = tool.rsplit('/').next().unwrap_or(tool).to_owned();
[vec!["-m".to_owned(), module], rest.to_vec()].concat()
};
let (mut line, rest): (Vec<String>, Vec<String>) = 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<Vec<String>> {
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() {
+52
View File
@@ -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], "<string>", "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()
@@ -5,6 +5,7 @@ const ipc = vi.hoisted(() => {
const ok = <T>(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);
});
@@ -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" }),
@@ -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" } });
+19 -4
View File
@@ -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);
})(),
),
},
+40 -6
View File
@@ -318,7 +318,7 @@ export function serverAddress(host: string, port: number): string {
}
function requestBase(
rc: RunConfig,
rc: Pick<RunConfig, "name">,
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<PythonAttachTarget>;
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)),
+11 -5
View File
@@ -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<void> {
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;
}
/**
+21
View File
@@ -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" });
});
});
+92
View File
@@ -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<void> {
let sessionId: string | null = null;
let ready: Ready | null = null;
let done = false;
const pending: { timer?: ReturnType<typeof setTimeout>; 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);
}
+20 -2
View File
@@ -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",
+134 -1
View File
@@ -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 });
+22 -6
View File
@@ -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.`,
),
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -553,7 +553,7 @@ function RunTargetsEditor() {
</T>{" "}
<T>
{
"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."
}
</T>
</div>
+5
View File
@@ -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."
}
+1 -2
View File
@@ -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)"
}
+5 -1
View File
@@ -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 优先。"
}
File renamed without changes.