Hook job lifecycle into DAP debugger behind containment gate

Wires three runner-internal call sites into the DAP debugger:
  - JobRunner: notifies the debugger once Pre/Main/Post step lists
    are populated, so it can seed the execution view and predict
    Post placeholders.
  - ExecutionContext.RegisterPostJobStep: notifies the debugger when
    a post step is registered, so it can claim the predicted
    placeholder or append a new entry.
  - StepsRunner: notifies the debugger when a Main step is skipped
    by its if: condition, so predicted Post placeholders for that
    step can be marked as skipped.

Each call site gates on jobContext.Global.Debugger?.Enabled == true
before calling HostContext.GetService<IDapDebugger>(). Without the
gate, GetService<T>() auto-instantiates the default singleton for
every non-debug job, breaking the 'no debugger, no risk' containment
property.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Francesco Renzi
2026-05-12 13:11:17 -07:00
committed by GitHub
parent 0387c108c0
commit 63ecc21590
6 changed files with 131 additions and 0 deletions

View File

@@ -338,6 +338,14 @@ namespace GitHub.Runner.Worker
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
Root.PostJobSteps.Push(step);
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
if (Global.Debugger?.Enabled == true)
{
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
}
}
public IExecutionContext CreateChild(

View File

@@ -230,6 +230,24 @@ namespace GitHub.Runner.Worker
jobContext.JobSteps.Enqueue(step);
}
if (jobContext.Global.Debugger?.Enabled == true)
{
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
var dapDebugger = HostContext.GetService<Dap.IDapDebugger>();
try
{
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobStepsInitialized error; continuing without DAP view.");
Trace.Error(ex);
}
}
await stepsRunner.RunAsync(jobContext);
}
catch (Exception ex)

View File

@@ -219,12 +219,18 @@ namespace GitHub.Runner.Worker
// Condition is false
Trace.Info("Skipping step due to condition evaluation.");
CompleteStep(step, TaskResult.Skipped, resultCode: conditionTraceWriter.Trace);
// Notify the DAP debugger so any predicted Post-step
// placeholder for this Main step can be marked as
// skipped — otherwise the rendered view leaves a
// stale "Post X" entry for a step that never ran.
dapDebugger?.OnStepCompleted(step);
}
else if (conditionEvaluateError != null)
{
// Condition error
step.ExecutionContext.Error(conditionEvaluateError);
CompleteStep(step, TaskResult.Failed);
dapDebugger?.OnStepCompleted(step);
}
else
{

View File

@@ -7,6 +7,7 @@ using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
@@ -405,6 +406,7 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -503,6 +505,7 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -544,6 +547,75 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RegisterPostJobAction_DebuggerDisabled_DoesNotInvokeDapDebugger()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message with EnableDebugger left at the default (false).
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
var pagingLogger = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()));
var actionRunner = new ActionRunner();
actionRunner.Initialize(hc);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(actionRunner as IActionRunner);
// Register a strict mock IDapDebugger. If the production code calls
// ANY method on it, the test fails — proving the containment guard
// short-circuited before HostContext.GetService<IDapDebugger>().
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
hc.SetSingleton(dapMock.Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
jobContext.Initialize(hc);
jobContext.InitializeJob(jobRequest, CancellationToken.None);
var action = jobContext.CreateChild(Guid.NewGuid(), "action_1", "action_1", null, null, 0);
var postRunner = hc.CreateService<IActionRunner>();
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
postRunner.Stage = ActionRunStage.Post;
postRunner.Condition = "always()";
postRunner.DisplayName = "post";
// Sanity: ensure the production code path actually believes the debugger is disabled.
Assert.True(jobContext.Global.Debugger == null || jobContext.Global.Debugger.Enabled == false);
// Act.
action.RegisterPostJobStep(postRunner);
// Assert: the debugger was never consulted on the non-debug path.
dapMock.VerifyNoOtherCalls();
Assert.Equal(1, jobContext.PostJobSteps.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]

View File

@@ -141,6 +141,7 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SetSingleton(_diagnosticLogManager.Object);
hc.SetSingleton(_jobHookProvider.Object);
hc.SetSingleton(_snapshotOperationProvider.Object);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // JobExecutionContext
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // job start hook
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // Initial Job

View File

@@ -1,5 +1,6 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using System;
using System.Collections.Generic;
@@ -83,6 +84,7 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SetSingleton(_extensions.Object);
hc.SetSingleton(_temp.Object);
hc.SetSingleton(_diagnosticLogManager.Object);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.EnqueueInstance<IExecutionContext>(_jobEc);
hc.EnqueueInstance<IPagingLogger>(_logger.Object);
hc.EnqueueInstance<IJobExtension>(_jobExtension.Object);
@@ -175,5 +177,29 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerDisabled_DoesNotInvokeDapDebugger()
{
using (TestHostContext hc = CreateTestContext())
{
// Override the lenient IDapDebugger singleton from CreateTestContext
// with a strict mock. If the containment guard fails, the production
// code will call OnJobStepsInitializedAsync and the strict mock will throw.
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
hc.SetSingleton(dapMock.Object);
var message = GetMessage();
// EnableDebugger defaults to false on AgentJobRequestMessage.
Assert.False(message.EnableDebugger);
await _jobRunner.RunAsync(message, _tokenSource.Token);
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
dapMock.VerifyNoOtherCalls();
}
}
}
}