diff --git a/.opencode/plans/dap-debugging.md b/.opencode/plans/dap-debugging.md new file mode 100644 index 000000000..c1143a04f --- /dev/null +++ b/.opencode/plans/dap-debugging.md @@ -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 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 _completedSteps = new(); + private TaskCompletionSource _commandTcs; + private bool _pauseAfterStep = false; + + // Object reference management for nested variables + private int _nextVariableReference = 1; + private readonly Dictionary _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(); + 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 ExecuteShellCommand(string command) +{ + var processInvoker = HostContext.CreateService(); + 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(); + 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) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs new file mode 100644 index 000000000..ba3868f30 --- /dev/null +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -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 +{ + /// + /// Debug session state machine states. + /// + public enum DapSessionState + { + /// + /// Initial state, waiting for client connection. + /// + WaitingForConnection, + + /// + /// Client connected, exchanging capabilities. + /// + Initializing, + + /// + /// ConfigurationDone received, ready to debug. + /// + Ready, + + /// + /// Paused before or after a step, waiting for user command. + /// + Paused, + + /// + /// Executing a step. + /// + Running, + + /// + /// Session disconnected or terminated. + /// + Terminated + } + + /// + /// Commands that can be issued from the debug client. + /// + public enum DapCommand + { + /// + /// Continue execution until end or next breakpoint. + /// + Continue, + + /// + /// Execute current step and pause before next. + /// + Next, + + /// + /// Pause execution. + /// + Pause, + + /// + /// Disconnect from the debug session. + /// + Disconnect + } + + /// + /// Reasons for stopping/pausing execution. + /// + 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"; + } + + /// + /// Interface for the DAP debug session. + /// Handles debug state, step coordination, and DAP request processing. + /// + [ServiceLocator(Default = typeof(DapDebugSession))] + public interface IDapDebugSession : IRunnerService + { + /// + /// Gets whether the debug session is active (initialized and configured). + /// + bool IsActive { get; } + + /// + /// Gets the current session state. + /// + DapSessionState State { get; } + + /// + /// Sets the DAP server for sending events. + /// + /// The DAP server + void SetDapServer(IDapServer server); + + /// + /// Handles an incoming DAP request and returns a response. + /// + /// The DAP request + /// The DAP response + Task HandleRequestAsync(Request request); + + /// + /// Called by StepsRunner before a step starts executing. + /// May block waiting for debugger commands. + /// + /// The step about to execute + /// The job execution context + /// Whether this is the first step in the job + /// Task that completes when execution should continue + Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep); + + /// + /// Called by StepsRunner after a step completes. + /// + /// The step that completed + void OnStepCompleted(IStep step); + + /// + /// Notifies the session that the job has completed. + /// + void OnJobCompleted(); + } + + /// + /// Debug session implementation for handling DAP requests and coordinating + /// step execution with the debugger. + /// + 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 _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 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(); + 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 + { + 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(); + + var frames = new System.Collections.Generic.List(); + + // 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 + { + 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(); + var variablesRef = args?.VariablesReference ?? 0; + + var body = new VariablesResponseBody + { + Variables = new System.Collections.Generic.List + { + 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 HandleEvaluateAsync(Request request) + { + var args = request.Arguments?.ToObject(); + 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(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 + } +} diff --git a/src/Runner.Worker/Dap/DapMessages.cs b/src/Runner.Worker/Dap/DapMessages.cs new file mode 100644 index 000000000..a305e543e --- /dev/null +++ b/src/Runner.Worker/Dap/DapMessages.cs @@ -0,0 +1,1125 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Base class of requests, responses, and events per DAP specification. + /// + public class ProtocolMessage + { + /// + /// Sequence number of the message (also known as message ID). + /// The seq for the first message sent by a client or debug adapter is 1, + /// and for each subsequent message is 1 greater than the previous message. + /// + [JsonProperty("seq")] + public int Seq { get; set; } + + /// + /// Message type: 'request', 'response', 'event' + /// + [JsonProperty("type")] + public string Type { get; set; } + } + + /// + /// A client or debug adapter initiated request. + /// + public class Request : ProtocolMessage + { + /// + /// The command to execute. + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Object containing arguments for the command. + /// Using JObject for flexibility with different argument types. + /// + [JsonProperty("arguments")] + public JObject Arguments { get; set; } + } + + /// + /// Response for a request. + /// + public class Response : ProtocolMessage + { + /// + /// Sequence number of the corresponding request. + /// + [JsonProperty("request_seq")] + public int RequestSeq { get; set; } + + /// + /// Outcome of the request. If true, the request was successful. + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// The command requested. + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Contains the raw error in short form if success is false. + /// + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; set; } + + /// + /// Contains request result if success is true and error details if success is false. + /// + [JsonProperty("body", NullValueHandling = NullValueHandling.Ignore)] + public object Body { get; set; } + } + + /// + /// A debug adapter initiated event. + /// + public class Event : ProtocolMessage + { + public Event() + { + Type = "event"; + } + + /// + /// Type of event. + /// + [JsonProperty("event")] + public string EventType { get; set; } + + /// + /// Event-specific information. + /// + [JsonProperty("body", NullValueHandling = NullValueHandling.Ignore)] + public object Body { get; set; } + } + + #region Initialize Request/Response + + /// + /// Arguments for 'initialize' request. + /// + public class InitializeRequestArguments + { + /// + /// The ID of the client using this adapter. + /// + [JsonProperty("clientID")] + public string ClientId { get; set; } + + /// + /// The human-readable name of the client using this adapter. + /// + [JsonProperty("clientName")] + public string ClientName { get; set; } + + /// + /// The ID of the debug adapter. + /// + [JsonProperty("adapterID")] + public string AdapterId { get; set; } + + /// + /// The ISO-639 locale of the client using this adapter, e.g. en-US or de-CH. + /// + [JsonProperty("locale")] + public string Locale { get; set; } + + /// + /// If true all line numbers are 1-based (default). + /// + [JsonProperty("linesStartAt1")] + public bool LinesStartAt1 { get; set; } = true; + + /// + /// If true all column numbers are 1-based (default). + /// + [JsonProperty("columnsStartAt1")] + public bool ColumnsStartAt1 { get; set; } = true; + + /// + /// Determines in what format paths are specified. The default is 'path'. + /// + [JsonProperty("pathFormat")] + public string PathFormat { get; set; } = "path"; + + /// + /// Client supports the type attribute for variables. + /// + [JsonProperty("supportsVariableType")] + public bool SupportsVariableType { get; set; } + + /// + /// Client supports the paging of variables. + /// + [JsonProperty("supportsVariablePaging")] + public bool SupportsVariablePaging { get; set; } + + /// + /// Client supports the runInTerminal request. + /// + [JsonProperty("supportsRunInTerminalRequest")] + public bool SupportsRunInTerminalRequest { get; set; } + + /// + /// Client supports memory references. + /// + [JsonProperty("supportsMemoryReferences")] + public bool SupportsMemoryReferences { get; set; } + + /// + /// Client supports progress reporting. + /// + [JsonProperty("supportsProgressReporting")] + public bool SupportsProgressReporting { get; set; } + } + + /// + /// Debug adapter capabilities returned in InitializeResponse. + /// + public class Capabilities + { + /// + /// The debug adapter supports the configurationDone request. + /// + [JsonProperty("supportsConfigurationDoneRequest")] + public bool SupportsConfigurationDoneRequest { get; set; } + + /// + /// The debug adapter supports function breakpoints. + /// + [JsonProperty("supportsFunctionBreakpoints")] + public bool SupportsFunctionBreakpoints { get; set; } + + /// + /// The debug adapter supports conditional breakpoints. + /// + [JsonProperty("supportsConditionalBreakpoints")] + public bool SupportsConditionalBreakpoints { get; set; } + + /// + /// The debug adapter supports a (side effect free) evaluate request for data hovers. + /// + [JsonProperty("supportsEvaluateForHovers")] + public bool SupportsEvaluateForHovers { get; set; } + + /// + /// The debug adapter supports stepping back via the stepBack and reverseContinue requests. + /// + [JsonProperty("supportsStepBack")] + public bool SupportsStepBack { get; set; } + + /// + /// The debug adapter supports setting a variable to a value. + /// + [JsonProperty("supportsSetVariable")] + public bool SupportsSetVariable { get; set; } + + /// + /// The debug adapter supports restarting a frame. + /// + [JsonProperty("supportsRestartFrame")] + public bool SupportsRestartFrame { get; set; } + + /// + /// The debug adapter supports the gotoTargets request. + /// + [JsonProperty("supportsGotoTargetsRequest")] + public bool SupportsGotoTargetsRequest { get; set; } + + /// + /// The debug adapter supports the stepInTargets request. + /// + [JsonProperty("supportsStepInTargetsRequest")] + public bool SupportsStepInTargetsRequest { get; set; } + + /// + /// The debug adapter supports the completions request. + /// + [JsonProperty("supportsCompletionsRequest")] + public bool SupportsCompletionsRequest { get; set; } + + /// + /// The debug adapter supports the modules request. + /// + [JsonProperty("supportsModulesRequest")] + public bool SupportsModulesRequest { get; set; } + + /// + /// The debug adapter supports the terminate request. + /// + [JsonProperty("supportsTerminateRequest")] + public bool SupportsTerminateRequest { get; set; } + + /// + /// The debug adapter supports the terminateDebuggee attribute on the disconnect request. + /// + [JsonProperty("supportTerminateDebuggee")] + public bool SupportTerminateDebuggee { get; set; } + + /// + /// The debug adapter supports the delayed loading of parts of the stack. + /// + [JsonProperty("supportsDelayedStackTraceLoading")] + public bool SupportsDelayedStackTraceLoading { get; set; } + + /// + /// The debug adapter supports the loadedSources request. + /// + [JsonProperty("supportsLoadedSourcesRequest")] + public bool SupportsLoadedSourcesRequest { get; set; } + + /// + /// The debug adapter supports sending progress reporting events. + /// + [JsonProperty("supportsProgressReporting")] + public bool SupportsProgressReporting { get; set; } + + /// + /// The debug adapter supports the runInTerminal request. + /// + [JsonProperty("supportsRunInTerminalRequest")] + public bool SupportsRunInTerminalRequest { get; set; } + + /// + /// The debug adapter supports the cancel request. + /// + [JsonProperty("supportsCancelRequest")] + public bool SupportsCancelRequest { get; set; } + + /// + /// The debug adapter supports exception options. + /// + [JsonProperty("supportsExceptionOptions")] + public bool SupportsExceptionOptions { get; set; } + + /// + /// The debug adapter supports value formatting options. + /// + [JsonProperty("supportsValueFormattingOptions")] + public bool SupportsValueFormattingOptions { get; set; } + + /// + /// The debug adapter supports exception info request. + /// + [JsonProperty("supportsExceptionInfoRequest")] + public bool SupportsExceptionInfoRequest { get; set; } + } + + #endregion + + #region Attach Request + + /// + /// Arguments for 'attach' request. Additional attributes are implementation specific. + /// + public class AttachRequestArguments + { + /// + /// Arbitrary data from the previous, restarted session. + /// + [JsonProperty("__restart", NullValueHandling = NullValueHandling.Ignore)] + public object Restart { get; set; } + } + + #endregion + + #region Disconnect Request + + /// + /// Arguments for 'disconnect' request. + /// + public class DisconnectRequestArguments + { + /// + /// A value of true indicates that this disconnect request is part of a restart sequence. + /// + [JsonProperty("restart")] + public bool Restart { get; set; } + + /// + /// Indicates whether the debuggee should be terminated when the debugger is disconnected. + /// + [JsonProperty("terminateDebuggee")] + public bool TerminateDebuggee { get; set; } + + /// + /// Indicates whether the debuggee should stay suspended when the debugger is disconnected. + /// + [JsonProperty("suspendDebuggee")] + public bool SuspendDebuggee { get; set; } + } + + #endregion + + #region Threads Request/Response + + /// + /// A Thread in DAP represents a unit of execution. + /// For Actions runner, we have a single thread representing the job. + /// + public class Thread + { + /// + /// Unique identifier for the thread. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// The name of the thread. + /// + [JsonProperty("name")] + public string Name { get; set; } + } + + /// + /// Response body for 'threads' request. + /// + public class ThreadsResponseBody + { + /// + /// All threads. + /// + [JsonProperty("threads")] + public List Threads { get; set; } = new List(); + } + + #endregion + + #region StackTrace Request/Response + + /// + /// Arguments for 'stackTrace' request. + /// + public class StackTraceArguments + { + /// + /// Retrieve the stacktrace for this thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// The index of the first frame to return. + /// + [JsonProperty("startFrame")] + public int? StartFrame { get; set; } + + /// + /// The maximum number of frames to return. + /// + [JsonProperty("levels")] + public int? Levels { get; set; } + } + + /// + /// A Stackframe contains the source location. + /// For Actions runner, each step is a stack frame. + /// + public class StackFrame + { + /// + /// An identifier for the stack frame. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// The name of the stack frame, typically a method name. + /// For Actions, this is the step display name. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The source of the frame. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The line within the source of the frame. + /// + [JsonProperty("line")] + public int Line { get; set; } + + /// + /// Start position of the range covered by the stack frame. + /// + [JsonProperty("column")] + public int Column { get; set; } + + /// + /// The end line of the range covered by the stack frame. + /// + [JsonProperty("endLine", NullValueHandling = NullValueHandling.Ignore)] + public int? EndLine { get; set; } + + /// + /// End position of the range covered by the stack frame. + /// + [JsonProperty("endColumn", NullValueHandling = NullValueHandling.Ignore)] + public int? EndColumn { get; set; } + + /// + /// A hint for how to present this frame in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + } + + /// + /// A Source is a descriptor for source code. + /// + public class Source + { + /// + /// The short name of the source. + /// + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } + + /// + /// The path of the source to be shown in the UI. + /// + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + public string Path { get; set; } + + /// + /// If the value > 0 the contents of the source must be retrieved through + /// the 'source' request (even if a path is specified). + /// + [JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)] + public int? SourceReference { get; set; } + + /// + /// A hint for how to present the source in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + } + + /// + /// Response body for 'stackTrace' request. + /// + public class StackTraceResponseBody + { + /// + /// The frames of the stack frame. + /// + [JsonProperty("stackFrames")] + public List StackFrames { get; set; } = new List(); + + /// + /// The total number of frames available in the stack. + /// + [JsonProperty("totalFrames", NullValueHandling = NullValueHandling.Ignore)] + public int? TotalFrames { get; set; } + } + + #endregion + + #region Scopes Request/Response + + /// + /// Arguments for 'scopes' request. + /// + public class ScopesArguments + { + /// + /// Retrieve the scopes for the stack frame identified by frameId. + /// + [JsonProperty("frameId")] + public int FrameId { get; set; } + } + + /// + /// A Scope is a named container for variables. + /// For Actions runner, scopes are: github, env, inputs, steps, secrets, runner, job + /// + public class Scope + { + /// + /// Name of the scope such as 'Arguments', 'Locals', or 'Registers'. + /// For Actions: 'github', 'env', 'inputs', 'steps', 'secrets', 'runner', 'job' + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// A hint for how to present this scope in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + + /// + /// The variables of this scope can be retrieved by passing the value of + /// variablesReference to the variables request. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named variables in this scope. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed variables in this scope. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// If true, the number of variables in this scope is large or expensive to retrieve. + /// + [JsonProperty("expensive")] + public bool Expensive { get; set; } + + /// + /// The source for this scope. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The start line of the range covered by this scope. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + + /// + /// Start position of the range covered by this scope. + /// + [JsonProperty("column", NullValueHandling = NullValueHandling.Ignore)] + public int? Column { get; set; } + + /// + /// The end line of the range covered by this scope. + /// + [JsonProperty("endLine", NullValueHandling = NullValueHandling.Ignore)] + public int? EndLine { get; set; } + + /// + /// End position of the range covered by this scope. + /// + [JsonProperty("endColumn", NullValueHandling = NullValueHandling.Ignore)] + public int? EndColumn { get; set; } + } + + /// + /// Response body for 'scopes' request. + /// + public class ScopesResponseBody + { + /// + /// The scopes of the stack frame. + /// + [JsonProperty("scopes")] + public List Scopes { get; set; } = new List(); + } + + #endregion + + #region Variables Request/Response + + /// + /// Arguments for 'variables' request. + /// + public class VariablesArguments + { + /// + /// The variable for which to retrieve its children. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// Filter to limit the child variables to either named or indexed. + /// + [JsonProperty("filter", NullValueHandling = NullValueHandling.Ignore)] + public string Filter { get; set; } + + /// + /// The index of the first variable to return. + /// + [JsonProperty("start", NullValueHandling = NullValueHandling.Ignore)] + public int? Start { get; set; } + + /// + /// The number of variables to return. + /// + [JsonProperty("count", NullValueHandling = NullValueHandling.Ignore)] + public int? Count { get; set; } + } + + /// + /// A Variable is a name/value pair. + /// + public class Variable + { + /// + /// The variable's name. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The variable's value. + /// + [JsonProperty("value")] + public string Value { get; set; } + + /// + /// The type of the variable's value. + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// If variablesReference is > 0, the variable is structured and its children + /// can be retrieved by passing variablesReference to the variables request. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named child variables. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed child variables. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// A memory reference to a location appropriate for this result. + /// + [JsonProperty("memoryReference", NullValueHandling = NullValueHandling.Ignore)] + public string MemoryReference { get; set; } + + /// + /// A reference that allows the client to request the location where the + /// variable's value is declared. + /// + [JsonProperty("declarationLocationReference", NullValueHandling = NullValueHandling.Ignore)] + public int? DeclarationLocationReference { get; set; } + + /// + /// The evaluatable name of this variable which can be passed to the evaluate + /// request to fetch the variable's value. + /// + [JsonProperty("evaluateName", NullValueHandling = NullValueHandling.Ignore)] + public string EvaluateName { get; set; } + } + + /// + /// Response body for 'variables' request. + /// + public class VariablesResponseBody + { + /// + /// All (or a range) of variables for the given variable reference. + /// + [JsonProperty("variables")] + public List Variables { get; set; } = new List(); + } + + #endregion + + #region Continue Request/Response + + /// + /// Arguments for 'continue' request. + /// + public class ContinueArguments + { + /// + /// Specifies the active thread. If the debug adapter supports single thread + /// execution, setting this will resume only the specified thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// If this flag is true, execution is resumed only for the thread with given + /// threadId. If false, all threads are resumed. + /// + [JsonProperty("singleThread")] + public bool SingleThread { get; set; } + } + + /// + /// Response body for 'continue' request. + /// + public class ContinueResponseBody + { + /// + /// If true, all threads are resumed. If false, only the thread with the given + /// threadId is resumed. + /// + [JsonProperty("allThreadsContinued")] + public bool AllThreadsContinued { get; set; } = true; + } + + #endregion + + #region Next Request + + /// + /// Arguments for 'next' request. + /// + public class NextArguments + { + /// + /// Specifies the thread for which to resume execution for one step. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// Stepping granularity. + /// + [JsonProperty("granularity", NullValueHandling = NullValueHandling.Ignore)] + public string Granularity { get; set; } + + /// + /// If this flag is true, all other suspended threads are not resumed. + /// + [JsonProperty("singleThread")] + public bool SingleThread { get; set; } + } + + #endregion + + #region Evaluate Request/Response + + /// + /// Arguments for 'evaluate' request. + /// + public class EvaluateArguments + { + /// + /// The expression to evaluate. + /// + [JsonProperty("expression")] + public string Expression { get; set; } + + /// + /// Evaluate the expression in the scope of this stack frame. + /// + [JsonProperty("frameId", NullValueHandling = NullValueHandling.Ignore)] + public int? FrameId { get; set; } + + /// + /// The context in which the evaluate request is used. + /// Values: 'watch', 'repl', 'hover', 'clipboard', 'variables' + /// + [JsonProperty("context", NullValueHandling = NullValueHandling.Ignore)] + public string Context { get; set; } + } + + /// + /// Response body for 'evaluate' request. + /// + public class EvaluateResponseBody + { + /// + /// The result of the evaluate request. + /// + [JsonProperty("result")] + public string Result { get; set; } + + /// + /// The type of the evaluate result. + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// If variablesReference is > 0, the evaluate result is structured. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named child variables. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed child variables. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// A memory reference to a location appropriate for this result. + /// + [JsonProperty("memoryReference", NullValueHandling = NullValueHandling.Ignore)] + public string MemoryReference { get; set; } + } + + #endregion + + #region Events + + /// + /// Body for 'stopped' event. + /// The event indicates that the execution of the debuggee has stopped. + /// + public class StoppedEventBody + { + /// + /// The reason for the event. For backward compatibility this string is shown + /// in the UI if the description attribute is missing. + /// Values: 'step', 'breakpoint', 'exception', 'pause', 'entry', 'goto', + /// 'function breakpoint', 'data breakpoint', 'instruction breakpoint' + /// + [JsonProperty("reason")] + public string Reason { get; set; } + + /// + /// The full reason for the event, e.g. 'Paused on exception'. + /// This string is shown in the UI as is and can be translated. + /// + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + public string Description { get; set; } + + /// + /// The thread which was stopped. + /// + [JsonProperty("threadId", NullValueHandling = NullValueHandling.Ignore)] + public int? ThreadId { get; set; } + + /// + /// A value of true hints to the client that this event should not change the focus. + /// + [JsonProperty("preserveFocusHint", NullValueHandling = NullValueHandling.Ignore)] + public bool? PreserveFocusHint { get; set; } + + /// + /// Additional information. E.g. if reason is 'exception', text contains the + /// exception name. + /// + [JsonProperty("text", NullValueHandling = NullValueHandling.Ignore)] + public string Text { get; set; } + + /// + /// If allThreadsStopped is true, a debug adapter can announce that all threads + /// have stopped. + /// + [JsonProperty("allThreadsStopped", NullValueHandling = NullValueHandling.Ignore)] + public bool? AllThreadsStopped { get; set; } + + /// + /// Ids of the breakpoints that triggered the event. + /// + [JsonProperty("hitBreakpointIds", NullValueHandling = NullValueHandling.Ignore)] + public List HitBreakpointIds { get; set; } + } + + /// + /// Body for 'continued' event. + /// The event indicates that the execution of the debuggee has continued. + /// + public class ContinuedEventBody + { + /// + /// The thread which was continued. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// If true, all threads have been resumed. + /// + [JsonProperty("allThreadsContinued", NullValueHandling = NullValueHandling.Ignore)] + public bool? AllThreadsContinued { get; set; } + } + + /// + /// Body for 'terminated' event. + /// The event indicates that debugging of the debuggee has terminated. + /// + public class TerminatedEventBody + { + /// + /// A debug adapter may set restart to true to request that the client + /// restarts the session. + /// + [JsonProperty("restart", NullValueHandling = NullValueHandling.Ignore)] + public object Restart { get; set; } + } + + /// + /// Body for 'output' event. + /// The event indicates that the target has produced some output. + /// + public class OutputEventBody + { + /// + /// The output category. If not specified, 'console' is assumed. + /// Values: 'console', 'important', 'stdout', 'stderr', 'telemetry' + /// + [JsonProperty("category", NullValueHandling = NullValueHandling.Ignore)] + public string Category { get; set; } + + /// + /// The output to report. + /// + [JsonProperty("output")] + public string Output { get; set; } + + /// + /// Support for keeping an output log organized by grouping related messages. + /// Values: 'start', 'startCollapsed', 'end' + /// + [JsonProperty("group", NullValueHandling = NullValueHandling.Ignore)] + public string Group { get; set; } + + /// + /// If variablesReference is > 0, the output contains objects which can be + /// retrieved by passing variablesReference to the variables request. + /// + [JsonProperty("variablesReference", NullValueHandling = NullValueHandling.Ignore)] + public int? VariablesReference { get; set; } + + /// + /// The source location where the output was produced. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The source location's line where the output was produced. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + + /// + /// The position in line where the output was produced. + /// + [JsonProperty("column", NullValueHandling = NullValueHandling.Ignore)] + public int? Column { get; set; } + + /// + /// Additional data to report. + /// + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; set; } + } + + /// + /// Body for 'thread' event. + /// The event indicates that a thread has started or exited. + /// + public class ThreadEventBody + { + /// + /// The reason for the event. + /// Values: 'started', 'exited' + /// + [JsonProperty("reason")] + public string Reason { get; set; } + + /// + /// The identifier of the thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + } + + /// + /// Body for 'exited' event. + /// The event indicates that the debuggee has exited and returns its exit code. + /// + public class ExitedEventBody + { + /// + /// The exit code returned from the debuggee. + /// + [JsonProperty("exitCode")] + public int ExitCode { get; set; } + } + + #endregion + + #region Error Response + + /// + /// A structured error message. + /// + public class Message + { + /// + /// Unique identifier for the message. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// A format string for the message. + /// + [JsonProperty("format")] + public string Format { get; set; } + + /// + /// An object used as a dictionary for looking up the variables in the format string. + /// + [JsonProperty("variables", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Variables { get; set; } + + /// + /// If true send to telemetry. + /// + [JsonProperty("sendTelemetry", NullValueHandling = NullValueHandling.Ignore)] + public bool? SendTelemetry { get; set; } + + /// + /// If true show user. + /// + [JsonProperty("showUser", NullValueHandling = NullValueHandling.Ignore)] + public bool? ShowUser { get; set; } + + /// + /// A url where additional information about this message can be found. + /// + [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] + public string Url { get; set; } + + /// + /// A label that is presented to the user as the UI for opening the url. + /// + [JsonProperty("urlLabel", NullValueHandling = NullValueHandling.Ignore)] + public string UrlLabel { get; set; } + } + + /// + /// Body for error responses. + /// + public class ErrorResponseBody + { + /// + /// A structured error message. + /// + [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] + public Message Error { get; set; } + } + + #endregion +} diff --git a/src/Runner.Worker/Dap/DapServer.cs b/src/Runner.Worker/Dap/DapServer.cs new file mode 100644 index 000000000..53479ceef --- /dev/null +++ b/src/Runner.Worker/Dap/DapServer.cs @@ -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 +{ + /// + /// DAP Server interface for handling Debug Adapter Protocol connections. + /// + [ServiceLocator(Default = typeof(DapServer))] + public interface IDapServer : IRunnerService, IDisposable + { + /// + /// Starts the DAP TCP server on the specified port. + /// + /// The port to listen on (default: 4711) + /// Cancellation token + Task StartAsync(int port, CancellationToken cancellationToken); + + /// + /// Blocks until a debug client connects. + /// + /// Cancellation token + Task WaitForConnectionAsync(CancellationToken cancellationToken); + + /// + /// Stops the DAP server and closes all connections. + /// + Task StopAsync(); + + /// + /// Sets the debug session that will handle DAP requests. + /// + /// The debug session + void SetSession(IDapDebugSession session); + + /// + /// Sends an event to the connected debug client. + /// + /// The event to send + void SendEvent(Event evt); + + /// + /// Gets whether a debug client is currently connected. + /// + bool IsConnected { get; } + } + + /// + /// TCP server implementation of the Debug Adapter Protocol. + /// Handles message framing (Content-Length headers) and JSON serialization. + /// + 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 _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(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(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); + } + } + + /// + /// Reads a DAP message from the stream. + /// DAP uses HTTP-like message framing: Content-Length: N\r\n\r\n{json} + /// + private async Task 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; + } + + /// + /// Reads a line from the stream (terminated by \r\n). + /// + private async Task 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); + } + } + + /// + /// Sends a DAP message to the stream with Content-Length framing. + /// Must be called within the _sendLock. + /// + 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; + } + } +}