diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs deleted file mode 100644 index 9f6b044a1..000000000 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ /dev/null @@ -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 -{ - /// - /// Stores information about a completed step for stack trace display. - /// - internal sealed class CompletedStepInfo - { - public string DisplayName { get; set; } - public TaskResult? Result { get; set; } - public int FrameId { get; set; } - } - - /// - /// 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. - /// - 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 _commandTcs; - private readonly object _stateLock = new object(); - - // Handshake completion — signaled when configurationDone is received - private readonly TaskCompletionSource _handshakeTcs = new TaskCompletionSource(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 _completedSteps = new List(); - 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(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(); - 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 - { - 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(); - - // 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(); - var frameId = args?.FrameId ?? CurrentFrameId; - - var context = GetExecutionContextForFrame(frameId); - if (context == null) - { - return CreateResponse(request, true, body: new ScopesResponseBody - { - Scopes = new List() - }); - } - - var scopes = _variableProvider.GetScopes(context); - return CreateResponse(request, true, body: new ScopesResponseBody - { - Scopes = scopes - }); - } - - private Response HandleVariables(Request request) - { - var args = request.Arguments?.ToObject(); - var variablesRef = args?.VariablesReference ?? 0; - - var context = GetCurrentExecutionContext(); - if (context == null) - { - return CreateResponse(request, true, body: new VariablesResponseBody - { - Variables = new List() - }); - } - - var variables = _variableProvider.GetVariables(context, variablesRef); - return CreateResponse(request, true, body: new VariablesResponseBody - { - Variables = variables - }); - } - - private async Task HandleEvaluateAsync(Request request, CancellationToken cancellationToken) - { - var args = request.Arguments?.ToObject(); - 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); - } - - /// - /// Routes REPL input through the DSL parser. If the input matches a - /// known command it is dispatched; otherwise it falls through to - /// expression evaluation. - /// - private async Task 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 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(); - var text = args?.Text ?? string.Empty; - - var items = new List(); - - // 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() }); - } - - 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 - - /// - /// Blocks the step execution thread until a debugger command is received - /// or the job is cancelled. - /// - private async Task WaitForCommandAsync(CancellationToken cancellationToken) - { - lock (_stateLock) - { - if (_state == DapSessionState.Terminated) - { - return; - } - _state = DapSessionState.Paused; - _commandTcs = new TaskCompletionSource(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 - } - }); - } - } - } - - /// - /// 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. - /// - 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; - } - } - - /// - /// Sends a stopped event to the connected client. - /// Silently no-ops if no client is connected. - /// - 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); - } - - /// - /// Creates a DAP response with common fields pre-populated. - /// - 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 - } -} diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index aa2048e7d..a10bda19a 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -1,45 +1,99 @@ 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 { /// - /// Single public facade for the Debug Adapter Protocol subsystem. - /// Owns the DapServer and DapDebugSession internally; external callers - /// (JobRunner, StepsRunner) interact only with this class. + /// Stores information about a completed step for stack trace display. /// - public sealed class DapDebugger : RunnerService, IDapDebugger + internal sealed class CompletedStepInfo + { + public string DisplayName { get; set; } + public TaskResult? Result { get; set; } + public int FrameId { get; set; } + } + + /// + /// Single public facade for the Debug Adapter Protocol subsystem. + /// Owns the DapServer internally and handles handshake, step-level + /// pauses, variable inspection, reconnection, and cancellation. + /// + public sealed class DapDebugger : RunnerService, IDapDebugger, IDapDebuggerCallbacks { private const int DefaultPort = 4711; private const int DefaultTimeoutMinutes = 15; private const string PortEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT"; private const string TimeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT"; + // 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 IDapDebugSession _session; + private volatile DapSessionState _state = DapSessionState.WaitingForConnection; private CancellationTokenRegistration? _cancellationRegistration; private volatile bool _started; private bool _isFirstStep = true; - public bool IsActive => _session?.IsActive == true; + // Synchronization for step execution + private TaskCompletionSource _commandTcs; + private readonly object _stateLock = new object(); + + // Handshake completion — signaled when configurationDone is received + private TaskCompletionSource _handshakeTcs = CreateHandshakeCompletionSource(); + + // 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 _completedSteps = new List(); + 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; + + internal DapSessionState State => _state; public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); + _variableProvider = new DapVariableProvider(hostContext); Trace.Info("DapDebugger initialized"); } public async Task StartAsync(CancellationToken cancellationToken) { + ResetSessionState(); var port = ResolvePort(); - _server = HostContext.GetService(); - _session = HostContext.GetService(); - - _server.SetSession(_session); - _session.SetDapServer(_server); + SetDapServer(HostContext.GetService()); + _server.SetDebugger(this); await _server.StartAsync(port, cancellationToken); _started = true; @@ -49,7 +103,7 @@ namespace GitHub.Runner.Worker.Dap public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) { - if (!_started || _server == null || _session == null) + if (!_started || _server == null) { return; } @@ -64,7 +118,7 @@ namespace GitHub.Runner.Worker.Dap await _server.WaitForConnectionAsync(linkedCts.Token); Trace.Info("Debugger client connected."); - await _session.WaitForHandshakeAsync(linkedCts.Token); + await WaitForHandshakeAsync(linkedCts.Token); Trace.Info("DAP handshake complete."); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) @@ -75,17 +129,17 @@ namespace GitHub.Runner.Worker.Dap _cancellationRegistration = cancellationToken.Register(() => { Trace.Info("Job cancellation requested, cancelling debug session."); - _session.CancelSession(); + CancelSession(); }); } public async Task OnJobCompletedAsync() { - if (_session != null && _started) + if (_started) { try { - _session.OnJobCompleted(); + OnJobCompleted(); } catch (Exception ex) { @@ -118,12 +172,55 @@ namespace GitHub.Runner.Worker.Dap } } + lock (_stateLock) + { + if (_started && _state != DapSessionState.Terminated) + { + _state = DapSessionState.Terminated; + } + } + + _isClientConnected = false; + _server = null; + _replExecutor = null; _started = false; } public void CancelSession() { - _session?.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"); } public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken) @@ -137,7 +234,7 @@ namespace GitHub.Runner.Worker.Dap { bool isFirst = _isFirstStep; _isFirstStep = false; - await _session.OnStepStartingAsync(step, jobContext, isFirst, cancellationToken); + await OnStepStartingAsync(step, jobContext, isFirst, cancellationToken); } catch (Exception ex) { @@ -154,7 +251,26 @@ namespace GitHub.Runner.Worker.Dap try { - _session.OnStepCompleted(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++ + }); + } } catch (Exception ex) { @@ -162,6 +278,786 @@ namespace GitHub.Runner.Worker.Dap } } + Task IDapDebuggerCallbacks.HandleMessageAsync(string messageJson, CancellationToken cancellationToken) + { + return HandleMessageAsync(messageJson, cancellationToken); + } + + void IDapDebuggerCallbacks.HandleClientConnected() + { + HandleClientConnected(); + } + + void IDapDebuggerCallbacks.HandleClientDisconnected() + { + HandleClientDisconnected(); + } + + internal async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken) + { + Request request = null; + try + { + request = JsonConvert.DeserializeObject(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); + } + } + } + + internal 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); + } + } + + internal 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. + } + + internal void SetDapServer(IDapServer server) + { + _server = server; + _replExecutor = new DapReplExecutor(HostContext, server); + Trace.Info("DAP server reference set"); + } + + internal 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"); + } + + internal 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); + } + + internal 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 + } + }); + } + + private static TaskCompletionSource CreateHandshakeCompletionSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private void ResetSessionState() + { + lock (_stateLock) + { + _state = DapSessionState.WaitingForConnection; + _commandTcs = null; + _handshakeTcs = CreateHandshakeCompletionSource(); + _pauseOnNextStep = true; + _isFirstStep = true; + _currentStep = null; + _jobContext = null; + _currentStepIndex = 0; + _completedSteps.Clear(); + _nextCompletedFrameId = CompletedFrameIdBase; + _isClientConnected = false; + } + } + + private Response HandleInitialize(Request request) + { + if (request.Arguments != null) + { + try + { + request.Arguments.ToObject(); + Trace.Info("Initialize arguments received"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to parse initialize arguments ({ex.GetType().Name})"); + } + } + + lock (_stateLock) + { + _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 + { + 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(); + + // 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(); + var frameId = args?.FrameId ?? CurrentFrameId; + + var context = GetExecutionContextForFrame(frameId); + if (context == null) + { + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = new List() + }); + } + + var scopes = _variableProvider.GetScopes(context); + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = scopes + }); + } + + private Response HandleVariables(Request request) + { + var args = request.Arguments?.ToObject(); + var variablesRef = args?.VariablesReference ?? 0; + + var context = GetCurrentExecutionContext(); + if (context == null) + { + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = new List() + }); + } + + var variables = _variableProvider.GetVariables(context, variablesRef); + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = variables + }); + } + + private async Task HandleEvaluateAsync(Request request, CancellationToken cancellationToken) + { + var args = request.Arguments?.ToObject(); + 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); + } + + /// + /// Routes REPL input through the DSL parser. If the input matches a + /// known command it is dispatched; otherwise it falls through to + /// expression evaluation. + /// + private async Task 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 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(); + var text = args?.Text ?? string.Empty; + + var items = new List(); + + // Offer DSL commands when the user is starting to type + if (string.IsNullOrEmpty(text) || "help".StartsWith(text, 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, 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, StringComparison.OrdinalIgnoreCase) + || text.StartsWith("run(", 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() }); + } + + private Response HandleSetExceptionBreakpoints(Request request) + { + // MVP: acknowledge but don't process exception breakpoints + return CreateResponse(request, true, body: null); + } + + /// + /// Blocks the step execution thread until a debugger command is received + /// or the job is cancelled. + /// + private async Task WaitForCommandAsync(CancellationToken cancellationToken) + { + lock (_stateLock) + { + if (_state == DapSessionState.Terminated) + { + return; + } + _state = DapSessionState.Paused; + _commandTcs = new TaskCompletionSource(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 + } + }); + } + } + } + + /// + /// 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. + /// + 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; + } + } + + /// + /// Sends a stopped event to the connected client. + /// Silently no-ops if no client is connected. + /// + 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) ?? value; + } + + /// + /// Creates a DAP response with common fields pre-populated. + /// + 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 + }; + } + private int ResolvePort() { var portEnv = Environment.GetEnvironmentVariable(PortEnvironmentVariable); diff --git a/src/Runner.Worker/Dap/DapServer.cs b/src/Runner.Worker/Dap/DapServer.cs index c10094501..13c921c3f 100644 --- a/src/Runner.Worker/Dap/DapServer.cs +++ b/src/Runner.Worker/Dap/DapServer.cs @@ -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 _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 or the /// runner's SecretMasker directly. See DapVariableProvider, DapReplExecutor, - /// and DapDebugSession for the call sites. + /// and DapDebugger for the call sites. /// private void SendMessageInternal(ProtocolMessage message) { diff --git a/src/Runner.Worker/Dap/IDapDebugSession.cs b/src/Runner.Worker/Dap/IDapDebugSession.cs deleted file mode 100644 index 5a45d49da..000000000 --- a/src/Runner.Worker/Dap/IDapDebugSession.cs +++ /dev/null @@ -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); - } -} diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs index 45a85e8fc..52de741c2 100644 --- a/src/Runner.Worker/Dap/IDapDebugger.cs +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -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 { diff --git a/src/Runner.Worker/Dap/IDapServer.cs b/src/Runner.Worker/Dap/IDapServer.cs index a5b879360..53faeaf42 100644 --- a/src/Runner.Worker/Dap/IDapServer.cs +++ b/src/Runner.Worker/Dap/IDapServer.cs @@ -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(); diff --git a/src/Runner.Worker/InternalsVisibleTo.cs b/src/Runner.Worker/InternalsVisibleTo.cs index a825116a6..de556bce3 100644 --- a/src/Runner.Worker/InternalsVisibleTo.cs +++ b/src/Runner.Worker/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Test")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index d5ecdeffd..828ba67a0 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -305,7 +305,10 @@ namespace GitHub.Runner.Worker runnerShutdownRegistration = null; } - await dapDebugger?.OnJobCompletedAsync(); + if (dapDebugger != null) + { + await dapDebugger.OnJobCompletedAsync(); + } await ShutdownQueue(throwOnFailure: false); } diff --git a/src/Test/L0/ServiceInterfacesL0.cs b/src/Test/L0/ServiceInterfacesL0.cs index 59b890285..0746e0433 100644 --- a/src/Test/L0/ServiceInterfacesL0.cs +++ b/src/Test/L0/ServiceInterfacesL0.cs @@ -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, diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs deleted file mode 100644 index c11a5f834..000000000 --- a/src/Test/L0/Worker/DapDebugSessionL0.cs +++ /dev/null @@ -1,1294 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using GitHub.DistributedTask.ObjectTemplating.Tokens; -using GitHub.DistributedTask.Pipelines.ContextData; -using GitHub.DistributedTask.WebApi; -using GitHub.Runner.Worker; -using GitHub.Runner.Worker.Dap; -using Moq; -using Newtonsoft.Json; -using Xunit; - -namespace GitHub.Runner.Common.Tests.Worker -{ - public sealed class DapDebugSessionL0 - { - private static readonly TimeSpan DefaultAsyncTimeout = TimeSpan.FromSeconds(5); - - private DapDebugSession _session; - private Mock _mockServer; - private List _sentEvents; - private List _sentResponses; - private readonly object _eventWaitersLock = new object(); - private List<(Predicate Predicate, TaskCompletionSource Completion)> _eventWaiters; - - private TestHostContext CreateTestContext([CallerMemberName] string testName = "") - { - var hc = new TestHostContext(this, testName); - - _session = new DapDebugSession(); - _session.Initialize(hc); - - _sentEvents = new List(); - _sentResponses = new List(); - _eventWaiters = new List<(Predicate, TaskCompletionSource)>(); - - _mockServer = new Mock(); - _mockServer.Setup(x => x.SendEvent(It.IsAny())) - .Callback(e => - { - List> matchedWaiters = null; - lock (_eventWaitersLock) - { - _sentEvents.Add(e); - for (int i = _eventWaiters.Count - 1; i >= 0; i--) - { - var waiter = _eventWaiters[i]; - if (!waiter.Predicate(e)) - { - continue; - } - - matchedWaiters ??= new List>(); - matchedWaiters.Add(waiter.Completion); - _eventWaiters.RemoveAt(i); - } - } - - if (matchedWaiters == null) - { - return; - } - - foreach (var waiter in matchedWaiters) - { - waiter.TrySetResult(e); - } - }); - _mockServer.Setup(x => x.SendResponse(It.IsAny())) - .Callback(r => _sentResponses.Add(r)); - - _session.SetDapServer(_mockServer.Object); - - return hc; - } - - private Mock CreateMockStep(string displayName, TaskResult? result = null) - { - var mockEc = new Mock(); - mockEc.SetupAllProperties(); - mockEc.Object.Result = result; - - var mockStep = new Mock(); - mockStep.Setup(x => x.DisplayName).Returns(displayName); - mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); - - return mockStep; - } - - private Mock CreateMockJobContext(string jobName = "test-job") - { - var mockJobContext = new Mock(); - mockJobContext.Setup(x => x.GetGitHubContext("job")).Returns(jobName); - return mockJobContext; - } - - private async Task InitializeSessionAsync() - { - var initializedEventTask = WaitForEventAsync(e => e.EventType == "initialized"); - - var initJson = JsonConvert.SerializeObject(new Request - { - Seq = 1, - Type = "request", - Command = "initialize" - }); - await _session.HandleMessageAsync(initJson, CancellationToken.None); - - var attachJson = JsonConvert.SerializeObject(new Request - { - Seq = 2, - Type = "request", - Command = "attach" - }); - await _session.HandleMessageAsync(attachJson, CancellationToken.None); - - var configJson = JsonConvert.SerializeObject(new Request - { - Seq = 3, - Type = "request", - Command = "configurationDone" - }); - await _session.HandleMessageAsync(configJson, CancellationToken.None); - await WaitForTaskAsync(initializedEventTask); - } - - private Task WaitForEventAsync(Predicate predicate) - { - lock (_eventWaitersLock) - { - foreach (var sentEvent in _sentEvents) - { - if (predicate(sentEvent)) - { - return Task.FromResult(sentEvent); - } - } - - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _eventWaiters.Add((predicate, completion)); - return completion.Task; - } - } - - private Task WaitForEventAsync(string eventType) - { - return WaitForEventAsync(e => string.Equals(e.EventType, eventType, StringComparison.Ordinal)); - } - - private static async Task WaitForTaskAsync(Task task) - { - await task.WaitAsync(DefaultAsyncTimeout); - } - - private static async Task WaitForTaskAsync(Task task) - { - return await task.WaitAsync(DefaultAsyncTimeout); - } - - private async Task WaitForStepPauseAsync(Task stepTask) - { - var stoppedEvent = await WaitForTaskAsync(WaitForEventAsync("stopped")); - Assert.False(stepTask.IsCompleted); - Assert.Equal(DapSessionState.Paused, _session.State); - return stoppedEvent; - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void InitialStateIsWaitingForConnection() - { - using (CreateTestContext()) - { - Assert.Equal(DapSessionState.WaitingForConnection, _session.State); - Assert.False(_session.IsActive); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task InitializeHandlerSetsInitializingState() - { - using (CreateTestContext()) - { - var json = JsonConvert.SerializeObject(new Request - { - Seq = 1, - Type = "request", - Command = "initialize" - }); - - await _session.HandleMessageAsync(json, CancellationToken.None); - - Assert.Equal(DapSessionState.Initializing, _session.State); - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ConfigurationDoneSetsReadyState() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - Assert.Equal(DapSessionState.Ready, _session.State); - Assert.True(_session.IsActive); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepStartingPausesAndSendsStoppedEvent() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Checkout code"); - var jobContext = CreateMockJobContext(); - - var cts = new CancellationTokenSource(); - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); - - await WaitForStepPauseAsync(stepTask); - - var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); - Assert.Single(stoppedEvents); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task NextCommandPausesOnFollowingStep() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step1 = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(step1Task); - - var nextJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "next" - }); - await _session.HandleMessageAsync(nextJson, CancellationToken.None); - await WaitForTaskAsync(step1Task); - - _session.OnStepCompleted(step1.Object); - _sentEvents.Clear(); - - var step2 = CreateMockStep("Step 2"); - var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); - - await WaitForStepPauseAsync(step2Task); - - var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); - Assert.Single(stoppedEvents); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 11, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(step2Task); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ContinueCommandSkipsNextPause() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step1 = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(step1Task); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(step1Task); - - _session.OnStepCompleted(step1.Object); - _sentEvents.Clear(); - - var step2 = CreateMockStep("Step 2"); - var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); - - await WaitForTaskAsync(step2Task); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task CancellationUnblocksPausedStep() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var cts = new CancellationTokenSource(); - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); - - await WaitForStepPauseAsync(stepTask); - - cts.Cancel(); - - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task CancelSessionSendsTerminatedAndExitedEvents() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _sentEvents.Clear(); - - _session.CancelSession(); - - Assert.Equal(DapSessionState.Terminated, _session.State); - Assert.False(_session.IsActive); - - var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); - var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); - Assert.Single(terminatedEvents); - Assert.Single(exitedEvents); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task CancelSessionReleasesBlockedStep() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Blocked Step"); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - _session.CancelSession(); - - await WaitForTaskAsync(stepTask); - Assert.Equal(DapSessionState.Terminated, _session.State); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ReconnectionResendStoppedEvent() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); - Assert.Single(stoppedEvents); - - _session.HandleClientDisconnected(); - Assert.Equal(DapSessionState.Paused, _session.State); - - _sentEvents.Clear(); - _session.HandleClientConnected(); - - Assert.Single(_sentEvents); - Assert.Equal("stopped", _sentEvents[0].EventType); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task DisconnectCommandTerminatesSession() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var disconnectJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "disconnect" - }); - await _session.HandleMessageAsync(disconnectJson, CancellationToken.None); - - Assert.Equal(DapSessionState.Terminated, _session.State); - Assert.False(_session.IsActive); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepCompletedTracksCompletedSteps() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var step1 = CreateMockStep("Step 1"); - step1.Object.ExecutionContext.Result = TaskResult.Succeeded; - var jobContext = CreateMockJobContext(); - - var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(step1Task); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(step1Task); - - _session.OnStepCompleted(step1.Object); - - var stackTraceJson = JsonConvert.SerializeObject(new Request - { - Seq = 11, - Type = "request", - Command = "stackTrace" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(stackTraceJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task StoppedEventAndStackTraceMaskSecretStepDisplayName() - { - using (var hc = CreateTestContext()) - { - hc.SecretMasker.AddValue("ghs_step_secret"); - - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Deploy ghs_step_secret"); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - var stoppedEvent = await WaitForStepPauseAsync(stepTask); - - var stoppedBody = Assert.IsType(stoppedEvent.Body); - Assert.Contains(DapVariableProvider.RedactedValue, stoppedBody.Description); - Assert.DoesNotContain("ghs_step_secret", stoppedBody.Description); - - var stackTraceJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "stackTrace" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(stackTraceJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - var stackTraceBody = Assert.IsType(_sentResponses[0].Body); - Assert.Single(stackTraceBody.StackFrames); - Assert.Contains(DapVariableProvider.RedactedValue, stackTraceBody.StackFrames[0].Name); - Assert.DoesNotContain("ghs_step_secret", stackTraceBody.StackFrames[0].Name); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 11, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnJobCompletedSendsTerminatedAndExitedEvents() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _sentEvents.Clear(); - - _session.OnJobCompleted(); - - Assert.Equal(DapSessionState.Terminated, _session.State); - - var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); - var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); - Assert.Single(terminatedEvents); - Assert.Single(exitedEvents); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepStartingNoOpWhenNotActive() - { - using (CreateTestContext()) - { - var step = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var task = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - - await WaitForTaskAsync(task); - - _mockServer.Verify(x => x.SendEvent(It.IsAny()), Times.Never); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ThreadsCommandReturnsJobThread() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var threadsJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "threads" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(threadsJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ThreadsCommandMasksSecretJobName() - { - using (var hc = CreateTestContext()) - { - hc.SecretMasker.AddValue("very-secret-job"); - - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var step = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext("very-secret-job"); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var threadsJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "threads" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(threadsJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - var threadsBody = Assert.IsType(_sentResponses[0].Body); - Assert.Single(threadsBody.Threads); - Assert.Contains(DapVariableProvider.RedactedValue, threadsBody.Threads[0].Name); - Assert.DoesNotContain("very-secret-job", threadsBody.Threads[0].Name); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 11, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task UnsupportedCommandReturnsErrorResponse() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var json = JsonConvert.SerializeObject(new Request - { - Seq = 99, - Type = "request", - Command = "stepIn" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(json, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.False(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task FullFlowInitAttachConfigStepContinueComplete() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - _sentResponses.Clear(); - - Assert.Equal(DapSessionState.Ready, _session.State); - - var step = CreateMockStep("Run tests"); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); - Assert.Single(stoppedEvents); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - - var continuedEvents = _sentEvents.FindAll(e => e.EventType == "continued"); - Assert.Single(continuedEvents); - - step.Object.ExecutionContext.Result = TaskResult.Succeeded; - _session.OnStepCompleted(step.Object); - - _sentEvents.Clear(); - _session.OnJobCompleted(); - - Assert.Equal(DapSessionState.Terminated, _session.State); - var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); - var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); - Assert.Single(terminatedEvents); - Assert.Single(exitedEvents); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task DoubleCancelSessionIsIdempotent() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _sentEvents.Clear(); - - _session.CancelSession(); - _session.CancelSession(); - - Assert.Equal(DapSessionState.Terminated, _session.State); - - var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); - Assert.Single(terminatedEvents); - } - } - - #region Scope inspection integration tests - - private Mock CreateMockStepWithContext( - string displayName, - DictionaryContextData expressionValues, - TaskResult? result = null) - { - var mockEc = new Mock(); - mockEc.SetupAllProperties(); - mockEc.Object.Result = result; - mockEc.Setup(x => x.ExpressionValues).Returns(expressionValues); - - var mockStep = new Mock(); - mockStep.Setup(x => x.DisplayName).Returns(displayName); - mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); - - return mockStep; - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ScopesRequestReturnsScopesFromExecutionContext() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "repository", new StringContextData("owner/repo") } - }; - exprValues["env"] = new DictionaryContextData - { - { "CI", new StringContextData("true") } - }; - - var step = CreateMockStepWithContext("Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var scopesJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "scopes", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new ScopesArguments { FrameId = 1 }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(scopesJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task VariablesRequestReturnsVariablesFromExecutionContext() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["env"] = new DictionaryContextData - { - { "CI", new StringContextData("true") }, - { "HOME", new StringContextData("/home/runner") } - }; - - var step = CreateMockStepWithContext("Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - // "env" is at ScopeNames index 1 → variablesReference = 2 - var variablesJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "variables", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new VariablesArguments { VariablesReference = 2 }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(variablesJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ScopesRequestReturnsEmptyWhenNoStepActive() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var scopesJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "scopes", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new ScopesArguments { FrameId = 1 }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(scopesJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task SecretsValuesAreRedactedThroughSession() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["secrets"] = new DictionaryContextData - { - { "MY_TOKEN", new StringContextData("ghp_verysecret") } - }; - - var step = CreateMockStepWithContext("Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - // "secrets" is at ScopeNames index 5 → variablesReference = 6 - var variablesJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "variables", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new VariablesArguments { VariablesReference = 6 }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(variablesJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Verify the response body actually contains redacted values - var body = _sentResponses[0].Body; - Assert.NotNull(body); - var varsBody = Assert.IsType(body); - Assert.NotEmpty(varsBody.Variables); - foreach (var variable in varsBody.Variables) - { - Assert.Equal(DapVariableProvider.RedactedValue, variable.Value); - Assert.DoesNotContain("ghp_verysecret", variable.Value); - } - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - #endregion - - #region Evaluate request integration tests - - private Mock CreateMockStepWithEvaluatableContext( - TestHostContext hc, - string displayName, - DictionaryContextData expressionValues, - TaskResult? result = null) - { - var mockEc = new Mock(); - mockEc.SetupAllProperties(); - mockEc.Object.Result = result; - mockEc.Setup(x => x.ExpressionValues).Returns(expressionValues); - mockEc.Setup(x => x.ExpressionFunctions) - .Returns(new List()); - mockEc.Setup(x => x.Global).Returns(new GlobalContext - { - FileTable = new List(), - Variables = new Variables(hc, new Dictionary()), - }); - mockEc.Setup(x => x.Write(It.IsAny(), It.IsAny())); - - var mockStep = new Mock(); - mockStep.Setup(x => x.DisplayName).Returns(displayName); - mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); - - return mockStep; - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task EvaluateRequestReturnsResult() - { - using (var hc = CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "repository", new StringContextData("owner/repo") } - }; - - var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "github.repository", - FrameId = 1, - Context = "watch" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task EvaluateRequestReturnsGracefulErrorWhenNoContext() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - // No step is active — evaluate should still succeed with - // a descriptive "no context" message, not an error response. - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "github.repository", - FrameId = 1, - Context = "hover" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task EvaluateRequestWithWrapperSyntax() - { - using (var hc = CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "event_name", new StringContextData("push") } - }; - - var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "${{ github.event_name }}", - FrameId = 1, - Context = "watch" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - #endregion - - #region REPL routing tests - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ReplHelpReturnsHelpText() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "help", - Context = "repl" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ReplExpressionFallsThroughToEvaluation() - { - using (var hc = CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "repository", new StringContextData("owner/repo") } - }; - - var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - // In REPL context, a non-DSL expression should still evaluate - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "github.repository", - Context = "repl" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ReplParseErrorReturnsErrorResult() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - // Malformed run() command - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "run()", - Context = "repl" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - // The response is successful at the DAP level (not an error - // response), but the result body conveys the parse error - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task WatchContextStillEvaluatesExpressions() - { - using (var hc = CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "repository", new StringContextData("owner/repo") } - }; - - var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - // watch context should NOT route through REPL even if input - // looks like a DSL command — it should evaluate as expression - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "github.repository", - Context = "watch" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - #endregion - } -} diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index ddf6f0153..f680294d7 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -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 CreateServerMock() + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.SendEvent(It.IsAny())); + mockServer.Setup(x => x.SendResponse(It.IsAny())); + 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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - + 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()), 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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - + 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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - + 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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - + 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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .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()), Times.Once); - mockSession.Verify(x => x.WaitForHandshakeAsync(It.IsAny()), 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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - 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(); - var mockJobContext = new Mock(); - - 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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - 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(); - var mockJobContext = new Mock(); - - await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None); - - mockSession.Verify(x => x.OnStepStartingAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - - await _debugger.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepCompletedDelegatesWhenActive() - { - using (var hc = CreateTestContext()) - { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - 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(); - _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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - 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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.IsActive).Returns(true); - mockSession.Setup(x => x.OnStepStartingAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .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(); - var mockJobContext = new Mock(); - - // 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(); - var mockSession = new Mock(); - - 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(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .Returns(Task.CompletedTask); + mockServer.Setup(x => x.SendResponse(It.IsAny())); 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(); mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) .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(); - 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(() => waitTask); Assert.IsNotType(ex); @@ -528,33 +324,16 @@ namespace GitHub.Runner.Common.Tests.Worker { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .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(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .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(); - 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(() => waitTask); Assert.IsNotType(ex); diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs index f91e5fa99..1f71d8537 100644 --- a/src/Test/L0/Worker/DapServerL0.cs +++ b/src/Test/L0/Worker/DapServerL0.cs @@ -44,8 +44,8 @@ namespace GitHub.Runner.Common.Tests.Worker { using (CreateTestContext()) { - var mockSession = new Mock(); - _server.SetSession(mockSession.Object); + var mockSession = new Mock(); + _server.SetDebugger(mockSession.Object); } } @@ -182,11 +182,11 @@ namespace GitHub.Runner.Common.Tests.Worker using (var hc = CreateTestContext()) { var messageReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var mockSession = new Mock(); + var mockSession = new Mock(); mockSession.Setup(x => x.HandleMessageAsync(It.IsAny(), It.IsAny())) .Callback((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);