From 9deae89de3dd67ad84ced90e0ba172460e8899aa Mon Sep 17 00:00:00 2001 From: almogbaku Date: Fri, 17 Apr 2026 23:00:21 +0300 Subject: [PATCH 1/3] fix(debugpy): auto-detect active venv, add --python override `dap debug script.py` previously always spawned `python3` from PATH, ignoring the activated virtualenv. This caused wrong-interpreter errors (and missing imports like `debugpy` itself) when users had a venv active. - CLI resolves `$VIRTUAL_ENV/bin/python{3,}` at invocation time (the daemon's env may be stale), plumbed via new `DebugArgs.Python` field. - New `--python` flag overrides auto-detection for conda/pyenv/shims. - `debugpyBackend` gains `Python` field; `Spawn` honors it. - Unit tests for `ResolveVenvPython` + `debugpyBackend.Python` wiring. - E2E tests: explicit `--python`, bogus `--python` fails cleanly, `VIRTUAL_ENV` auto-detection end-to-end. - Docs: venv/`--python` note in installing-debuggers.md. Co-Authored-By: Claude Opus 4.7 --- backend.go | 28 ++++- backend_test.go | 102 ++++++++++++++++++ cli.go | 7 ++ daemon.go | 3 + e2e_test.go | 78 ++++++++++++++ protocol.go | 1 + .../references/installing-debuggers.md | 6 ++ 7 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 backend_test.go diff --git a/backend.go b/backend.go index 903aeab..e87e836 100644 --- a/backend.go +++ b/backend.go @@ -68,6 +68,23 @@ type Backend interface { PIDAttachArgs(pid int) (map[string]any, error) } +// ResolveVenvPython returns the active virtualenv's python binary if $VIRTUAL_ENV +// is set and contains one, otherwise "". Callers pass this into DebugArgs.Python +// so the daemon (which may have a stale env) uses the correct interpreter. +func ResolveVenvPython() string { + venv := os.Getenv("VIRTUAL_ENV") + if venv == "" { + return "" + } + for _, name := range []string{"python3", "python"} { + p := filepath.Join(venv, "bin", name) + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + // DetectBackend returns the appropriate backend based on file extension. func DetectBackend(script string) Backend { switch strings.ToLower(filepath.Ext(script)) { @@ -102,12 +119,19 @@ func GetBackendByName(name string) (Backend, error) { // --- debugpy backend (Python) --- -type debugpyBackend struct{} +// debugpyBackend spawns debugpy with Python. If Python is empty, "python3" from PATH is used. +type debugpyBackend struct { + Python string +} func (b *debugpyBackend) Spawn(port string) (*exec.Cmd, string, error) { _, actualPort := normalizePort(port) - cmd := exec.Command("python3", "-m", "debugpy.adapter", "--host", "127.0.0.1", "--port", actualPort, "--log-stderr") + python := b.Python + if python == "" { + python = "python3" + } + cmd := exec.Command(python, "-m", "debugpy.adapter", "--host", "127.0.0.1", "--port", actualPort, "--log-stderr") cmd.Stdout = nil stderrPipe, err := cmd.StderrPipe() diff --git a/backend_test.go b/backend_test.go new file mode 100644 index 0000000..b8832f4 --- /dev/null +++ b/backend_test.go @@ -0,0 +1,102 @@ +package dap + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveVenvPython_NoEnv(t *testing.T) { + t.Setenv("VIRTUAL_ENV", "") + if got := ResolveVenvPython(); got != "" { + t.Errorf("ResolveVenvPython() = %q, want empty", got) + } +} + +func TestResolveVenvPython_MissingBinary(t *testing.T) { + dir := t.TempDir() + t.Setenv("VIRTUAL_ENV", dir) + if got := ResolveVenvPython(); got != "" { + t.Errorf("ResolveVenvPython() = %q, want empty (no python in venv)", got) + } +} + +func TestResolveVenvPython_FindsPython3(t *testing.T) { + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + py3 := filepath.Join(binDir, "python3") + if err := os.WriteFile(py3, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("VIRTUAL_ENV", dir) + if got := ResolveVenvPython(); got != py3 { + t.Errorf("ResolveVenvPython() = %q, want %q", got, py3) + } +} + +func TestResolveVenvPython_FallsBackToPython(t *testing.T) { + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + py := filepath.Join(binDir, "python") + if err := os.WriteFile(py, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("VIRTUAL_ENV", dir) + if got := ResolveVenvPython(); got != py { + t.Errorf("ResolveVenvPython() = %q, want %q", got, py) + } +} + +func TestResolveVenvPython_PrefersPython3OverPython(t *testing.T) { + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + py3 := filepath.Join(binDir, "python3") + py := filepath.Join(binDir, "python") + for _, p := range []string{py3, py} { + if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("VIRTUAL_ENV", dir) + if got := ResolveVenvPython(); got != py3 { + t.Errorf("ResolveVenvPython() = %q, want python3 path %q", got, py3) + } +} + +// TestDebugpyBackend_SpawnUsesConfiguredPython verifies that debugpyBackend.Python, +// when set, is the binary invoked by Spawn (rather than falling back to "python3" +// on PATH). We pass a stub binary that exits immediately; Spawn should attempt to +// run it, and since it is not a real debugpy, we expect Spawn to fail *after* the +// stub runs. Failure mode diagnoses whether the configured binary was actually +// used. +func TestDebugpyBackend_SpawnUsesConfiguredPython(t *testing.T) { + dir := t.TempDir() + stub := filepath.Join(dir, "my-python") + // Stub exits cleanly without printing "Listening" so waitForReady fails — + // confirming the configured binary was executed (not some other python). + script := "#!/bin/sh\nexit 0\n" + if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + b := &debugpyBackend{Python: stub} + _, _, err := b.Spawn(":0") + if err == nil { + t.Fatal("Spawn with stub python should fail (no Listening output)") + } + // The error should be about process exiting without reporting listen address, + // proving the configured python was the one that ran. + if want := "process exited without reporting listen address"; !strings.Contains(err.Error(), want) { + t.Errorf("Spawn error = %q, want to contain %q", err.Error(), want) + } +} diff --git a/cli.go b/cli.go index bdc5f1a..cb494e6 100644 --- a/cli.go +++ b/cli.go @@ -180,6 +180,7 @@ func newDebugCmd() *cobra.Command { backend string stopOnEntry bool exceptionFilters []string + python string ) cmd := &cobra.Command{ @@ -215,6 +216,10 @@ Blocks until the program hits a breakpoint or exits, then returns auto-context.` return err } + resolvedPython := python + if resolvedPython == "" { + resolvedPython = ResolveVenvPython() + } debugArgs := DebugArgs{ Breaks: []Breakpoint(breaks), StopOnEntry: stopOnEntry, @@ -223,6 +228,7 @@ Blocks until the program hits a breakpoint or exits, then returns auto-context.` Backend: backend, ExceptionFilters: exceptionFilters, ContextLines: globalFlags.contextLines, + Python: resolvedPython, } if len(args) > 0 { debugArgs.Script = args[0] @@ -254,6 +260,7 @@ Blocks until the program hits a breakpoint or exits, then returns auto-context.` cmd.Flags().IntVar(&pid, "pid", 0, "Attach to a running process by PID (requires --backend)") cmd.Flags().StringVar(&backend, "backend", "", "Debugger backend (debugpy, dlv, js-debug, lldb-dap); auto-detected from file extension") cmd.Flags().BoolVar(&stopOnEntry, "stop-on-entry", false, "Stop at first line") + cmd.Flags().StringVar(&python, "python", "", "Python interpreter for debugpy backend (default: $VIRTUAL_ENV/bin/python, else python3 on PATH)") cmd.Flags().StringArrayVar(&exceptionFilters, "break-on-exception", nil, "Stop on exception; repeatable (e.g. --break-on-exception raised).\n"+ "Filter IDs are backend-specific:\n"+ diff --git a/daemon.go b/daemon.go index d743b99..03a9a93 100644 --- a/daemon.go +++ b/daemon.go @@ -529,6 +529,9 @@ func (d *Daemon) handleDebug(rawArgs json.RawMessage) *Response { } else { return errResponse("script path, --attach, or --pid required") } + if b, ok := backend.(*debugpyBackend); ok && args.Python != "" { + b.Python = args.Python + } d.backend = backend d.stopSession() // clean up any previous session diff --git a/e2e_test.go b/e2e_test.go index 8bd9feb..181b3d1 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -100,6 +100,15 @@ func (e *e2eEnv) run(args ...string) (string, error) { return string(out), err } +// runEnv is like run but with extra environment variables merged into os.Environ(). +func (e *e2eEnv) runEnv(extraEnv []string, args ...string) (string, error) { + cmd := exec.Command(e.binary, append(args, "--socket", e.socketPath)...) + cmd.Dir = projectRoot(e.t) + cmd.Env = append(os.Environ(), extraEnv...) + out, err := cmd.CombinedOutput() + return string(out), err +} + // --- Python tests --- // TestE2E_DebugPython runs a full debug session: debug → step → eval → continue → stop. @@ -197,6 +206,75 @@ func TestE2E_JSONOutput(t *testing.T) { } } +// TestE2E_DebugPython_ExplicitPython verifies --python uses the specified interpreter. +// Uses `exec.LookPath("python3")` to get a real interpreter, then confirms the flag is +// accepted end-to-end (daemon → debugpyBackend → spawn). +func TestE2E_DebugPython_ExplicitPython(t *testing.T) { + py, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not on PATH") + } + if err := exec.Command(py, "-c", "import debugpy").Run(); err != nil { + t.Skip("debugpy not installed") + } + + env := newE2EEnv(t) + scriptPath := filepath.Join(projectRoot(t), "testdata", "python", "simple.py") + + out, err := env.run("debug", scriptPath, "--break", scriptPath+":3", "--python", py) + if err != nil { + t.Fatalf("debug --python failed: %v\n%s", err, out) + } + if !strings.Contains(out, "Stopped: breakpoint") { + t.Errorf("expected breakpoint stop, got:\n%s", out) + } + _, _ = env.run("stop") +} + +// TestE2E_DebugPython_BogusPythonFails verifies --python with a non-existent binary +// produces a clear error (instead of silently falling back to system python3). +func TestE2E_DebugPython_BogusPythonFails(t *testing.T) { + env := newE2EEnv(t) + scriptPath := filepath.Join(projectRoot(t), "testdata", "python", "simple.py") + + bogus := filepath.Join(t.TempDir(), "nope-python") + out, err := env.run("debug", scriptPath, "--python", bogus) + if err == nil { + t.Fatalf("expected failure with bogus --python, got success:\n%s", out) + } +} + +// TestE2E_DebugPython_VenvAutoDetect verifies that $VIRTUAL_ENV is picked up +// automatically: a venv dir with a stub "python3" that is NOT debugpy-capable +// should be used (and fail visibly), proving the venv's interpreter was chosen +// over the system one. +func TestE2E_DebugPython_VenvAutoDetect(t *testing.T) { + if err := exec.Command("python3", "-c", "import debugpy").Run(); err != nil { + t.Skip("debugpy not installed on system python3") + } + + env := newE2EEnv(t) + scriptPath := filepath.Join(projectRoot(t), "testdata", "python", "simple.py") + + // Build a fake venv whose python3 exits immediately (no debugpy). + venv := t.TempDir() + binDir := filepath.Join(venv, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + stub := filepath.Join(binDir, "python3") + if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + + // With VIRTUAL_ENV pointing at the fake venv, dap should use the stub + // (and fail to start the adapter) rather than falling back to system python3. + out, err := env.runEnv([]string{"VIRTUAL_ENV=" + venv}, "debug", scriptPath, "--break", scriptPath+":3") + if err == nil { + t.Fatalf("expected failure using stub venv python, got success — venv was not picked up:\n%s", out) + } +} + // TestE2E_DebugPython_Scheduler exercises cross-file breakpoints across a // multifile Python app: main.py → runner.py → resolver.py. func TestE2E_DebugPython_Scheduler(t *testing.T) { diff --git a/protocol.go b/protocol.go index 3981cd3..bc6dee4 100644 --- a/protocol.go +++ b/protocol.go @@ -129,6 +129,7 @@ type DebugArgs struct { ProgramArgs []string `json:"program_args,omitempty"` ExceptionFilters []string `json:"exception_filters,omitempty"` // backend-specific filter IDs ContextLines int `json:"context_lines,omitempty"` + Python string `json:"python,omitempty"` // debugpy only: interpreter path; resolved from $VIRTUAL_ENV if empty } // StepArgs are arguments for the "step" command. diff --git a/skills/debugging-code/references/installing-debuggers.md b/skills/debugging-code/references/installing-debuggers.md index 4823635..a616efa 100644 --- a/skills/debugging-code/references/installing-debuggers.md +++ b/skills/debugging-code/references/installing-debuggers.md @@ -10,6 +10,12 @@ **Install:** `pip install debugpy` +**Virtualenv:** `dap` picks up the active venv automatically via `$VIRTUAL_ENV`. +Activate the venv (`source .venv/bin/activate`) before running `dap debug`, or pass +`--python /path/to/venv/bin/python` to override. Without either, `dap` falls back +to `python3` on PATH — which may not have `debugpy` installed or may be the wrong +interpreter version. + --- ## Go — Delve From d782677c409028091fb2e7814949308918aff9ed Mon Sep 17 00:00:00 2001 From: almogbaku Date: Fri, 17 Apr 2026 23:09:09 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(debugpy):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20Windows=20layout,=20CLI=20resolution,=20unexport=20?= =?UTF-8?q?field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResolveVenvPython now picks the correct layout per-OS: Scripts\python.exe on Windows, bin/python{3,} on POSIX. Returns absolute paths. - New ResolvePythonFlag resolves --python at the CLI layer: bare names go through exec.LookPath (caller's PATH, not the daemon's), paths are made absolute. Unknown/missing binaries fail with a clear CLI error instead of silently falling through to the daemon. - Rename debugpyBackend.Python -> debugpyBackend.python (unexported struct shouldn't expose fields). - Unit tests for ResolvePythonFlag (abs, relative, bare-name, nonexistent, bare-name-not-found) and OS-aware venv tests. Co-Authored-By: Claude Opus 4.7 --- backend.go | 52 ++++++++++++++++++-- backend_test.go | 124 ++++++++++++++++++++++++++++++++++++++++-------- cli.go | 10 +++- daemon.go | 2 +- 4 files changed, 160 insertions(+), 28 deletions(-) diff --git a/backend.go b/backend.go index e87e836..3461993 100644 --- a/backend.go +++ b/backend.go @@ -71,20 +71,62 @@ type Backend interface { // ResolveVenvPython returns the active virtualenv's python binary if $VIRTUAL_ENV // is set and contains one, otherwise "". Callers pass this into DebugArgs.Python // so the daemon (which may have a stale env) uses the correct interpreter. +// Returned path is absolute. func ResolveVenvPython() string { venv := os.Getenv("VIRTUAL_ENV") if venv == "" { return "" } - for _, name := range []string{"python3", "python"} { - p := filepath.Join(venv, "bin", name) + // Windows venvs put the interpreter under Scripts\; POSIX venvs use bin/. + var candidates []string + if runtime.GOOS == "windows" { + candidates = []string{ + filepath.Join(venv, "Scripts", "python.exe"), + filepath.Join(venv, "Scripts", "python3.exe"), + } + } else { + candidates = []string{ + filepath.Join(venv, "bin", "python3"), + filepath.Join(venv, "bin", "python"), + } + } + for _, p := range candidates { if _, err := os.Stat(p); err == nil { + if abs, err := filepath.Abs(p); err == nil { + return abs + } return p } } return "" } +// ResolvePythonFlag turns a user-supplied --python value into an absolute path +// the daemon can exec directly. Bare names go through exec.LookPath (using the +// caller's PATH, not the daemon's); paths are made absolute. Returns an error +// if the binary cannot be found or doesn't exist. +func ResolvePythonFlag(python string) (string, error) { + if strings.ContainsRune(python, filepath.Separator) || (runtime.GOOS == "windows" && strings.ContainsRune(python, '/')) { + abs, err := filepath.Abs(python) + if err != nil { + return "", fmt.Errorf("resolving --python path: %w", err) + } + if _, err := os.Stat(abs); err != nil { + return "", fmt.Errorf("--python %q: %w", python, err) + } + return abs, nil + } + found, err := exec.LookPath(python) + if err != nil { + return "", fmt.Errorf("--python %q not found on PATH: %w", python, err) + } + abs, err := filepath.Abs(found) + if err != nil { + return found, nil + } + return abs, nil +} + // DetectBackend returns the appropriate backend based on file extension. func DetectBackend(script string) Backend { switch strings.ToLower(filepath.Ext(script)) { @@ -119,15 +161,15 @@ func GetBackendByName(name string) (Backend, error) { // --- debugpy backend (Python) --- -// debugpyBackend spawns debugpy with Python. If Python is empty, "python3" from PATH is used. +// debugpyBackend spawns debugpy with python. If python is empty, "python3" from PATH is used. type debugpyBackend struct { - Python string + python string } func (b *debugpyBackend) Spawn(port string) (*exec.Cmd, string, error) { _, actualPort := normalizePort(port) - python := b.Python + python := b.python if python == "" { python = "python3" } diff --git a/backend_test.go b/backend_test.go index b8832f4..d78d5d7 100644 --- a/backend_test.go +++ b/backend_test.go @@ -2,7 +2,9 @@ package dap import ( "os" + "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -22,54 +24,136 @@ func TestResolveVenvPython_MissingBinary(t *testing.T) { } } -func TestResolveVenvPython_FindsPython3(t *testing.T) { +// venvLayout returns the (binSubdir, preferredName, fallbackName) for the current +// OS's virtualenv layout. On Windows that's Scripts\python.exe etc. +func venvLayout() (string, string, string) { + if runtime.GOOS == "windows" { + return "Scripts", "python.exe", "python3.exe" + } + return "bin", "python3", "python" +} + +func TestResolveVenvPython_FindsPreferred(t *testing.T) { dir := t.TempDir() - binDir := filepath.Join(dir, "bin") + sub, preferred, _ := venvLayout() + binDir := filepath.Join(dir, sub) if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatal(err) } - py3 := filepath.Join(binDir, "python3") - if err := os.WriteFile(py3, []byte("#!/bin/sh\n"), 0o755); err != nil { + target := filepath.Join(binDir, preferred) + if err := os.WriteFile(target, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("VIRTUAL_ENV", dir) - if got := ResolveVenvPython(); got != py3 { - t.Errorf("ResolveVenvPython() = %q, want %q", got, py3) + got := ResolveVenvPython() + wantAbs, _ := filepath.Abs(target) + if got != wantAbs { + t.Errorf("ResolveVenvPython() = %q, want %q", got, wantAbs) } } -func TestResolveVenvPython_FallsBackToPython(t *testing.T) { +func TestResolveVenvPython_FallsBackToSecondary(t *testing.T) { dir := t.TempDir() - binDir := filepath.Join(dir, "bin") + sub, _, fallback := venvLayout() + binDir := filepath.Join(dir, sub) if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatal(err) } - py := filepath.Join(binDir, "python") - if err := os.WriteFile(py, []byte("#!/bin/sh\n"), 0o755); err != nil { + target := filepath.Join(binDir, fallback) + if err := os.WriteFile(target, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("VIRTUAL_ENV", dir) - if got := ResolveVenvPython(); got != py { - t.Errorf("ResolveVenvPython() = %q, want %q", got, py) + got := ResolveVenvPython() + wantAbs, _ := filepath.Abs(target) + if got != wantAbs { + t.Errorf("ResolveVenvPython() = %q, want %q", got, wantAbs) } } -func TestResolveVenvPython_PrefersPython3OverPython(t *testing.T) { +func TestResolveVenvPython_PrefersPreferredOverFallback(t *testing.T) { dir := t.TempDir() - binDir := filepath.Join(dir, "bin") + sub, preferred, fallback := venvLayout() + binDir := filepath.Join(dir, sub) if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatal(err) } - py3 := filepath.Join(binDir, "python3") - py := filepath.Join(binDir, "python") - for _, p := range []string{py3, py} { + preferredPath := filepath.Join(binDir, preferred) + fallbackPath := filepath.Join(binDir, fallback) + for _, p := range []string{preferredPath, fallbackPath} { if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } } t.Setenv("VIRTUAL_ENV", dir) - if got := ResolveVenvPython(); got != py3 { - t.Errorf("ResolveVenvPython() = %q, want python3 path %q", got, py3) + got := ResolveVenvPython() + wantAbs, _ := filepath.Abs(preferredPath) + if got != wantAbs { + t.Errorf("ResolveVenvPython() = %q, want preferred %q", got, wantAbs) + } +} + +func TestResolvePythonFlag_AbsolutePath(t *testing.T) { + dir := t.TempDir() + py := filepath.Join(dir, "python3") + if err := os.WriteFile(py, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + got, err := ResolvePythonFlag(py) + if err != nil { + t.Fatalf("ResolvePythonFlag(%q): %v", py, err) + } + if got != py { + t.Errorf("got %q, want %q", got, py) + } +} + +func TestResolvePythonFlag_RelativePath(t *testing.T) { + dir := t.TempDir() + py := filepath.Join(dir, "python3") + if err := os.WriteFile(py, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + // chdir to tmp, pass relative path + wd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(wd) }) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + got, err := ResolvePythonFlag("./python3") + if err != nil { + t.Fatalf("ResolvePythonFlag: %v", err) + } + if !filepath.IsAbs(got) { + t.Errorf("expected absolute path, got %q", got) + } +} + +func TestResolvePythonFlag_NonexistentPath(t *testing.T) { + _, err := ResolvePythonFlag("/definitely/not/a/real/python-xyz") + if err == nil { + t.Fatal("expected error for nonexistent path") + } +} + +func TestResolvePythonFlag_BareNameViaLookPath(t *testing.T) { + // Use a name we know is on PATH — "sh" works on all Unix + a lot of Windows dev envs. + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not on PATH") + } + got, err := ResolvePythonFlag("sh") + if err != nil { + t.Fatalf("ResolvePythonFlag(sh): %v", err) + } + if !filepath.IsAbs(got) { + t.Errorf("expected absolute path from LookPath, got %q", got) + } +} + +func TestResolvePythonFlag_BareNameNotFound(t *testing.T) { + _, err := ResolvePythonFlag("definitely-not-a-real-binary-xyz-123") + if err == nil { + t.Fatal("expected error for unknown bare name") } } @@ -89,7 +173,7 @@ func TestDebugpyBackend_SpawnUsesConfiguredPython(t *testing.T) { t.Fatal(err) } - b := &debugpyBackend{Python: stub} + b := &debugpyBackend{python: stub} _, _, err := b.Spawn(":0") if err == nil { t.Fatal("Spawn with stub python should fail (no Listening output)") diff --git a/cli.go b/cli.go index cb494e6..9f1a15f 100644 --- a/cli.go +++ b/cli.go @@ -216,8 +216,14 @@ Blocks until the program hits a breakpoint or exits, then returns auto-context.` return err } - resolvedPython := python - if resolvedPython == "" { + var resolvedPython string + if python != "" { + p, err := ResolvePythonFlag(python) + if err != nil { + return err + } + resolvedPython = p + } else { resolvedPython = ResolveVenvPython() } debugArgs := DebugArgs{ diff --git a/daemon.go b/daemon.go index 03a9a93..ac48f1e 100644 --- a/daemon.go +++ b/daemon.go @@ -530,7 +530,7 @@ func (d *Daemon) handleDebug(rawArgs json.RawMessage) *Response { return errResponse("script path, --attach, or --pid required") } if b, ok := backend.(*debugpyBackend); ok && args.Python != "" { - b.Python = args.Python + b.python = args.Python } d.backend = backend d.stopSession() // clean up any previous session From f102c1e6b8738fb451506f989aafedb750b8a35d Mon Sep 17 00:00:00 2001 From: almogbaku Date: Fri, 17 Apr 2026 23:31:37 +0300 Subject: [PATCH 3/3] fix: pin DAP client in readLoop to avoid nil-deref race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing race: stopSession() sets d.client = nil while readLoop dereferences d.client.ReadMessage() on the next iteration — panics with SIGSEGV in CI under load. Pin the client to a local at loop start via getClient(); Close() on the client will surface as a read error and the loop exits cleanly. Co-Authored-By: Claude Opus 4.7 --- daemon.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/daemon.go b/daemon.go index ac48f1e..baddbe9 100644 --- a/daemon.go +++ b/daemon.go @@ -260,8 +260,15 @@ func (d *Daemon) readExpected() (godap.Message, error) { // └─ default ──► DROP (ProcessEvent, ThreadEvent, ModuleEvent, etc.) func (d *Daemon) readLoop() { defer close(d.expectCh) + // Pin the client for this loop's lifetime: stopSession() sets d.client = nil, + // which would race with d.client.ReadMessage() here and panic. The client's + // Close() will unblock ReadMessage() with an error and the loop returns. + client := d.getClient() + if client == nil { + return + } for { - msg, err := d.client.ReadMessage() + msg, err := client.ReadMessage() if err != nil { return }