Skip to content

Debug adapter protocol client implementation and bridge mode for IDE#78

Draft
danegsta wants to merge 25 commits intomainfrom
dev/danegsta/dap
Draft

Debug adapter protocol client implementation and bridge mode for IDE#78
danegsta wants to merge 25 commits intomainfrom
dev/danegsta/dap

Conversation

@danegsta
Copy link
Member

Implements a debug adapter protocol (DAP) client and support for a new debug adapter protocol bridge based IDE debugging mode.

Opening this as a draft because it's both very large and I want to spend more time on the Aspire side of the bridge to make sure things are fully stable before I mark it ready for review.

@danegsta danegsta requested a review from karolz-ms February 11, 2026 21:40
Copy link
Collaborator

@karolz-ms karolz-ms left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Big PR, so will be sending my feedback in "chunks" -- hope this helps!

(this is the first one)


l.closed = true

closeErr := l.listener.Close()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Release lock before calling Close()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented the retained error, which means we have to hold the lock until we've actually resolved the error value.

Copy link
Collaborator

@karolz-ms karolz-ms left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial PR review part 2

t.Parallel()
rootDir := shortTempDir(t)

listener, createErr := NewSecureSocketListener(rootDir, "afc-")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test that we should really have is the following:

  1. Create a new socket listener and do Accept() (blocks).
  2. Launch 10 different goroutines and make them race to call Close() on the listener.
  3. Verify that Accept() returns with errClosed (a distinct error that allows the caller to differentiate between "somebody closed be because it is time to shut down" vs another, unexpected error).
  4. Verify that all calls to Close() returned with no error.

Copy link
Collaborator

@karolz-ms karolz-ms left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments on the DAP implmentation, part 1

Comment on lines +207 to +217
// If the adapter did not send a TerminatedEvent, synthesize one for the IDE.
// Also send an error OutputEvent if we exited due to a transport error.
terminated := b.terminatedEventSeen.Load()

if !terminated {
if loopErr != nil && !errors.Is(loopErr, io.EOF) && !errors.Is(loopErr, context.Canceled) {
b.sendErrorToIDE(fmt.Sprintf("Debug session ended unexpectedly: %v", loopErr))
} else {
b.sendTerminatedToIDE()
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer if the forwardAdapterToIDE goroutine was "told" to do this (e.g. via a separate channel). This eliminates the race between the two goroutines with regards to reading/writing terminatedEventSeen and also ensures that a single goroutine is writing to the IDE socket.

To be clear, having multiple goroutines writing to the same socket is not strictly forbidden, but adds another degree of asynchrony.

func (b *DapBridge) handleOutputEvent(event *dap.OutputEvent) {
// Only capture output if runInTerminal wasn't used
// (if runInTerminal was used, we capture directly from the process)
if !b.runInTerminalUsed.Load() && b.config.OutputHandler != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like runInTerminalUsed is only used by the adapter --> IDE goroutine, so it can probably be just a regular bool flag.

Copy link
Collaborator

@karolz-ms karolz-ms left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DAP bridge review, part 2

Comment on lines +58 to +60
type HandshakeReader struct {
conn net.Conn
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a particular reason to define HandshakeReader this way as oppose do

Suggested change
type HandshakeReader struct {
conn net.Conn
}
type HandshakeReader net.Conn

Comment on lines +109 to +111
type HandshakeWriter struct {
conn net.Conn
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as for HandshakeReader

Suggested change
type HandshakeWriter struct {
conn net.Conn
}
type HandshakeWriter net.Conn

// Start begins listening on the Unix socket and accepting connections.
// This method blocks until the context is cancelled.
// Connections are handled in separate goroutines.
func (m *BridgeManager) Start(ctx context.Context) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe "Run()" (suggesting a blocking nature of the method)

// Close the listener when the context is cancelled so that Accept() unblocks.
// PrivateUnixSocketListener.Close() is idempotent, so the deferred Close above
// is still safe.
go func() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

context.AfterFunc()?

conn, acceptErr := m.listener.Accept()
if acceptErr != nil {
// Check if context was cancelled (listener was closed by the goroutine above)
select {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just check ctx.Err() != ni, no need to do a select

// handleConnection processes a single incoming connection.
func (m *BridgeManager) handleConnection(ctx context.Context, conn net.Conn) {
defer func() {
if r := recover(); r != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use resiliency.MakePanicError()

Copy link
Collaborator

@karolz-ms karolz-ms left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finished initial review. This is a fantastic and impressive change!

}

func (t *connTransport) WriteMessage(msg dap.Message) error {
t.writeMu.Lock()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more thing: if you want to make it highly probably that, upon contention, messages are written in the order in which WriteMessage() was called (as opposed to random), our concurrency.Semaphore type will ensure just that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants