mirror of
https://github.com/actions/runner.git
synced 2026-01-16 00:35:13 +08:00
Compare commits
7 Commits
v2.331.0
...
rentziass/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f45c5d0785 | ||
|
|
7e4f99337f | ||
|
|
186656e153 | ||
|
|
2e02381901 | ||
|
|
a55696a429 | ||
|
|
379ac038b2 | ||
|
|
14e8e1f667 |
299
.opencode/plans/dap-debugging-fixes.md
Normal file
299
.opencode/plans/dap-debugging-fixes.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# DAP Debugging - Bug Fixes and Enhancements
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
**Date:** January 2026
|
||||||
|
**Related:** [dap-debugging.md](./dap-debugging.md)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document tracks bug fixes and enhancements for the DAP debugging implementation after the initial phases were completed.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
### Bug 1: Double Output in REPL Shell Commands
|
||||||
|
|
||||||
|
**Symptom:** Running commands in the REPL shell produces double output - the first one unmasked, the second one with secrets masked.
|
||||||
|
|
||||||
|
**Root Cause:** In `DapDebugSession.ExecuteShellCommandAsync()` (lines 670-773), output is sent to the debugger twice:
|
||||||
|
|
||||||
|
1. **Real-time streaming (unmasked):** Lines 678-712 stream output via DAP `output` events as data arrives from the process - but this output is NOT masked
|
||||||
|
2. **Final result (masked):** Lines 765-769 return the combined output as `EvaluateResponseBody.Result` with secrets masked
|
||||||
|
|
||||||
|
The DAP client displays both the streamed events AND the evaluate response result, causing duplication.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Mask secrets in the real-time streaming output (add `HostContext.SecretMasker.MaskSecrets()` to lines ~690 and ~708)
|
||||||
|
2. Change the final `Result` to only show exit code summary instead of full output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug 2: Expressions Interpreted as Shell Commands
|
||||||
|
|
||||||
|
**Symptom:** Evaluating expressions like `${{github.event_name}} == 'push'` in the Watch/Expressions pane results in them being executed as shell commands instead of being evaluated as GitHub Actions expressions.
|
||||||
|
|
||||||
|
**Root Cause:** In `DapDebugSession.HandleEvaluateAsync()` (line 514), the condition to detect shell commands is too broad:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (evalContext == "repl" || expression.StartsWith("!") || expression.StartsWith("$"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `${{github.event_name}}` starts with `$`, it gets routed to shell execution instead of expression evaluation.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Check for `${{` prefix first - these are always GitHub Actions expressions
|
||||||
|
2. Remove the `expression.StartsWith("$")` condition entirely (ambiguous and unnecessary since REPL context handles shell commands)
|
||||||
|
3. Keep `expression.StartsWith("!")` for explicit shell override in non-REPL contexts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Enhancement: Expression Interpolation in REPL Commands
|
||||||
|
|
||||||
|
**Request:** When running REPL commands like `echo ${{github.event_name}}`, the `${{ }}` expressions should be expanded before shell execution, similar to how `run:` steps work.
|
||||||
|
|
||||||
|
**Approach:** Add a helper method that uses the existing `PipelineTemplateEvaluator` infrastructure to expand expressions in the command string before passing it to the shell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### File: `src/Runner.Worker/Dap/DapDebugSession.cs`
|
||||||
|
|
||||||
|
#### Change 1: Mask Real-Time Streaming Output
|
||||||
|
|
||||||
|
**Location:** Lines ~678-712 (OutputDataReceived and ErrorDataReceived handlers)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```csharp
|
||||||
|
processInvoker.OutputDataReceived += (sender, args) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(args.Data))
|
||||||
|
{
|
||||||
|
output.AppendLine(args.Data);
|
||||||
|
_server?.SendEvent(new Event
|
||||||
|
{
|
||||||
|
EventType = "output",
|
||||||
|
Body = new OutputEventBody
|
||||||
|
{
|
||||||
|
Category = "stdout",
|
||||||
|
Output = args.Data + "\n" // NOT MASKED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```csharp
|
||||||
|
processInvoker.OutputDataReceived += (sender, args) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(args.Data))
|
||||||
|
{
|
||||||
|
output.AppendLine(args.Data);
|
||||||
|
var maskedData = HostContext.SecretMasker.MaskSecrets(args.Data);
|
||||||
|
_server?.SendEvent(new Event
|
||||||
|
{
|
||||||
|
EventType = "output",
|
||||||
|
Body = new OutputEventBody
|
||||||
|
{
|
||||||
|
Category = "stdout",
|
||||||
|
Output = maskedData + "\n"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same change to `ErrorDataReceived` handler (~lines 696-712).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Change 2: Return Only Exit Code in Result
|
||||||
|
|
||||||
|
**Location:** Lines ~767-772 (return statement in ExecuteShellCommandAsync)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```csharp
|
||||||
|
return new EvaluateResponseBody
|
||||||
|
{
|
||||||
|
Result = result.TrimEnd('\r', '\n'),
|
||||||
|
Type = exitCode == 0 ? "string" : "error",
|
||||||
|
VariablesReference = 0
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```csharp
|
||||||
|
return new EvaluateResponseBody
|
||||||
|
{
|
||||||
|
Result = $"(exit code: {exitCode})",
|
||||||
|
Type = exitCode == 0 ? "string" : "error",
|
||||||
|
VariablesReference = 0
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Also remove the result combination logic (lines ~747-762) since we no longer need to build the full result string for the response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Change 3: Fix Expression vs Shell Routing
|
||||||
|
|
||||||
|
**Location:** Lines ~511-536 (HandleEvaluateAsync method)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```csharp
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if this is a REPL/shell command (context: "repl") or starts with shell prefix
|
||||||
|
if (evalContext == "repl" || expression.StartsWith("!") || expression.StartsWith("$"))
|
||||||
|
{
|
||||||
|
// Shell execution mode
|
||||||
|
var command = expression.TrimStart('!', '$').Trim();
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Expression evaluation mode
|
||||||
|
var result = EvaluateExpression(expression, executionContext);
|
||||||
|
return CreateSuccessResponse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```csharp
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// GitHub Actions expressions start with "${{" - always evaluate as expressions
|
||||||
|
if (expression.StartsWith("${{"))
|
||||||
|
{
|
||||||
|
var result = EvaluateExpression(expression, executionContext);
|
||||||
|
return CreateSuccessResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a REPL/shell command:
|
||||||
|
// - context is "repl" (from Debug Console pane)
|
||||||
|
// - expression starts with "!" (explicit shell prefix for Watch pane)
|
||||||
|
if (evalContext == "repl" || expression.StartsWith("!"))
|
||||||
|
{
|
||||||
|
// Shell execution mode
|
||||||
|
var command = expression.TrimStart('!').Trim();
|
||||||
|
if (string.IsNullOrEmpty(command))
|
||||||
|
{
|
||||||
|
return CreateSuccessResponse(new EvaluateResponseBody
|
||||||
|
{
|
||||||
|
Result = "(empty command)",
|
||||||
|
Type = "string",
|
||||||
|
VariablesReference = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await ExecuteShellCommandAsync(command, executionContext);
|
||||||
|
return CreateSuccessResponse(result);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Expression evaluation mode (Watch pane, hover, etc.)
|
||||||
|
var result = EvaluateExpression(expression, executionContext);
|
||||||
|
return CreateSuccessResponse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Change 4: Add Expression Expansion Helper Method
|
||||||
|
|
||||||
|
**Location:** Add new method before `ExecuteShellCommandAsync` (~line 667)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Expands ${{ }} expressions within a command string.
|
||||||
|
/// For example: "echo ${{github.event_name}}" -> "echo push"
|
||||||
|
/// </summary>
|
||||||
|
private string ExpandExpressionsInCommand(string command, IExecutionContext context)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(command) || !command.Contains("${{"))
|
||||||
|
{
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create a StringToken with the command
|
||||||
|
var token = new StringToken(null, null, null, command);
|
||||||
|
|
||||||
|
// Use the template evaluator to expand expressions
|
||||||
|
var templateEvaluator = context.ToPipelineTemplateEvaluator();
|
||||||
|
var result = templateEvaluator.EvaluateStepDisplayName(
|
||||||
|
token,
|
||||||
|
context.ExpressionValues,
|
||||||
|
context.ExpressionFunctions);
|
||||||
|
|
||||||
|
// Mask secrets in the expanded command
|
||||||
|
result = HostContext.SecretMasker.MaskSecrets(result ?? command);
|
||||||
|
|
||||||
|
Trace.Info($"Expanded command: {result}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Info($"Expression expansion failed, using original command: {ex.Message}");
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required import:** Add `using GitHub.DistributedTask.ObjectTemplating.Tokens;` at the top of the file if not already present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Change 5: Use Expression Expansion in Shell Execution
|
||||||
|
|
||||||
|
**Location:** Beginning of `ExecuteShellCommandAsync` method (~line 670)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```csharp
|
||||||
|
private async Task<EvaluateResponseBody> ExecuteShellCommandAsync(string command, IExecutionContext context)
|
||||||
|
{
|
||||||
|
Trace.Info($"Executing shell command: {command}");
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```csharp
|
||||||
|
private async Task<EvaluateResponseBody> ExecuteShellCommandAsync(string command, IExecutionContext context)
|
||||||
|
{
|
||||||
|
// Expand ${{ }} expressions in the command first
|
||||||
|
command = ExpandExpressionsInCommand(command, context);
|
||||||
|
|
||||||
|
Trace.Info($"Executing shell command: {command}");
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DAP Context Reference
|
||||||
|
|
||||||
|
For future reference, these are the DAP evaluate context values:
|
||||||
|
|
||||||
|
| DAP Context | Source UI | Behavior |
|
||||||
|
|-------------|-----------|----------|
|
||||||
|
| `"repl"` | Debug Console / REPL pane | Shell execution (with expression expansion) |
|
||||||
|
| `"watch"` | Watch / Expressions pane | Expression evaluation |
|
||||||
|
| `"hover"` | Editor hover (default) | Expression evaluation |
|
||||||
|
| `"variables"` | Variables pane | Expression evaluation |
|
||||||
|
| `"clipboard"` | Copy to clipboard | Expression evaluation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] REPL command output is masked and appears only once
|
||||||
|
- [ ] REPL command shows exit code in result field
|
||||||
|
- [ ] Expression `${{github.event_name}}` evaluates correctly in Watch pane
|
||||||
|
- [ ] Expression `${{github.event_name}} == 'push'` evaluates correctly
|
||||||
|
- [ ] REPL command `echo ${{github.event_name}}` expands and executes correctly
|
||||||
|
- [ ] REPL command `!ls -la` from Watch pane works (explicit shell prefix)
|
||||||
|
- [ ] Secrets are masked in all outputs (streaming and expanded commands)
|
||||||
536
.opencode/plans/dap-debugging.md
Normal file
536
.opencode/plans/dap-debugging.md
Normal 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)
|
||||||
@@ -1 +1 @@
|
|||||||
2.331.0
|
<Update to ./src/runnerversion when creating release>
|
||||||
|
|||||||
1092
src/Runner.Worker/Dap/DapDebugSession.cs
Normal file
1092
src/Runner.Worker/Dap/DapDebugSession.cs
Normal file
File diff suppressed because it is too large
Load Diff
1125
src/Runner.Worker/Dap/DapMessages.cs
Normal file
1125
src/Runner.Worker/Dap/DapMessages.cs
Normal file
File diff suppressed because it is too large
Load Diff
480
src/Runner.Worker/Dap/DapServer.cs
Normal file
480
src/Runner.Worker/Dap/DapServer.cs
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using GitHub.Runner.Common;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker.Dap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DAP Server interface for handling Debug Adapter Protocol connections.
|
||||||
|
/// </summary>
|
||||||
|
[ServiceLocator(Default = typeof(DapServer))]
|
||||||
|
public interface IDapServer : IRunnerService, IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the DAP TCP server on the specified port.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="port">The port to listen on (default: 4711)</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
Task StartAsync(int port, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blocks until a debug client connects.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
Task WaitForConnectionAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the DAP server and closes all connections.
|
||||||
|
/// </summary>
|
||||||
|
Task StopAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the debug session that will handle DAP requests.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="session">The debug session</param>
|
||||||
|
void SetSession(IDapDebugSession session);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an event to the connected debug client.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="evt">The event to send</param>
|
||||||
|
void SendEvent(Event evt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether a debug client is currently connected.
|
||||||
|
/// </summary>
|
||||||
|
bool IsConnected { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TCP server implementation of the Debug Adapter Protocol.
|
||||||
|
/// Handles message framing (Content-Length headers) and JSON serialization.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DapServer : RunnerService, IDapServer
|
||||||
|
{
|
||||||
|
private const string ContentLengthHeader = "Content-Length: ";
|
||||||
|
private const string HeaderTerminator = "\r\n\r\n";
|
||||||
|
|
||||||
|
private TcpListener _listener;
|
||||||
|
private TcpClient _client;
|
||||||
|
private NetworkStream _stream;
|
||||||
|
private IDapDebugSession _session;
|
||||||
|
private CancellationTokenSource _cts;
|
||||||
|
private Task _messageLoopTask;
|
||||||
|
private TaskCompletionSource<bool> _connectionTcs;
|
||||||
|
private int _nextSeq = 1;
|
||||||
|
private readonly object _sendLock = new object();
|
||||||
|
private bool _disposed = false;
|
||||||
|
|
||||||
|
public bool IsConnected => _client?.Connected == true;
|
||||||
|
|
||||||
|
public override void Initialize(IHostContext hostContext)
|
||||||
|
{
|
||||||
|
base.Initialize(hostContext);
|
||||||
|
Trace.Info("DapServer initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSession(IDapDebugSession session)
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
Trace.Info("Debug session set");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(int port, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Trace.Info($"Starting DAP server on port {port}");
|
||||||
|
|
||||||
|
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
_connectionTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||||
|
_listener.Start();
|
||||||
|
Trace.Info($"DAP server listening on 127.0.0.1:{port}");
|
||||||
|
|
||||||
|
// Start accepting connections in the background
|
||||||
|
_ = AcceptConnectionAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Failed to start DAP server: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptConnectionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Trace.Info("Waiting for debug client connection...");
|
||||||
|
|
||||||
|
// Use cancellation-aware accept
|
||||||
|
using (cancellationToken.Register(() => _listener?.Stop()))
|
||||||
|
{
|
||||||
|
_client = await _listener.AcceptTcpClientAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_stream = _client.GetStream();
|
||||||
|
var remoteEndPoint = _client.Client.RemoteEndPoint;
|
||||||
|
Trace.Info($"Debug client connected from {remoteEndPoint}");
|
||||||
|
|
||||||
|
// Signal that connection is established
|
||||||
|
_connectionTcs.TrySetResult(true);
|
||||||
|
|
||||||
|
// Start processing messages
|
||||||
|
_messageLoopTask = ProcessMessagesAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Expected when cancellation stops the listener
|
||||||
|
Trace.Info("Connection accept cancelled");
|
||||||
|
_connectionTcs.TrySetCanceled();
|
||||||
|
}
|
||||||
|
catch (SocketException ex) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Expected when cancellation stops the listener
|
||||||
|
Trace.Info($"Connection accept cancelled: {ex.Message}");
|
||||||
|
_connectionTcs.TrySetCanceled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Error accepting connection: {ex.Message}");
|
||||||
|
_connectionTcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitForConnectionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Trace.Info("Waiting for debug client to connect...");
|
||||||
|
|
||||||
|
using (cancellationToken.Register(() => _connectionTcs.TrySetCanceled()))
|
||||||
|
{
|
||||||
|
await _connectionTcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace.Info("Debug client connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
Trace.Info("Stopping DAP server");
|
||||||
|
|
||||||
|
_cts?.Cancel();
|
||||||
|
|
||||||
|
// Wait for message loop to complete
|
||||||
|
if (_messageLoopTask != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _messageLoopTask;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning($"Message loop ended with error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up resources
|
||||||
|
_stream?.Close();
|
||||||
|
_client?.Close();
|
||||||
|
_listener?.Stop();
|
||||||
|
|
||||||
|
Trace.Info("DAP server stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SendEvent(Event evt)
|
||||||
|
{
|
||||||
|
if (!IsConnected)
|
||||||
|
{
|
||||||
|
Trace.Warning($"Cannot send event '{evt.EventType}': no client connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_sendLock)
|
||||||
|
{
|
||||||
|
evt.Seq = _nextSeq++;
|
||||||
|
SendMessageInternal(evt);
|
||||||
|
}
|
||||||
|
Trace.Info($"Sent event: {evt.EventType}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Failed to send event '{evt.EventType}': {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMessagesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Trace.Info("Starting DAP message processing loop");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested && IsConnected)
|
||||||
|
{
|
||||||
|
var json = await ReadMessageAsync(cancellationToken);
|
||||||
|
if (json == null)
|
||||||
|
{
|
||||||
|
Trace.Info("Client disconnected (end of stream)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ProcessMessageAsync(json, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Trace.Info("Message processing cancelled");
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
Trace.Info($"Connection closed: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Error in message loop: {ex}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace.Info("DAP message processing loop ended");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMessageAsync(string json, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Request request = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parse the incoming message
|
||||||
|
request = JsonConvert.DeserializeObject<Request>(json);
|
||||||
|
if (request == null || request.Type != "request")
|
||||||
|
{
|
||||||
|
Trace.Warning($"Received non-request message: {json}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace.Info($"Received request: seq={request.Seq}, command={request.Command}");
|
||||||
|
|
||||||
|
// Dispatch to session for handling
|
||||||
|
if (_session == null)
|
||||||
|
{
|
||||||
|
Trace.Error("No debug session configured");
|
||||||
|
SendErrorResponse(request, "No debug session configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _session.HandleRequestAsync(request);
|
||||||
|
response.RequestSeq = request.Seq;
|
||||||
|
response.Command = request.Command;
|
||||||
|
response.Type = "response";
|
||||||
|
|
||||||
|
lock (_sendLock)
|
||||||
|
{
|
||||||
|
response.Seq = _nextSeq++;
|
||||||
|
SendMessageInternal(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace.Info($"Sent response: seq={response.Seq}, command={response.Command}, success={response.Success}");
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Failed to parse request: {ex.Message}");
|
||||||
|
Trace.Error($"JSON: {json}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Error processing request: {ex}");
|
||||||
|
if (request != null)
|
||||||
|
{
|
||||||
|
SendErrorResponse(request, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendErrorResponse(Request request, string message)
|
||||||
|
{
|
||||||
|
var response = new Response
|
||||||
|
{
|
||||||
|
Type = "response",
|
||||||
|
RequestSeq = request.Seq,
|
||||||
|
Command = request.Command,
|
||||||
|
Success = false,
|
||||||
|
Message = message,
|
||||||
|
Body = new ErrorResponseBody
|
||||||
|
{
|
||||||
|
Error = new Message
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Format = message,
|
||||||
|
ShowUser = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lock (_sendLock)
|
||||||
|
{
|
||||||
|
response.Seq = _nextSeq++;
|
||||||
|
SendMessageInternal(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a DAP message from the stream.
|
||||||
|
/// DAP uses HTTP-like message framing: Content-Length: N\r\n\r\n{json}
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> ReadMessageAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Read headers until we find Content-Length
|
||||||
|
var headerBuilder = new StringBuilder();
|
||||||
|
int contentLength = -1;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var line = await ReadLineAsync(cancellationToken);
|
||||||
|
if (line == null)
|
||||||
|
{
|
||||||
|
// End of stream
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Length == 0)
|
||||||
|
{
|
||||||
|
// Empty line marks end of headers
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
headerBuilder.AppendLine(line);
|
||||||
|
|
||||||
|
if (line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var lengthStr = line.Substring(ContentLengthHeader.Length).Trim();
|
||||||
|
if (!int.TryParse(lengthStr, out contentLength))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Invalid Content-Length: {lengthStr}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLength < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Missing Content-Length header");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the JSON body
|
||||||
|
var buffer = new byte[contentLength];
|
||||||
|
var totalRead = 0;
|
||||||
|
while (totalRead < contentLength)
|
||||||
|
{
|
||||||
|
var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken);
|
||||||
|
if (bytesRead == 0)
|
||||||
|
{
|
||||||
|
throw new EndOfStreamException("Connection closed while reading message body");
|
||||||
|
}
|
||||||
|
totalRead += bytesRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = Encoding.UTF8.GetString(buffer);
|
||||||
|
Trace.Verbose($"Received: {json}");
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a line from the stream (terminated by \r\n).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> ReadLineAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var lineBuilder = new StringBuilder();
|
||||||
|
var buffer = new byte[1];
|
||||||
|
var previousWasCr = false;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken);
|
||||||
|
if (bytesRead == 0)
|
||||||
|
{
|
||||||
|
// End of stream
|
||||||
|
return lineBuilder.Length > 0 ? lineBuilder.ToString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var c = (char)buffer[0];
|
||||||
|
|
||||||
|
if (c == '\n' && previousWasCr)
|
||||||
|
{
|
||||||
|
// Found \r\n, return the line (without the \r)
|
||||||
|
if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r')
|
||||||
|
{
|
||||||
|
lineBuilder.Length--;
|
||||||
|
}
|
||||||
|
return lineBuilder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
previousWasCr = (c == '\r');
|
||||||
|
lineBuilder.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a DAP message to the stream with Content-Length framing.
|
||||||
|
/// Must be called within the _sendLock.
|
||||||
|
/// </summary>
|
||||||
|
private void SendMessageInternal(ProtocolMessage message)
|
||||||
|
{
|
||||||
|
var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings
|
||||||
|
{
|
||||||
|
NullValueHandling = NullValueHandling.Ignore
|
||||||
|
});
|
||||||
|
|
||||||
|
var bodyBytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n";
|
||||||
|
var headerBytes = Encoding.UTF8.GetBytes(header);
|
||||||
|
|
||||||
|
_stream.Write(headerBytes, 0, headerBytes.Length);
|
||||||
|
_stream.Write(bodyBytes, 0, bodyBytes.Length);
|
||||||
|
_stream.Flush();
|
||||||
|
|
||||||
|
Trace.Verbose($"Sent: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_stream?.Dispose();
|
||||||
|
_client?.Dispose();
|
||||||
|
_listener?.Stop();
|
||||||
|
_cts?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
293
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal file
293
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
|
|||||||
using GitHub.Runner.Common;
|
using GitHub.Runner.Common;
|
||||||
using GitHub.Runner.Common.Util;
|
using GitHub.Runner.Common.Util;
|
||||||
using GitHub.Runner.Sdk;
|
using GitHub.Runner.Sdk;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
using GitHub.Services.Common;
|
using GitHub.Services.Common;
|
||||||
using GitHub.Services.WebApi;
|
using GitHub.Services.WebApi;
|
||||||
using Sdk.RSWebApi.Contracts;
|
using Sdk.RSWebApi.Contracts;
|
||||||
@@ -112,6 +113,7 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
IExecutionContext jobContext = null;
|
IExecutionContext jobContext = null;
|
||||||
CancellationTokenRegistration? runnerShutdownRegistration = null;
|
CancellationTokenRegistration? runnerShutdownRegistration = null;
|
||||||
|
IDapServer dapServer = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Create the job execution context.
|
// Create the job execution context.
|
||||||
@@ -159,6 +161,47 @@ namespace GitHub.Runner.Worker
|
|||||||
if (jobContext.Global.WriteDebug)
|
if (jobContext.Global.WriteDebug)
|
||||||
{
|
{
|
||||||
jobContext.SetRunnerContext("debug", "1");
|
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);
|
jobContext.SetRunnerContext("os", VarUtil.OS);
|
||||||
@@ -259,6 +302,20 @@ namespace GitHub.Runner.Worker
|
|||||||
runnerShutdownRegistration = null;
|
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);
|
await ShutdownQueue(throwOnFailure: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -10,6 +10,7 @@ using GitHub.DistributedTask.WebApi;
|
|||||||
using GitHub.Runner.Common;
|
using GitHub.Runner.Common;
|
||||||
using GitHub.Runner.Common.Util;
|
using GitHub.Runner.Common.Util;
|
||||||
using GitHub.Runner.Sdk;
|
using GitHub.Runner.Sdk;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
using GitHub.Runner.Worker.Expressions;
|
using GitHub.Runner.Worker.Expressions;
|
||||||
|
|
||||||
namespace GitHub.Runner.Worker
|
namespace GitHub.Runner.Worker
|
||||||
@@ -50,6 +51,12 @@ namespace GitHub.Runner.Worker
|
|||||||
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
|
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
|
||||||
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||||
bool checkPostJobActions = false;
|
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)
|
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
|
||||||
{
|
{
|
||||||
if (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
|
// Evaluate condition
|
||||||
step.ExecutionContext.Debug($"Evaluating condition for step: '{step.DisplayName}'");
|
step.ExecutionContext.Debug($"Evaluating condition for step: '{step.DisplayName}'");
|
||||||
var conditionTraceWriter = new ConditionTraceWriter(Trace, step.ExecutionContext);
|
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}'.");
|
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}'");
|
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)
|
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)
|
||||||
|
|||||||
Reference in New Issue
Block a user