mirror of
https://github.com/actions/runner.git
synced 2026-01-22 20:44:30 +08:00
Phase 1 done
This commit is contained in:
485
.opencode/plans/dap-debugging.md
Normal file
485
.opencode/plans/dap-debugging.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# DAP-Based Debugging for GitHub Actions Runner
|
||||
|
||||
**Status:** Draft
|
||||
**Author:** GitHub Actions Team
|
||||
**Date:** January 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of Debug Adapter Protocol (DAP) support in the GitHub Actions runner, enabling rich debugging of workflow jobs from any DAP-compatible editor (nvim-dap, VS Code, etc.).
|
||||
|
||||
## Goals
|
||||
|
||||
- **Primary:** Create a working demo to demonstrate the feasibility of DAP-based workflow debugging
|
||||
- **Non-goal:** Production-ready, polished implementation (this is proof-of-concept)
|
||||
|
||||
## User Experience
|
||||
|
||||
1. User re-runs a failed job with "Enable debug logging" checked in GitHub UI
|
||||
2. Runner (running locally) detects debug mode and starts DAP server on port 4711
|
||||
3. Runner prints "Waiting for debugger on port 4711..." and pauses
|
||||
4. User opens editor (nvim with nvim-dap), connects to debugger
|
||||
5. Job execution begins, pausing before the first step
|
||||
6. User can:
|
||||
- **Inspect variables:** View `github`, `env`, `inputs`, `steps`, `secrets` (redacted), `runner`, `job` contexts
|
||||
- **Evaluate expressions:** `${{ github.event.pull_request.title }}`
|
||||
- **Execute shell commands:** Run arbitrary commands in the job's environment (REPL)
|
||||
- **Step through job:** `next` moves to next step, `continue` runs to end
|
||||
- **Pause after steps:** Inspect step outputs before continuing
|
||||
|
||||
## Activation
|
||||
|
||||
DAP debugging activates automatically when the job is in debug mode:
|
||||
|
||||
- User enables "Enable debug logging" when re-running a job in GitHub UI
|
||||
- Server sends `ACTIONS_STEP_DEBUG=true` in job variables
|
||||
- Runner sets `Global.WriteDebug = true` and `runner.debug = "1"`
|
||||
- DAP server starts on port 4711
|
||||
|
||||
**No additional configuration required.**
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `ACTIONS_DAP_PORT` | `4711` | TCP port for DAP server (optional override) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌─────────────────────────────────────────┐
|
||||
│ nvim-dap │ │ Runner.Worker │
|
||||
│ (DAP Client) │◄───TCP:4711───────►│ ┌─────────────────────────────────┐ │
|
||||
│ │ │ │ DapServer │ │
|
||||
└─────────────────────┘ │ │ - TCP listener │ │
|
||||
│ │ - DAP JSON protocol │ │
|
||||
│ └──────────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────▼──────────────────┐ │
|
||||
│ │ DapDebugSession │ │
|
||||
│ │ - Debug state management │ │
|
||||
│ │ - Step coordination │ │
|
||||
│ │ - Variable exposure │ │
|
||||
│ │ - Expression evaluation │ │
|
||||
│ │ - Shell execution (REPL) │ │
|
||||
│ └──────────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────▼──────────────────┐ │
|
||||
│ │ StepsRunner (modified) │ │
|
||||
│ │ - Pause before/after steps │ │
|
||||
│ │ - Notify debug session │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## DAP Concept Mapping
|
||||
|
||||
| DAP Concept | Actions Runner Equivalent |
|
||||
|-------------|---------------------------|
|
||||
| Thread | Single job execution |
|
||||
| Stack Frame | Current step + completed steps (step history) |
|
||||
| Scope | Context category: `github`, `env`, `inputs`, `steps`, `secrets`, `runner`, `job` |
|
||||
| Variable | Individual context values |
|
||||
| Breakpoint | Pause before specific step (future enhancement) |
|
||||
| Step Over (Next) | Execute current step, pause before next |
|
||||
| Continue | Run until job end |
|
||||
| Evaluate | Evaluate `${{ }}` expressions OR execute shell commands (REPL) |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/Runner.Worker/
|
||||
├── Dap/
|
||||
│ ├── DapServer.cs # TCP listener, JSON protocol handling
|
||||
│ ├── DapDebugSession.cs # Debug state, step coordination
|
||||
│ ├── DapMessages.cs # DAP protocol message types
|
||||
│ └── DapVariableProvider.cs # Converts ExecutionContext to DAP variables
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: DAP Protocol Infrastructure
|
||||
|
||||
#### 1.1 Protocol Messages (`Dap/DapMessages.cs`)
|
||||
|
||||
Base message types following DAP spec:
|
||||
|
||||
```csharp
|
||||
public abstract class ProtocolMessage
|
||||
{
|
||||
public int seq { get; set; }
|
||||
public string type { get; set; } // "request", "response", "event"
|
||||
}
|
||||
|
||||
public class Request : ProtocolMessage
|
||||
{
|
||||
public string command { get; set; }
|
||||
public object arguments { get; set; }
|
||||
}
|
||||
|
||||
public class Response : ProtocolMessage
|
||||
{
|
||||
public int request_seq { get; set; }
|
||||
public bool success { get; set; }
|
||||
public string command { get; set; }
|
||||
public string message { get; set; }
|
||||
public object body { get; set; }
|
||||
}
|
||||
|
||||
public class Event : ProtocolMessage
|
||||
{
|
||||
public string @event { get; set; }
|
||||
public object body { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
Message framing: `Content-Length: N\r\n\r\n{json}`
|
||||
|
||||
#### 1.2 DAP Server (`Dap/DapServer.cs`)
|
||||
|
||||
```csharp
|
||||
[ServiceLocator(Default = typeof(DapServer))]
|
||||
public interface IDapServer : IRunnerService
|
||||
{
|
||||
Task StartAsync(int port);
|
||||
Task WaitForConnectionAsync();
|
||||
Task StopAsync();
|
||||
void SendEvent(Event evt);
|
||||
}
|
||||
|
||||
public sealed class DapServer : RunnerService, IDapServer
|
||||
{
|
||||
private TcpListener _listener;
|
||||
private TcpClient _client;
|
||||
private IDapDebugSession _session;
|
||||
|
||||
// TCP listener on configurable port
|
||||
// Single-client connection
|
||||
// Async read/write loop
|
||||
// Dispatch requests to DapDebugSession
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Debug Session Logic
|
||||
|
||||
#### 2.1 Debug Session (`Dap/DapDebugSession.cs`)
|
||||
|
||||
```csharp
|
||||
public enum DapCommand { Continue, Next, Pause, Disconnect }
|
||||
public enum PauseReason { Entry, Step, Breakpoint, Pause }
|
||||
|
||||
[ServiceLocator(Default = typeof(DapDebugSession))]
|
||||
public interface IDapDebugSession : IRunnerService
|
||||
{
|
||||
bool IsActive { get; }
|
||||
|
||||
// Called by DapServer
|
||||
void Initialize(InitializeRequestArguments args);
|
||||
void Attach(AttachRequestArguments args);
|
||||
void ConfigurationDone();
|
||||
Task<DapCommand> WaitForCommandAsync();
|
||||
|
||||
// Called by StepsRunner
|
||||
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext);
|
||||
void OnStepCompleted(IStep step);
|
||||
|
||||
// DAP requests
|
||||
ThreadsResponse GetThreads();
|
||||
StackTraceResponse GetStackTrace(int threadId);
|
||||
ScopesResponse GetScopes(int frameId);
|
||||
VariablesResponse GetVariables(int variablesReference);
|
||||
EvaluateResponse Evaluate(string expression, string context);
|
||||
}
|
||||
|
||||
public sealed class DapDebugSession : RunnerService, IDapDebugSession
|
||||
{
|
||||
private IExecutionContext _jobContext;
|
||||
private IStep _currentStep;
|
||||
private readonly List<IStep> _completedSteps = new();
|
||||
private TaskCompletionSource<DapCommand> _commandTcs;
|
||||
private bool _pauseAfterStep = false;
|
||||
|
||||
// Object reference management for nested variables
|
||||
private int _nextVariableReference = 1;
|
||||
private readonly Dictionary<int, object> _variableReferences = new();
|
||||
}
|
||||
```
|
||||
|
||||
Core state machine:
|
||||
1. **Waiting for client:** Server started, no client connected
|
||||
2. **Initializing:** Client connected, exchanging capabilities
|
||||
3. **Ready:** `configurationDone` received, waiting to start
|
||||
4. **Paused (before step):** Stopped before step execution, waiting for command
|
||||
5. **Running:** Executing a step
|
||||
6. **Paused (after step):** Stopped after step execution, waiting for command
|
||||
|
||||
#### 2.2 Variable Provider (`Dap/DapVariableProvider.cs`)
|
||||
|
||||
Maps `ExecutionContext.ExpressionValues` to DAP scopes and variables:
|
||||
|
||||
| Scope | Source | Notes |
|
||||
|-------|--------|-------|
|
||||
| `github` | `ExpressionValues["github"]` | Full github context |
|
||||
| `env` | `ExpressionValues["env"]` | Environment variables |
|
||||
| `inputs` | `ExpressionValues["inputs"]` | Step inputs (when available) |
|
||||
| `steps` | `Global.StepsContext.GetScope()` | Completed step outputs |
|
||||
| `secrets` | `ExpressionValues["secrets"]` | Keys shown, values = `[REDACTED]` |
|
||||
| `runner` | `ExpressionValues["runner"]` | Runner context |
|
||||
| `job` | `ExpressionValues["job"]` | Job status |
|
||||
|
||||
Nested objects (e.g., `github.event.pull_request`) become expandable variables with child references.
|
||||
|
||||
### Phase 3: StepsRunner Integration
|
||||
|
||||
#### 3.1 Modify `StepsRunner.cs`
|
||||
|
||||
Add debug hooks at step boundaries:
|
||||
|
||||
```csharp
|
||||
public async Task RunAsync(IExecutionContext jobContext)
|
||||
{
|
||||
// Get debug session if available
|
||||
var debugSession = HostContext.TryGetService<IDapDebugSession>();
|
||||
bool isFirstStep = true;
|
||||
|
||||
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
|
||||
{
|
||||
// ... existing dequeue logic ...
|
||||
|
||||
var step = jobContext.JobSteps.Dequeue();
|
||||
|
||||
// Pause BEFORE step execution
|
||||
if (debugSession?.IsActive == true)
|
||||
{
|
||||
var reason = isFirstStep ? PauseReason.Entry : PauseReason.Step;
|
||||
await debugSession.OnStepStartingAsync(step, jobContext, reason);
|
||||
isFirstStep = false;
|
||||
}
|
||||
|
||||
// ... existing step execution (condition eval, RunStepAsync, etc.) ...
|
||||
|
||||
// Pause AFTER step execution (if requested)
|
||||
if (debugSession?.IsActive == true)
|
||||
{
|
||||
debugSession.OnStepCompleted(step);
|
||||
// Session may pause here to let user inspect outputs
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Expression Evaluation & Shell (REPL)
|
||||
|
||||
#### 4.1 Expression Evaluation
|
||||
|
||||
Reuse existing `PipelineTemplateEvaluator`:
|
||||
|
||||
```csharp
|
||||
private string EvaluateExpression(string expression)
|
||||
{
|
||||
// Strip ${{ }} wrapper if present
|
||||
var expr = expression.Trim();
|
||||
if (expr.StartsWith("${{") && expr.EndsWith("}}"))
|
||||
{
|
||||
expr = expr.Substring(3, expr.Length - 5).Trim();
|
||||
}
|
||||
|
||||
var templateEvaluator = _currentStep.ExecutionContext.ToPipelineTemplateEvaluator();
|
||||
var token = new BasicExpressionToken(null, null, null, expr);
|
||||
|
||||
var result = templateEvaluator.EvaluateStepDisplayName(
|
||||
token,
|
||||
_currentStep.ExecutionContext.ExpressionValues,
|
||||
_currentStep.ExecutionContext.ExpressionFunctions
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 Shell Execution (REPL)
|
||||
|
||||
When `evaluate` is called with `context: "repl"`, spawn shell in step's environment:
|
||||
|
||||
```csharp
|
||||
private async Task<EvaluateResponse> ExecuteShellCommand(string command)
|
||||
{
|
||||
var processInvoker = HostContext.CreateService<IProcessInvoker>();
|
||||
var output = new StringBuilder();
|
||||
|
||||
processInvoker.OutputDataReceived += (_, line) =>
|
||||
{
|
||||
output.AppendLine(line);
|
||||
// Stream to client in real-time
|
||||
_server.SendEvent(new OutputEvent
|
||||
{
|
||||
category = "stdout",
|
||||
output = line + "\n"
|
||||
});
|
||||
};
|
||||
|
||||
processInvoker.ErrorDataReceived += (_, line) =>
|
||||
{
|
||||
_server.SendEvent(new OutputEvent
|
||||
{
|
||||
category = "stderr",
|
||||
output = line + "\n"
|
||||
});
|
||||
};
|
||||
|
||||
var env = BuildStepEnvironment(_currentStep);
|
||||
var workDir = _currentStep.ExecutionContext.GetGitHubContext("workspace");
|
||||
|
||||
int exitCode = await processInvoker.ExecuteAsync(
|
||||
workingDirectory: workDir,
|
||||
fileName: GetDefaultShell(), // /bin/bash on unix, pwsh/powershell on windows
|
||||
arguments: BuildShellArgs(command),
|
||||
environment: env,
|
||||
requireExitCodeZero: false,
|
||||
cancellationToken: CancellationToken.None
|
||||
);
|
||||
|
||||
return new EvaluateResponse
|
||||
{
|
||||
result = output.ToString(),
|
||||
variablesReference = 0
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Startup Integration
|
||||
|
||||
#### 5.1 Modify `JobRunner.cs`
|
||||
|
||||
Add DAP server startup after debug mode is detected (around line 159):
|
||||
|
||||
```csharp
|
||||
if (jobContext.Global.WriteDebug)
|
||||
{
|
||||
jobContext.SetRunnerContext("debug", "1");
|
||||
|
||||
// Start DAP server for interactive debugging
|
||||
var dapServer = HostContext.GetService<IDapServer>();
|
||||
var port = int.Parse(
|
||||
Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT") ?? "4711");
|
||||
|
||||
await dapServer.StartAsync(port);
|
||||
Trace.Info($"DAP server listening on port {port}");
|
||||
jobContext.Output($"DAP debugger waiting for connection on port {port}...");
|
||||
|
||||
// Block until debugger connects
|
||||
await dapServer.WaitForConnectionAsync();
|
||||
Trace.Info("DAP client connected, continuing job execution");
|
||||
}
|
||||
```
|
||||
|
||||
## DAP Capabilities
|
||||
|
||||
Capabilities to advertise in `InitializeResponse`:
|
||||
|
||||
```json
|
||||
{
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsEvaluateForHovers": true,
|
||||
"supportsTerminateDebuggee": true,
|
||||
"supportsStepBack": false,
|
||||
"supportsSetVariable": false,
|
||||
"supportsRestartFrame": false,
|
||||
"supportsGotoTargetsRequest": false,
|
||||
"supportsStepInTargetsRequest": false,
|
||||
"supportsCompletionsRequest": false,
|
||||
"supportsModulesRequest": false,
|
||||
"supportsExceptionOptions": false,
|
||||
"supportsValueFormattingOptions": false,
|
||||
"supportsExceptionInfoRequest": false,
|
||||
"supportsDelayedStackTraceLoading": false,
|
||||
"supportsLoadedSourcesRequest": false,
|
||||
"supportsProgressReporting": false,
|
||||
"supportsRunInTerminalRequest": false
|
||||
}
|
||||
```
|
||||
|
||||
## Client Configuration (nvim-dap)
|
||||
|
||||
Example configuration for nvim-dap:
|
||||
|
||||
```lua
|
||||
local dap = require('dap')
|
||||
|
||||
dap.adapters.actions = {
|
||||
type = 'server',
|
||||
host = '127.0.0.1',
|
||||
port = 4711,
|
||||
}
|
||||
|
||||
dap.configurations.yaml = {
|
||||
{
|
||||
type = 'actions',
|
||||
request = 'attach',
|
||||
name = 'Attach to Actions Runner',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Demo Flow
|
||||
|
||||
1. Trigger job re-run with "Enable debug logging" checked in GitHub UI
|
||||
2. Runner starts, detects debug mode (`Global.WriteDebug == true`)
|
||||
3. DAP server starts, console shows: `DAP debugger waiting for connection on port 4711...`
|
||||
4. In nvim: `:lua require('dap').continue()`
|
||||
5. Connection established, capabilities exchanged
|
||||
6. Job begins, pauses before first step
|
||||
7. nvim shows "stopped" state, variables panel shows contexts
|
||||
8. User explores variables, evaluates expressions, runs shell commands
|
||||
9. User presses `n` (next) to advance to next step
|
||||
10. After step completes, user can inspect outputs before continuing
|
||||
11. Repeat until job completes
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests:** DAP protocol serialization, variable provider mapping
|
||||
2. **Integration tests:** Mock DAP client verifying request/response sequences
|
||||
3. **Manual testing:** Real job with nvim-dap attached
|
||||
|
||||
## Future Enhancements (Out of Scope for Demo)
|
||||
|
||||
- Composite action step-in (expand into sub-steps)
|
||||
- Breakpoints on specific step names
|
||||
- Watch expressions
|
||||
- Conditional breakpoints
|
||||
- Remote debugging (runner not on localhost)
|
||||
- VS Code extension
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Phase | Effort |
|
||||
|-------|--------|
|
||||
| Phase 1: Protocol Infrastructure | 4-6 hours |
|
||||
| Phase 2: Debug Session Logic | 4-6 hours |
|
||||
| Phase 3: StepsRunner Integration | 2-3 hours |
|
||||
| Phase 4: Expression & Shell | 3-4 hours |
|
||||
| Phase 5: Startup & Polish | 2-3 hours |
|
||||
| **Total** | **~2-3 days** |
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/Runner.Worker/JobRunner.cs` | Start DAP server when debug mode enabled |
|
||||
| `src/Runner.Worker/StepsRunner.cs` | Add pause hooks before/after step execution |
|
||||
| `src/Runner.Worker/Runner.Worker.csproj` | Add new Dap/ folder files |
|
||||
|
||||
## Key Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/Runner.Worker/Dap/DapServer.cs` | TCP server, protocol framing |
|
||||
| `src/Runner.Worker/Dap/DapDebugSession.cs` | Debug state machine, command handling |
|
||||
| `src/Runner.Worker/Dap/DapMessages.cs` | Protocol message types |
|
||||
| `src/Runner.Worker/Dap/DapVariableProvider.cs` | Context → DAP variable conversion |
|
||||
|
||||
## Reference Links
|
||||
|
||||
- [DAP Overview](https://microsoft.github.io/debug-adapter-protocol/overview)
|
||||
- [DAP Specification](https://microsoft.github.io/debug-adapter-protocol/specification)
|
||||
- [Enable Debug Logging (GitHub Docs)](https://docs.github.com/en/actions/how-tos/monitor-workflows/enable-debug-logging)
|
||||
643
src/Runner.Worker/Dap/DapDebugSession.cs
Normal file
643
src/Runner.Worker/Dap/DapDebugSession.cs
Normal file
@@ -0,0 +1,643 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Debug session state machine states.
|
||||
/// </summary>
|
||||
public enum DapSessionState
|
||||
{
|
||||
/// <summary>
|
||||
/// Initial state, waiting for client connection.
|
||||
/// </summary>
|
||||
WaitingForConnection,
|
||||
|
||||
/// <summary>
|
||||
/// Client connected, exchanging capabilities.
|
||||
/// </summary>
|
||||
Initializing,
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDone received, ready to debug.
|
||||
/// </summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>
|
||||
/// Paused before or after a step, waiting for user command.
|
||||
/// </summary>
|
||||
Paused,
|
||||
|
||||
/// <summary>
|
||||
/// Executing a step.
|
||||
/// </summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// Session disconnected or terminated.
|
||||
/// </summary>
|
||||
Terminated
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commands that can be issued from the debug client.
|
||||
/// </summary>
|
||||
public enum DapCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Continue execution until end or next breakpoint.
|
||||
/// </summary>
|
||||
Continue,
|
||||
|
||||
/// <summary>
|
||||
/// Execute current step and pause before next.
|
||||
/// </summary>
|
||||
Next,
|
||||
|
||||
/// <summary>
|
||||
/// Pause execution.
|
||||
/// </summary>
|
||||
Pause,
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect from the debug session.
|
||||
/// </summary>
|
||||
Disconnect
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reasons for stopping/pausing execution.
|
||||
/// </summary>
|
||||
public static class StopReason
|
||||
{
|
||||
public const string Entry = "entry";
|
||||
public const string Step = "step";
|
||||
public const string Breakpoint = "breakpoint";
|
||||
public const string Pause = "pause";
|
||||
public const string Exception = "exception";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for the DAP debug session.
|
||||
/// Handles debug state, step coordination, and DAP request processing.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(DapDebugSession))]
|
||||
public interface IDapDebugSession : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the debug session is active (initialized and configured).
|
||||
/// </summary>
|
||||
bool IsActive { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current session state.
|
||||
/// </summary>
|
||||
DapSessionState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sets the DAP server for sending events.
|
||||
/// </summary>
|
||||
/// <param name="server">The DAP server</param>
|
||||
void SetDapServer(IDapServer server);
|
||||
|
||||
/// <summary>
|
||||
/// Handles an incoming DAP request and returns a response.
|
||||
/// </summary>
|
||||
/// <param name="request">The DAP request</param>
|
||||
/// <returns>The DAP response</returns>
|
||||
Task<Response> HandleRequestAsync(Request request);
|
||||
|
||||
/// <summary>
|
||||
/// Called by StepsRunner before a step starts executing.
|
||||
/// May block waiting for debugger commands.
|
||||
/// </summary>
|
||||
/// <param name="step">The step about to execute</param>
|
||||
/// <param name="jobContext">The job execution context</param>
|
||||
/// <param name="isFirstStep">Whether this is the first step in the job</param>
|
||||
/// <returns>Task that completes when execution should continue</returns>
|
||||
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep);
|
||||
|
||||
/// <summary>
|
||||
/// Called by StepsRunner after a step completes.
|
||||
/// </summary>
|
||||
/// <param name="step">The step that completed</param>
|
||||
void OnStepCompleted(IStep step);
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the session that the job has completed.
|
||||
/// </summary>
|
||||
void OnJobCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debug session implementation for handling DAP requests and coordinating
|
||||
/// step execution with the debugger.
|
||||
/// </summary>
|
||||
public sealed class DapDebugSession : RunnerService, IDapDebugSession
|
||||
{
|
||||
// Thread ID for the single job execution thread
|
||||
private const int JobThreadId = 1;
|
||||
|
||||
// Frame ID for the current step
|
||||
private const int CurrentFrameId = 1;
|
||||
|
||||
private IDapServer _server;
|
||||
private DapSessionState _state = DapSessionState.WaitingForConnection;
|
||||
private InitializeRequestArguments _clientCapabilities;
|
||||
|
||||
// Synchronization for step execution
|
||||
private TaskCompletionSource<DapCommand> _commandTcs;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
// Current execution context (set during OnStepStartingAsync)
|
||||
private IStep _currentStep;
|
||||
private IExecutionContext _jobContext;
|
||||
|
||||
public bool IsActive => _state == DapSessionState.Ready || _state == DapSessionState.Paused || _state == DapSessionState.Running;
|
||||
|
||||
public DapSessionState State => _state;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
Trace.Info("DapDebugSession initialized");
|
||||
}
|
||||
|
||||
public void SetDapServer(IDapServer server)
|
||||
{
|
||||
_server = server;
|
||||
Trace.Info("DAP server reference set");
|
||||
}
|
||||
|
||||
public async Task<Response> HandleRequestAsync(Request request)
|
||||
{
|
||||
Trace.Info($"Handling DAP request: {request.Command}");
|
||||
|
||||
try
|
||||
{
|
||||
return request.Command switch
|
||||
{
|
||||
"initialize" => HandleInitialize(request),
|
||||
"attach" => HandleAttach(request),
|
||||
"configurationDone" => HandleConfigurationDone(request),
|
||||
"disconnect" => HandleDisconnect(request),
|
||||
"threads" => HandleThreads(request),
|
||||
"stackTrace" => HandleStackTrace(request),
|
||||
"scopes" => HandleScopes(request),
|
||||
"variables" => HandleVariables(request),
|
||||
"continue" => HandleContinue(request),
|
||||
"next" => HandleNext(request),
|
||||
"pause" => HandlePause(request),
|
||||
"evaluate" => await HandleEvaluateAsync(request),
|
||||
"setBreakpoints" => HandleSetBreakpoints(request),
|
||||
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
|
||||
_ => CreateErrorResponse($"Unknown command: {request.Command}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Error handling request '{request.Command}': {ex}");
|
||||
return CreateErrorResponse(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
#region DAP Request Handlers
|
||||
|
||||
private Response HandleInitialize(Request request)
|
||||
{
|
||||
// Parse client capabilities
|
||||
if (request.Arguments != null)
|
||||
{
|
||||
_clientCapabilities = request.Arguments.ToObject<InitializeRequestArguments>();
|
||||
Trace.Info($"Client: {_clientCapabilities.ClientName ?? _clientCapabilities.ClientId ?? "unknown"}");
|
||||
}
|
||||
|
||||
_state = DapSessionState.Initializing;
|
||||
|
||||
// Build our capabilities response
|
||||
var capabilities = new Capabilities
|
||||
{
|
||||
SupportsConfigurationDoneRequest = true,
|
||||
SupportsEvaluateForHovers = true,
|
||||
SupportTerminateDebuggee = true,
|
||||
SupportsTerminateRequest = true,
|
||||
// We don't support these features (yet)
|
||||
SupportsStepBack = false,
|
||||
SupportsSetVariable = false,
|
||||
SupportsRestartFrame = false,
|
||||
SupportsGotoTargetsRequest = false,
|
||||
SupportsStepInTargetsRequest = false,
|
||||
SupportsCompletionsRequest = false,
|
||||
SupportsModulesRequest = false,
|
||||
SupportsFunctionBreakpoints = false,
|
||||
SupportsConditionalBreakpoints = false,
|
||||
SupportsExceptionOptions = false,
|
||||
SupportsValueFormattingOptions = false,
|
||||
SupportsExceptionInfoRequest = false,
|
||||
SupportsDelayedStackTraceLoading = false,
|
||||
SupportsLoadedSourcesRequest = false,
|
||||
SupportsProgressReporting = false,
|
||||
SupportsRunInTerminalRequest = false,
|
||||
SupportsCancelRequest = false,
|
||||
};
|
||||
|
||||
// Queue the initialized event to be sent after the response
|
||||
Task.Run(() =>
|
||||
{
|
||||
// Small delay to ensure response is sent first
|
||||
System.Threading.Thread.Sleep(50);
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "initialized"
|
||||
});
|
||||
Trace.Info("Sent initialized event");
|
||||
});
|
||||
|
||||
Trace.Info("Initialize request handled, capabilities sent");
|
||||
return CreateSuccessResponse(capabilities);
|
||||
}
|
||||
|
||||
private Response HandleAttach(Request request)
|
||||
{
|
||||
Trace.Info("Attach request handled");
|
||||
return CreateSuccessResponse(null);
|
||||
}
|
||||
|
||||
private Response HandleConfigurationDone(Request request)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_state = DapSessionState.Ready;
|
||||
}
|
||||
Trace.Info("Configuration done, debug session is ready");
|
||||
|
||||
// Complete any pending wait for configuration
|
||||
return CreateSuccessResponse(null);
|
||||
}
|
||||
|
||||
private Response HandleDisconnect(Request request)
|
||||
{
|
||||
Trace.Info("Disconnect request received");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_state = DapSessionState.Terminated;
|
||||
|
||||
// Release any blocked step execution
|
||||
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||
}
|
||||
|
||||
return CreateSuccessResponse(null);
|
||||
}
|
||||
|
||||
private Response HandleThreads(Request request)
|
||||
{
|
||||
// We have a single thread representing the job execution
|
||||
var body = new ThreadsResponseBody
|
||||
{
|
||||
Threads = new System.Collections.Generic.List<Thread>
|
||||
{
|
||||
new Thread
|
||||
{
|
||||
Id = JobThreadId,
|
||||
Name = _jobContext != null
|
||||
? $"Job: {_jobContext.GetGitHubContext("job") ?? "workflow job"}"
|
||||
: "Job Thread"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return CreateSuccessResponse(body);
|
||||
}
|
||||
|
||||
private Response HandleStackTrace(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<StackTraceArguments>();
|
||||
|
||||
var frames = new System.Collections.Generic.List<StackFrame>();
|
||||
|
||||
// Add current step as the top frame
|
||||
if (_currentStep != null)
|
||||
{
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = CurrentFrameId,
|
||||
Name = _currentStep.DisplayName ?? "Current Step",
|
||||
Line = 1,
|
||||
Column = 1,
|
||||
PresentationHint = "normal"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = CurrentFrameId,
|
||||
Name = "(no step executing)",
|
||||
Line = 1,
|
||||
Column = 1,
|
||||
PresentationHint = "subtle"
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: In Phase 2, add completed steps as additional frames
|
||||
|
||||
var body = new StackTraceResponseBody
|
||||
{
|
||||
StackFrames = frames,
|
||||
TotalFrames = frames.Count
|
||||
};
|
||||
|
||||
return CreateSuccessResponse(body);
|
||||
}
|
||||
|
||||
private Response HandleScopes(Request request)
|
||||
{
|
||||
// Stub implementation - Phase 2 will populate with actual contexts
|
||||
var body = new ScopesResponseBody
|
||||
{
|
||||
Scopes = new System.Collections.Generic.List<Scope>
|
||||
{
|
||||
new Scope { Name = "github", VariablesReference = 1, Expensive = false },
|
||||
new Scope { Name = "env", VariablesReference = 2, Expensive = false },
|
||||
new Scope { Name = "runner", VariablesReference = 3, Expensive = false },
|
||||
new Scope { Name = "job", VariablesReference = 4, Expensive = false },
|
||||
new Scope { Name = "steps", VariablesReference = 5, Expensive = false },
|
||||
new Scope { Name = "secrets", VariablesReference = 6, Expensive = false, PresentationHint = "registers" },
|
||||
}
|
||||
};
|
||||
|
||||
return CreateSuccessResponse(body);
|
||||
}
|
||||
|
||||
private Response HandleVariables(Request request)
|
||||
{
|
||||
// Stub implementation - Phase 2 will populate with actual variable values
|
||||
var args = request.Arguments?.ToObject<VariablesArguments>();
|
||||
var variablesRef = args?.VariablesReference ?? 0;
|
||||
|
||||
var body = new VariablesResponseBody
|
||||
{
|
||||
Variables = new System.Collections.Generic.List<Variable>
|
||||
{
|
||||
new Variable
|
||||
{
|
||||
Name = "(stub)",
|
||||
Value = $"Variables for scope {variablesRef} will be implemented in Phase 2",
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return CreateSuccessResponse(body);
|
||||
}
|
||||
|
||||
private Response HandleContinue(Request request)
|
||||
{
|
||||
Trace.Info("Continue command received");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused)
|
||||
{
|
||||
_state = DapSessionState.Running;
|
||||
_commandTcs?.TrySetResult(DapCommand.Continue);
|
||||
}
|
||||
}
|
||||
|
||||
return CreateSuccessResponse(new ContinueResponseBody
|
||||
{
|
||||
AllThreadsContinued = true
|
||||
});
|
||||
}
|
||||
|
||||
private Response HandleNext(Request request)
|
||||
{
|
||||
Trace.Info("Next (step over) command received");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused)
|
||||
{
|
||||
_state = DapSessionState.Running;
|
||||
_commandTcs?.TrySetResult(DapCommand.Next);
|
||||
}
|
||||
}
|
||||
|
||||
return CreateSuccessResponse(null);
|
||||
}
|
||||
|
||||
private Response HandlePause(Request request)
|
||||
{
|
||||
Trace.Info("Pause command received");
|
||||
|
||||
// The runner will pause at the next step boundary
|
||||
lock (_stateLock)
|
||||
{
|
||||
// Just acknowledge - actual pause happens at step boundary
|
||||
}
|
||||
|
||||
return CreateSuccessResponse(null);
|
||||
}
|
||||
|
||||
private async Task<Response> HandleEvaluateAsync(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<EvaluateArguments>();
|
||||
var expression = args?.Expression ?? "";
|
||||
var context = args?.Context ?? "hover";
|
||||
|
||||
Trace.Info($"Evaluate: '{expression}' (context: {context})");
|
||||
|
||||
// Stub implementation - Phase 4 will implement expression evaluation
|
||||
var body = new EvaluateResponseBody
|
||||
{
|
||||
Result = $"(evaluation of '{expression}' will be implemented in Phase 4)",
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
|
||||
return CreateSuccessResponse(body);
|
||||
}
|
||||
|
||||
private Response HandleSetBreakpoints(Request request)
|
||||
{
|
||||
// Stub - breakpoints not implemented in demo
|
||||
Trace.Info("SetBreakpoints request (not implemented)");
|
||||
|
||||
return CreateSuccessResponse(new
|
||||
{
|
||||
breakpoints = new object[0]
|
||||
});
|
||||
}
|
||||
|
||||
private Response HandleSetExceptionBreakpoints(Request request)
|
||||
{
|
||||
// Stub - exception breakpoints not implemented in demo
|
||||
Trace.Info("SetExceptionBreakpoints request (not implemented)");
|
||||
|
||||
return CreateSuccessResponse(new
|
||||
{
|
||||
breakpoints = new object[0]
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Step Coordination (called by StepsRunner)
|
||||
|
||||
public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep)
|
||||
{
|
||||
if (!IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentStep = step;
|
||||
_jobContext = jobContext;
|
||||
|
||||
var reason = isFirstStep ? StopReason.Entry : StopReason.Step;
|
||||
var description = isFirstStep
|
||||
? $"Stopped at job entry: {step.DisplayName}"
|
||||
: $"Stopped before step: {step.DisplayName}";
|
||||
|
||||
Trace.Info($"Step starting: {step.DisplayName} (reason: {reason})");
|
||||
|
||||
// Send stopped event to debugger
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "stopped",
|
||||
Body = new StoppedEventBody
|
||||
{
|
||||
Reason = reason,
|
||||
Description = description,
|
||||
ThreadId = JobThreadId,
|
||||
AllThreadsStopped = true
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for debugger command
|
||||
await WaitForCommandAsync();
|
||||
}
|
||||
|
||||
public void OnStepCompleted(IStep step)
|
||||
{
|
||||
if (!IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Trace.Info($"Step completed: {step.DisplayName}, result: {step.ExecutionContext?.Result}");
|
||||
|
||||
// The step context will be available for inspection
|
||||
// Future: could pause here if "pause after step" is enabled
|
||||
}
|
||||
|
||||
public void OnJobCompleted()
|
||||
{
|
||||
if (!IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Trace.Info("Job completed, sending terminated event");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_state = DapSessionState.Terminated;
|
||||
}
|
||||
|
||||
// Send terminated event
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "terminated",
|
||||
Body = new TerminatedEventBody()
|
||||
});
|
||||
|
||||
// Send exited event
|
||||
var exitCode = _jobContext?.Result == GitHub.DistributedTask.WebApi.TaskResult.Succeeded ? 0 : 1;
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "exited",
|
||||
Body = new ExitedEventBody
|
||||
{
|
||||
ExitCode = exitCode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task WaitForCommandAsync()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_state = DapSessionState.Paused;
|
||||
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
Trace.Info("Waiting for debugger command...");
|
||||
|
||||
var command = await _commandTcs.Task;
|
||||
|
||||
Trace.Info($"Received command: {command}");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused)
|
||||
{
|
||||
_state = DapSessionState.Running;
|
||||
}
|
||||
}
|
||||
|
||||
// Send continued event
|
||||
if (command == DapCommand.Continue || command == DapCommand.Next)
|
||||
{
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "continued",
|
||||
Body = new ContinuedEventBody
|
||||
{
|
||||
ThreadId = JobThreadId,
|
||||
AllThreadsContinued = true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Helpers
|
||||
|
||||
private Response CreateSuccessResponse(object body)
|
||||
{
|
||||
return new Response
|
||||
{
|
||||
Success = true,
|
||||
Body = body
|
||||
};
|
||||
}
|
||||
|
||||
private Response CreateErrorResponse(string message)
|
||||
{
|
||||
return new Response
|
||||
{
|
||||
Success = false,
|
||||
Message = message,
|
||||
Body = new ErrorResponseBody
|
||||
{
|
||||
Error = new Message
|
||||
{
|
||||
Id = 1,
|
||||
Format = message,
|
||||
ShowUser = true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
1125
src/Runner.Worker/Dap/DapMessages.cs
Normal file
1125
src/Runner.Worker/Dap/DapMessages.cs
Normal file
File diff suppressed because it is too large
Load Diff
480
src/Runner.Worker/Dap/DapServer.cs
Normal file
480
src/Runner.Worker/Dap/DapServer.cs
Normal file
@@ -0,0 +1,480 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// DAP Server interface for handling Debug Adapter Protocol connections.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(DapServer))]
|
||||
public interface IDapServer : IRunnerService, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts the DAP TCP server on the specified port.
|
||||
/// </summary>
|
||||
/// <param name="port">The port to listen on (default: 4711)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
Task StartAsync(int port, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until a debug client connects.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
Task WaitForConnectionAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Stops the DAP server and closes all connections.
|
||||
/// </summary>
|
||||
Task StopAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the debug session that will handle DAP requests.
|
||||
/// </summary>
|
||||
/// <param name="session">The debug session</param>
|
||||
void SetSession(IDapDebugSession session);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an event to the connected debug client.
|
||||
/// </summary>
|
||||
/// <param name="evt">The event to send</param>
|
||||
void SendEvent(Event evt);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a debug client is currently connected.
|
||||
/// </summary>
|
||||
bool IsConnected { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TCP server implementation of the Debug Adapter Protocol.
|
||||
/// Handles message framing (Content-Length headers) and JSON serialization.
|
||||
/// </summary>
|
||||
public sealed class DapServer : RunnerService, IDapServer
|
||||
{
|
||||
private const string ContentLengthHeader = "Content-Length: ";
|
||||
private const string HeaderTerminator = "\r\n\r\n";
|
||||
|
||||
private TcpListener _listener;
|
||||
private TcpClient _client;
|
||||
private NetworkStream _stream;
|
||||
private IDapDebugSession _session;
|
||||
private CancellationTokenSource _cts;
|
||||
private Task _messageLoopTask;
|
||||
private TaskCompletionSource<bool> _connectionTcs;
|
||||
private int _nextSeq = 1;
|
||||
private readonly object _sendLock = new object();
|
||||
private bool _disposed = false;
|
||||
|
||||
public bool IsConnected => _client?.Connected == true;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
Trace.Info("DapServer initialized");
|
||||
}
|
||||
|
||||
public void SetSession(IDapDebugSession session)
|
||||
{
|
||||
_session = session;
|
||||
Trace.Info("Debug session set");
|
||||
}
|
||||
|
||||
public async Task StartAsync(int port, CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.Info($"Starting DAP server on port {port}");
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_connectionTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
try
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||
_listener.Start();
|
||||
Trace.Info($"DAP server listening on 127.0.0.1:{port}");
|
||||
|
||||
// Start accepting connections in the background
|
||||
_ = AcceptConnectionAsync(_cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Failed to start DAP server: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task AcceptConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info("Waiting for debug client connection...");
|
||||
|
||||
// Use cancellation-aware accept
|
||||
using (cancellationToken.Register(() => _listener?.Stop()))
|
||||
{
|
||||
_client = await _listener.AcceptTcpClientAsync();
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stream = _client.GetStream();
|
||||
var remoteEndPoint = _client.Client.RemoteEndPoint;
|
||||
Trace.Info($"Debug client connected from {remoteEndPoint}");
|
||||
|
||||
// Signal that connection is established
|
||||
_connectionTcs.TrySetResult(true);
|
||||
|
||||
// Start processing messages
|
||||
_messageLoopTask = ProcessMessagesAsync(_cts.Token);
|
||||
}
|
||||
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Expected when cancellation stops the listener
|
||||
Trace.Info("Connection accept cancelled");
|
||||
_connectionTcs.TrySetCanceled();
|
||||
}
|
||||
catch (SocketException ex) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Expected when cancellation stops the listener
|
||||
Trace.Info($"Connection accept cancelled: {ex.Message}");
|
||||
_connectionTcs.TrySetCanceled();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Error accepting connection: {ex.Message}");
|
||||
_connectionTcs.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WaitForConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.Info("Waiting for debug client to connect...");
|
||||
|
||||
using (cancellationToken.Register(() => _connectionTcs.TrySetCanceled()))
|
||||
{
|
||||
await _connectionTcs.Task;
|
||||
}
|
||||
|
||||
Trace.Info("Debug client connected");
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
Trace.Info("Stopping DAP server");
|
||||
|
||||
_cts?.Cancel();
|
||||
|
||||
// Wait for message loop to complete
|
||||
if (_messageLoopTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _messageLoopTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Message loop ended with error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
_stream?.Close();
|
||||
_client?.Close();
|
||||
_listener?.Stop();
|
||||
|
||||
Trace.Info("DAP server stopped");
|
||||
}
|
||||
|
||||
public void SendEvent(Event evt)
|
||||
{
|
||||
if (!IsConnected)
|
||||
{
|
||||
Trace.Warning($"Cannot send event '{evt.EventType}': no client connected");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
evt.Seq = _nextSeq++;
|
||||
SendMessageInternal(evt);
|
||||
}
|
||||
Trace.Info($"Sent event: {evt.EventType}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Failed to send event '{evt.EventType}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.Info("Starting DAP message processing loop");
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && IsConnected)
|
||||
{
|
||||
var json = await ReadMessageAsync(cancellationToken);
|
||||
if (json == null)
|
||||
{
|
||||
Trace.Info("Client disconnected (end of stream)");
|
||||
break;
|
||||
}
|
||||
|
||||
await ProcessMessageAsync(json, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Message processing cancelled");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Trace.Info($"Connection closed: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Error in message loop: {ex}");
|
||||
}
|
||||
|
||||
Trace.Info("DAP message processing loop ended");
|
||||
}
|
||||
|
||||
private async Task ProcessMessageAsync(string json, CancellationToken cancellationToken)
|
||||
{
|
||||
Request request = null;
|
||||
try
|
||||
{
|
||||
// Parse the incoming message
|
||||
request = JsonConvert.DeserializeObject<Request>(json);
|
||||
if (request == null || request.Type != "request")
|
||||
{
|
||||
Trace.Warning($"Received non-request message: {json}");
|
||||
return;
|
||||
}
|
||||
|
||||
Trace.Info($"Received request: seq={request.Seq}, command={request.Command}");
|
||||
|
||||
// Dispatch to session for handling
|
||||
if (_session == null)
|
||||
{
|
||||
Trace.Error("No debug session configured");
|
||||
SendErrorResponse(request, "No debug session configured");
|
||||
return;
|
||||
}
|
||||
|
||||
var response = await _session.HandleRequestAsync(request);
|
||||
response.RequestSeq = request.Seq;
|
||||
response.Command = request.Command;
|
||||
response.Type = "response";
|
||||
|
||||
lock (_sendLock)
|
||||
{
|
||||
response.Seq = _nextSeq++;
|
||||
SendMessageInternal(response);
|
||||
}
|
||||
|
||||
Trace.Info($"Sent response: seq={response.Seq}, command={response.Command}, success={response.Success}");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Trace.Error($"Failed to parse request: {ex.Message}");
|
||||
Trace.Error($"JSON: {json}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Error processing request: {ex}");
|
||||
if (request != null)
|
||||
{
|
||||
SendErrorResponse(request, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SendErrorResponse(Request request, string message)
|
||||
{
|
||||
var response = new Response
|
||||
{
|
||||
Type = "response",
|
||||
RequestSeq = request.Seq,
|
||||
Command = request.Command,
|
||||
Success = false,
|
||||
Message = message,
|
||||
Body = new ErrorResponseBody
|
||||
{
|
||||
Error = new Message
|
||||
{
|
||||
Id = 1,
|
||||
Format = message,
|
||||
ShowUser = true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
lock (_sendLock)
|
||||
{
|
||||
response.Seq = _nextSeq++;
|
||||
SendMessageInternal(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a DAP message from the stream.
|
||||
/// DAP uses HTTP-like message framing: Content-Length: N\r\n\r\n{json}
|
||||
/// </summary>
|
||||
private async Task<string> ReadMessageAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Read headers until we find Content-Length
|
||||
var headerBuilder = new StringBuilder();
|
||||
int contentLength = -1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var line = await ReadLineAsync(cancellationToken);
|
||||
if (line == null)
|
||||
{
|
||||
// End of stream
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.Length == 0)
|
||||
{
|
||||
// Empty line marks end of headers
|
||||
break;
|
||||
}
|
||||
|
||||
headerBuilder.AppendLine(line);
|
||||
|
||||
if (line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var lengthStr = line.Substring(ContentLengthHeader.Length).Trim();
|
||||
if (!int.TryParse(lengthStr, out contentLength))
|
||||
{
|
||||
throw new InvalidDataException($"Invalid Content-Length: {lengthStr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLength < 0)
|
||||
{
|
||||
throw new InvalidDataException("Missing Content-Length header");
|
||||
}
|
||||
|
||||
// Read the JSON body
|
||||
var buffer = new byte[contentLength];
|
||||
var totalRead = 0;
|
||||
while (totalRead < contentLength)
|
||||
{
|
||||
var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
throw new EndOfStreamException("Connection closed while reading message body");
|
||||
}
|
||||
totalRead += bytesRead;
|
||||
}
|
||||
|
||||
var json = Encoding.UTF8.GetString(buffer);
|
||||
Trace.Verbose($"Received: {json}");
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a line from the stream (terminated by \r\n).
|
||||
/// </summary>
|
||||
private async Task<string> ReadLineAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lineBuilder = new StringBuilder();
|
||||
var buffer = new byte[1];
|
||||
var previousWasCr = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
// End of stream
|
||||
return lineBuilder.Length > 0 ? lineBuilder.ToString() : null;
|
||||
}
|
||||
|
||||
var c = (char)buffer[0];
|
||||
|
||||
if (c == '\n' && previousWasCr)
|
||||
{
|
||||
// Found \r\n, return the line (without the \r)
|
||||
if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r')
|
||||
{
|
||||
lineBuilder.Length--;
|
||||
}
|
||||
return lineBuilder.ToString();
|
||||
}
|
||||
|
||||
previousWasCr = (c == '\r');
|
||||
lineBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a DAP message to the stream with Content-Length framing.
|
||||
/// Must be called within the _sendLock.
|
||||
/// </summary>
|
||||
private void SendMessageInternal(ProtocolMessage message)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
});
|
||||
|
||||
var bodyBytes = Encoding.UTF8.GetBytes(json);
|
||||
var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n";
|
||||
var headerBytes = Encoding.UTF8.GetBytes(header);
|
||||
|
||||
_stream.Write(headerBytes, 0, headerBytes.Length);
|
||||
_stream.Write(bodyBytes, 0, bodyBytes.Length);
|
||||
_stream.Flush();
|
||||
|
||||
Trace.Verbose($"Sent: {json}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_stream?.Dispose();
|
||||
_client?.Dispose();
|
||||
_listener?.Stop();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user