Compare commits

..

5 Commits

Author SHA1 Message Date
Francesco Renzi
186656e153 Phase 5 done 2026-01-14 21:24:59 +00:00
Francesco Renzi
2e02381901 phase 4 complete 2026-01-14 21:14:10 +00:00
Francesco Renzi
a55696a429 Phase 3 complete 2026-01-14 21:05:55 +00:00
Francesco Renzi
379ac038b2 Phase 2 done 2026-01-14 20:34:11 +00:00
Francesco Renzi
14e8e1f667 Phase 1 done 2026-01-14 20:21:52 +00:00
8 changed files with 3618 additions and 3 deletions

View File

@@ -0,0 +1,536 @@
# DAP-Based Debugging for GitHub Actions Runner
**Status:** Draft
**Author:** GitHub Actions Team
**Date:** January 2026
## Progress Checklist
- [x] **Phase 1:** DAP Protocol Infrastructure (DapMessages.cs, DapServer.cs, basic DapDebugSession.cs)
- [x] **Phase 2:** Debug Session Logic (DapVariableProvider.cs, variable inspection, step history tracking)
- [x] **Phase 3:** StepsRunner Integration (pause hooks before/after step execution)
- [x] **Phase 4:** Expression Evaluation & Shell (REPL)
- [x] **Phase 5:** Startup Integration (JobRunner.cs modifications)
## 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 EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context)
{
// Strip ${{ }} wrapper if present
var expr = expression.Trim();
if (expr.StartsWith("${{") && expr.EndsWith("}}"))
{
expr = expr.Substring(3, expr.Length - 5).Trim();
}
var expressionToken = new BasicExpressionToken(fileId: null, line: null, column: null, expression: expr);
var templateEvaluator = context.ToPipelineTemplateEvaluator();
var result = templateEvaluator.EvaluateStepDisplayName(
expressionToken,
context.ExpressionValues,
context.ExpressionFunctions
);
// Mask secrets and determine type
result = HostContext.SecretMasker.MaskSecrets(result ?? "null");
return new EvaluateResponseBody
{
Result = result,
Type = DetermineResultType(result),
VariablesReference = 0
};
}
```
**Supported expression formats:**
- Plain expression: `github.ref`, `steps.build.outputs.result`
- Wrapped expression: `${{ github.event.pull_request.title }}`
#### 4.2 Shell Execution (REPL)
Shell execution is triggered when:
1. The evaluate request has `context: "repl"`, OR
2. The expression starts with `!` (e.g., `!ls -la`), OR
3. The expression starts with `$` followed by a shell command (e.g., `$env`)
**Usage examples in debug console:**
```
!ls -la # List files in workspace
!env | grep GITHUB # Show GitHub environment variables
!cat $GITHUB_EVENT_PATH # View the event payload
!echo ${{ github.ref }} # Mix shell and expression (evaluated first)
```
**Implementation:**
```csharp
private async Task<EvaluateResponseBody> ExecuteShellCommandAsync(string command, IExecutionContext context)
{
var processInvoker = HostContext.CreateService<IProcessInvoker>();
var output = new StringBuilder();
processInvoker.OutputDataReceived += (sender, args) =>
{
output.AppendLine(args.Data);
// Stream to client in real-time via DAP output event
_server?.SendEvent(new Event
{
EventType = "output",
Body = new OutputEventBody { Category = "stdout", Output = args.Data + "\n" }
});
};
processInvoker.ErrorDataReceived += (sender, args) =>
{
_server?.SendEvent(new Event
{
EventType = "output",
Body = new OutputEventBody { Category = "stderr", Output = args.Data + "\n" }
});
};
// Build environment from job context (includes GITHUB_*, env context, prepend path)
var env = BuildShellEnvironment(context);
var workDir = GetWorkingDirectory(context); // Uses github.workspace
var (shell, shellArgs) = GetDefaultShell(); // Platform-specific detection
int exitCode = await processInvoker.ExecuteAsync(
workingDirectory: workDir,
fileName: shell,
arguments: string.Format(shellArgs, command),
environment: env,
requireExitCodeZero: false,
cancellationToken: CancellationToken.None
);
return new EvaluateResponseBody
{
Result = HostContext.SecretMasker.MaskSecrets(output.ToString()),
Type = exitCode == 0 ? "string" : "error",
VariablesReference = 0
};
}
```
**Shell detection by platform:**
| Platform | Priority | Shell | Arguments |
|----------|----------|-------|-----------|
| Windows | 1 | `pwsh` | `-NoProfile -NonInteractive -Command "{0}"` |
| Windows | 2 | `powershell` | `-NoProfile -NonInteractive -Command "{0}"` |
| Windows | 3 | `cmd.exe` | `/C "{0}"` |
| Unix | 1 | `bash` | `-c "{0}"` |
| Unix | 2 | `sh` | `-c "{0}"` |
**Environment built for shell commands:**
- Current system environment variables
- GitHub Actions context variables (from `IEnvironmentContextData.GetRuntimeEnvironmentVariables()`)
- Prepend path from job context added to `PATH`
### 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)

View File

@@ -1 +1 @@
2.331.0
<Update to ./src/runnerversion when creating release>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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;
}
}
}

View File

@@ -0,0 +1,293 @@
using System;
using System.Collections.Generic;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Provides DAP variable information from the execution context.
/// Maps workflow contexts (github, env, runner, job, steps, secrets) to DAP scopes and variables.
/// </summary>
public sealed class DapVariableProvider
{
// Well-known scope names that map to top-level contexts
private static readonly string[] ScopeNames = { "github", "env", "runner", "job", "steps", "secrets", "inputs", "vars", "matrix", "needs" };
// Reserved variable reference ranges for scopes (1-100)
private const int ScopeReferenceBase = 1;
private const int ScopeReferenceMax = 100;
// Dynamic variable references start after scope range
private const int DynamicReferenceBase = 101;
private readonly IHostContext _hostContext;
private readonly Dictionary<int, (PipelineContextData Data, string Path)> _variableReferences = new();
private int _nextVariableReference = DynamicReferenceBase;
public DapVariableProvider(IHostContext hostContext)
{
_hostContext = hostContext;
}
/// <summary>
/// Resets the variable reference state. Call this when the execution context changes.
/// </summary>
public void Reset()
{
_variableReferences.Clear();
_nextVariableReference = DynamicReferenceBase;
}
/// <summary>
/// Gets the list of scopes for a given execution context.
/// Each scope represents a top-level context like 'github', 'env', etc.
/// </summary>
public List<Scope> GetScopes(IExecutionContext context, int frameId)
{
var scopes = new List<Scope>();
if (context?.ExpressionValues == null)
{
return scopes;
}
for (int i = 0; i < ScopeNames.Length; i++)
{
var scopeName = ScopeNames[i];
if (context.ExpressionValues.TryGetValue(scopeName, out var value) && value != null)
{
var variablesRef = ScopeReferenceBase + i;
var scope = new Scope
{
Name = scopeName,
VariablesReference = variablesRef,
Expensive = false,
// Secrets get a special presentation hint
PresentationHint = scopeName == "secrets" ? "registers" : null
};
// Count named variables if it's a dictionary
if (value is DictionaryContextData dict)
{
scope.NamedVariables = dict.Count;
}
else if (value is CaseSensitiveDictionaryContextData csDict)
{
scope.NamedVariables = csDict.Count;
}
scopes.Add(scope);
}
}
return scopes;
}
/// <summary>
/// Gets variables for a given variable reference.
/// </summary>
public List<Variable> GetVariables(IExecutionContext context, int variablesReference)
{
var variables = new List<Variable>();
if (context?.ExpressionValues == null)
{
return variables;
}
PipelineContextData data = null;
string basePath = null;
bool isSecretsScope = false;
// Check if this is a scope reference (1-100)
if (variablesReference >= ScopeReferenceBase && variablesReference <= ScopeReferenceMax)
{
var scopeIndex = variablesReference - ScopeReferenceBase;
if (scopeIndex < ScopeNames.Length)
{
var scopeName = ScopeNames[scopeIndex];
isSecretsScope = scopeName == "secrets";
if (context.ExpressionValues.TryGetValue(scopeName, out data))
{
basePath = scopeName;
}
}
}
// Check dynamic references
else if (_variableReferences.TryGetValue(variablesReference, out var refData))
{
data = refData.Data;
basePath = refData.Path;
// Check if we're inside the secrets scope
isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true;
}
if (data == null)
{
return variables;
}
// Convert the data to variables
ConvertToVariables(data, basePath, isSecretsScope, variables);
return variables;
}
/// <summary>
/// Converts PipelineContextData to DAP Variable objects.
/// </summary>
private void ConvertToVariables(PipelineContextData data, string basePath, bool isSecretsScope, List<Variable> variables)
{
switch (data)
{
case DictionaryContextData dict:
ConvertDictionaryToVariables(dict, basePath, isSecretsScope, variables);
break;
case CaseSensitiveDictionaryContextData csDict:
ConvertCaseSensitiveDictionaryToVariables(csDict, basePath, isSecretsScope, variables);
break;
case ArrayContextData array:
ConvertArrayToVariables(array, basePath, isSecretsScope, variables);
break;
default:
// Scalar value - shouldn't typically get here for a container
break;
}
}
private void ConvertDictionaryToVariables(DictionaryContextData dict, string basePath, bool isSecretsScope, List<Variable> variables)
{
foreach (var pair in dict)
{
var variable = CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope);
variables.Add(variable);
}
}
private void ConvertCaseSensitiveDictionaryToVariables(CaseSensitiveDictionaryContextData dict, string basePath, bool isSecretsScope, List<Variable> variables)
{
foreach (var pair in dict)
{
var variable = CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope);
variables.Add(variable);
}
}
private void ConvertArrayToVariables(ArrayContextData array, string basePath, bool isSecretsScope, List<Variable> variables)
{
for (int i = 0; i < array.Count; i++)
{
var item = array[i];
var variable = CreateVariable($"[{i}]", item, basePath, isSecretsScope);
variable.Name = $"[{i}]";
variables.Add(variable);
}
}
private Variable CreateVariable(string name, PipelineContextData value, string basePath, bool isSecretsScope)
{
var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}";
var variable = new Variable
{
Name = name,
EvaluateName = $"${{{{ {childPath} }}}}"
};
if (value == null)
{
variable.Value = "null";
variable.Type = "null";
variable.VariablesReference = 0;
return variable;
}
switch (value)
{
case StringContextData str:
if (isSecretsScope)
{
// Always mask secrets regardless of value
variable.Value = "[REDACTED]";
}
else
{
// Mask any secret values that might be in non-secret contexts
variable.Value = MaskSecrets(str.Value);
}
variable.Type = "string";
variable.VariablesReference = 0;
break;
case NumberContextData num:
variable.Value = num.ToString();
variable.Type = "number";
variable.VariablesReference = 0;
break;
case BooleanContextData boolVal:
variable.Value = boolVal.Value ? "true" : "false";
variable.Type = "boolean";
variable.VariablesReference = 0;
break;
case DictionaryContextData dict:
variable.Value = $"Object ({dict.Count} properties)";
variable.Type = "object";
variable.VariablesReference = RegisterVariableReference(dict, childPath);
variable.NamedVariables = dict.Count;
break;
case CaseSensitiveDictionaryContextData csDict:
variable.Value = $"Object ({csDict.Count} properties)";
variable.Type = "object";
variable.VariablesReference = RegisterVariableReference(csDict, childPath);
variable.NamedVariables = csDict.Count;
break;
case ArrayContextData array:
variable.Value = $"Array ({array.Count} items)";
variable.Type = "array";
variable.VariablesReference = RegisterVariableReference(array, childPath);
variable.IndexedVariables = array.Count;
break;
default:
// Unknown type - convert to string representation
var rawValue = value.ToJToken()?.ToString() ?? "unknown";
variable.Value = MaskSecrets(rawValue);
variable.Type = value.GetType().Name;
variable.VariablesReference = 0;
break;
}
return variable;
}
/// <summary>
/// Registers a nested variable reference and returns its ID.
/// </summary>
private int RegisterVariableReference(PipelineContextData data, string path)
{
var reference = _nextVariableReference++;
_variableReferences[reference] = (data, path);
return reference;
}
/// <summary>
/// Masks any secret values in the string using the host context's secret masker.
/// </summary>
private string MaskSecrets(string value)
{
if (string.IsNullOrEmpty(value))
{
return value ?? string.Empty;
}
return _hostContext.SecretMasker.MaskSecrets(value);
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Dap;
using GitHub.Services.Common;
using GitHub.Services.WebApi;
using Sdk.RSWebApi.Contracts;
@@ -112,6 +113,7 @@ namespace GitHub.Runner.Worker
IExecutionContext jobContext = null;
CancellationTokenRegistration? runnerShutdownRegistration = null;
IDapServer dapServer = null;
try
{
// Create the job execution context.
@@ -159,6 +161,47 @@ namespace GitHub.Runner.Worker
if (jobContext.Global.WriteDebug)
{
jobContext.SetRunnerContext("debug", "1");
// Start DAP server for interactive debugging
// This allows debugging workflow jobs with DAP-compatible editors (nvim-dap, VS Code, etc.)
try
{
var port = 4711;
var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT");
if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort))
{
port = customPort;
}
dapServer = HostContext.GetService<IDapServer>();
var debugSession = HostContext.GetService<IDapDebugSession>();
// Wire up the server and session
dapServer.SetSession(debugSession);
debugSession.SetDapServer(dapServer);
await dapServer.StartAsync(port, jobRequestCancellationToken);
Trace.Info($"DAP server listening on port {port}");
jobContext.Output($"DAP debugger waiting for connection on port {port}...");
jobContext.Output($"Connect your DAP client (nvim-dap, VS Code, etc.) to attach to this job.");
// Block until debugger connects
await dapServer.WaitForConnectionAsync(jobRequestCancellationToken);
Trace.Info("DAP client connected, continuing job execution");
jobContext.Output("Debugger connected. Job execution will pause before each step.");
}
catch (OperationCanceledException)
{
// Job was cancelled before debugger connected
Trace.Info("Job cancelled while waiting for DAP client connection");
}
catch (Exception ex)
{
// Log but don't fail the job if DAP server fails to start
Trace.Warning($"Failed to start DAP server: {ex.Message}");
jobContext.Warning($"DAP debugging unavailable: {ex.Message}");
dapServer = null;
}
}
jobContext.SetRunnerContext("os", VarUtil.OS);
@@ -259,6 +302,20 @@ namespace GitHub.Runner.Worker
runnerShutdownRegistration = null;
}
// Stop DAP server if it was started
if (dapServer != null)
{
try
{
Trace.Info("Stopping DAP server");
await dapServer.StopAsync();
}
catch (Exception ex)
{
Trace.Warning($"Error stopping DAP server: {ex.Message}");
}
}
await ShutdownQueue(throwOnFailure: false);
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -10,6 +10,7 @@ using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Expressions;
namespace GitHub.Runner.Worker
@@ -50,6 +51,12 @@ namespace GitHub.Runner.Worker
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
bool checkPostJobActions = false;
// Get debug session for DAP debugging support
// The session's IsActive property determines if debugging is actually enabled
var debugSession = HostContext.GetService<IDapDebugSession>();
bool isFirstStep = true;
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
{
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
@@ -181,6 +188,14 @@ namespace GitHub.Runner.Worker
}
}
// Pause for DAP debugger BEFORE step execution
// This happens after expression values are set up so the debugger can inspect variables
if (debugSession?.IsActive == true)
{
await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep);
isFirstStep = false;
}
// Evaluate condition
step.ExecutionContext.Debug($"Evaluating condition for step: '{step.DisplayName}'");
var conditionTraceWriter = new ConditionTraceWriter(Trace, step.ExecutionContext);
@@ -253,8 +268,17 @@ namespace GitHub.Runner.Worker
Trace.Info($"No need for updating job result with current step result '{step.ExecutionContext.Result}'.");
}
// Notify DAP debugger AFTER step execution
if (debugSession?.IsActive == true)
{
debugSession.OnStepCompleted(step);
}
Trace.Info($"Current state: job state = '{jobContext.Result}'");
}
// Notify DAP debugger that the job has completed
debugSession?.OnJobCompleted();
}
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)