Repository: go-delve/delve

Issue:
service/dap: telemetry report: DAP calls IsRunning on a nil debugger
```
#!stacks
"sigpanic" && "IsRunning"
```
Issue created by [stacks](https://pkg.go.dev/golang.org/x/tools/gopls/internal/telemetry/cmd/stacks).

This stack `hacR3A` was [reported by telemetry](https://storage.googleapis.com/prod-telemetry-merged/2025-05-20.json):

- `delve/bug`
- [`github.com/go-delve/delve/service/dap.(*Session).recoverPanic:+2`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L579)
- [`runtime.gopanic:+50`](https://cs.opensource.google/go/go/+/go1.24.3:src/runtime/panic.go;l=792)
- `runtime.panicmem:=262`
- [`runtime.sigpanic:+19`](https://cs.opensource.google/go/go/+/go1.24.3:src/runtime/signal_unix.go;l=925)
- [`github.com/go-delve/delve/service/debugger.(*Debugger).IsRunning:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/debugger/debugger.go#L1056)
- [`github.com/go-delve/delve/service/debugger.(*Debugger).State:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/debugger/debugger.go#L555)
- [`github.com/go-delve/delve/service/dap.(*Session).resumeOnce:+17`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3682)
- [`github.com/go-delve/delve/service/dap.(*Session).resumeOnceAndCheckStop:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3828)
- [`github.com/go-delve/delve/service/dap.init.func1:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3824)
- [`github.com/go-delve/delve/service/dap.(*Session).runUntilStop:+10`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3816)
- [`github.com/go-delve/delve/service/dap.(*Session).runUntilStopAndNotify:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3696)
- [`github.com/go-delve/delve/service/dap.(*Session).onConfigurationDoneRequest:+15`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L1710)
- [`github.com/go-delve/delve/service/dap.(*Session).handleRequest.func1:+2`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L707)
- `runtime.goexit:+0`
```
github.com/go-delve/delve/cmd/dlv@v1.24.2 go1.24.3 linux/amd64 (1)
```
Dups: 83gzDw 41J36g

Discussion:
@aarzilli: Fixed by [#4028](https://github.com/go-delve/delve/pull/4028)@aarzilli: This stack `83gzDw` was [reported by telemetry](https://telemetry.go.dev/data/2025-06-07):

- `delve/bug`
- [`github.com/go-delve/delve/service/dap.(*Session).recoverPanic:+2`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L579)
- [`runtime.gopanic:+50`](https://cs.opensource.google/go/go/+/go1.23.7:src/runtime/panic.go;l=791)
- `runtime.panicmem:=262`
- [`runtime.sigpanic:+19`](https://cs.opensource.google/go/go/+/go1.23.7:src/runtime/signal_unix.go;l=917)
- [`github.com/go-delve/delve/service/debugger.(*Debugger).IsRunning:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/debugger/debugger.go#L1056)
- [`github.com/go-delve/delve/service/debugger.(*Debugger).State:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/debugger/debugger.go#L555)
- [`github.com/go-delve/delve/service/dap.(*Session).resumeOnce:+17`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3682)
- [`github.com/go-delve/delve/service/dap.(*Session).resumeOnceAndCheckStop:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3828)
- [`github.com/go-delve/delve/service/dap.init.func1:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3824)
- [`github.com/go-delve/delve/service/dap.(*Session).runUntilStop:+10`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3816)
- [`github.com/go-delve/delve/service/dap.(*Session).runUntilStopAndNotify:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L3696)
- [`github.com/go-delve/delve/service/dap.(*Session).onContinueRequest:+5`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L1721)
- [`github.com/go-delve/delve/service/dap.(*Session).handleRequest.func2:+2`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L713)
- `runtime.goexit:+0`
```
github.com/go-delve/delve/cmd/dlv@v1.24.2 go1.24.2 darwin/arm64 (1)
github.com/go-delve/delve/cmd/dlv@v1.24.2 go1.23.7 darwin/arm64 (1)
```

This stack `41J36g` was [reported by telemetry](https://telemetry.go.dev/data/2025-06-11):

- `delve/bug`
- [`github.com/go-delve/delve/service/dap.(*Session).recoverPanic:+2`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L579)
- [`runtime.gopanic:+50`](https://cs.opensource.google/go/go/+/go1.24.3:src/runtime/panic.go;l=792)
- `runtime.panicmem:=262`
- [`runtime.sigpanic:+19`](https://cs.opensource.google/go/go/+/go1.24.3:src/runtime/signal_unix.go;l=925)
- [`github.com/go-delve/delve/service/debugger.(*Debugger).IsRunning:+1`](https://github.com/go-delve/delve/blob/v1.24.2/service/debugger/debugger.go#L1056)
- [`github.com/go-delve/delve/service/dap.(*Session).halt:+3`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L1393)
- [`github.com/go-delve/delve/service/dap.(*Session).onPauseRequest:+4`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L2125)
- [`github.com/go-delve/delve/service/dap.(*Session).handleRequest:+38`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L623)
- [`github.com/go-delve/delve/service/dap.(*Session).ServeDAPCodec:+39`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L565)
- [`github.com/go-delve/delve/service/dap.(*Server).runSession:+4`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L497)
- [`github.com/go-delve/delve/service/dap.(*Server).Run.func1:+18`](https://github.com/go-delve/delve/blob/v1.24.2/service/dap/server.go#L489)
- `runtime.goexit:+0`
```
github.com/go-delve/delve/cmd/dlv@v1.24.2 go1.24.3 darwin/arm64 (3)
```



Solution patch:
diff --git a/service/dap/server.go b/service/dap/server.go
index ea70547f..4e61361d 100644
--- a/service/dap/server.go
+++ b/service/dap/server.go
@@ -143,6 +143,8 @@ type Session struct {
 	// to ensure that messages do not get interleaved
 	sendingMu sync.Mutex
 
+	disconnected bool // disconnect was called
+
 	// runningCmd tracks whether the server is running an asynchronous
 	// command that resumes execution, which may not correspond to the actual
 	// running state of the process (e.g. if a command is temporarily interrupted).
@@ -412,6 +414,8 @@ func (s *Session) Close() {
 		s.stopDebugSession(killProcess)
 	} else if s.noDebugProcess != nil {
 		s.stopNoDebugProcess()
+	} else {
+		s.disconnected = true
 	}
 	// The binary is no longer in use by the debugger. It is safe to remove it.
 	if s.binaryToRemove != "" {
@@ -1238,6 +1242,7 @@ func (s *Session) newNoDebugProcess(program string, targetArgs []string, wd stri
 // onDisconnectRequest (run goroutine) and requires holding mu lock.
 func (s *Session) stopNoDebugProcess() {
 	if s.noDebugProcess == nil {
+		s.disconnected = true
 		// We already handled termination or there was never a process
 		return
 	}
@@ -1285,7 +1290,7 @@ func (s *Session) onDisconnectRequest(request *dap.DisconnectRequest) {
 		s.send(&dap.DisconnectResponse{Response: *newResponse(request.Request)})
 		s.send(&dap.TerminatedEvent{Event: *newEvent("terminated")})
 		s.conn.Close()
-		s.debugger = nil
+		s.disconnected = true
 		// The target is left in whatever state it is already in - halted or running.
 		// The users therefore have the flexibility to choose the appropriate state
 		// for their case before disconnecting. This is also desirable in case of
@@ -1308,6 +1313,8 @@ func (s *Session) onDisconnectRequest(request *dap.DisconnectRequest) {
 		err = s.stopDebugSession(killProcess)
 	} else if s.noDebugProcess != nil {
 		s.stopNoDebugProcess()
+	} else {
+		s.disconnected = true
 	}
 	if err != nil {
 		s.sendErrorResponse(request.Request, DisconnectError, "Error while disconnecting", err.Error())
@@ -1327,10 +1334,10 @@ func (s *Session) stopDebugSession(killProcess bool) error {
 	defer func() {
 		// Avoid running stop sequence twice.
 		// It's not fatal, but will result in duplicate logging.
-		s.debugger = nil
+		s.disconnected = true
 		s.changeStateMu.Unlock()
 	}()
-	if s.debugger == nil {
+	if s.debugger == nil || s.disconnected {
 		return nil
 	}
 	var exited error



Test patch:
diff --git a/service/dap/server_test.go b/service/dap/server_test.go
index b5366e99..2b16f37e 100644
--- a/service/dap/server_test.go
+++ b/service/dap/server_test.go
@@ -9,6 +9,7 @@ import (
 	"math"
 	"math/rand"
 	"net"
+	"net/http"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -145,8 +146,8 @@ func verifySessionStopped(t *testing.T, session *Session) {
 		t.Error("session must always have a set connection")
 	}
 	verifyConnStopped(t, session.conn)
-	if session.debugger != nil {
-		t.Error("session should have no pointer to debugger after shutdown")
+	if !session.disconnected {
+		t.Error("session should have disconnected set after shutdown")
 	}
 	if session.binaryToRemove != "" {
 		t.Error("session should have no binary to remove after shutdown")
@@ -234,8 +235,9 @@ func TestSessionStop(t *testing.T) {
 		if binaryToRemoveSet && s.binaryToRemove == "" || !binaryToRemoveSet && s.binaryToRemove != "" {
 			t.Errorf("binaryToRemove: got %s, want set=%v", s.binaryToRemove, binaryToRemoveSet)
 		}
-		if debuggerSet && s.debugger == nil || !debuggerSet && s.debugger != nil {
-			t.Errorf("debugger: got %v, want set=%v", s.debugger, debuggerSet)
+		connected := s.debugger != nil && !s.disconnected
+		if debuggerSet != connected {
+			t.Errorf("debugger: got debugger!=nil: %v disconnected: %v, want set=%v", s.debugger != nil, s.disconnected, debuggerSet)
 		}
 		if disconnectChanSet && s.config.DisconnectChan == nil || !disconnectChanSet && s.config.DisconnectChan != nil {
 			t.Errorf("disconnectChan: got %v, want set=%v", s.config.DisconnectChan, disconnectChanSet)
@@ -7694,6 +7696,74 @@ func TestRedirect(t *testing.T) {
 	})
 }
 
+func TestBreakpointAfterDisconnect(t *testing.T) {
+	fixture := protest.BuildFixture(t, "testnextnethttp", protest.AllNonOptimized)
+
+	cmd := exec.Command(fixture.Path)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if err := cmd.Start(); err != nil {
+		t.Fatal(err)
+	}
+
+	var server MultiClientCloseServerMock
+	server.stopped = make(chan struct{})
+	server.impl, server.forceStop = startDAPServer(t, false, server.stopped)
+
+	dbg, err := debugger.New(&debugger.Config{Backend: "default", AttachPid: cmd.Process.Pid}, nil)
+	if err != nil {
+		t.Fatalf("debugger.New: %v", err)
+	}
+
+	server.debugger = dbg
+
+	client := server.acceptNewClient(t)
+
+	client.InitializeRequest()
+	client.ExpectInitializeResponse(t)
+
+	client.SetBreakpointsRequestWithArgs(fixture.Source, []int{16}, nil, nil, map[int]string{16: "{w}"})
+	client.ExpectSetBreakpointsResponse(t)
+
+	client.ContinueRequest(1)
+	client.ExpectContinueResponse(t)
+
+	client.DisconnectRequestWithKillOption(false)
+	client.ExpectOutputEvent(t)
+	client.ExpectDisconnectResponse(t)
+	client.Close()
+
+	time.Sleep(200 * time.Millisecond)
+
+	server.impl.session.conn = &connection{ReadWriteCloser: discard{}} // fake a race condition between onDisconnectRequest and the runUntilStopAndNotify goroutine
+
+	httpClient := &http.Client{Timeout: time.Second}
+
+	_, err = httpClient.Get("http://127.0.0.1:9191/nobp")
+	if err != nil {
+		t.Fatalf("Page request after disconnect failed: %v", err)
+	}
+
+	time.Sleep(200 * time.Millisecond)
+
+	cmd.Process.Kill()
+}
+
+type discard struct {
+}
+
+func (discard) Read([]byte) (int, error) {
+	return 0, nil
+}
+
+func (discard) Close() error {
+	return nil
+}
+
+func (discard) Write(buf []byte) (int, error) {
+	return len(buf), nil
+}
+
 // Helper functions for checking ErrorMessage field values.
 
 func checkErrorMessageId(er *dap.ErrorMessage, id int) bool {