From ba8b0a51ab5803074a7de9ce13bb37b7cb6b3c1c Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 3 Jun 2026 14:01:04 +0100 Subject: [PATCH 1/4] Add DAP execution view integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugger.cs | 290 +++++++++++++++++++++++++- src/Runner.Worker/Dap/DapMessages.cs | 40 ++++ src/Runner.Worker/Dap/IDapDebugger.cs | 5 +- src/Runner.Worker/ExecutionContext.cs | 13 ++ src/Runner.Worker/JobRunner.cs | 7 + 5 files changed, 348 insertions(+), 7 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index d5dba2fe25d..8c6a6611041 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -16,6 +16,7 @@ using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Newtonsoft.Json; +using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Worker.Dap { @@ -27,6 +28,7 @@ internal sealed class CompletedStepInfo public string DisplayName { get; set; } public TaskResult? Result { get; set; } public int FrameId { get; set; } + public int? SourceLine { get; set; } } /// @@ -54,6 +56,9 @@ public sealed class DapDebugger : RunnerService, IDapDebugger // Frame IDs for completed steps start at 1000 private const int _completedFrameIdBase = 1000; + // Stable session-scoped source reference for the synthesized job step list. + private const int _jobStepsSourceReference = 1; + private TcpListener _listener; private TcpClient _client; private NetworkStream _stream; @@ -98,6 +103,8 @@ public sealed class DapDebugger : RunnerService, IDapDebugger // Track completed steps for stack trace private readonly List _completedSteps = new List(); private int _nextCompletedFrameId = _completedFrameIdBase; + private JobExecutionView _jobStepsSource; + private bool _jobCompleted; // Client connection tracking for reconnection support private volatile bool _isClientConnected; @@ -240,6 +247,179 @@ public async Task WaitUntilReadyAsync() } } + public Task OnJobStepsInitializedAsync(IEnumerable steps, IEnumerable initialPostSteps) + { + if (!IsActive) + { + return Task.CompletedTask; + } + + try + { + IExecutionContext jobContext; + lock (_stateLock) + { + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return Task.CompletedTask; + } + + jobContext = _jobContext; + } + + var stepList = steps?.Where(step => step != null).ToList() ?? new List(); + var initialPostStepList = initialPostSteps?.Where(step => step != null).ToList() ?? new List(); + var jobId = jobContext?.GetGitHubContext("job"); + var snapshot = new JobExecutionView( + jobId, + stepList, + initialPostStepList, + PredictPostSteps(jobContext, stepList, initialPostStepList)); + + lock (_stateLock) + { + _jobStepsSource = snapshot; + _jobCompleted = false; + } + Trace.Info("DAP job steps source initialized"); + } + catch (Exception ex) + { + Trace.Warning("DAP OnJobStepsInitialized error."); + Trace.Error(ex); + } + + return Task.CompletedTask; + } + + public void OnPostStepRegistered(IStep step) + { + try + { + if (step is IActionRunner postRunner && postRunner.Action != null) + { + JobExecutionView snapshot; + lock (_stateLock) + { + snapshot = _jobStepsSource; + } + + var line = snapshot?.TryClaimPredictedStep(MatchKeyFor(postRunner.Action.Id), step); + if (line.HasValue) + { + Trace.Info($"DAP job steps source claimed predicted post step '{step.DisplayName}' at line {line.Value}."); + } + else + { + Trace.Info($"DAP job steps source had no predicted line for post step '{step.DisplayName}'."); + } + } + } + catch (Exception ex) + { + Trace.Warning("DAP OnPostStepRegistered error."); + Trace.Error(ex); + } + } + + private IReadOnlyList PredictPostSteps( + IExecutionContext jobContext, + IReadOnlyList steps, + IReadOnlyList initialPostSteps) + { + if (jobContext == null || steps == null || steps.Count == 0) + { + return Array.Empty(); + } + + IActionManager actionManager; + try + { + actionManager = HostContext.GetService(); + } + catch (Exception ex) + { + Trace.Info($"DAP post-step predictor skipped because IActionManager is unavailable ({ex.Message})."); + return Array.Empty(); + } + + var predictions = new List(); + var seenActionIds = new HashSet(); + if (initialPostSteps != null) + { + foreach (var postStep in initialPostSteps) + { + if (postStep is IActionRunner postRunner && postRunner.Action != null) + { + seenActionIds.Add(postRunner.Action.Id); + } + } + } + + foreach (var step in steps) + { + if (step is not IActionRunner runner || + runner.Stage == ActionRunStage.Post || + runner.Action == null) + { + continue; + } + + var action = runner.Action; + if (action.Reference is not Pipelines.RepositoryPathReference repoRef) + { + continue; + } + + if (!seenActionIds.Add(action.Id)) + { + continue; + } + + Definition definition; + try + { + definition = actionManager.LoadAction(jobContext, action); + } + catch (Exception ex) + { + Trace.Info($"DAP post-step predictor could not load action '{repoRef.Name}' ({ex.Message})."); + continue; + } + + if (definition?.Data?.Execution?.HasPost != true) + { + continue; + } + + predictions.Add(new JobExecutionView.PredictedPostStep( + GetPostDisplayName(runner), + MatchKeyFor(action.Id))); + } + + predictions.Reverse(); + return predictions; + } + + private static string GetPostDisplayName(IActionRunner runner) + { + var displayName = string.IsNullOrEmpty(runner.DisplayName) ? "step" : runner.DisplayName; + if (runner.Stage == ActionRunStage.Pre && + displayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase)) + { + displayName = displayName.Substring("Pre ".Length); + } + + return $"Post {displayName}"; + } + + private static string MatchKeyFor(Guid actionId) + { + return $"post:{actionId:N}"; + } + public async Task OnJobCompletedAsync() { if (_state != DapSessionState.NotStarted) @@ -253,6 +433,11 @@ public async Task OnJobCompletedAsync() if (_jobContext != null) { Trace.Info("Job completed — pausing for inspection"); + lock (_stateLock) + { + _jobCompleted = true; + } + SendStoppedEvent("completed", "Job completed — inspect variables before the session ends."); await WaitForCommandAsync(_jobContext.CancellationToken); @@ -359,6 +544,7 @@ public async Task StopAsync() { _state = DapSessionState.Terminated; } + _jobStepsSource = null; } _isClientConnected = false; @@ -417,7 +603,8 @@ public void OnStepCompleted(IStep step) { DisplayName = step.DisplayName, Result = result, - FrameId = _nextCompletedFrameId++ + FrameId = _nextCompletedFrameId++, + SourceLine = _jobStepsSource?.TryGetLineForStep(step) }); } } @@ -468,6 +655,7 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can "next" => HandleNext(request), "setBreakpoints" => HandleSetBreakpoints(request), "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), + "source" => HandleSource(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), @@ -857,6 +1045,7 @@ internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) { bool pauseOnNextStep; CancellationToken cancellationToken; + lock (_stateLock) { if (_state != DapSessionState.Ready && @@ -868,6 +1057,7 @@ internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) _currentStep = step; _currentStepIndex = _completedSteps.Count; + _jobCompleted = false; pauseOnNextStep = _pauseOnNextStep; cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None; } @@ -1050,29 +1240,46 @@ private Response HandleThreads(Request request) private Response HandleStackTrace(Request request) { IStep currentStep; - int currentStepIndex; CompletedStepInfo[] completedSteps; + JobExecutionView jobStepsSource; + bool jobCompleted; lock (_stateLock) { currentStep = _currentStep; - currentStepIndex = _currentStepIndex; completedSteps = _completedSteps.ToArray(); + jobStepsSource = _jobStepsSource; + jobCompleted = _jobCompleted; } var frames = new List(); + var source = jobStepsSource != null ? BuildJobStepsSource(jobStepsSource) : null; // Add current step as the top frame - if (currentStep != null) + if (jobCompleted && jobStepsSource != null) + { + frames.Add(new StackFrame + { + Id = _currentFrameId, + Name = "Complete job [completed]", + Source = source, + Line = jobStepsSource.CompleteJobLine, + Column = 1, + PresentationHint = "normal" + }); + } + else if (currentStep != null) { var resultIndicator = currentStep.ExecutionContext?.Result != null ? $" [{currentStep.ExecutionContext.Result}]" : " [running]"; + var currentSourceLine = jobStepsSource?.TryGetLineForStep(currentStep); frames.Add(new StackFrame { Id = _currentFrameId, Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), - Line = currentStepIndex + 1, + Source = currentSourceLine.HasValue ? source : null, + Line = currentSourceLine ?? 0, Column = 1, PresentationHint = "normal" }); @@ -1098,7 +1305,8 @@ private Response HandleStackTrace(Request request) { Id = completedStep.FrameId, Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"), - Line = 1, + Source = completedStep.SourceLine.HasValue ? source : null, + Line = completedStep.SourceLine ?? 0, Column = 1, PresentationHint = "subtle" }); @@ -1113,6 +1321,76 @@ private Response HandleStackTrace(Request request) return CreateResponse(request, true, body: body); } + private Source BuildJobStepsSource(JobExecutionView snapshot) + { + return new Source + { + Name = MaskUserVisibleText(snapshot.SourceFileName), + Path = MaskUserVisibleText($"{SanitizeSourcePathSegment(snapshot.JobId)}/{snapshot.SourceFileName}"), + SourceReference = _jobStepsSourceReference, + PresentationHint = "normal" + }; + } + + private static string SanitizeSourcePathSegment(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "job"; + } + + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(char.IsControl(character) || character == '/' || character == '\\' + ? '_' + : character); + } + + return builder.Length == 0 ? "job" : builder.ToString(); + } + + internal Response HandleSource(Request request) + { + SourceArguments args; + try + { + args = request.Arguments?.ToObject(); + } + catch (Exception ex) + { + Trace.Warning($"Failed to parse source arguments: {ex.GetType().Name}"); + return CreateResponse(request, false, "Invalid source arguments.", body: null); + } + + var sourceReference = args?.Source?.SourceReference ?? args?.SourceReference; + if (!sourceReference.HasValue) + { + return CreateResponse(request, false, "Missing source reference.", body: null); + } + + JobExecutionView snapshot; + lock (_stateLock) + { + snapshot = _jobStepsSource; + } + + if (snapshot == null) + { + return CreateResponse(request, false, "Job steps source not yet available.", body: null); + } + + if (sourceReference.Value != _jobStepsSourceReference) + { + return CreateResponse(request, false, $"Unknown source reference: {sourceReference.Value}.", body: null); + } + + return CreateResponse(request, true, body: new SourceResponseBody + { + Content = MaskUserVisibleText(snapshot.Content) + }); + } + private Response HandleScopes(Request request) { var args = request.Arguments?.ToObject(); diff --git a/src/Runner.Worker/Dap/DapMessages.cs b/src/Runner.Worker/Dap/DapMessages.cs index 53cd7a436b8..53cdd5d64b5 100644 --- a/src/Runner.Worker/Dap/DapMessages.cs +++ b/src/Runner.Worker/Dap/DapMessages.cs @@ -537,6 +537,46 @@ public class StackTraceResponseBody #endregion + #region Source Request/Response + + /// + /// Arguments for 'source' request. + /// + public class SourceArguments + { + /// + /// Source descriptor. Some clients send sourceReference only here. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The reference to the source. + /// + [JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)] + public int? SourceReference { get; set; } + } + + /// + /// Response body for 'source' request. + /// + public class SourceResponseBody + { + /// + /// Content of the source as a string. + /// + [JsonProperty("content")] + public string Content { get; set; } + + /// + /// Optional content type / mime type of the source. + /// + [JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)] + public string MimeType { get; set; } + } + + #endregion + #region Scopes Request/Response /// diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs index 07ebcab6926..f19351bf31c 100644 --- a/src/Runner.Worker/Dap/IDapDebugger.cs +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; using GitHub.Runner.Common; namespace GitHub.Runner.Worker.Dap @@ -19,6 +20,8 @@ public interface IDapDebugger : IRunnerService { Task StartAsync(IExecutionContext jobContext); Task WaitUntilReadyAsync(); + Task OnJobStepsInitializedAsync(IEnumerable steps, IEnumerable initialPostSteps); + void OnPostStepRegistered(IStep step); Task OnStepStartingAsync(IStep step); void OnStepCompleted(IStep step); Task OnJobCompletedAsync(); diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index b753c152b07..f48dc16f197 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -343,6 +343,19 @@ public void RegisterPostJobStep(IStep step) step.ExecutionContext.StepTelemetry.Action = step.DisplayName.ToLowerInvariant().Replace(' ', '_'); } Root.PostJobSteps.Push(step); + + if (Root.Global.Debugger?.Enabled == true) + { + try + { + HostContext.GetService().OnPostStepRegistered(step); + } + catch (Exception ex) + { + Trace.Warning("Failed to notify DAP debugger about registered post job step."); + Trace.Error(ex); + } + } } public IExecutionContext CreateChild( diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 8308b434220..3c4799a29e0 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -13,6 +13,7 @@ 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; @@ -230,6 +231,12 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat jobContext.JobSteps.Enqueue(step); } + if (jobContext.Global.Debugger?.Enabled == true) + { + var dapDebugger = HostContext.GetService(); + await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps); + } + await stepsRunner.RunAsync(jobContext); } catch (Exception ex) From 7991e62cea876e636bd6d973cec64631f5e96989 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 3 Jun 2026 14:01:04 +0100 Subject: [PATCH 2/4] Add DAP source message coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Test/L0/Worker/DapMessagesL0.cs | 32 ++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Test/L0/Worker/DapMessagesL0.cs b/src/Test/L0/Worker/DapMessagesL0.cs index 1b828571736..aeff0a0aa1c 100644 --- a/src/Test/L0/Worker/DapMessagesL0.cs +++ b/src/Test/L0/Worker/DapMessagesL0.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; @@ -171,6 +171,36 @@ public void StackFrameSerialization() Assert.Equal("normal", deserialized.PresentationHint); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SourceRequestAndResponseSerialization() + { + var args = new SourceArguments + { + Source = new Source + { + SourceReference = 1 + } + }; + + var argsJson = JsonConvert.SerializeObject(args); + var deserializedArgs = JsonConvert.DeserializeObject(argsJson); + + Assert.Equal(1, deserializedArgs.Source.SourceReference); + + var body = new SourceResponseBody + { + Content = "pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Complete job\"\n" + }; + + var bodyJson = JsonConvert.SerializeObject(body); + var deserializedBody = JsonConvert.DeserializeObject(bodyJson); + + Assert.Equal("pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Complete job\"\n", deserializedBody.Content); + Assert.Null(deserializedBody.MimeType); + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] From 81425e45fbd6a864b69414aa264f7216a5755254 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 3 Jun 2026 14:01:04 +0100 Subject: [PATCH 3/4] Add DAP execution view coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Test/L0/Worker/DapDebuggerL0.cs | 478 +++++++++++++++++++++++++++- 1 file changed, 477 insertions(+), 1 deletion(-) diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index 92efbaa00c9..8215f82b1fb 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -11,7 +11,9 @@ using GitHub.Runner.Worker; using GitHub.Runner.Worker.Dap; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Xunit; +using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Common.Tests.Worker { @@ -255,6 +257,78 @@ private static Mock CreateJobContextWithTunnel(CancellationTo return jobContext; } + private static Mock CreateStep(string displayName, ActionRunStage? stage = null) + { + var step = new Mock(); + step.Setup(s => s.DisplayName).Returns(displayName); + if (stage.HasValue) + { + var executionContext = new Mock(); + executionContext.Setup(x => x.Stage).Returns(stage.Value); + step.Setup(s => s.ExecutionContext).Returns(executionContext.Object); + } + else + { + step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null); + } + + return step; + } + + private static Mock CreateActionRunner(string displayName, ActionRunStage stage, Pipelines.ActionStep action) + { + var executionContext = new Mock(); + executionContext.Setup(x => x.Stage).Returns(stage); + + var runner = new Mock(); + runner.Setup(s => s.DisplayName).Returns(displayName); + runner.Setup(s => s.ExecutionContext).Returns(executionContext.Object); + runner.Setup(s => s.Stage).Returns(stage); + runner.Setup(s => s.Action).Returns(action); + return runner; + } + + private static Pipelines.ActionStep CreateRepositoryActionStep(string name) + { + return new Pipelines.ActionStep + { + Id = Guid.NewGuid(), + Name = name, + Reference = new Pipelines.RepositoryPathReference + { + Name = name, + Ref = "v1", + RepositoryType = Pipelines.RepositoryTypes.GitHub + } + }; + } + + private static Definition CreateActionDefinitionWithPost() + { + return new Definition + { + Data = new ActionDefinitionData + { + Execution = new NodeJSActionExecutionData + { + Script = "main.js", + Post = "post.js" + } + } + }; + } + + private static Request MakeRequest(string command, object arguments) + { + return new Request + { + Seq = 1, + Type = "request", + Command = command, + Arguments = JObject.FromObject(arguments) + }; + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -718,6 +792,325 @@ public async Task StopAsyncSafeAtAnyLifecyclePoint() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleSourceReturnsJobStepsSource() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("secret-step"); + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await waitTask; + + var pre = CreateStep("Pre cache", ActionRunStage.Pre); + var checkout = CreateStep("Checkout"); + var secret = CreateStep("secret-step"); + var post = CreateStep("Post cache", ActionRunStage.Post); + await _debugger.OnJobStepsInitializedAsync( + new[] { pre.Object, checkout.Object, secret.Object }, + new[] { post.Object }); + + var response = _debugger.HandleSource(MakeRequest( + "source", + new SourceArguments { SourceReference = 1 })); + + Assert.True(response.Success); + var body = Assert.IsType(response.Body); + Assert.Equal( + "pre:\n - step: \"Setup job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n - step: \"***\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n", + body.Content); + Assert.Null(body.MimeType); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StackTraceUsesJobStepsSourceLine() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await waitTask; + + var checkout = CreateStep("Checkout"); + var build = CreateStep("Build"); + await _debugger.OnJobStepsInitializedAsync( + new[] { checkout.Object, build.Object }, + Array.Empty()); + + var stepTask = _debugger.OnStepStartingAsync(build.Object); + var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"stopped\"", stoppedEvent); + + var bannerEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", bannerEvent); + + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "stackTrace" + }); + + var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + var stackTrace = JObject.Parse(stackTraceJson); + var frame = stackTrace["body"]?["stackFrames"]?[0]; + + Assert.NotNull(frame); + Assert.Equal(6, frame["line"].Value()); + Assert.Equal(1, frame["source"]["sourceReference"].Value()); + Assert.Equal("execution.yml", frame["source"]["name"].Value()); + + await SendRequestAsync(stream, new Request + { + Seq = 3, + Type = "request", + Command = "continue" + }); + await stepTask; + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StackTraceOmitsSourceForUnmappedCurrentStep() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await waitTask; + + var checkout = CreateStep("Checkout"); + var build = CreateStep("Build"); + await _debugger.OnJobStepsInitializedAsync( + new[] { checkout.Object }, + Array.Empty()); + + var stepTask = _debugger.OnStepStartingAsync(build.Object); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "stackTrace" + }); + + var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + var stackTrace = JObject.Parse(stackTraceJson); + var frame = stackTrace["body"]?["stackFrames"]?[0]; + + Assert.NotNull(frame); + Assert.Equal(0, frame["line"].Value()); + Assert.Null(frame["source"]); + + await SendRequestAsync(stream, new Request + { + Seq = 3, + Type = "request", + Command = "continue" + }); + await stepTask; + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task PredictedPostStepIsServedAtInitializationAndClaimedAtRegistration() + { + using (var hc = CreateTestContext()) + { + var action = CreateRepositoryActionStep("actions/cache"); + var actionManager = new Mock(); + actionManager + .Setup(x => x.LoadAction(It.IsAny(), action)) + .Returns(CreateActionDefinitionWithPost()); + hc.SetSingleton(actionManager.Object); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await waitTask; + + var checkout = CreateActionRunner("Checkout", ActionRunStage.Main, action); + await _debugger.OnJobStepsInitializedAsync( + new[] { checkout.Object }, + Array.Empty()); + + var sourceResponse = _debugger.HandleSource(MakeRequest( + "source", + new SourceArguments { SourceReference = 1 })); + var sourceBody = Assert.IsType(sourceResponse.Body); + Assert.Equal( + "pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n", + sourceBody.Content); + + var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action); + _debugger.OnPostStepRegistered(post.Object); + + var stepTask = _debugger.OnStepStartingAsync(post.Object); + var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"stopped\"", stoppedEvent); + + var bannerEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", bannerEvent); + + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "stackTrace" + }); + + var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + var stackTrace = JObject.Parse(stackTraceJson); + var frame = stackTrace["body"]?["stackFrames"]?[0]; + + Assert.NotNull(frame); + Assert.Equal(8, frame["line"].Value()); + Assert.Equal(1, frame["source"]["sourceReference"].Value()); + + await SendRequestAsync(stream, new Request + { + Seq = 3, + Type = "request", + Command = "continue" + }); + await stepTask; + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StackTraceSanitizesSyntheticSourcePath() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, jobName: "my/job\\name"); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await waitTask; + + var checkout = CreateStep("Checkout"); + await _debugger.OnJobStepsInitializedAsync( + new[] { checkout.Object }, + Array.Empty()); + + var stepTask = _debugger.OnStepStartingAsync(checkout.Object); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "stackTrace" + }); + + var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + var stackTrace = JObject.Parse(stackTraceJson); + var frame = stackTrace["body"]?["stackFrames"]?[0]; + + Assert.NotNull(frame); + Assert.Equal("my_job_name/execution.yml", frame["source"]["path"].Value()); + + await SendRequestAsync(stream, new Request + { + Seq = 3, + Type = "request", + Command = "continue" + }); + await stepTask; + + await _debugger.StopAsync(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -746,6 +1139,11 @@ public async Task OnJobCompletedSendsTerminatedAndExitedEvents() await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); await waitTask; + var checkout = CreateStep("Checkout"); + await _debugger.OnJobStepsInitializedAsync( + new[] { checkout.Object }, + Array.Empty()); + // Complete the job — OnJobCompletedAsync pauses when stepping, // so run it in the background and send continue to unblock. var completedTask = _debugger.OnJobCompletedAsync(); @@ -754,11 +1152,27 @@ public async Task OnJobCompletedSendsTerminatedAndExitedEvents() var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); Assert.Contains("\"event\":\"stopped\"", stoppedMsg); - // Send continue to unblock the pause await SendRequestAsync(stream, new Request { Seq = 2, Type = "request", + Command = "stackTrace" + }); + + var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + var stackTrace = JObject.Parse(stackTraceJson); + var frame = stackTrace["body"]?["stackFrames"]?[0]; + + Assert.NotNull(frame); + Assert.Equal("Complete job [completed]", frame["name"].Value()); + Assert.Equal(8, frame["line"].Value()); + Assert.Equal(1, frame["source"]["sourceReference"].Value()); + + // Send continue to unblock the pause + await SendRequestAsync(stream, new Request + { + Seq = 3, + Type = "request", Command = "continue" }); @@ -777,6 +1191,68 @@ public async Task OnJobCompletedSendsTerminatedAndExitedEvents() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobCompletedUsesSyntheticCompleteJobLineWhenPostStepSharesName() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await waitTask; + + var checkout = CreateStep("Checkout"); + var realPost = CreateStep("Complete job", ActionRunStage.Post); + await _debugger.OnJobStepsInitializedAsync( + new[] { checkout.Object }, + new[] { realPost.Object }); + + var completedTask = _debugger.OnJobCompletedAsync(); + + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "stackTrace" + }); + + var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + var stackTrace = JObject.Parse(stackTraceJson); + var frame = stackTrace["body"]?["stackFrames"]?[0]; + + Assert.NotNull(frame); + Assert.Equal("Complete job [completed]", frame["name"].Value()); + Assert.Equal(9, frame["line"].Value()); + + await SendRequestAsync(stream, new Request + { + Seq = 3, + Type = "request", + Command = "continue" + }); + + await completedTask; + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] From ff728852c9c6f74853a638d45e7d544697d8bb49 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 4 Jun 2026 15:10:26 +0100 Subject: [PATCH 4/4] Use 'Set up job' to match runner timeline display name --- src/Test/L0/Worker/DapDebuggerL0.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index 8215f82b1fb..be026ab9da7 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -833,7 +833,7 @@ await _debugger.OnJobStepsInitializedAsync( Assert.True(response.Success); var body = Assert.IsType(response.Body); Assert.Equal( - "pre:\n - step: \"Setup job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n - step: \"***\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n", + "pre:\n - step: \"Set up job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n - step: \"***\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n", body.Content); Assert.Null(body.MimeType); @@ -1011,7 +1011,7 @@ await _debugger.OnJobStepsInitializedAsync( new SourceArguments { SourceReference = 1 })); var sourceBody = Assert.IsType(sourceResponse.Body); Assert.Equal( - "pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n", + "pre:\n - step: \"Set up job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n", sourceBody.Content); var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action);