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
3 changes: 2 additions & 1 deletion backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@ func (g *gdbBackend) LaunchArgs(mode, programPath string, stopOnEntry bool, prog
return args, nil
}

// CoreRequestType returns "attach" because GDB handles core dumps via the attach request.
// CoreRequestType returns "attach" because GDB native DAP handles core dumps
// via the attach request with a "coreFile" argument.
func (g *gdbBackend) CoreRequestType() string {
return "attach"
}
Expand Down
3 changes: 3 additions & 0 deletions backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ func TestGDBBackendTransportMode(t *testing.T) {

func TestGDBBackendCoreArgs(t *testing.T) {
backend := &gdbBackend{gdbPath: "gdb"}
if backend.CoreRequestType() != "attach" {
t.Errorf("expected CoreRequestType 'attach', got: %s", backend.CoreRequestType())
}
args, err := backend.CoreArgs("/path/to/program", "/path/to/core")
if err != nil {
t.Fatalf("unexpected error: %v", err)
Expand Down
39 changes: 28 additions & 11 deletions tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -1124,13 +1124,15 @@ func (ds *debuggerSession) debug(ctx context.Context, _ *mcp.CallToolRequest, pa

// Launch or attach using backend-specific args
stopOnEntry := params.StopOnEntry || len(params.Breakpoints) == 0
launchSeq := -1
switch mode {
case "source", "binary":
launchArgs, err := ds.backend.LaunchArgs(mode, params.Path, stopOnEntry, params.Args)
if err != nil {
return nil, nil, err
}
req := ds.client.newRequest("launch")
launchSeq = req.Seq
request := &dap.LaunchRequest{Request: *req}
request.Arguments = toRawMessage(launchArgs)
if err := ds.client.send(request); err != nil {
Expand All @@ -1145,9 +1147,11 @@ func (ds *debuggerSession) debug(ctx context.Context, _ *mcp.CallToolRequest, pa
var request dap.Message
if ds.backend.CoreRequestType() == "attach" {
req := ds.client.newRequest("attach")
launchSeq = req.Seq
request = &dap.AttachRequest{Request: *req, Arguments: rawArgs}
} else if ds.backend.CoreRequestType() == "launch" {
req := ds.client.newRequest("launch")
launchSeq = req.Seq
request = &dap.LaunchRequest{Request: *req, Arguments: rawArgs}
} else {
return nil, nil, fmt.Errorf("unsupported core request type: %s", ds.backend.CoreRequestType())
Expand All @@ -1161,6 +1165,7 @@ func (ds *debuggerSession) debug(ctx context.Context, _ *mcp.CallToolRequest, pa
return nil, nil, err
}
req := ds.client.newRequest("attach")
launchSeq = req.Seq
request := &dap.AttachRequest{Request: *req}
request.Arguments = toRawMessage(attachArgs)
if err := ds.client.send(request); err != nil {
Expand All @@ -1171,13 +1176,13 @@ func (ds *debuggerSession) debug(ctx context.Context, _ *mcp.CallToolRequest, pa
//
// Delve: launch response arrives immediately, then initialized event.
//
// GDB native DAP: may send an "initialized" event before or after the
// launch response.
// GDB native DAP: sends initialized event first, then DEFERS the
// launch/attach response until after configurationDone is processed.
//
// We unify both by reading messages until we see the initialized event.
// We read messages until we see the initialized event.
// The launch response may arrive before or after — if it arrives here,
// we consume it. If it arrives later, it will be automatically skipped
// as an out-of-order response by subsequent seq-based readers.
// we consume it and check for errors. If it arrives later (GDB's deferred
// pattern), it will be checked in subsequent message-reading loops.
for {
msg, err := ds.client.ReadMessage()
if err != nil {
Expand All @@ -1188,7 +1193,6 @@ func (ds *debuggerSession) debug(ctx context.Context, _ *mcp.CallToolRequest, pa
if !resp.GetResponse().Success {
return nil, nil, fmt.Errorf("unable to start debug session: %s", resp.GetResponse().Message)
}
// Launch response consumed; continue reading for initialized event
case *dap.InitializedEvent:
_ = resp
goto initialized
Expand Down Expand Up @@ -1230,16 +1234,17 @@ initialized:
return nil, nil, err
}

// If the launch response was deferred (arrived after the initialized event),
// it will be automatically consumed and skipped as an out-of-order response by
// subsequent readAndValidateResponse/readTypedResponse calls, which match
// by request_seq.

// Register session-specific tools based on capabilities
ds.registerSessionTools()

// For core dump mode, the program is already stopped at the crash point.
// Wait for the StoppedEvent from the adapter before returning context.
//
// GDB native DAP defers the launch/attach response until after
// configurationDone is processed. If the attach fails (e.g. unsupported
// coreFile parameter), the error response arrives here. We must check
// ResponseMessages to avoid hanging forever waiting for a StoppedEvent
// that will never come.
if mode == "core" {
for {
msg, err := ds.client.ReadMessage()
Expand All @@ -1257,6 +1262,12 @@ initialized:
return result, nil, err
}
return stopSummary(result, ev.Body.Reason), nil, nil
case dap.ResponseMessage:
r := ev.GetResponse()
if r.RequestSeq == launchSeq && !r.Success {
return nil, nil, fmt.Errorf("unable to start debug session: %s", r.Message)
}
// Successful deferred response; keep waiting for StoppedEvent
case dap.EventMessage:
continue
}
Expand Down Expand Up @@ -1295,6 +1306,12 @@ initialized:
goto stopped
case *dap.TerminatedEvent:
goto stopped
case dap.ResponseMessage:
r := ev.GetResponse()
if r.RequestSeq == launchSeq && !r.Success {
return nil, nil, fmt.Errorf("unable to start debug session: %s", r.Message)
}
// Successful deferred response; keep waiting for StoppedEvent
}
}
stopped:
Expand Down
104 changes: 87 additions & 17 deletions tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"flag"
"fmt"
"io"
"net/http"
Expand All @@ -18,6 +19,46 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)

var dapLogDir string

func TestMain(m *testing.M) {
flag.StringVar(&dapLogDir, "dap-log", "", "directory for DAP protocol and tool logs (enables verbose DAP logging)")
flag.Parse()
if dapLogDir != "" {
if err := os.MkdirAll(dapLogDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "failed to create dap-log dir: %v\n", err)
os.Exit(1)
}
}
os.Exit(m.Run())
}

func dapLogArgs(t *testing.T) map[string]any {
if dapLogDir == "" {
return nil
}
name := strings.ReplaceAll(t.Name(), "/", "_")
return map[string]any{
"protocolLog": filepath.Join(dapLogDir, name+".protocol.log"),
"toolLog": filepath.Join(dapLogDir, name+".tool.log"),
}
}

func dumpDAPLogs(t *testing.T) {
if dapLogDir == "" {
return
}
name := strings.ReplaceAll(t.Name(), "/", "_")
for _, suffix := range []string{".protocol.log", ".tool.log"} {
path := filepath.Join(dapLogDir, name+suffix)
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
continue
}
t.Logf("\n=== %s ===\n%s", filepath.Base(path), string(data))
}
}

// testSetup holds the common test infrastructure
type testSetup struct {
cwd string
Expand Down Expand Up @@ -129,6 +170,9 @@ func (ts *testSetup) startDebugSession(t *testing.T, port string, binaryPath str
if len(programArgs) > 0 {
args["args"] = programArgs
}
for k, v := range dapLogArgs(t) {
args[k] = v
}

result, err := ts.session.CallTool(ts.ctx, &mcp.CallToolParams{
Name: "debug",
Expand Down Expand Up @@ -239,6 +283,13 @@ func requireGDBDeps(t *testing.T) {
}
}

func skipIfGDBLacksCoreFileSupport(t *testing.T, errorMsg string) {
t.Helper()
if strings.Contains(errorMsg, "attach requires either") {
t.Skipf("GDB does not support DAP core file loading (requires GDB 18+): %s", errorMsg)
}
}

// compileTestCProgram compiles a C test program with debug symbols and returns the binary path.
func compileTestCProgram(t *testing.T, cwd, name string) (binaryPath string, cleanup func()) {
t.Helper()
Expand Down Expand Up @@ -1105,14 +1156,27 @@ func TestGDBCoreDump(t *testing.T) {
corePath := generateCoreDump(t, binaryPath)
defer os.Remove(corePath)

// Remove execute permission so GDB cannot re-run the binary.
// Core dump analysis only needs to read the binary for symbols;
// if GDB tries to execute it instead, the session will fail.
if err := os.Chmod(binaryPath, 0o444); err != nil {
t.Fatalf("Failed to chmod binary: %v", err)
}

debugArgs := map[string]any{
"debugger": "gdb",
"mode": "core",
"path": binaryPath,
"coreFilePath": corePath,
}
for k, v := range dapLogArgs(t) {
debugArgs[k] = v
}
defer dumpDAPLogs(t)

result, err := ts.session.CallTool(ts.ctx, &mcp.CallToolParams{
Name: "debug",
Arguments: map[string]any{
"debugger": "gdb",
"mode": "core",
"path": binaryPath,
"coreFilePath": corePath,
},
Name: "debug",
Arguments: debugArgs,
})
if err != nil {
t.Fatalf("Failed to start GDB core debug session: %v", err)
Expand All @@ -1124,6 +1188,7 @@ func TestGDBCoreDump(t *testing.T) {
errorMsg = tc.Text
}
}
skipIfGDBLacksCoreFileSupport(t, errorMsg)
t.Fatalf("GDB core debug session returned error: %s", errorMsg)
}

Expand All @@ -1140,9 +1205,8 @@ func TestGDBCoreDump(t *testing.T) {
ts.stopDebugger(t)
}

// Start a 'core' session for GDB passing a core file but no
// executable. GDB should be able to figure out the executable
// from the core file.
// GDB can auto-detect the executable from the core file,
// so the program path should be optional.
func TestGDBCoreDumpWithoutPath(t *testing.T) {
requireGDBDeps(t)

Expand All @@ -1155,14 +1219,19 @@ func TestGDBCoreDumpWithoutPath(t *testing.T) {
corePath := generateCoreDump(t, binaryPath)
defer os.Remove(corePath)

// Do not pass "path" — GDB should auto-detect the executable from the core file.
debugArgs := map[string]any{
"debugger": "gdb",
"mode": "core",
"coreFilePath": corePath,
}
for k, v := range dapLogArgs(t) {
debugArgs[k] = v
}
defer dumpDAPLogs(t)

result, err := ts.session.CallTool(ts.ctx, &mcp.CallToolParams{
Name: "debug",
Arguments: map[string]any{
"debugger": "gdb",
"mode": "core",
"coreFilePath": corePath,
},
Name: "debug",
Arguments: debugArgs,
})
if err != nil {
t.Fatalf("Failed to start GDB core debug session without path: %v", err)
Expand All @@ -1174,6 +1243,7 @@ func TestGDBCoreDumpWithoutPath(t *testing.T) {
errorMsg = tc.Text
}
}
skipIfGDBLacksCoreFileSupport(t, errorMsg)
t.Fatalf("GDB core debug session without path returned error: %s", errorMsg)
}

Expand Down
Loading