Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,65 @@ 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.
// Returned path is absolute.
func ResolveVenvPython() string {
venv := os.Getenv("VIRTUAL_ENV")
if venv == "" {
return ""
}
// 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 ""
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}

// 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)) {
Expand Down Expand Up @@ -102,12 +161,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()
Expand Down
186 changes: 186 additions & 0 deletions backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package dap

import (
"os"
"os/exec"
"path/filepath"
"runtime"
"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)
}
}

// 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()
sub, preferred, _ := venvLayout()
binDir := filepath.Join(dir, sub)
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
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)
got := ResolveVenvPython()
wantAbs, _ := filepath.Abs(target)
if got != wantAbs {
t.Errorf("ResolveVenvPython() = %q, want %q", got, wantAbs)
}
}

func TestResolveVenvPython_FallsBackToSecondary(t *testing.T) {
dir := t.TempDir()
sub, _, fallback := venvLayout()
binDir := filepath.Join(dir, sub)
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
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)
got := ResolveVenvPython()
wantAbs, _ := filepath.Abs(target)
if got != wantAbs {
t.Errorf("ResolveVenvPython() = %q, want %q", got, wantAbs)
}
}

func TestResolveVenvPython_PrefersPreferredOverFallback(t *testing.T) {
dir := t.TempDir()
sub, preferred, fallback := venvLayout()
binDir := filepath.Join(dir, sub)
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
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)
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")
}
}

// 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)
}
}
13 changes: 13 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ func newDebugCmd() *cobra.Command {
backend string
stopOnEntry bool
exceptionFilters []string
python string
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -215,6 +216,16 @@ Blocks until the program hits a breakpoint or exits, then returns auto-context.`
return err
}

var resolvedPython string
if python != "" {
p, err := ResolvePythonFlag(python)
if err != nil {
return err
}
resolvedPython = p
} else {
resolvedPython = ResolveVenvPython()
}
debugArgs := DebugArgs{
Breaks: []Breakpoint(breaks),
StopOnEntry: stopOnEntry,
Expand All @@ -223,6 +234,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]
Expand Down Expand Up @@ -254,6 +266,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"+
Expand Down
12 changes: 11 additions & 1 deletion daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -529,6 +536,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

Expand Down
Loading
Loading