diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 89879e2ff..ec3adaf3e 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -243,6 +243,24 @@ namespace GitHub.Runner.Worker.Dap { if (_state != DapSessionState.NotStarted) { + // Pause so the user can inspect final job state before we tear down. + if (IsActive && _pauseOnNextStep) + { + try + { + var cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None; + + Trace.Info("Job completed — pausing for inspection"); + SendStoppedEvent("completed", "Job completed — inspect variables before the session ends."); + + await WaitForCommandAsync(cancellationToken); + } + catch (Exception ex) + { + Trace.Warning($"DAP job-completed pause error: {ex.Message}"); + } + } + try { OnJobCompleted(); diff --git a/src/Runner.Worker/JobExtension.cs b/src/Runner.Worker/JobExtension.cs index efcba0f53..991e4b37b 100644 --- a/src/Runner.Worker/JobExtension.cs +++ b/src/Runner.Worker/JobExtension.cs @@ -16,6 +16,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Services.Common; using Newtonsoft.Json; using Pipelines = GitHub.DistributedTask.Pipelines; @@ -50,6 +51,7 @@ namespace GitHub.Runner.Worker private Task _diskSpaceCheckTask = null; private CancellationTokenSource _serviceConnectivityCheckToken = new(); private Task _serviceConnectivityCheckTask = null; + private IDapDebugger _dapDebugger; // Download all required actions. // Make sure all condition inputs are valid. @@ -481,6 +483,53 @@ namespace GitHub.Runner.Worker Trace.Info($"Start checking service connectivity in background."); _serviceConnectivityCheckTask = CheckServiceConnectivityAsync(context, _serviceConnectivityCheckToken.Token); + // Start the DAP debugger and wait for a client connection inside + // "Set up job" so the step stays in-progress while we wait. + if (jobContext.Global.Debugger?.Enabled == true) + { + Trace.Info("Debugger enabled — starting inside Set up job"); + context.Output("Starting debugger…"); + + try + { + _dapDebugger = HostContext.GetService(); + await _dapDebugger.StartAsync(jobContext); + } + catch (Exception ex) + { + Trace.Error($"Failed to start DAP debugger: {ex.Message}"); + AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); + context.Error("Failed to start debugger."); + context.Result = TaskResult.Failed; + throw; + } + + context.Output("Waiting for debugger client to connect…"); + + try + { + await _dapDebugger.WaitUntilReadyAsync(); + context.Output("Debugger connected."); + AddDebuggerConnectionTelemetry(jobContext, "Connected"); + } + catch (OperationCanceledException) when (jobContext.CancellationToken.IsCancellationRequested) + { + Trace.Info("Job was cancelled before debugger client connected."); + AddDebuggerConnectionTelemetry(jobContext, "Canceled"); + context.Error("Job was cancelled before debugger client connected."); + context.Result = TaskResult.Canceled; + throw; + } + catch (Exception ex) + { + Trace.Error($"DAP debugger failed to become ready: {ex.Message}"); + AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); + context.Error("The debugger failed to start or no debugger client connected in time."); + context.Result = TaskResult.Failed; + throw; + } + } + return steps; } catch (OperationCanceledException ex) when (jobContext.CancellationToken.IsCancellationRequested) @@ -507,6 +556,15 @@ namespace GitHub.Runner.Worker } } + private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result) + { + jobContext.Global.JobTelemetry.Add(new JobTelemetry + { + Type = JobTelemetryType.General, + Message = $"DebuggerConnectionResult: {result}" + }); + } + private string GetWorkflowReference(IDictionary variables) { var reference = ""; @@ -782,6 +840,22 @@ namespace GitHub.Runner.Worker } finally { + // Pause for debugger inspection, then tear down the DAP session. + // OnJobCompletedAsync pauses first, then sends terminated/exited + // events and stops the transport. + if (_dapDebugger != null) + { + context.Output("Job completed — pausing for debugger inspection. Press continue to finish."); + try + { + await _dapDebugger.OnJobCompletedAsync(); + } + catch (Exception ex) + { + Trace.Warning($"DAP debugger cleanup error: {ex.Message}"); + } + } + context.Debug("Finishing: Complete job"); context.Complete(); } diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 2ccad0c0c..8308b4342 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -13,7 +13,6 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; -using GitHub.Runner.Worker.Dap; using GitHub.Services.Common; using GitHub.Services.WebApi; using Sdk.RSWebApi.Contracts; @@ -29,7 +28,6 @@ namespace GitHub.Runner.Worker public sealed class JobRunner : RunnerService, IJobRunner { - private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult"; private IJobServerQueue _jobServerQueue; private RunnerSettings _runnerSettings; private ITempDirectoryManager _tempDirectoryManager; @@ -114,7 +112,6 @@ namespace GitHub.Runner.Worker IExecutionContext jobContext = null; CancellationTokenRegistration? runnerShutdownRegistration = null; - IDapDebugger dapDebugger = null; try { // Create the job execution context. @@ -181,25 +178,6 @@ namespace GitHub.Runner.Worker _tempDirectoryManager = HostContext.GetService(); _tempDirectoryManager.InitializeTempDirectory(jobContext); - // Setup the debugger - if (jobContext.Global.Debugger?.Enabled == true) - { - Trace.Info("Debugger enabled for this job run"); - - try - { - dapDebugger = HostContext.GetService(); - await dapDebugger.StartAsync(jobContext); - } - catch (Exception ex) - { - Trace.Error($"Failed to start DAP debugger: {ex.Message}"); - AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); - jobContext.Error("Failed to start debugger."); - return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); - } - } - // Get the job extension. Trace.Info("Getting job extension."); @@ -242,33 +220,6 @@ namespace GitHub.Runner.Worker await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000)); } - // Wait for DAP debugger client connection and handshake after "Set up job" - // so the job page shows the setup step before we block on the debugger - if (dapDebugger != null) - { - try - { - await dapDebugger.WaitUntilReadyAsync(); - AddDebuggerConnectionTelemetry(jobContext, "Connected"); - } - catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) - { - Trace.Info("Job was cancelled before debugger client connected."); - AddDebuggerConnectionTelemetry(jobContext, "Canceled"); - jobContext.Error("Job was cancelled before debugger client connected."); - return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled); - } - catch (Exception ex) - { - Trace.Error($"DAP debugger failed to become ready: {ex.Message}"); - AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); - - // If debugging was requested but the debugger is not available, fail the job - jobContext.Error("The debugger failed to start or no debugger client connected in time."); - return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); - } - } - // Run all job steps Trace.Info("Run all job steps."); var stepsRunner = HostContext.GetService(); @@ -309,11 +260,6 @@ namespace GitHub.Runner.Worker runnerShutdownRegistration = null; } - if (dapDebugger != null) - { - await dapDebugger.OnJobCompletedAsync(); - } - await ShutdownQueue(throwOnFailure: false); } } @@ -495,15 +441,6 @@ namespace GitHub.Runner.Worker throw new AggregateException(exceptions); } - private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result) - { - jobContext.Global.JobTelemetry.Add(new JobTelemetry - { - Type = JobTelemetryType.General, - Message = $"{DebuggerConnectionTelemetryPrefix}: {result}" - }); - } - private void MaskTelemetrySecrets(List jobTelemetry) { foreach (var telemetryItem in jobTelemetry) diff --git a/src/Test/L0/Worker/JobExtensionL0.cs b/src/Test/L0/Worker/JobExtensionL0.cs index 40c495a81..b9cfc7e26 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -760,5 +760,125 @@ namespace GitHub.Runner.Common.Tests.Worker Environment.SetEnvironmentVariable("RUNNER_ENVIRONMENT", null); Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", null); } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DebuggerStartedInSetupJobWhenEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + var jobExtension = new JobExtension(); + jobExtension.Initialize(hc); + + // Enable debugger on the message + _message.EnableDebugger = true; + _message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo + { + TunnelId = "test-tunnel", + ClusterId = "test-cluster", + HostToken = "test-token", + Port = 9229 + }; + + // Re-initialize the execution context so it picks up debugger config + _jobEc = new Runner.Worker.ExecutionContext(); + _jobEc.Initialize(hc); + _jobEc.InitializeJob(_message, _tokenSource.Token); + + // Set up mock debugger + var mockDebugger = new Mock(); + mockDebugger.Setup(x => x.StartAsync(It.IsAny())).Returns(Task.CompletedTask); + mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask); + hc.SetSingleton(mockDebugger.Object); + + _actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(Task.FromResult(new PrepareResult(new List(), new Dictionary()))); + + List result = await jobExtension.InitializeJob(_jobEc, _message); + + // Verify DAP debugger was started and waited on + mockDebugger.Verify(x => x.StartAsync(It.IsAny()), Times.Once); + mockDebugger.Verify(x => x.WaitUntilReadyAsync(), Times.Once); + + // Verify steps are still returned correctly + Assert.Equal(5, result.Count); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DebuggerNotStartedInSetupJobWhenDisabled() + { + using (TestHostContext hc = CreateTestContext()) + { + var jobExtension = new JobExtension(); + jobExtension.Initialize(hc); + + // Debugger NOT enabled on the message — should not be started + + // Set up mock debugger (should NOT be called) + var mockDebugger = new Mock(); + hc.SetSingleton(mockDebugger.Object); + + _actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(Task.FromResult(new PrepareResult(new List(), new Dictionary()))); + + List result = await jobExtension.InitializeJob(_jobEc, _message); + + // Verify DAP debugger was NOT started during setup job + mockDebugger.Verify(x => x.StartAsync(It.IsAny()), Times.Never); + mockDebugger.Verify(x => x.WaitUntilReadyAsync(), Times.Never); + + Assert.Equal(5, result.Count); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DebuggerCleanedUpInFinalizeJob() + { + using (TestHostContext hc = CreateTestContext()) + { + var jobExtension = new JobExtension(); + jobExtension.Initialize(hc); + + // Enable debugger on the message + _message.EnableDebugger = true; + _message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo + { + TunnelId = "test-tunnel", + ClusterId = "test-cluster", + HostToken = "test-token", + Port = 9229 + }; + + // Re-initialize the execution context so it picks up debugger config + _jobEc = new Runner.Worker.ExecutionContext(); + _jobEc.Initialize(hc); + _jobEc.InitializeJob(_message, _tokenSource.Token); + + // Set up mock debugger + var mockDebugger = new Mock(); + mockDebugger.Setup(x => x.StartAsync(It.IsAny())).Returns(Task.CompletedTask); + mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask); + mockDebugger.Setup(x => x.OnJobCompletedAsync()).Returns(Task.CompletedTask); + hc.SetSingleton(mockDebugger.Object); + + _actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(Task.FromResult(new PrepareResult(new List(), new Dictionary()))); + + // Run InitializeJob to start the debugger + await jobExtension.InitializeJob(_jobEc, _message); + + // Run FinalizeJob — should pause (inside OnJobCompletedAsync) then clean up + await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow); + + // Verify OnJobCompletedAsync was called (it handles pause + cleanup) + mockDebugger.Verify(x => x.OnJobCompletedAsync(), Times.Once); + } + } } }