From 1e907480f69be225c89093b2bd0c51ec42911cbd Mon Sep 17 00:00:00 2001 From: almogbaku Date: Fri, 13 Mar 2026 10:00:57 +0200 Subject: [PATCH 1/2] fix: graceful shutdown of debug adapter on stop Instead of immediately SIGKILL-ing the adapter process, send DisconnectRequest(terminateDebuggee=true) and wait up to 3s for it to exit cleanly. Only hard-kill if it doesn't exit in time. Co-Authored-By: Claude Opus 4.6 --- daemon.go | 14 ++++++++++++-- daemon_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/daemon.go b/daemon.go index 8f69faa..dceaa99 100644 --- a/daemon.go +++ b/daemon.go @@ -1268,8 +1268,18 @@ func (d *Daemon) stopSession() { c.Close() } if d.adapterCmd != nil && d.adapterCmd.Process != nil { - _ = d.adapterCmd.Process.Kill() - _ = d.adapterCmd.Wait() + // Give the adapter time to shut down gracefully before hard-killing + done := make(chan struct{}) + go func() { + _ = d.adapterCmd.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + _ = d.adapterCmd.Process.Kill() + <-done + } d.adapterCmd = nil } if d.cleanupFn != nil { diff --git a/daemon_test.go b/daemon_test.go index aa080a9..a349289 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -3,8 +3,10 @@ package dap import ( "encoding/json" "fmt" + "os/exec" "strings" "testing" + "time" ) func TestOutputBufferBoundedAtWrite(t *testing.T) { @@ -87,6 +89,50 @@ func TestTempBinaryCleanup_NilSafe(t *testing.T) { d.stopSession() // should not panic } +func TestStopSessionGracefulShutdown(t *testing.T) { + // Process that exits quickly on its own — should not need SIGKILL + cmd := exec.Command("sleep", "0") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + d := &Daemon{adapterCmd: cmd} + + start := time.Now() + d.stopSession() + elapsed := time.Since(start) + + if elapsed > 2*time.Second { + t.Errorf("graceful shutdown took too long: %v (expected < 2s)", elapsed) + } + if d.adapterCmd != nil { + t.Error("adapterCmd should be nil after stopSession") + } +} + +func TestStopSessionKillsHangingProcess(t *testing.T) { + // Process that ignores signals and hangs — should be killed after timeout + cmd := exec.Command("sleep", "60") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + d := &Daemon{adapterCmd: cmd} + + start := time.Now() + d.stopSession() + elapsed := time.Since(start) + + // Should take ~3s (the timeout), not 60s + if elapsed > 5*time.Second { + t.Errorf("expected kill after ~3s, took %v", elapsed) + } + if elapsed < 2*time.Second { + t.Errorf("expected to wait for timeout, but finished in %v", elapsed) + } + if d.adapterCmd != nil { + t.Error("adapterCmd should be nil after stopSession") + } +} + func TestParseBreakpointSpec(t *testing.T) { tests := []struct { name string From 1f049975cdc59a1428eec086aecc3494b65c156b Mon Sep 17 00:00:00 2001 From: almogbaku Date: Fri, 13 Mar 2026 10:10:11 +0200 Subject: [PATCH 2/2] fix: capture adapterCmd in local var to prevent race stopSession bypasses cmdMu, so d.adapterCmd could be reassigned by startAdapter concurrently. Copy to a local before the goroutine, mirroring the existing pattern used for d.client. Co-Authored-By: Claude Opus 4.6 --- daemon.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/daemon.go b/daemon.go index dceaa99..d743b99 100644 --- a/daemon.go +++ b/daemon.go @@ -1267,20 +1267,21 @@ func (d *Daemon) stopSession() { _ = c.DisconnectRequest(true) c.Close() } - if d.adapterCmd != nil && d.adapterCmd.Process != nil { + cmd := d.adapterCmd + d.adapterCmd = nil + if cmd != nil && cmd.Process != nil { // Give the adapter time to shut down gracefully before hard-killing done := make(chan struct{}) go func() { - _ = d.adapterCmd.Wait() + _ = cmd.Wait() close(done) }() select { case <-done: case <-time.After(3 * time.Second): - _ = d.adapterCmd.Process.Kill() + _ = cmd.Process.Kill() <-done } - d.adapterCmd = nil } if d.cleanupFn != nil { d.cleanupFn()