mirror of
https://github.com/actions/runner.git
synced 2026-03-19 08:48:42 +08:00
Merge DapDebugger and DapDebugSession
This commit is contained in:
@@ -1,905 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores information about a completed step for stack trace display.
|
||||
/// </summary>
|
||||
internal sealed class CompletedStepInfo
|
||||
{
|
||||
public string DisplayName { get; set; }
|
||||
public TaskResult? Result { get; set; }
|
||||
public int FrameId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles step-level breakpoints with next/continue flow control,
|
||||
/// scope/variable inspection, client reconnection, and cancellation
|
||||
/// signal propagation.
|
||||
///
|
||||
/// REPL, step manipulation, and time-travel debugging are intentionally
|
||||
/// deferred to future iterations.
|
||||
/// </summary>
|
||||
public sealed class DapDebugSession : RunnerService, IDapDebugSession
|
||||
{
|
||||
// Thread ID for the single job execution thread
|
||||
private const int JobThreadId = 1;
|
||||
|
||||
// Frame ID for the current step (always 1)
|
||||
private const int CurrentFrameId = 1;
|
||||
|
||||
// Frame IDs for completed steps start at 1000
|
||||
private const int CompletedFrameIdBase = 1000;
|
||||
|
||||
private IDapServer _server;
|
||||
private volatile DapSessionState _state = DapSessionState.WaitingForConnection;
|
||||
|
||||
// Synchronization for step execution
|
||||
private TaskCompletionSource<DapCommand> _commandTcs;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
// Handshake completion — signaled when configurationDone is received
|
||||
private readonly TaskCompletionSource<bool> _handshakeTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// Whether to pause before the next step (set by 'next' command)
|
||||
private bool _pauseOnNextStep = true;
|
||||
|
||||
// Current execution context
|
||||
private IStep _currentStep;
|
||||
private IExecutionContext _jobContext;
|
||||
private int _currentStepIndex;
|
||||
|
||||
// Track completed steps for stack trace
|
||||
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
|
||||
private int _nextCompletedFrameId = CompletedFrameIdBase;
|
||||
|
||||
// Client connection tracking for reconnection support
|
||||
private volatile bool _isClientConnected;
|
||||
|
||||
// Scope/variable inspection provider — reusable by future DAP features
|
||||
private DapVariableProvider _variableProvider;
|
||||
|
||||
// REPL command executor for run() commands
|
||||
private DapReplExecutor _replExecutor;
|
||||
|
||||
public bool IsActive =>
|
||||
_state == DapSessionState.Ready ||
|
||||
_state == DapSessionState.Paused ||
|
||||
_state == DapSessionState.Running;
|
||||
|
||||
public DapSessionState State => _state;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_variableProvider = new DapVariableProvider(hostContext);
|
||||
Trace.Info("DapDebugSession initialized");
|
||||
}
|
||||
|
||||
public void SetDapServer(IDapServer server)
|
||||
{
|
||||
_server = server;
|
||||
_replExecutor = new DapReplExecutor(HostContext, server);
|
||||
Trace.Info("DAP server reference set");
|
||||
}
|
||||
|
||||
public async Task WaitForHandshakeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.Info("Waiting for DAP handshake (configurationDone)...");
|
||||
|
||||
using (cancellationToken.Register(() => _handshakeTcs.TrySetCanceled()))
|
||||
{
|
||||
await _handshakeTcs.Task;
|
||||
}
|
||||
|
||||
Trace.Info("DAP handshake complete, session is ready");
|
||||
}
|
||||
|
||||
#region Message Dispatch
|
||||
|
||||
public async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Request request = null;
|
||||
try
|
||||
{
|
||||
request = JsonConvert.DeserializeObject<Request>(messageJson);
|
||||
if (request == null)
|
||||
{
|
||||
Trace.Warning("Failed to deserialize DAP request");
|
||||
return;
|
||||
}
|
||||
|
||||
Trace.Info("Handling DAP request");
|
||||
|
||||
Response response;
|
||||
if (request.Command == "evaluate")
|
||||
{
|
||||
response = await HandleEvaluateAsync(request, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response = request.Command switch
|
||||
{
|
||||
"initialize" => HandleInitialize(request),
|
||||
"attach" => HandleAttach(request),
|
||||
"configurationDone" => HandleConfigurationDone(request),
|
||||
"disconnect" => HandleDisconnect(request),
|
||||
"threads" => HandleThreads(request),
|
||||
"stackTrace" => HandleStackTrace(request),
|
||||
"scopes" => HandleScopes(request),
|
||||
"variables" => HandleVariables(request),
|
||||
"continue" => HandleContinue(request),
|
||||
"next" => HandleNext(request),
|
||||
"setBreakpoints" => HandleSetBreakpoints(request),
|
||||
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
|
||||
"completions" => HandleCompletions(request),
|
||||
"stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level — use 'next' to advance to the next step.", body: null),
|
||||
"stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level — use 'continue' to resume.", body: null),
|
||||
"stepBack" => CreateResponse(request, false, "Step Back is not yet supported.", body: null),
|
||||
"reverseContinue" => CreateResponse(request, false, "Reverse Continue is not yet supported.", body: null),
|
||||
"pause" => CreateResponse(request, false, "Pause is not supported. The debugger pauses automatically at step boundaries.", body: null),
|
||||
_ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null)
|
||||
};
|
||||
}
|
||||
|
||||
response.RequestSeq = request.Seq;
|
||||
response.Command = request.Command;
|
||||
|
||||
_server?.SendResponse(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Error handling DAP request ({ex.GetType().Name})");
|
||||
if (request != null)
|
||||
{
|
||||
var maskedMessage = HostContext?.SecretMasker?.MaskSecrets(ex.Message) ?? ex.Message;
|
||||
var errorResponse = CreateResponse(request, false, maskedMessage, body: null);
|
||||
errorResponse.RequestSeq = request.Seq;
|
||||
errorResponse.Command = request.Command;
|
||||
_server?.SendResponse(errorResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DAP Request Handlers
|
||||
|
||||
private Response HandleInitialize(Request request)
|
||||
{
|
||||
if (request.Arguments != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
request.Arguments.ToObject<InitializeRequestArguments>();
|
||||
Trace.Info("Initialize arguments received");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to parse initialize arguments ({ex.GetType().Name})");
|
||||
}
|
||||
}
|
||||
|
||||
_state = DapSessionState.Initializing;
|
||||
|
||||
// Build capabilities — MVP only supports configurationDone
|
||||
var capabilities = new Capabilities
|
||||
{
|
||||
SupportsConfigurationDoneRequest = true,
|
||||
SupportsEvaluateForHovers = true,
|
||||
|
||||
// All other capabilities are false for MVP
|
||||
SupportsFunctionBreakpoints = false,
|
||||
SupportsConditionalBreakpoints = false,
|
||||
SupportsStepBack = false,
|
||||
SupportsSetVariable = false,
|
||||
SupportsRestartFrame = false,
|
||||
SupportsGotoTargetsRequest = false,
|
||||
SupportsStepInTargetsRequest = false,
|
||||
SupportsCompletionsRequest = true,
|
||||
SupportsModulesRequest = false,
|
||||
SupportsTerminateRequest = false,
|
||||
SupportTerminateDebuggee = false,
|
||||
SupportsDelayedStackTraceLoading = false,
|
||||
SupportsLoadedSourcesRequest = false,
|
||||
SupportsProgressReporting = false,
|
||||
SupportsRunInTerminalRequest = false,
|
||||
SupportsCancelRequest = false,
|
||||
SupportsExceptionOptions = false,
|
||||
SupportsValueFormattingOptions = false,
|
||||
SupportsExceptionInfoRequest = false,
|
||||
};
|
||||
|
||||
// Send initialized event after a brief delay to ensure the
|
||||
// response is delivered first (DAP spec requirement)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(50);
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "initialized"
|
||||
});
|
||||
Trace.Info("Sent initialized event");
|
||||
});
|
||||
|
||||
Trace.Info("Initialize request handled, capabilities sent");
|
||||
return CreateResponse(request, true, body: capabilities);
|
||||
}
|
||||
|
||||
private Response HandleAttach(Request request)
|
||||
{
|
||||
Trace.Info("Attach request handled");
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
private Response HandleConfigurationDone(Request request)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_state = DapSessionState.Ready;
|
||||
}
|
||||
|
||||
_handshakeTcs.TrySetResult(true);
|
||||
|
||||
Trace.Info("Configuration done, debug session is ready");
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
private Response HandleDisconnect(Request request)
|
||||
{
|
||||
Trace.Info("Disconnect request received");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_state = DapSessionState.Terminated;
|
||||
|
||||
// Release any blocked step execution
|
||||
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||
}
|
||||
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
private Response HandleThreads(Request request)
|
||||
{
|
||||
IExecutionContext jobContext;
|
||||
lock (_stateLock)
|
||||
{
|
||||
jobContext = _jobContext;
|
||||
}
|
||||
|
||||
var threadName = jobContext != null
|
||||
? MaskUserVisibleText($"Job: {jobContext.GetGitHubContext("job") ?? "workflow job"}")
|
||||
: "Job Thread";
|
||||
|
||||
var body = new ThreadsResponseBody
|
||||
{
|
||||
Threads = new List<Thread>
|
||||
{
|
||||
new Thread
|
||||
{
|
||||
Id = JobThreadId,
|
||||
Name = threadName
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
private Response HandleStackTrace(Request request)
|
||||
{
|
||||
IStep currentStep;
|
||||
int currentStepIndex;
|
||||
CompletedStepInfo[] completedSteps;
|
||||
lock (_stateLock)
|
||||
{
|
||||
currentStep = _currentStep;
|
||||
currentStepIndex = _currentStepIndex;
|
||||
completedSteps = _completedSteps.ToArray();
|
||||
}
|
||||
|
||||
var frames = new List<StackFrame>();
|
||||
|
||||
// Add current step as the top frame
|
||||
if (currentStep != null)
|
||||
{
|
||||
var resultIndicator = currentStep.ExecutionContext?.Result != null
|
||||
? $" [{currentStep.ExecutionContext.Result}]"
|
||||
: " [running]";
|
||||
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = CurrentFrameId,
|
||||
Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"),
|
||||
Line = currentStepIndex + 1,
|
||||
Column = 1,
|
||||
PresentationHint = "normal"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = CurrentFrameId,
|
||||
Name = "(no step executing)",
|
||||
Line = 0,
|
||||
Column = 1,
|
||||
PresentationHint = "subtle"
|
||||
});
|
||||
}
|
||||
|
||||
// Add completed steps as additional frames (most recent first)
|
||||
for (int i = completedSteps.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var completedStep = completedSteps[i];
|
||||
var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : "";
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = completedStep.FrameId,
|
||||
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
|
||||
Line = 1,
|
||||
Column = 1,
|
||||
PresentationHint = "subtle"
|
||||
});
|
||||
}
|
||||
|
||||
var body = new StackTraceResponseBody
|
||||
{
|
||||
StackFrames = frames,
|
||||
TotalFrames = frames.Count
|
||||
};
|
||||
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
private Response HandleScopes(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<ScopesArguments>();
|
||||
var frameId = args?.FrameId ?? CurrentFrameId;
|
||||
|
||||
var context = GetExecutionContextForFrame(frameId);
|
||||
if (context == null)
|
||||
{
|
||||
return CreateResponse(request, true, body: new ScopesResponseBody
|
||||
{
|
||||
Scopes = new List<Scope>()
|
||||
});
|
||||
}
|
||||
|
||||
var scopes = _variableProvider.GetScopes(context);
|
||||
return CreateResponse(request, true, body: new ScopesResponseBody
|
||||
{
|
||||
Scopes = scopes
|
||||
});
|
||||
}
|
||||
|
||||
private Response HandleVariables(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<VariablesArguments>();
|
||||
var variablesRef = args?.VariablesReference ?? 0;
|
||||
|
||||
var context = GetCurrentExecutionContext();
|
||||
if (context == null)
|
||||
{
|
||||
return CreateResponse(request, true, body: new VariablesResponseBody
|
||||
{
|
||||
Variables = new List<Variable>()
|
||||
});
|
||||
}
|
||||
|
||||
var variables = _variableProvider.GetVariables(context, variablesRef);
|
||||
return CreateResponse(request, true, body: new VariablesResponseBody
|
||||
{
|
||||
Variables = variables
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Response> HandleEvaluateAsync(Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<EvaluateArguments>();
|
||||
var expression = args?.Expression ?? string.Empty;
|
||||
var frameId = args?.FrameId ?? CurrentFrameId;
|
||||
var evalContext = args?.Context ?? "hover";
|
||||
|
||||
Trace.Info("Evaluate request received");
|
||||
|
||||
// REPL context → route through the DSL dispatcher
|
||||
if (string.Equals(evalContext, "repl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var result = await HandleReplInputAsync(expression, frameId, cancellationToken);
|
||||
return CreateResponse(request, true, body: result);
|
||||
}
|
||||
|
||||
// Watch/hover/variables/clipboard → expression evaluation only
|
||||
var context = GetExecutionContextForFrame(frameId);
|
||||
var evalResult = _variableProvider.EvaluateExpression(expression, context);
|
||||
return CreateResponse(request, true, body: evalResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes REPL input through the DSL parser. If the input matches a
|
||||
/// known command it is dispatched; otherwise it falls through to
|
||||
/// expression evaluation.
|
||||
/// </summary>
|
||||
private async Task<EvaluateResponseBody> HandleReplInputAsync(
|
||||
string input,
|
||||
int frameId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Try to parse as a DSL command
|
||||
var command = DapReplParser.TryParse(input, out var parseError);
|
||||
|
||||
if (parseError != null)
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = parseError,
|
||||
Type = "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
|
||||
if (command != null)
|
||||
{
|
||||
return await DispatchReplCommandAsync(command, frameId, cancellationToken);
|
||||
}
|
||||
|
||||
// Not a DSL command → evaluate as a GitHub Actions expression
|
||||
// (this lets the REPL console also work for ad-hoc expression queries)
|
||||
var context = GetExecutionContextForFrame(frameId);
|
||||
return _variableProvider.EvaluateExpression(input, context);
|
||||
}
|
||||
|
||||
private async Task<EvaluateResponseBody> DispatchReplCommandAsync(
|
||||
DapReplCommand command,
|
||||
int frameId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case HelpCommand help:
|
||||
var helpText = string.IsNullOrEmpty(help.Topic)
|
||||
? DapReplParser.GetGeneralHelp()
|
||||
: help.Topic.Equals("run", StringComparison.OrdinalIgnoreCase)
|
||||
? DapReplParser.GetRunHelp()
|
||||
: $"Unknown help topic: {help.Topic}. Try: help or help(\"run\")";
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = helpText,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
|
||||
case RunCommand run:
|
||||
var context = GetExecutionContextForFrame(frameId);
|
||||
return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken);
|
||||
|
||||
default:
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = $"Unknown command type: {command.GetType().Name}",
|
||||
Type = "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private Response HandleCompletions(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<CompletionsArguments>();
|
||||
var text = args?.Text ?? string.Empty;
|
||||
|
||||
var items = new List<CompletionItem>();
|
||||
|
||||
// Offer DSL commands when the user is starting to type
|
||||
if (string.IsNullOrEmpty(text) || "help".StartsWith(text, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new CompletionItem
|
||||
{
|
||||
Label = "help",
|
||||
Text = "help",
|
||||
Detail = "Show available debug console commands",
|
||||
Type = "function"
|
||||
});
|
||||
}
|
||||
if (string.IsNullOrEmpty(text) || "help(\"run\")".StartsWith(text, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new CompletionItem
|
||||
{
|
||||
Label = "help(\"run\")",
|
||||
Text = "help(\"run\")",
|
||||
Detail = "Show help for the run command",
|
||||
Type = "function"
|
||||
});
|
||||
}
|
||||
if (string.IsNullOrEmpty(text) || "run(".StartsWith(text, System.StringComparison.OrdinalIgnoreCase)
|
||||
|| text.StartsWith("run(", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new CompletionItem
|
||||
{
|
||||
Label = "run(\"...\")",
|
||||
Text = "run(\"",
|
||||
Detail = "Execute a script (like a workflow run step)",
|
||||
Type = "function"
|
||||
});
|
||||
}
|
||||
|
||||
return CreateResponse(request, true, body: new CompletionsResponseBody
|
||||
{
|
||||
Targets = items
|
||||
});
|
||||
}
|
||||
|
||||
private Response HandleContinue(Request request)
|
||||
{
|
||||
Trace.Info("Continue command received");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused)
|
||||
{
|
||||
_state = DapSessionState.Running;
|
||||
_pauseOnNextStep = false;
|
||||
_commandTcs?.TrySetResult(DapCommand.Continue);
|
||||
}
|
||||
}
|
||||
|
||||
return CreateResponse(request, true, body: new ContinueResponseBody
|
||||
{
|
||||
AllThreadsContinued = true
|
||||
});
|
||||
}
|
||||
|
||||
private Response HandleNext(Request request)
|
||||
{
|
||||
Trace.Info("Next (step over) command received");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused)
|
||||
{
|
||||
_state = DapSessionState.Running;
|
||||
_pauseOnNextStep = true;
|
||||
_commandTcs?.TrySetResult(DapCommand.Next);
|
||||
}
|
||||
}
|
||||
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
private Response HandleSetBreakpoints(Request request)
|
||||
{
|
||||
// MVP: acknowledge but don't process breakpoints
|
||||
// All steps pause automatically via _pauseOnNextStep
|
||||
return CreateResponse(request, true, body: new { breakpoints = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
private Response HandleSetExceptionBreakpoints(Request request)
|
||||
{
|
||||
// MVP: acknowledge but don't process exception breakpoints
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Step Lifecycle
|
||||
|
||||
public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken)
|
||||
{
|
||||
bool pauseOnNextStep;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state != DapSessionState.Ready &&
|
||||
_state != DapSessionState.Paused &&
|
||||
_state != DapSessionState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentStep = step;
|
||||
_jobContext = jobContext;
|
||||
_currentStepIndex = _completedSteps.Count;
|
||||
pauseOnNextStep = _pauseOnNextStep;
|
||||
}
|
||||
|
||||
// Reset variable references so stale nested refs from the
|
||||
// previous step are not served to the client.
|
||||
_variableProvider?.Reset();
|
||||
|
||||
// Determine if we should pause
|
||||
bool shouldPause = isFirstStep || pauseOnNextStep;
|
||||
|
||||
if (!shouldPause)
|
||||
{
|
||||
Trace.Info("Step starting without debugger pause");
|
||||
return;
|
||||
}
|
||||
|
||||
var reason = isFirstStep ? "entry" : "step";
|
||||
var description = isFirstStep
|
||||
? $"Stopped at job entry: {step.DisplayName}"
|
||||
: $"Stopped before step: {step.DisplayName}";
|
||||
|
||||
Trace.Info("Step starting with debugger pause");
|
||||
|
||||
// Send stopped event to debugger (only if client is connected)
|
||||
SendStoppedEvent(reason, description);
|
||||
|
||||
// Wait for debugger command
|
||||
await WaitForCommandAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void OnStepCompleted(IStep step)
|
||||
{
|
||||
var result = step.ExecutionContext?.Result;
|
||||
Trace.Info("Step completed");
|
||||
|
||||
// Add to completed steps list for stack trace
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state != DapSessionState.Ready &&
|
||||
_state != DapSessionState.Paused &&
|
||||
_state != DapSessionState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_completedSteps.Add(new CompletedStepInfo
|
||||
{
|
||||
DisplayName = step.DisplayName,
|
||||
Result = result,
|
||||
FrameId = _nextCompletedFrameId++
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void OnJobCompleted()
|
||||
{
|
||||
Trace.Info("Job completed, sending terminated event");
|
||||
|
||||
int exitCode;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Terminated)
|
||||
{
|
||||
Trace.Info("Session already terminated, skipping OnJobCompleted events");
|
||||
return;
|
||||
}
|
||||
_state = DapSessionState.Terminated;
|
||||
exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1;
|
||||
}
|
||||
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "terminated",
|
||||
Body = new TerminatedEventBody()
|
||||
});
|
||||
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "exited",
|
||||
Body = new ExitedEventBody
|
||||
{
|
||||
ExitCode = exitCode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void CancelSession()
|
||||
{
|
||||
Trace.Info("CancelSession called - terminating debug session");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Terminated)
|
||||
{
|
||||
Trace.Info("Session already terminated, ignoring CancelSession");
|
||||
return;
|
||||
}
|
||||
_state = DapSessionState.Terminated;
|
||||
}
|
||||
|
||||
// Send terminated event to debugger so it updates its UI
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "terminated",
|
||||
Body = new TerminatedEventBody()
|
||||
});
|
||||
|
||||
// Send exited event with cancellation exit code (130 = SIGINT convention)
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "exited",
|
||||
Body = new ExitedEventBody { ExitCode = 130 }
|
||||
});
|
||||
|
||||
// Release any pending command waits
|
||||
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||
|
||||
// Release handshake wait if still pending
|
||||
_handshakeTcs.TrySetCanceled();
|
||||
|
||||
Trace.Info("Debug session cancelled");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Client Connection Tracking
|
||||
|
||||
public void HandleClientConnected()
|
||||
{
|
||||
_isClientConnected = true;
|
||||
Trace.Info("Client connected to debug session");
|
||||
|
||||
// If we're paused, re-send the stopped event so the new client
|
||||
// knows the current state (important for reconnection)
|
||||
string description = null;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused && _currentStep != null)
|
||||
{
|
||||
description = $"Stopped before step: {_currentStep.DisplayName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (description != null)
|
||||
{
|
||||
Trace.Info("Re-sending stopped event to reconnected client");
|
||||
SendStoppedEvent("step", description);
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleClientDisconnected()
|
||||
{
|
||||
_isClientConnected = false;
|
||||
Trace.Info("Client disconnected from debug session");
|
||||
|
||||
// Intentionally do NOT release the command TCS here.
|
||||
// The session stays paused, waiting for a client to reconnect.
|
||||
// The server's connection loop will accept a new client and
|
||||
// call HandleClientConnected, which re-sends the stopped event.
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Blocks the step execution thread until a debugger command is received
|
||||
/// or the job is cancelled.
|
||||
/// </summary>
|
||||
private async Task WaitForCommandAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Terminated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_state = DapSessionState.Paused;
|
||||
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
Trace.Info("Waiting for debugger command...");
|
||||
|
||||
using (cancellationToken.Register(() =>
|
||||
{
|
||||
Trace.Info("Job cancellation detected, releasing debugger wait");
|
||||
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||
}))
|
||||
{
|
||||
var command = await _commandTcs.Task;
|
||||
|
||||
Trace.Info("Received debugger command");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state == DapSessionState.Paused)
|
||||
{
|
||||
_state = DapSessionState.Running;
|
||||
}
|
||||
}
|
||||
|
||||
// Send continued event for normal flow commands
|
||||
if (!cancellationToken.IsCancellationRequested &&
|
||||
(command == DapCommand.Continue || command == DapCommand.Next))
|
||||
{
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "continued",
|
||||
Body = new ContinuedEventBody
|
||||
{
|
||||
ThreadId = JobThreadId,
|
||||
AllThreadsContinued = true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the execution context for a given stack frame ID.
|
||||
/// Frame 1 = current step; frames 1000+ = completed steps (no
|
||||
/// context available — those steps have already finished).
|
||||
/// Falls back to the job-level context when no step is active.
|
||||
/// </summary>
|
||||
private IExecutionContext GetExecutionContextForFrame(int frameId)
|
||||
{
|
||||
if (frameId == CurrentFrameId)
|
||||
{
|
||||
return GetCurrentExecutionContext();
|
||||
}
|
||||
|
||||
// Completed-step frames don't carry a live execution context.
|
||||
return null;
|
||||
}
|
||||
|
||||
private IExecutionContext GetCurrentExecutionContext()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _currentStep?.ExecutionContext ?? _jobContext;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a stopped event to the connected client.
|
||||
/// Silently no-ops if no client is connected.
|
||||
/// </summary>
|
||||
private void SendStoppedEvent(string reason, string description)
|
||||
{
|
||||
if (!_isClientConnected)
|
||||
{
|
||||
Trace.Info("No client connected, deferring stopped event");
|
||||
return;
|
||||
}
|
||||
|
||||
_server?.SendEvent(new Event
|
||||
{
|
||||
EventType = "stopped",
|
||||
Body = new StoppedEventBody
|
||||
{
|
||||
Reason = reason,
|
||||
Description = MaskUserVisibleText(description),
|
||||
ThreadId = JobThreadId,
|
||||
AllThreadsStopped = true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private string MaskUserVisibleText(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
return HostContext?.SecretMasker?.MaskSecrets(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a DAP response with common fields pre-populated.
|
||||
/// </summary>
|
||||
private Response CreateResponse(Request request, bool success, string message = null, object body = null)
|
||||
{
|
||||
return new Response
|
||||
{
|
||||
Type = "response",
|
||||
RequestSeq = request.Seq,
|
||||
Command = request.Command,
|
||||
Success = success,
|
||||
Message = success ? null : message,
|
||||
Body = body
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
private TcpListener _listener;
|
||||
private TcpClient _client;
|
||||
private NetworkStream _stream;
|
||||
private IDapDebugSession _session;
|
||||
private IDapDebuggerCallbacks _debugger;
|
||||
private CancellationTokenSource _cts;
|
||||
private TaskCompletionSource<bool> _connectionTcs;
|
||||
private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
|
||||
@@ -38,10 +38,15 @@ namespace GitHub.Runner.Worker.Dap
|
||||
Trace.Info("DapServer initialized");
|
||||
}
|
||||
|
||||
public void SetSession(IDapDebugSession session)
|
||||
void IDapServer.SetDebugger(IDapDebuggerCallbacks debugger)
|
||||
{
|
||||
_session = session;
|
||||
Trace.Info("Debug session set");
|
||||
SetDebugger(debugger);
|
||||
}
|
||||
|
||||
internal void SetDebugger(IDapDebuggerCallbacks debugger)
|
||||
{
|
||||
_debugger = debugger;
|
||||
Trace.Info("Debugger callbacks set");
|
||||
}
|
||||
|
||||
public Task StartAsync(int port, CancellationToken cancellationToken)
|
||||
@@ -95,15 +100,15 @@ namespace GitHub.Runner.Worker.Dap
|
||||
// Signal first connection (no-op on subsequent connections)
|
||||
_connectionTcs.TrySetResult(true);
|
||||
|
||||
// Notify session of new client
|
||||
_session?.HandleClientConnected();
|
||||
// Notify debugger of new client
|
||||
_debugger?.HandleClientConnected();
|
||||
|
||||
// Process messages until client disconnects
|
||||
await ProcessMessagesAsync(cancellationToken);
|
||||
|
||||
// Client disconnected — notify session and clean up
|
||||
// Client disconnected — notify debugger and clean up
|
||||
Trace.Info("Client disconnected, waiting for reconnection...");
|
||||
_session?.HandleClientDisconnected();
|
||||
_debugger?.HandleClientDisconnected();
|
||||
CleanupConnection();
|
||||
}
|
||||
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
|
||||
@@ -243,16 +248,16 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
Trace.Info("Received DAP request");
|
||||
|
||||
if (_session == null)
|
||||
if (_debugger == null)
|
||||
{
|
||||
Trace.Error("No debug session configured");
|
||||
SendErrorResponse(request, "No debug session configured");
|
||||
Trace.Error("No debugger configured");
|
||||
SendErrorResponse(request, "No debugger configured");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass raw JSON to session — session handles deserialization, dispatch,
|
||||
// Pass raw JSON to the debugger — it handles deserialization, dispatch,
|
||||
// and calls back to SendResponse when done.
|
||||
await _session.HandleMessageAsync(json, cancellationToken);
|
||||
await _debugger.HandleMessageAsync(json, cancellationToken);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
@@ -397,7 +402,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
/// Instead, each DAP producer masks user-visible text at the point of
|
||||
/// construction via <see cref="DapVariableProvider.MaskSecrets"/> or the
|
||||
/// runner's SecretMasker directly. See DapVariableProvider, DapReplExecutor,
|
||||
/// and DapDebugSession for the call sites.
|
||||
/// and DapDebugger for the call sites.
|
||||
/// </summary>
|
||||
private void SendMessageInternal(ProtocolMessage message)
|
||||
{
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
public enum DapSessionState
|
||||
{
|
||||
WaitingForConnection,
|
||||
Initializing,
|
||||
Ready,
|
||||
Paused,
|
||||
Running,
|
||||
Terminated
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(DapDebugSession))]
|
||||
public interface IDapDebugSession : IRunnerService
|
||||
{
|
||||
bool IsActive { get; }
|
||||
DapSessionState State { get; }
|
||||
void SetDapServer(IDapServer server);
|
||||
Task WaitForHandshakeAsync(CancellationToken cancellationToken);
|
||||
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken);
|
||||
void OnStepCompleted(IStep step);
|
||||
void OnJobCompleted();
|
||||
void CancelSession();
|
||||
void HandleClientConnected();
|
||||
void HandleClientDisconnected();
|
||||
Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,16 @@ using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
public enum DapSessionState
|
||||
{
|
||||
WaitingForConnection,
|
||||
Initializing,
|
||||
Ready,
|
||||
Paused,
|
||||
Running,
|
||||
Terminated
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(DapDebugger))]
|
||||
public interface IDapDebugger : IRunnerService
|
||||
{
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
using System.Threading;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
[ServiceLocator(Default = typeof(DapServer))]
|
||||
public interface IDapServer : IRunnerService
|
||||
internal interface IDapDebuggerCallbacks
|
||||
{
|
||||
void SetSession(IDapDebugSession session);
|
||||
Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken);
|
||||
void HandleClientConnected();
|
||||
void HandleClientDisconnected();
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(DapServer))]
|
||||
internal interface IDapServer : IRunnerService
|
||||
{
|
||||
void SetDebugger(IDapDebuggerCallbacks debugger);
|
||||
Task StartAsync(int port, CancellationToken cancellationToken);
|
||||
Task WaitForConnectionAsync(CancellationToken cancellationToken);
|
||||
Task StopAsync();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Test")]
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
|
||||
|
||||
@@ -305,7 +305,10 @@ namespace GitHub.Runner.Worker
|
||||
runnerShutdownRegistration = null;
|
||||
}
|
||||
|
||||
await dapDebugger?.OnJobCompletedAsync();
|
||||
if (dapDebugger != null)
|
||||
{
|
||||
await dapDebugger.OnJobCompletedAsync();
|
||||
}
|
||||
|
||||
await ShutdownQueue(throwOnFailure: false);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using GitHub.Runner.Listener.Check;
|
||||
using GitHub.Runner.Listener.Configuration;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Runner.Worker.Container.ContainerHooks;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
using System;
|
||||
@@ -71,6 +72,7 @@ namespace GitHub.Runner.Common.Tests
|
||||
typeof(IDiagnosticLogManager),
|
||||
typeof(IEnvironmentContextData),
|
||||
typeof(IHookArgs),
|
||||
typeof(IDapDebuggerCallbacks),
|
||||
};
|
||||
Validate(
|
||||
assembly: typeof(IStepsRunner).GetTypeInfo().Assembly,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
@@ -21,6 +22,31 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
return hc;
|
||||
}
|
||||
|
||||
private static Mock<IDapServer> CreateServerMock()
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.SendEvent(It.IsAny<Event>()));
|
||||
mockServer.Setup(x => x.SendResponse(It.IsAny<Response>()));
|
||||
return mockServer;
|
||||
}
|
||||
|
||||
private Task CompleteHandshakeAsync()
|
||||
{
|
||||
var configJson = JsonConvert.SerializeObject(new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "configurationDone"
|
||||
});
|
||||
return _debugger.HandleMessageAsync(configJson, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -40,22 +66,13 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
mockServer.Verify(x => x.SetSession(mockSession.Object), Times.Once);
|
||||
mockSession.Verify(x => x.SetDapServer(mockServer.Object), Times.Once);
|
||||
mockServer.Verify(x => x.SetDebugger(It.IsAny<IDapDebuggerCallbacks>()), Times.Once);
|
||||
mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
@@ -70,16 +87,8 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "9999");
|
||||
try
|
||||
@@ -105,16 +114,8 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "not-a-number");
|
||||
try
|
||||
@@ -122,7 +123,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
// Falls back to default port
|
||||
mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
@@ -141,16 +141,8 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "99999");
|
||||
try
|
||||
@@ -158,7 +150,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
// Falls back to default port
|
||||
mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
@@ -173,31 +164,20 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WaitUntilReadyCallsServerAndSession()
|
||||
public async Task WaitUntilReadyCallsServerAndCompletesHandshake()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
await CompleteHandshakeAsync();
|
||||
await _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
|
||||
mockServer.Verify(x => x.WaitForConnectionAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
mockSession.Verify(x => x.WaitForHandshakeAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Equal(DapSessionState.Ready, _debugger.State);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
@@ -210,29 +190,17 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
await CompleteHandshakeAsync();
|
||||
await _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
|
||||
// Trigger cancellation — should call CancelSession on the session
|
||||
cts.Cancel();
|
||||
mockSession.Verify(x => x.CancelSession(), Times.Once);
|
||||
|
||||
Assert.Equal(DapSessionState.Terminated, _debugger.State);
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
@@ -251,181 +219,24 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnStepStartingDelegatesWhenActive()
|
||||
public async Task OnJobCompletedStopsServer()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.IsActive).Returns(true);
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
var mockStep = new Mock<IStep>();
|
||||
var mockJobContext = new Mock<IExecutionContext>();
|
||||
|
||||
await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None);
|
||||
|
||||
mockSession.Verify(x => x.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None), Times.Once);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnStepStartingSkipsWhenNotActive()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.IsActive).Returns(false);
|
||||
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
var mockStep = new Mock<IStep>();
|
||||
var mockJobContext = new Mock<IExecutionContext>();
|
||||
|
||||
await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None);
|
||||
|
||||
mockSession.Verify(x => x.OnStepStartingAsync(It.IsAny<IStep>(), It.IsAny<IExecutionContext>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnStepCompletedDelegatesWhenActive()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.IsActive).Returns(true);
|
||||
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
var mockStep = new Mock<IStep>();
|
||||
_debugger.OnStepCompleted(mockStep.Object);
|
||||
|
||||
mockSession.Verify(x => x.OnStepCompleted(mockStep.Object), Times.Once);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnJobCompletedDelegatesWhenActive()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.IsActive).Returns(true);
|
||||
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
await CompleteHandshakeAsync();
|
||||
await _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
|
||||
await _debugger.OnJobCompletedAsync();
|
||||
|
||||
mockSession.Verify(x => x.OnJobCompleted(), Times.Once);
|
||||
mockServer.Verify(x => x.StopAsync(), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnStepStartingSwallowsSessionException()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.IsActive).Returns(true);
|
||||
mockSession.Setup(x => x.OnStepStartingAsync(It.IsAny<IStep>(), It.IsAny<IExecutionContext>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("test error"));
|
||||
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
var mockStep = new Mock<IStep>();
|
||||
var mockJobContext = new Mock<IExecutionContext>();
|
||||
|
||||
// Should not throw
|
||||
await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void CancelSessionDelegatesToSession()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
// CancelSession before start should not throw
|
||||
_debugger.CancelSession();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -433,7 +244,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
// Should not throw or block
|
||||
await _debugger.WaitUntilReadyAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -455,20 +265,15 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.SendResponse(It.IsAny<Response>()));
|
||||
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
await CompleteHandshakeAsync();
|
||||
await _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
|
||||
// The token passed to WaitForConnectionAsync should be a linked token
|
||||
// (combines job cancellation + internal timeout), not the raw job token
|
||||
Assert.NotEqual(cts.Token, capturedToken);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
@@ -482,8 +287,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// Mock WaitForConnectionAsync to block until its cancellation token fires,
|
||||
// then throw OperationCanceledException — simulating "no client connected"
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
@@ -497,22 +300,15 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var jobCts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(jobCts.Token);
|
||||
|
||||
// Start wait in background
|
||||
var waitTask = _debugger.WaitUntilReadyAsync(jobCts.Token);
|
||||
await Task.Delay(50);
|
||||
Assert.False(waitTask.IsCompleted);
|
||||
|
||||
// The linked token includes the internal timeout CTS.
|
||||
// We can't easily make it fire fast (it uses minutes), but we can
|
||||
// verify the contract: cancelling the job token produces OCE, not TimeoutException.
|
||||
jobCts.Cancel();
|
||||
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask);
|
||||
Assert.IsNotType<TimeoutException>(ex);
|
||||
@@ -528,33 +324,16 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "30");
|
||||
try
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
// The timeout is applied internally — we can verify it worked
|
||||
// by checking the trace output contains the custom value
|
||||
await CompleteHandshakeAsync();
|
||||
await _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
|
||||
// If we got here without exception, the custom timeout was accepted
|
||||
// (it didn't default to something that would fail)
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
finally
|
||||
@@ -571,29 +350,16 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "not-a-number");
|
||||
try
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
await CompleteHandshakeAsync();
|
||||
await _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
|
||||
// Should succeed with default timeout (no crash from bad env var)
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
finally
|
||||
@@ -610,29 +376,16 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var mockServer = new Mock<IDapServer>();
|
||||
mockServer.Setup(x => x.StartAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockServer = CreateServerMock();
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "0");
|
||||
try
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
await CompleteHandshakeAsync();
|
||||
await _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
|
||||
// Zero is not > 0, so falls back to default (should succeed, not throw)
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
finally
|
||||
@@ -662,10 +415,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
mockServer.Setup(x => x.StopAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
|
||||
hc.SetSingleton(mockServer.Object);
|
||||
hc.SetSingleton(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
@@ -673,7 +423,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
var waitTask = _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
|
||||
// Cancel the job token — should surface as OperationCanceledException, NOT TimeoutException
|
||||
cts.Cancel();
|
||||
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask);
|
||||
Assert.IsNotType<TimeoutException>(ex);
|
||||
|
||||
@@ -44,8 +44,8 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
_server.SetSession(mockSession.Object);
|
||||
var mockSession = new Mock<IDapDebuggerCallbacks>();
|
||||
_server.SetDebugger(mockSession.Object);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,11 +182,11 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var messageReceived = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var mockSession = new Mock<IDapDebugSession>();
|
||||
var mockSession = new Mock<IDapDebuggerCallbacks>();
|
||||
mockSession.Setup(x => x.HandleMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, CancellationToken>((json, ct) => messageReceived.TrySetResult(json))
|
||||
.Returns(Task.CompletedTask);
|
||||
_server.SetSession(mockSession.Object);
|
||||
_server.SetDebugger(mockSession.Object);
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await _server.StartAsync(0, cts.Token);
|
||||
|
||||
Reference in New Issue
Block a user