diff --git a/src/Runner.Worker/BackgroundStepContext.cs b/src/Runner.Worker/BackgroundStepContext.cs
new file mode 100644
index 00000000000..28796054c15
--- /dev/null
+++ b/src/Runner.Worker/BackgroundStepContext.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GitHub.Runner.Worker
+{
+ ///
+ /// Tracks a background step's execution state.
+ ///
+ internal sealed class BackgroundStepContext
+ {
+ public string StepId { get; }
+ public IStep Step { get; }
+ public Task ExecutionTask { get; set; }
+ public CancellationTokenSource Cts { get; set; }
+ public GitHub.DistributedTask.WebApi.TaskResult? Result { get; set; }
+ public bool IsCompleted => ExecutionTask?.IsCompleted ?? false;
+ public string ExternalId => Step.ExecutionContext == null || Step.ExecutionContext.Id == Guid.Empty ? null : Step.ExecutionContext.Id.ToString("N");
+
+ public BackgroundStepContext(string stepId, IStep step)
+ {
+ StepId = stepId;
+ Step = step;
+ }
+ }
+}
diff --git a/src/Runner.Worker/CancelStepRunner.cs b/src/Runner.Worker/CancelStepRunner.cs
new file mode 100644
index 00000000000..79dc610747f
--- /dev/null
+++ b/src/Runner.Worker/CancelStepRunner.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Threading.Tasks;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using GitHub.DistributedTask.Pipelines.ContextData;
+
+namespace GitHub.Runner.Worker
+{
+ ///
+ /// A step that cancels a specific background step.
+ /// Execution is handled by StepsRunner, not by RunAsync.
+ ///
+ public sealed class CancelStepRunner : IStep
+ {
+ public string CancelStepId { get; set; }
+ public Guid StepId { get; set; }
+ public string StepName { get; set; }
+ public int RecordOrder { get; set; }
+ public string Condition { get; set; }
+ public TemplateToken ContinueOnError => null;
+ public string DisplayName { get; set; }
+ public IExecutionContext ExecutionContext { get; set; }
+ public TemplateToken Timeout => null;
+
+ public bool TryUpdateDisplayName(out bool updated)
+ {
+ updated = false;
+ return true;
+ }
+
+ public bool EvaluateDisplayName(DictionaryContextData contextData, IExecutionContext context, out bool updated)
+ {
+ updated = false;
+ return true;
+ }
+
+ public Task RunAsync()
+ {
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs
index f072335b440..c77f173925a 100644
--- a/src/Runner.Worker/ExecutionContext.cs
+++ b/src/Runner.Worker/ExecutionContext.cs
@@ -105,6 +105,7 @@ public interface IExecutionContext : IRunnerService
void UpdateDetailTimelineRecord(TimelineRecord record);
void UpdateTimelineRecordDisplayName(string displayName);
+ void SetTimelineRecordVariable(string name, string value);
// matchers
void Add(OnMatcherChanged handler);
@@ -511,6 +512,24 @@ public TaskResult Complete(TaskResult? result = null, string currentOperation =
Annotations = new List()
};
+ // Populate background step metadata from timeline record variables
+ if (_record.Variables.TryGetValue("is_background", out var bgVar) && bgVar.Value == "true")
+ {
+ stepResult.IsBackground = true;
+ }
+ if (_record.Variables.TryGetValue("step_type", out var stVar) && !string.IsNullOrEmpty(stVar.Value))
+ {
+ stepResult.StepType = stVar.Value;
+ }
+ if (_record.Variables.TryGetValue("wait_step_ids", out var wsVar) && !string.IsNullOrEmpty(wsVar.Value))
+ {
+ stepResult.WaitStepIds = wsVar.Value.Split(',');
+ }
+ if (_record.Variables.TryGetValue("cancel_step_id", out var csVar) && !string.IsNullOrEmpty(csVar.Value))
+ {
+ stepResult.CancelStepId = csVar.Value;
+ }
+
_record.Issues?.ForEach(issue =>
{
var annotation = issue.ToAnnotation();
@@ -807,6 +826,12 @@ public void UpdateTimelineRecordDisplayName(string displayName)
_jobServerQueue.QueueTimelineRecordUpdate(_mainTimelineId, _record);
}
+ public void SetTimelineRecordVariable(string name, string value)
+ {
+ _record.Variables[name] = new VariableValue(value);
+ _jobServerQueue.QueueTimelineRecordUpdate(_mainTimelineId, _record);
+ }
+
public void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token)
{
// Validation
@@ -1332,8 +1357,9 @@ public void ApplyContinueOnError(TemplateToken continueOnErrorToken)
UpdateGlobalStepsContext();
}
- internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
+ internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null)
{
+ var allowServiceContainerCommand = Global.Variables.GetBoolean(Constants.Runner.Features.ServiceContainerCommand) ?? false;
return new PipelineTemplateEvaluatorWrapper(HostContext, this, allowServiceContainerCommand, traceWriter);
}
@@ -1422,13 +1448,10 @@ public static IEnumerable> ToExpressionState(this I
public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
{
- var allowServiceContainerCommand = (context.Global.Variables.GetBoolean(Constants.Runner.Features.ServiceContainerCommand) ?? false)
- || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_SERVICE_CONTAINER_COMMAND"));
-
// Create wrapper?
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER")))
{
- return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(allowServiceContainerCommand, traceWriter);
+ return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter);
}
// Legacy
@@ -1440,7 +1463,6 @@ public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecu
return new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
{
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
- AllowServiceContainerCommand = allowServiceContainerCommand,
};
}
diff --git a/src/Runner.Worker/JobExtension.cs b/src/Runner.Worker/JobExtension.cs
index 838009fc9c2..e23890cc725 100644
--- a/src/Runner.Worker/JobExtension.cs
+++ b/src/Runner.Worker/JobExtension.cs
@@ -315,8 +315,10 @@ public async Task> InitializeJob(IExecutionContext jobContext, Pipel
}
// Add action steps
+ var stepOrder = 0;
foreach (var step in message.Steps)
{
+ stepOrder++;
if (step.Type == Pipelines.StepType.Action)
{
var action = step as Pipelines.ActionStep;
@@ -345,6 +347,53 @@ public async Task> InitializeJob(IExecutionContext jobContext, Pipel
preJobSteps.Add(preStep);
}
}
+ else if (step.Type == Pipelines.StepType.Wait)
+ {
+ var waitStep = step as Pipelines.WaitStep;
+ Trace.Info($"Adding wait step for: {string.Join(", ", waitStep.WaitStepIds ?? System.Array.Empty())}");
+ Trace.Info($"Wait step: DisplayNameToken={waitStep.DisplayNameToken?.GetType().Name ?? "null"}, DisplayName={step.DisplayName ?? "null"}, Name={step.Name ?? "null"}");
+ var waitStepName = (waitStep.DisplayNameToken as GitHub.DistributedTask.ObjectTemplating.Tokens.StringToken)?.Value
+ ?? step.DisplayName ?? step.Name ?? "Wait for background steps";
+ Trace.Info($"Wait step resolved name: {waitStepName}");
+ var waitRunner = new WaitStepRunner
+ {
+ StepIds = waitStep.WaitStepIds,
+ DisplayName = waitStepName,
+ Condition = step.Condition,
+ StepId = step.Id,
+ StepName = step.Name,
+ };
+ // ExecutionContext created later in "Create execution context for job steps" loop
+ jobSteps.Add(waitRunner);
+ }
+ else if (step.Type == Pipelines.StepType.WaitAll)
+ {
+ Trace.Info("Adding wait-all step.");
+ var waitAllRunner = new WaitAllStepRunner
+ {
+ DisplayName = step.DisplayName ?? step.Name ?? "Wait for all background steps",
+ Condition = step.Condition,
+ StepId = step.Id,
+ StepName = step.Name,
+ };
+ // ExecutionContext created later in "Create execution context for job steps" loop
+ jobSteps.Add(waitAllRunner);
+ }
+ else if (step.Type == Pipelines.StepType.Cancel)
+ {
+ var cancelStep = step as Pipelines.CancelStep;
+ Trace.Info($"Adding cancel step for: {cancelStep.CancelStepId}");
+ var cancelRunner = new CancelStepRunner
+ {
+ CancelStepId = cancelStep.CancelStepId,
+ DisplayName = (cancelStep.DisplayNameToken as GitHub.DistributedTask.ObjectTemplating.Tokens.StringToken)?.Value ?? step.DisplayName ?? step.Name ?? "Cancel background step",
+ Condition = step.Condition,
+ StepId = step.Id,
+ StepName = step.Name,
+ };
+ // ExecutionContext created later in "Create execution context for job steps" loop
+ jobSteps.Add(cancelRunner);
+ }
}
if (message.Variables.TryGetValue("system.workflowFileFullPath", out VariableValue workflowFileFullPath))
@@ -407,6 +456,42 @@ public async Task> InitializeJob(IExecutionContext jobContext, Pipel
ArgUtil.NotNull(actionStep, step.DisplayName);
intraActionStates.TryGetValue(actionStep.Action.Id, out var intraActionState);
actionStep.ExecutionContext = jobContext.CreateChild(actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name, null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState);
+
+ // Store background step metadata on the timeline record for results service
+ if (actionStep.Action?.Background == true)
+ {
+ actionStep.ExecutionContext.SetTimelineRecordVariable("is_background", "true");
+ actionStep.ExecutionContext.SetTimelineRecordVariable("step_type", "action");
+ }
+ }
+ else if (step is WaitStepRunner waitRunner)
+ {
+ waitRunner.ExecutionContext = jobContext.CreateChild(
+ waitRunner.StepId, waitRunner.DisplayName, waitRunner.StepName,
+ null, waitRunner.StepName, ActionRunStage.Main);
+ waitRunner.ExecutionContext.SetTimelineRecordVariable("step_type", "wait");
+ if (waitRunner.StepIds != null && waitRunner.StepIds.Length > 0)
+ {
+ waitRunner.ExecutionContext.SetTimelineRecordVariable("wait_step_ids", string.Join(",", waitRunner.StepIds));
+ }
+ }
+ else if (step is WaitAllStepRunner waitAllRunner)
+ {
+ waitAllRunner.ExecutionContext = jobContext.CreateChild(
+ waitAllRunner.StepId, waitAllRunner.DisplayName, waitAllRunner.StepName,
+ null, waitAllRunner.StepName, ActionRunStage.Main);
+ waitAllRunner.ExecutionContext.SetTimelineRecordVariable("step_type", "wait-all");
+ }
+ else if (step is CancelStepRunner cancelRunner)
+ {
+ cancelRunner.ExecutionContext = jobContext.CreateChild(
+ cancelRunner.StepId, cancelRunner.DisplayName, cancelRunner.StepName,
+ null, cancelRunner.StepName, ActionRunStage.Main);
+ cancelRunner.ExecutionContext.SetTimelineRecordVariable("step_type", "cancel");
+ if (!string.IsNullOrEmpty(cancelRunner.CancelStepId))
+ {
+ cancelRunner.ExecutionContext.SetTimelineRecordVariable("cancel_step_id", cancelRunner.CancelStepId);
+ }
}
}
diff --git a/src/Runner.Worker/StepsContext.cs b/src/Runner.Worker/StepsContext.cs
index 6f16956e51e..5e4adcca2e5 100644
--- a/src/Runner.Worker/StepsContext.cs
+++ b/src/Runner.Worker/StepsContext.cs
@@ -18,6 +18,7 @@ public sealed class StepsContext
{
private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
private readonly DictionaryContextData _contextData = new();
+ private readonly object _lock = new();
///
/// Clears memory for a composite action's isolated "steps" context, after the action
@@ -67,16 +68,19 @@ public void SetOutput(
string value,
out string reference)
{
- var step = GetStep(scopeName, stepName);
- var outputs = step["outputs"].AssertDictionary("outputs");
- outputs[outputName] = new StringContextData(value);
- if (_propertyRegex.IsMatch(outputName))
+ lock (_lock)
{
- reference = $"steps.{stepName}.outputs.{outputName}";
- }
- else
- {
- reference = $"steps['{stepName}']['outputs']['{outputName}']";
+ var step = GetStep(scopeName, stepName);
+ var outputs = step["outputs"].AssertDictionary("outputs");
+ outputs[outputName] = new StringContextData(value);
+ if (_propertyRegex.IsMatch(outputName))
+ {
+ reference = $"steps.{stepName}.outputs.{outputName}";
+ }
+ else
+ {
+ reference = $"steps['{stepName}']['outputs']['{outputName}']";
+ }
}
}
@@ -85,8 +89,11 @@ public void SetConclusion(
string stepName,
ActionResult conclusion)
{
- var step = GetStep(scopeName, stepName);
- step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
+ lock (_lock)
+ {
+ var step = GetStep(scopeName, stepName);
+ step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
+ }
}
public void SetOutcome(
@@ -94,8 +101,11 @@ public void SetOutcome(
string stepName,
ActionResult outcome)
{
- var step = GetStep(scopeName, stepName);
- step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
+ lock (_lock)
+ {
+ var step = GetStep(scopeName, stepName);
+ step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
+ }
}
private DictionaryContextData GetStep(string scopeName, string stepName)
diff --git a/src/Runner.Worker/StepsRunner.cs b/src/Runner.Worker/StepsRunner.cs
index 21bdfa6f779..9254a2c2a18 100644
--- a/src/Runner.Worker/StepsRunner.cs
+++ b/src/Runner.Worker/StepsRunner.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
@@ -35,6 +37,11 @@ public interface IStepsRunner : IRunnerService
public sealed class StepsRunner : RunnerService, IStepsRunner
{
+ // Track active background steps
+ private readonly ConcurrentDictionary _backgroundSteps = new();
+ private readonly HashSet _waitedStepIds = new();
+ private readonly SemaphoreSlim _backgroundSlotSemaphore = new(10); // max 10 concurrent background steps
+
// StepsRunner should never throw exception to caller
public async Task RunAsync(IExecutionContext jobContext)
{
@@ -57,6 +64,31 @@ public async Task RunAsync(IExecutionContext jobContext)
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
{
checkPostJobActions = true;
+
+ // Implicit wait-all before post-job hooks:
+ // If any background steps haven't been waited on, inject a visible wait-all step.
+ if (_backgroundSteps.Count > 0)
+ {
+ var unwaitedIds = _backgroundSteps.Keys.Where(id => !_waitedStepIds.Contains(id)).ToList();
+ if (unwaitedIds.Count > 0)
+ {
+ Trace.Info($"Injecting implicit wait-all for {unwaitedIds.Count} unwaited background step(s): {string.Join(", ", unwaitedIds)}");
+ var implicitWaitAll = new WaitAllStepRunner
+ {
+ DisplayName = "Wait for all background steps",
+ Condition = "always()",
+ StepId = Guid.NewGuid(),
+ StepName = "__implicit_wait_all",
+ };
+ implicitWaitAll.ExecutionContext = jobContext.CreateChild(
+ implicitWaitAll.StepId, implicitWaitAll.DisplayName, implicitWaitAll.StepName,
+ null, implicitWaitAll.StepName, ActionRunStage.Main);
+ implicitWaitAll.ExecutionContext.SetTimelineRecordVariable("step_type", "wait-all");
+ jobContext.JobSteps.Enqueue(implicitWaitAll);
+ continue;
+ }
+ }
+
while (jobContext.PostJobSteps.TryPop(out var postStep))
{
jobContext.JobSteps.Enqueue(postStep);
@@ -68,12 +100,72 @@ public async Task RunAsync(IExecutionContext jobContext)
var step = jobContext.JobSteps.Dequeue();
Trace.Info($"Processing step: DisplayName='{step.DisplayName}'");
+
+ // Handle background step control-flow types
+ if (step is WaitStepRunner waitStep)
+ {
+ Trace.Info($"Processing wait step for: {string.Join(", ", waitStep.StepIds ?? Array.Empty())}");
+ step.ExecutionContext.Start();
+ SetWaitStepIdsTimelineVariable(step.ExecutionContext, waitStep.StepIds);
+ await HandleWaitAsync(waitStep, jobContext.CancellationToken);
+ var waitResult = GetWaitResult(waitStep.StepIds);
+ CompleteStep(step, waitResult);
+ // Track these steps as already waited on so wait-all skips them
+ foreach (var id in waitStep.StepIds ?? Array.Empty())
+ {
+ _waitedStepIds.Add(id);
+ }
+ if (waitResult == TaskResult.Failed)
+ {
+ Trace.Info("Background step failure detected at wait point.");
+ jobContext.Result = TaskResultUtil.MergeTaskResults(jobContext.Result, TaskResult.Failed);
+ jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
+ }
+ continue;
+ }
+ if (step is WaitAllStepRunner waitAllStep)
+ {
+ Trace.Info("Processing wait-all step");
+ step.ExecutionContext.Start();
+ // Exclude steps already waited on by a previous wait step
+ var remainingStepIds = _backgroundSteps.Keys.Where(id => !_waitedStepIds.Contains(id)).ToList();
+ SetWaitStepIdsTimelineVariable(step.ExecutionContext, remainingStepIds);
+ await HandleWaitAllAsync(jobContext.CancellationToken);
+ var waitAllResult = GetWaitAllResult(remainingStepIds);
+ CompleteStep(step, waitAllResult);
+ if (waitAllResult == TaskResult.Failed)
+ {
+ Trace.Info("Background step failure detected at wait-all point.");
+ jobContext.Result = TaskResultUtil.MergeTaskResults(jobContext.Result, TaskResult.Failed);
+ jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
+ }
+ continue;
+ }
+ if (step is CancelStepRunner cancelStep)
+ {
+ Trace.Info($"Processing cancel step for: {cancelStep.CancelStepId}");
+ step.ExecutionContext.Start();
+ SetCancelStepIdTimelineVariable(step.ExecutionContext, cancelStep.CancelStepId);
+ await HandleCancelAsync(cancelStep);
+ CompleteStep(step, TaskResult.Succeeded);
+ continue;
+ }
+
ArgUtil.NotNull(step.ExecutionContext, nameof(step.ExecutionContext));
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
ArgUtil.NotNull(step.ExecutionContext.Global.Variables, nameof(step.ExecutionContext.Global.Variables));
- // Start
- step.ExecutionContext.Start();
+ bool isBackground = false;
+ if (step is IActionRunner actionRunnerCheck && actionRunnerCheck.Action?.Background == true)
+ {
+ isBackground = true;
+ }
+
+ // Start — defer for background steps until the slot is acquired
+ if (!isBackground)
+ {
+ step.ExecutionContext.Start();
+ }
// Expression functions
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo(PipelineTemplateConstants.Always, 0, 0));
@@ -228,14 +320,23 @@ public async Task RunAsync(IExecutionContext jobContext)
}
else
{
- // Pause for DAP debugger before step execution
- await dapDebugger?.OnStepStartingAsync(step);
+ if (isBackground)
+ {
+ // Start background step without awaiting
+ // Don't call CompleteStep here — the background task will complete itself
+ await StartBackgroundStepAsync(step, jobContext.CancellationToken);
+ }
+ else
+ {
+ // Pause for DAP debugger before step execution
+ await dapDebugger?.OnStepStartingAsync(step);
- // Run the step
- await RunStepAsync(step, jobContext.CancellationToken);
- CompleteStep(step);
+ // Run the step synchronously (normal behavior)
+ await RunStepAsync(step, jobContext.CancellationToken);
+ CompleteStep(step);
- dapDebugger?.OnStepCompleted(step);
+ dapDebugger?.OnStepCompleted(step);
+ }
}
}
finally
@@ -342,5 +443,317 @@ private void CompleteStep(IStep step, TaskResult? result = null, string resultCo
executionContext.Complete(result, resultCode: resultCode);
}
+
+ private async Task StartBackgroundStepAsync(IStep step, CancellationToken jobCancellationToken)
+ {
+ var stepId = step.ExecutionContext?.ContextName ?? step.DisplayName;
+
+ // Block until a background slot is available (max 10 concurrent)
+ Trace.Info($"Background step '{stepId}' waiting for slot (active: {10 - _backgroundSlotSemaphore.CurrentCount}/10).");
+ await _backgroundSlotSemaphore.WaitAsync(jobCancellationToken);
+ Trace.Info($"Background step '{stepId}' acquired slot.");
+
+ // Give the background step its own copy of the GitHubContext.
+ // FileCommandManager.InitializeFiles sets github.output / github.path / github.env
+ // on the GitHubContext, which is shared across all child ExecutionContexts.
+ // Without isolation, concurrent steps overwrite each other's GITHUB_OUTPUT paths,
+ // causing outputs to be written to the wrong file.
+ if (step.ExecutionContext.ExpressionValues.TryGetValue("github", out var ghCtx) && ghCtx is GitHubContext sharedGitHub)
+ {
+ step.ExecutionContext.ExpressionValues["github"] = sharedGitHub.ShallowCopy();
+ }
+
+ // Mark InProgress only after the slot is acquired
+ step.ExecutionContext.Start();
+
+ var bgCts = CancellationTokenSource.CreateLinkedTokenSource(jobCancellationToken);
+
+ // Set the timeout for background steps (same as foreground steps)
+ var timeoutMinutes = 0;
+ try
+ {
+ var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator();
+ timeoutMinutes = templateEvaluator.EvaluateStepTimeout(step.Timeout, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions);
+ }
+ catch (Exception ex)
+ {
+ Trace.Info($"Error determining timeout for background step '{stepId}': {ex.Message}");
+ }
+ if (timeoutMinutes > 0)
+ {
+ var timeout = TimeSpan.FromMinutes(timeoutMinutes);
+ step.ExecutionContext.SetTimeout(timeout);
+ }
+
+ var bgContext = new BackgroundStepContext(stepId, step)
+ {
+ Cts = bgCts,
+ };
+
+ bgContext.ExecutionTask = Task.Run(async () =>
+ {
+ try
+ {
+ await step.RunAsync();
+ bgContext.Result = step.ExecutionContext.Result ?? TaskResult.Succeeded;
+ }
+ catch (OperationCanceledException) when (bgCts.Token.IsCancellationRequested)
+ {
+ bgContext.Result = TaskResult.Canceled;
+ }
+ catch (OperationCanceledException) when (step.ExecutionContext.CancellationToken.IsCancellationRequested)
+ {
+ // Step-level timeout
+ Trace.Error($"Background step '{stepId}' timed out after {timeoutMinutes} minutes.");
+ step.ExecutionContext.Error($"The background step '{step.DisplayName}' has timed out after {timeoutMinutes} minutes.");
+ bgContext.Result = TaskResult.Failed;
+ }
+ catch (Exception ex)
+ {
+ Trace.Error($"Background step '{stepId}' failed: {ex.Message}");
+ step.ExecutionContext.Error(ex);
+ bgContext.Result = TaskResult.Failed;
+ }
+ finally
+ {
+ _backgroundSlotSemaphore.Release();
+
+ // Merge command result
+ if (step.ExecutionContext.CommandResult != null)
+ {
+ bgContext.Result = TaskResultUtil.MergeTaskResults(
+ bgContext.Result, step.ExecutionContext.CommandResult.Value);
+ }
+
+ // Apply continue-on-error: if the step failed and has continue-on-error: true,
+ // this changes ExecutionContext.Result to Succeeded (Outcome keeps the raw failure).
+ // We sync bgContext.Result so that wait/wait-all see the adjusted result.
+ step.ExecutionContext.Result = bgContext.Result;
+ step.ExecutionContext.ApplyContinueOnError(step.ContinueOnError);
+ bgContext.Result = step.ExecutionContext.Result;
+
+ // Update steps context with outcome/conclusion
+ step.ExecutionContext.Complete(bgContext.Result);
+ Trace.Info($"Background step '{stepId}' completed with result: {bgContext.Result}");
+ }
+ });
+
+ _backgroundSteps[stepId] = bgContext;
+ Trace.Info($"Background step '{stepId}' started.");
+ }
+
+ private void SetWaitStepIdsTimelineVariable(IExecutionContext executionContext, IEnumerable logicalStepIds)
+ {
+ var externalIds = GetBackgroundExternalIds(logicalStepIds);
+ if (externalIds.Count > 0)
+ {
+ executionContext.SetTimelineRecordVariable("wait_step_ids", string.Join(",", externalIds));
+ }
+ }
+
+ private void SetCancelStepIdTimelineVariable(IExecutionContext executionContext, string logicalStepId)
+ {
+ var externalId = GetBackgroundExternalId(logicalStepId);
+ if (!string.IsNullOrEmpty(externalId))
+ {
+ executionContext.SetTimelineRecordVariable("cancel_step_id", externalId);
+ }
+ }
+
+ private List GetBackgroundExternalIds(IEnumerable logicalStepIds)
+ {
+ return (logicalStepIds ?? Array.Empty())
+ .Select(GetBackgroundExternalId)
+ .Where(id => !string.IsNullOrEmpty(id))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private string GetBackgroundExternalId(string logicalStepId)
+ {
+ if (string.IsNullOrEmpty(logicalStepId))
+ {
+ return null;
+ }
+
+ return _backgroundSteps.TryGetValue(logicalStepId, out var bgCtx) ? bgCtx.ExternalId : null;
+ }
+
+ private async Task HandleWaitAsync(WaitStepRunner waitStep, CancellationToken cancellationToken)
+ {
+ var stepIds = waitStep.StepIds ?? Array.Empty();
+ var tasks = new List();
+
+ foreach (var stepId in stepIds)
+ {
+ if (_backgroundSteps.TryGetValue(stepId, out var bgCtx))
+ {
+ tasks.Add(bgCtx.ExecutionTask);
+ }
+ else
+ {
+ Trace.Warning($"Wait references unknown background step: {stepId}");
+ }
+ }
+
+ if (tasks.Count > 0)
+ {
+ Trace.Info($"Waiting for {tasks.Count} background step(s)...");
+ var cancelTask = Task.Delay(Timeout.Infinite, cancellationToken);
+ var completed = await Task.WhenAny(Task.WhenAll(tasks), cancelTask);
+ if (cancellationToken.IsCancellationRequested)
+ {
+ Trace.Info("Wait interrupted by job cancellation — cancelling waited background steps.");
+ foreach (var stepId in stepIds)
+ {
+ if (_backgroundSteps.TryGetValue(stepId, out var bgCtx) && !bgCtx.IsCompleted)
+ {
+ bgCtx.Step.ExecutionContext.CancelToken();
+ bgCtx.Cts.Cancel();
+ }
+ }
+ await Task.WhenAny(Task.WhenAll(tasks), Task.Delay(TimeSpan.FromSeconds(7.5)));
+ }
+ }
+ }
+
+ private async Task HandleWaitAllAsync(CancellationToken cancellationToken)
+ {
+ var tasks = _backgroundSteps.Values
+ .Where(bg => !bg.IsCompleted)
+ .Select(bg => bg.ExecutionTask)
+ .ToList();
+
+ if (tasks.Count > 0)
+ {
+ Trace.Info($"Waiting for {tasks.Count} active background step(s)...");
+ var cancelTask = Task.Delay(Timeout.Infinite, cancellationToken);
+ await Task.WhenAny(Task.WhenAll(tasks), cancelTask);
+ if (cancellationToken.IsCancellationRequested)
+ {
+ Trace.Info("Wait-all interrupted by job cancellation — cancelling all background steps.");
+ await CancelAllBackgroundStepsAsync();
+ }
+ }
+ }
+
+ private async Task CancelAllBackgroundStepsAsync()
+ {
+ var activeSteps = _backgroundSteps.Values
+ .Where(bg => !bg.IsCompleted)
+ .ToList();
+
+ if (activeSteps.Count == 0) return;
+
+ Trace.Info($"Cancelling {activeSteps.Count} active background step(s)...");
+
+ // Send cancel signal to all active background steps
+ foreach (var bgCtx in activeSteps)
+ {
+ Trace.Info($"Sending cancel to background step '{bgCtx.StepId}'");
+ bgCtx.Step.ExecutionContext.CancelToken();
+ bgCtx.Cts.Cancel();
+ }
+
+ // Wait for all to finish with a grace period
+ var gracePeriod = TimeSpan.FromSeconds(7.5);
+ var allTasks = activeSteps.Select(bg => bg.ExecutionTask).ToArray();
+ await Task.WhenAny(Task.WhenAll(allTasks), Task.Delay(gracePeriod));
+
+ var stillRunning = activeSteps.Where(bg => !bg.IsCompleted).ToList();
+ if (stillRunning.Count > 0)
+ {
+ Trace.Warning($"{stillRunning.Count} background step(s) did not terminate gracefully.");
+ }
+
+ // Final wait for all tasks to complete
+ await Task.WhenAll(allTasks);
+ }
+
+ private async Task HandleCancelAsync(CancelStepRunner cancelStep)
+ {
+ if (_backgroundSteps.TryGetValue(cancelStep.CancelStepId, out var bgCtx))
+ {
+ if (!bgCtx.IsCompleted)
+ {
+ Trace.Info($"Cancelling background step '{cancelStep.CancelStepId}'...");
+
+ // Cancel the step's execution context token — this is what
+ // ProcessInvoker listens to for sending SIGTERM to the process.
+ bgCtx.Step.ExecutionContext.CancelToken();
+ bgCtx.Cts.Cancel();
+
+ // Wait for grace period (7.5 seconds)
+ var gracePeriod = TimeSpan.FromSeconds(7.5);
+ await Task.WhenAny(bgCtx.ExecutionTask, Task.Delay(gracePeriod));
+
+ if (!bgCtx.IsCompleted)
+ {
+ Trace.Warning($"Background step '{cancelStep.CancelStepId}' did not terminate gracefully after {gracePeriod.TotalSeconds}s.");
+ }
+ }
+
+ await bgCtx.ExecutionTask;
+ Trace.Info($"Background step '{cancelStep.CancelStepId}' cancelled/completed.");
+ }
+ else
+ {
+ Trace.Warning($"Cancel references unknown background step: {cancelStep.CancelStepId}");
+ }
+ }
+
+ ///
+ /// Check if any specific waited-for background steps failed.
+ /// Returns Failed if any referenced step failed, Succeeded otherwise.
+ ///
+ private TaskResult GetWaitResult(string[] stepIds)
+ {
+ if (stepIds == null) return TaskResult.Succeeded;
+
+ foreach (var stepId in stepIds)
+ {
+ if (_backgroundSteps.TryGetValue(stepId, out var bgCtx) && bgCtx.Result == TaskResult.Failed)
+ {
+ Trace.Info($"Background step '{stepId}' failed.");
+ return TaskResult.Failed;
+ }
+ }
+ return TaskResult.Succeeded;
+ }
+
+ ///
+ /// Check if any background step in the given set has failed.
+ /// Returns Failed if any step failed, Succeeded otherwise.
+ ///
+ private TaskResult GetWaitAllResult(IEnumerable stepIds)
+ {
+ foreach (var id in stepIds)
+ {
+ if (_backgroundSteps.TryGetValue(id, out var bgCtx) && bgCtx.Result == TaskResult.Failed)
+ {
+ Trace.Info($"Background step '{bgCtx.StepId}' failed.");
+ return TaskResult.Failed;
+ }
+ }
+ return TaskResult.Succeeded;
+ }
+
+ ///
+ /// Propagate any background step failures to the job result.
+ /// Called during implicit wait-all before post-hooks.
+ ///
+ private void PropagateBackgroundStepFailures(IExecutionContext jobContext)
+ {
+ foreach (var bgCtx in _backgroundSteps.Values)
+ {
+ if (bgCtx.Result == TaskResult.Failed)
+ {
+ Trace.Info($"Propagating failure from background step '{bgCtx.StepId}' to job result.");
+ jobContext.Result = TaskResultUtil.MergeTaskResults(jobContext.Result, TaskResult.Failed);
+ jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
+ break;
+ }
+ }
+ }
}
}
diff --git a/src/Runner.Worker/WaitAllStepRunner.cs b/src/Runner.Worker/WaitAllStepRunner.cs
new file mode 100644
index 00000000000..acceb26bc54
--- /dev/null
+++ b/src/Runner.Worker/WaitAllStepRunner.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Threading.Tasks;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using GitHub.DistributedTask.Pipelines.ContextData;
+
+namespace GitHub.Runner.Worker
+{
+ ///
+ /// A step that blocks until all prior background steps complete.
+ /// Execution is handled by StepsRunner, not by RunAsync.
+ ///
+ public sealed class WaitAllStepRunner : IStep
+ {
+ public Guid StepId { get; set; }
+ public string StepName { get; set; }
+ public int RecordOrder { get; set; }
+ public string Condition { get; set; }
+ public TemplateToken ContinueOnError => null;
+ public string DisplayName { get; set; }
+ public IExecutionContext ExecutionContext { get; set; }
+ public TemplateToken Timeout => null;
+
+ public bool TryUpdateDisplayName(out bool updated)
+ {
+ updated = false;
+ return true;
+ }
+
+ public bool EvaluateDisplayName(DictionaryContextData contextData, IExecutionContext context, out bool updated)
+ {
+ updated = false;
+ return true;
+ }
+
+ public Task RunAsync()
+ {
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/Runner.Worker/WaitStepRunner.cs b/src/Runner.Worker/WaitStepRunner.cs
new file mode 100644
index 00000000000..4048e6874a2
--- /dev/null
+++ b/src/Runner.Worker/WaitStepRunner.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Threading.Tasks;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using GitHub.DistributedTask.Pipelines.ContextData;
+
+namespace GitHub.Runner.Worker
+{
+ ///
+ /// A step that blocks until specific background step(s) complete.
+ /// Execution is handled by StepsRunner, not by RunAsync.
+ ///
+ public sealed class WaitStepRunner : IStep
+ {
+ public string[] StepIds { get; set; }
+ public Guid StepId { get; set; }
+ public string StepName { get; set; }
+ public int RecordOrder { get; set; }
+ public string Condition { get; set; }
+ public TemplateToken ContinueOnError => null;
+ public string DisplayName { get; set; }
+ public IExecutionContext ExecutionContext { get; set; }
+ public TemplateToken Timeout => null;
+
+ public bool TryUpdateDisplayName(out bool updated)
+ {
+ updated = false;
+ return true;
+ }
+
+ public bool EvaluateDisplayName(DictionaryContextData contextData, IExecutionContext context, out bool updated)
+ {
+ updated = false;
+ return true;
+ }
+
+ public Task RunAsync()
+ {
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/Sdk/DTPipelines/Pipelines/ActionStep.cs b/src/Sdk/DTPipelines/Pipelines/ActionStep.cs
index f4ed5f041b5..01bf3381ef5 100644
--- a/src/Sdk/DTPipelines/Pipelines/ActionStep.cs
+++ b/src/Sdk/DTPipelines/Pipelines/ActionStep.cs
@@ -25,6 +25,7 @@ private ActionStep(ActionStep actionToClone)
Inputs = actionToClone.Inputs?.Clone();
ContextName = actionToClone?.ContextName;
DisplayNameToken = actionToClone.DisplayNameToken?.Clone();
+ Background = actionToClone.Background;
}
public override StepType Type => StepType.Action;
@@ -49,6 +50,9 @@ public ActionStepDefinitionReference Reference
[DataMember(EmitDefaultValue = false)]
public TemplateToken Inputs { get; set; }
+ [DataMember(EmitDefaultValue = false)]
+ public bool Background { get; set; }
+
public override Step Clone()
{
return new ActionStep(this);
diff --git a/src/Sdk/DTPipelines/Pipelines/CancelStep.cs b/src/Sdk/DTPipelines/Pipelines/CancelStep.cs
new file mode 100644
index 00000000000..0bebc5ec788
--- /dev/null
+++ b/src/Sdk/DTPipelines/Pipelines/CancelStep.cs
@@ -0,0 +1,41 @@
+using System;
+using System.ComponentModel;
+using System.Runtime.Serialization;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using Newtonsoft.Json;
+
+namespace GitHub.DistributedTask.Pipelines
+{
+ ///
+ /// Represents a cancel step that terminates a specific background step.
+ ///
+ [DataContract]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public class CancelStep : JobStep
+ {
+ [JsonConstructor]
+ public CancelStep()
+ {
+ }
+
+ private CancelStep(CancelStep stepToClone)
+ : base(stepToClone)
+ {
+ this.CancelStepId = stepToClone.CancelStepId;
+ this.DisplayNameToken = stepToClone.DisplayNameToken?.Clone();
+ }
+
+ public override StepType Type => StepType.Cancel;
+
+ [DataMember(EmitDefaultValue = false)]
+ public string CancelStepId { get; set; }
+
+ [DataMember(EmitDefaultValue = false)]
+ public TemplateToken DisplayNameToken { get; set; }
+
+ public override Step Clone()
+ {
+ return new CancelStep(this);
+ }
+ }
+}
diff --git a/src/Sdk/DTPipelines/Pipelines/Step.cs b/src/Sdk/DTPipelines/Pipelines/Step.cs
index 8c2492eaa28..08e1fd23470 100644
--- a/src/Sdk/DTPipelines/Pipelines/Step.cs
+++ b/src/Sdk/DTPipelines/Pipelines/Step.cs
@@ -7,6 +7,9 @@ namespace GitHub.DistributedTask.Pipelines
{
[DataContract]
[KnownType(typeof(ActionStep))]
+ [KnownType(typeof(WaitStep))]
+ [KnownType(typeof(WaitAllStep))]
+ [KnownType(typeof(CancelStep))]
[JsonConverter(typeof(StepConverter))]
[EditorBrowsable(EditorBrowsableState.Never)]
public abstract class Step
@@ -68,5 +71,11 @@ public enum StepType
{
[DataMember]
Action = 4,
+ [DataMember]
+ Wait = 5,
+ [DataMember]
+ WaitAll = 6,
+ [DataMember]
+ Cancel = 7,
}
}
diff --git a/src/Sdk/DTPipelines/Pipelines/StepConverter.cs b/src/Sdk/DTPipelines/Pipelines/StepConverter.cs
index c6b9ad559b5..822bbe7ecaf 100644
--- a/src/Sdk/DTPipelines/Pipelines/StepConverter.cs
+++ b/src/Sdk/DTPipelines/Pipelines/StepConverter.cs
@@ -51,6 +51,15 @@ public override object ReadJson(
case StepType.Action:
stepObject = new ActionStep();
break;
+ case StepType.Wait:
+ stepObject = new WaitStep();
+ break;
+ case StepType.WaitAll:
+ stepObject = new WaitAllStep();
+ break;
+ case StepType.Cancel:
+ stepObject = new CancelStep();
+ break;
}
using (var objectReader = value.CreateReader())
diff --git a/src/Sdk/DTPipelines/Pipelines/WaitAllStep.cs b/src/Sdk/DTPipelines/Pipelines/WaitAllStep.cs
new file mode 100644
index 00000000000..e8c2980d4dc
--- /dev/null
+++ b/src/Sdk/DTPipelines/Pipelines/WaitAllStep.cs
@@ -0,0 +1,37 @@
+using System;
+using System.ComponentModel;
+using System.Runtime.Serialization;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using Newtonsoft.Json;
+
+namespace GitHub.DistributedTask.Pipelines
+{
+ ///
+ /// Represents a wait-all step that blocks until all prior background steps complete.
+ ///
+ [DataContract]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public class WaitAllStep : JobStep
+ {
+ [JsonConstructor]
+ public WaitAllStep()
+ {
+ }
+
+ private WaitAllStep(WaitAllStep stepToClone)
+ : base(stepToClone)
+ {
+ this.DisplayNameToken = stepToClone.DisplayNameToken?.Clone();
+ }
+
+ public override StepType Type => StepType.WaitAll;
+
+ [DataMember(EmitDefaultValue = false)]
+ public TemplateToken DisplayNameToken { get; set; }
+
+ public override Step Clone()
+ {
+ return new WaitAllStep(this);
+ }
+ }
+}
diff --git a/src/Sdk/DTPipelines/Pipelines/WaitStep.cs b/src/Sdk/DTPipelines/Pipelines/WaitStep.cs
new file mode 100644
index 00000000000..8464b600515
--- /dev/null
+++ b/src/Sdk/DTPipelines/Pipelines/WaitStep.cs
@@ -0,0 +1,43 @@
+using System;
+using System.ComponentModel;
+using System.Runtime.Serialization;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using Newtonsoft.Json;
+
+namespace GitHub.DistributedTask.Pipelines
+{
+ ///
+ /// Represents a wait step that blocks until specific background step(s) complete.
+ ///
+ [DataContract]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public class WaitStep : JobStep
+ {
+ [JsonConstructor]
+ public WaitStep()
+ {
+ }
+
+ private WaitStep(WaitStep stepToClone)
+ : base(stepToClone)
+ {
+ this.WaitStepIds = stepToClone.WaitStepIds != null
+ ? (string[])stepToClone.WaitStepIds.Clone()
+ : null;
+ this.DisplayNameToken = stepToClone.DisplayNameToken?.Clone();
+ }
+
+ public override StepType Type => StepType.Wait;
+
+ [DataMember(EmitDefaultValue = false)]
+ public string[] WaitStepIds { get; set; }
+
+ [DataMember(EmitDefaultValue = false)]
+ public TemplateToken DisplayNameToken { get; set; }
+
+ public override Step Clone()
+ {
+ return new WaitStep(this);
+ }
+ }
+}
diff --git a/src/Sdk/RSWebApi/Contracts/StepResult.cs b/src/Sdk/RSWebApi/Contracts/StepResult.cs
index 300fb7741a7..c89e413a780 100644
--- a/src/Sdk/RSWebApi/Contracts/StepResult.cs
+++ b/src/Sdk/RSWebApi/Contracts/StepResult.cs
@@ -50,5 +50,17 @@ public class StepResult
[DataMember(Name = "annotations", EmitDefaultValue = false)]
public List Annotations { get; set; }
+
+ [DataMember(Name = "is_background", EmitDefaultValue = false)]
+ public bool IsBackground { get; set; }
+
+ [DataMember(Name = "step_type", EmitDefaultValue = false)]
+ public string StepType { get; set; }
+
+ [DataMember(Name = "wait_step_ids", EmitDefaultValue = false)]
+ public string[] WaitStepIds { get; set; }
+
+ [DataMember(Name = "cancel_step_id", EmitDefaultValue = false)]
+ public string CancelStepId { get; set; }
}
}
diff --git a/src/Sdk/WebApi/WebApi/Contracts.cs b/src/Sdk/WebApi/WebApi/Contracts.cs
index 0018062ea58..de2235d00c9 100644
--- a/src/Sdk/WebApi/WebApi/Contracts.cs
+++ b/src/Sdk/WebApi/WebApi/Contracts.cs
@@ -179,6 +179,30 @@ public class Step
public string CompletedAt;
[DataMember]
public Conclusion Conclusion;
+ [DataMember(EmitDefaultValue = false)]
+ public bool IsBackground;
+ [DataMember(EmitDefaultValue = false)]
+ public string StepType;
+ [DataMember(EmitDefaultValue = false)]
+ public WaitControlDto Wait;
+ [DataMember(EmitDefaultValue = false)]
+ public CancelControlDto Cancel;
+ }
+
+ [DataContract]
+ [JsonObject(NamingStrategyType = typeof(SnakeCaseNamingStrategy))]
+ public class WaitControlDto
+ {
+ [DataMember]
+ public string[] StepIds;
+ }
+
+ [DataContract]
+ [JsonObject(NamingStrategyType = typeof(SnakeCaseNamingStrategy))]
+ public class CancelControlDto
+ {
+ [DataMember]
+ public string StepId;
}
public enum Status
diff --git a/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs b/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs
index 31819a4b2bf..1a840c1f925 100644
--- a/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs
+++ b/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs
@@ -514,7 +514,19 @@ public async Task UploadResultsDiagnosticLogsAsync(string planId, string jobId,
private Step ConvertTimelineRecordToStep(TimelineRecord r)
{
- return new Step()
+ // DEBUG: Log all variables on this timeline record to a file
+ try
+ {
+ var debugLine = $"[BG-DEBUG] ConvertTimelineRecordToStep: name={r.Name}, id={r.Id}, variableCount={r.Variables.Count}";
+ foreach (var kvp in r.Variables)
+ {
+ debugLine += $"\n Variable: {kvp.Key}={kvp.Value.Value}";
+ }
+ System.IO.File.AppendAllText("/tmp/bg-steps-debug.log", debugLine + "\n");
+ }
+ catch { }
+
+ var step = new Step()
{
ExternalId = r.Id.ToString(),
Number = r.Order.GetValueOrDefault(),
@@ -524,6 +536,36 @@ private Step ConvertTimelineRecordToStep(TimelineRecord r)
CompletedAt = r.FinishTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
Conclusion = ConvertResultToConclusion(r.Result)
};
+
+ // Populate background step metadata from TimelineRecord.Variables
+ if (r.Variables.TryGetValue("is_background", out var bgVar) && bgVar.Value == "true")
+ {
+ step.IsBackground = true;
+ }
+ if (r.Variables.TryGetValue("step_type", out var stVar) && !string.IsNullOrEmpty(stVar.Value))
+ {
+ // Map internal step type names to protobuf enum names for JSON serialization
+ step.StepType = stVar.Value switch
+ {
+ "run" => "STEP_TYPE_RUN",
+ "action" => "STEP_TYPE_ACTION",
+ "wait" => "STEP_TYPE_WAIT",
+ "wait-all" => "STEP_TYPE_WAIT_ALL",
+ "cancel" => "STEP_TYPE_CANCEL",
+ _ => stVar.Value
+ };
+ }
+ if (r.Variables.TryGetValue("wait_step_ids", out var wsVar) && !string.IsNullOrEmpty(wsVar.Value))
+ {
+ step.Wait = new WaitControlDto { StepIds = wsVar.Value.Split(',') };
+ }
+ if (r.Variables.TryGetValue("cancel_step_id", out var csVar) && !string.IsNullOrEmpty(csVar.Value))
+ {
+ step.Cancel = new CancelControlDto { StepId = csVar.Value };
+ }
+
+ System.IO.File.AppendAllText("/tmp/bg-steps-debug.log", $"[BG-DEBUG] Result: name={step.Name}, isBackground={step.IsBackground}, stepType={step.StepType}\n");
+ return step;
}
private Status ConvertStateToStatus(TimelineRecordState s)
@@ -567,7 +609,18 @@ private Conclusion ConvertResultToConclusion(TaskResult? r)
public async Task UpdateWorkflowStepsAsync(Guid planId, IEnumerable records, CancellationToken cancellationToken)
{
var timestamp = DateTime.UtcNow.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture);
- var stepRecords = records.Where(r => String.Equals(r.RecordType, "Task", StringComparison.Ordinal));
+ var stepRecords = records.Where(r => String.Equals(r.RecordType, "Task", StringComparison.Ordinal)).ToList();
+
+ try
+ {
+ System.IO.File.AppendAllText("/tmp/bg-steps-debug.log", $"[BG-DEBUG] UpdateWorkflowStepsAsync: {stepRecords.Count} task records\n");
+ foreach (var r in stepRecords)
+ {
+ System.IO.File.AppendAllText("/tmp/bg-steps-debug.log", $"[BG-DEBUG] Record: name={r.Name}, id={r.Id}, vars={r.Variables.Count}\n");
+ }
+ }
+ catch { }
+
var stepUpdateRequests = stepRecords.GroupBy(r => r.ParentId).Select(sg => new StepsUpdateRequest()
{
WorkflowRunBackendId = planId.ToString(),
@@ -579,6 +632,16 @@ public async Task UpdateWorkflowStepsAsync(Guid planId, IEnumerable(stepUpdateEndpoint, cancellationToken, request, timestamp);
}
}
diff --git a/src/Test/L0/Worker/BackgroundStepsL0.cs b/src/Test/L0/Worker/BackgroundStepsL0.cs
new file mode 100644
index 00000000000..79323c75ab5
--- /dev/null
+++ b/src/Test/L0/Worker/BackgroundStepsL0.cs
@@ -0,0 +1,561 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Moq;
+using Xunit;
+using GitHub.DistributedTask.Expressions2;
+using GitHub.DistributedTask.Pipelines.ContextData;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using GitHub.DistributedTask.WebApi;
+using GitHub.Runner.Common.Util;
+using GitHub.Runner.Worker;
+using GitHub.Runner.Worker.Dap;
+
+namespace GitHub.Runner.Common.Tests.Worker
+{
+ public sealed class BackgroundStepsL0
+ {
+ private Mock _ec;
+ private StepsRunner _stepsRunner;
+ private Variables _variables;
+ private Dictionary _env;
+ private DictionaryContextData _contexts;
+ private JobContext _jobContext;
+ private StepsContext _stepContext;
+
+ private TestHostContext CreateTestContext([CallerMemberName] String testName = "")
+ {
+ var hc = new TestHostContext(this, testName);
+ Dictionary variablesToCopy = new();
+ _variables = new Variables(
+ hostContext: hc,
+ copy: variablesToCopy);
+ _env = new Dictionary()
+ {
+ {"env1", "1"},
+ {"test", "github_actions"}
+ };
+ _ec = new Mock();
+ _ec.SetupAllProperties();
+ _ec.Setup(x => x.Global).Returns(new GlobalContext { WriteDebug = true });
+ _ec.Object.Global.Variables = _variables;
+ _ec.Object.Global.EnvironmentVariables = _env;
+
+ _contexts = new DictionaryContextData();
+ _jobContext = new JobContext();
+ _contexts["github"] = new GitHubContext();
+ _contexts["runner"] = new DictionaryContextData();
+ _contexts["job"] = _jobContext;
+ _ec.Setup(x => x.ExpressionValues).Returns(_contexts);
+ _ec.Setup(x => x.ExpressionFunctions).Returns(new List());
+ _ec.Setup(x => x.JobContext).Returns(_jobContext);
+ _ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
+
+ _stepContext = new StepsContext();
+ _ec.Object.Global.StepsContext = _stepContext;
+
+ _ec.Setup(x => x.PostJobSteps).Returns(new Stack());
+
+ var trace = hc.GetTrace();
+
+ // Mock CreateChild for implicit wait-all step injection
+ _ec.Setup(x => x.CreateChild(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny>(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((Guid recordId, string displayName, string refName, string scopeName, string contextName,
+ ActionRunStage stage, Dictionary intraActionState, int? recordOrder, IPagingLogger logger,
+ bool isEmbedded, List issues, CancellationTokenSource cts, Guid embeddedId, string siblingScopeName, TimeSpan? timeout) =>
+ {
+ var childEc = new Mock();
+ childEc.SetupAllProperties();
+ childEc.Setup(x => x.Global).Returns(() => _ec.Object.Global);
+ childEc.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
+ childEc.Setup(x => x.ExpressionFunctions).Returns(new List());
+ childEc.Setup(x => x.ContextName).Returns(contextName);
+ childEc.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
+ childEc.Setup(x => x.SetTimelineRecordVariable(It.IsAny(), It.IsAny()));
+ childEc.Setup(x => x.Complete(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((TaskResult? r, string currentOperation, string resultCode) =>
+ {
+ if (r != null) childEc.Object.Result = r;
+ });
+ childEc.Setup(x => x.Write(It.IsAny(), It.IsAny())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
+ return childEc.Object;
+ });
+
+ _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
+
+ _stepsRunner = new StepsRunner();
+ _stepsRunner.Initialize(hc);
+
+ var mockDapDebugger = new Mock();
+ hc.SetSingleton(mockDapDebugger.Object);
+
+ return hc;
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task BackgroundStepRunsConcurrentlyWithForeground()
+ {
+ using (TestHostContext hc = CreateTestContext())
+ {
+ // Arrange: background step that takes time, followed by a foreground step
+ var executionOrder = new List();
+
+ var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg-step", contextName: "bg");
+ bgStep.Setup(x => x.RunAsync()).Returns(async () =>
+ {
+ executionOrder.Add("bg-start");
+ await Task.Delay(200);
+ executionOrder.Add("bg-end");
+ });
+ bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
+ {
+ Name = "bg-step",
+ Id = Guid.NewGuid(),
+ ContextName = "bg",
+ Background = true,
+ });
+
+ var fgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "fg-step", contextName: "fg");
+ fgStep.Setup(x => x.RunAsync()).Returns(() =>
+ {
+ executionOrder.Add("fg-run");
+ return Task.CompletedTask;
+ });
+
+ var waitAllStep = CreateWaitAllStep(hc);
+
+ _ec.Object.Result = null;
+ _ec.Setup(x => x.JobSteps).Returns(new Queue(new IStep[]
+ {
+ bgStep.Object, fgStep.Object, waitAllStep
+ }));
+
+ // Act
+ await _stepsRunner.RunAsync(jobContext: _ec.Object);
+
+ // Assert: foreground step should start before background step finishes
+ Assert.Contains("bg-start", executionOrder);
+ Assert.Contains("fg-run", executionOrder);
+ Assert.Contains("bg-end", executionOrder);
+ var fgIndex = executionOrder.IndexOf("fg-run");
+ var bgEndIndex = executionOrder.IndexOf("bg-end");
+ Assert.True(fgIndex < bgEndIndex, "Foreground step should run before background step completes");
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task WaitStepBlocksUntilBackgroundCompletes()
+ {
+ using (TestHostContext hc = CreateTestContext())
+ {
+ // Arrange
+ var bgCompleted = false;
+ var timelineVariables = new Dictionary();
+ var bgExternalId = Guid.NewGuid();
+
+ var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "db", contextName: "db", recordId: bgExternalId);
+ bgStep.Setup(x => x.RunAsync()).Returns(async () =>
+ {
+ await Task.Delay(100);
+ bgCompleted = true;
+ });
+ bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
+ {
+ Name = "db",
+ Id = Guid.NewGuid(),
+ ContextName = "db",
+ Background = true,
+ });
+
+ var waitStep = CreateWaitStep(hc, new[] { "db" }, timelineVariables);
+
+ _ec.Object.Result = null;
+ _ec.Setup(x => x.JobSteps).Returns(new Queue(new IStep[]
+ {
+ bgStep.Object, waitStep
+ }));
+
+ // Act
+ await _stepsRunner.RunAsync(jobContext: _ec.Object);
+
+ // Assert: background step must have completed after wait
+ Assert.True(bgCompleted, "Background step should have completed after wait");
+ Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
+ Assert.Equal(bgExternalId.ToString("N"), timelineVariables["wait_step_ids"]);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task BackgroundStepFailurePropagatesAtWait()
+ {
+ using (TestHostContext hc = CreateTestContext())
+ {
+ // Arrange: background step that fails
+ var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "flaky", contextName: "flaky");
+ bgStep.Setup(x => x.RunAsync()).Returns(() =>
+ {
+ throw new Exception("Service crashed");
+ });
+ bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
+ {
+ Name = "flaky",
+ Id = Guid.NewGuid(),
+ ContextName = "flaky",
+ Background = true,
+ });
+
+ var waitStep = CreateWaitStep(hc, new[] { "flaky" });
+
+ _ec.Object.Result = null;
+ _ec.Setup(x => x.JobSteps).Returns(new Queue(new IStep[]
+ {
+ bgStep.Object, waitStep
+ }));
+
+ // Act
+ await _stepsRunner.RunAsync(jobContext: _ec.Object);
+
+ // Assert: job should fail because background step failed
+ Assert.Equal(TaskResult.Failed, _ec.Object.Result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task CancelStepTerminatesBackgroundStep()
+ {
+ using (TestHostContext hc = CreateTestContext())
+ {
+ // Arrange: background step that runs until cancelled
+ var bgCts = new CancellationTokenSource();
+
+ var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server");
+ bgStep.Setup(x => x.RunAsync()).Returns(async () =>
+ {
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(30), bgCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ });
+ bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
+ {
+ Name = "server",
+ Id = Guid.NewGuid(),
+ ContextName = "server",
+ Background = true,
+ });
+
+ var cancelStep = CreateCancelStep(hc, "server");
+
+ _ec.Object.Result = null;
+ _ec.Setup(x => x.JobSteps).Returns(new Queue(new IStep[]
+ {
+ bgStep.Object, cancelStep
+ }));
+
+ // Act
+ await _stepsRunner.RunAsync(jobContext: _ec.Object);
+
+ // Assert: background step should have been cancelled
+ // Note: the cancel mechanism uses the BackgroundStepContext.Cts, not bgCts
+ // so wasCancelled may not be true in this mock, but the step should complete
+ Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task WaitAllWaitsForAllBackgroundSteps()
+ {
+ using (TestHostContext hc = CreateTestContext())
+ {
+ // Arrange: two background steps
+ var step1Done = false;
+ var step2Done = false;
+ var timelineVariables = new Dictionary();
+ var bgStep1ExternalId = Guid.NewGuid();
+ var bgStep2ExternalId = Guid.NewGuid();
+
+ var bgStep1 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc1", contextName: "svc1", recordId: bgStep1ExternalId);
+ bgStep1.Setup(x => x.RunAsync()).Returns(async () =>
+ {
+ await Task.Delay(50);
+ step1Done = true;
+ });
+ bgStep1.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
+ {
+ Name = "svc1",
+ Id = Guid.NewGuid(),
+ ContextName = "svc1",
+ Background = true,
+ });
+
+ var bgStep2 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc2", contextName: "svc2", recordId: bgStep2ExternalId);
+ bgStep2.Setup(x => x.RunAsync()).Returns(async () =>
+ {
+ await Task.Delay(100);
+ step2Done = true;
+ });
+ bgStep2.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
+ {
+ Name = "svc2",
+ Id = Guid.NewGuid(),
+ ContextName = "svc2",
+ Background = true,
+ });
+
+ var waitAllStep = CreateWaitAllStep(hc, timelineVariables);
+
+ _ec.Object.Result = null;
+ _ec.Setup(x => x.JobSteps).Returns(new Queue(new IStep[]
+ {
+ bgStep1.Object, bgStep2.Object, waitAllStep
+ }));
+
+ // Act
+ await _stepsRunner.RunAsync(jobContext: _ec.Object);
+
+ // Assert
+ Assert.True(step1Done, "Background step 1 should have completed");
+ Assert.True(step2Done, "Background step 2 should have completed");
+ Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
+ Assert.Equal(
+ new[] { bgStep1ExternalId.ToString("N"), bgStep2ExternalId.ToString("N") }.OrderBy(id => id),
+ timelineVariables["wait_step_ids"].Split(',').OrderBy(id => id));
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task CancelStepPublishesCanceledBackgroundExternalId()
+ {
+ using (TestHostContext hc = CreateTestContext())
+ {
+ var timelineVariables = new Dictionary();
+ var bgExternalId = Guid.NewGuid();
+
+ var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", recordId: bgExternalId);
+ bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
+ bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
+ {
+ Name = "server",
+ Id = bgExternalId,
+ ContextName = "server",
+ Background = true,
+ });
+
+ var cancelStep = CreateCancelStep(hc, "server", timelineVariables);
+
+ _ec.Object.Result = null;
+ _ec.Setup(x => x.JobSteps).Returns(new Queue(new IStep[]
+ {
+ bgStep.Object, cancelStep
+ }));
+
+ await _stepsRunner.RunAsync(jobContext: _ec.Object);
+
+ Assert.Equal(bgExternalId.ToString("N"), timelineVariables["cancel_step_id"]);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task StepsContextThreadSafety()
+ {
+ // Test that concurrent SetOutput/SetConclusion doesn't throw
+ var stepsContext = new StepsContext();
+ var tasks = new List();
+
+ for (int i = 0; i < 100; i++)
+ {
+ var index = i;
+ tasks.Add(Task.Run(() =>
+ {
+ stepsContext.SetOutput("", $"step{index}", "out", $"value{index}", out _);
+ stepsContext.SetConclusion("", $"step{index}", ActionResult.Success);
+ stepsContext.SetOutcome("", $"step{index}", ActionResult.Success);
+ }));
+ }
+
+ await Task.WhenAll(tasks);
+
+ // Assert: all 100 steps should have their data set
+ var scope = stepsContext.GetScope("");
+ Assert.Equal(100, scope.Count);
+ }
+
+ #region Helpers
+
+ private Mock CreateStep(TestHostContext hc, TaskResult result, string condition, string name = "Test", string contextName = null, Guid? recordId = null)
+ {
+ var stepRecordId = recordId ?? Guid.NewGuid();
+ var step = new Mock();
+ step.Setup(x => x.Condition).Returns(condition);
+ step.Setup(x => x.ContinueOnError).Returns(new BooleanToken(null, null, null, false));
+ step.Setup(x => x.Action)
+ .Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
+ {
+ Name = name,
+ Id = stepRecordId,
+ ContextName = contextName ?? name,
+ });
+
+ var stepContext = new Mock();
+ stepContext.SetupAllProperties();
+ stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
+ var expressionValues = new DictionaryContextData();
+ foreach (var pair in _ec.Object.ExpressionValues)
+ {
+ expressionValues[pair.Key] = pair.Value;
+ }
+ stepContext.Setup(x => x.ExpressionValues).Returns(expressionValues);
+ stepContext.Setup(x => x.ExpressionFunctions).Returns(new List());
+ stepContext.Setup(x => x.JobContext).Returns(_jobContext);
+ stepContext.Setup(x => x.Id).Returns(stepRecordId);
+ stepContext.Setup(x => x.ContextName).Returns(step.Object.Action.ContextName);
+ stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
+ stepContext.Setup(x => x.Complete(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((TaskResult? r, string currentOperation, string resultCode) =>
+ {
+ if (r != null)
+ {
+ stepContext.Object.Result = r;
+ }
+ _stepContext.SetOutcome("", stepContext.Object.ContextName, (stepContext.Object.Outcome ?? stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
+ _stepContext.SetConclusion("", stepContext.Object.ContextName, (stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
+ });
+ stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List());
+ stepContext.Setup(x => x.ApplyContinueOnError(It.IsAny()));
+
+ var trace = hc.GetTrace();
+ stepContext.Setup(x => x.Write(It.IsAny(), It.IsAny())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
+ stepContext.Object.Result = result;
+ step.Setup(x => x.ExecutionContext).Returns(stepContext.Object);
+ step.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
+
+ return step;
+ }
+
+ private WaitStepRunner CreateWaitStep(TestHostContext hc, string[] stepIds, Dictionary timelineVariables = null)
+ {
+ var waitRunner = new WaitStepRunner
+ {
+ StepIds = stepIds,
+ DisplayName = "Wait",
+ Condition = "success()",
+ };
+
+ var stepContext = new Mock();
+ stepContext.SetupAllProperties();
+ stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
+ stepContext.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
+ stepContext.Setup(x => x.ExpressionFunctions).Returns(new List());
+ stepContext.Setup(x => x.ContextName).Returns("__wait");
+ stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
+ if (timelineVariables != null)
+ {
+ stepContext.Setup(x => x.SetTimelineRecordVariable(It.IsAny(), It.IsAny()))
+ .Callback((string name, string value) => timelineVariables[name] = value);
+ }
+ stepContext.Setup(x => x.Complete(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((TaskResult? r, string currentOperation, string resultCode) =>
+ {
+ if (r != null) stepContext.Object.Result = r;
+ });
+ var trace = hc.GetTrace();
+ stepContext.Setup(x => x.Write(It.IsAny(), It.IsAny())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
+
+ waitRunner.ExecutionContext = stepContext.Object;
+ return waitRunner;
+ }
+
+ private WaitAllStepRunner CreateWaitAllStep(TestHostContext hc, Dictionary timelineVariables = null)
+ {
+ var waitAllRunner = new WaitAllStepRunner
+ {
+ DisplayName = "Wait All",
+ Condition = "success()",
+ };
+
+ var stepContext = new Mock();
+ stepContext.SetupAllProperties();
+ stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
+ stepContext.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
+ stepContext.Setup(x => x.ExpressionFunctions).Returns(new List());
+ stepContext.Setup(x => x.ContextName).Returns("__wait-all");
+ stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
+ if (timelineVariables != null)
+ {
+ stepContext.Setup(x => x.SetTimelineRecordVariable(It.IsAny(), It.IsAny()))
+ .Callback((string name, string value) => timelineVariables[name] = value);
+ }
+ stepContext.Setup(x => x.Complete(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((TaskResult? r, string currentOperation, string resultCode) =>
+ {
+ if (r != null) stepContext.Object.Result = r;
+ });
+ var trace = hc.GetTrace();
+ stepContext.Setup(x => x.Write(It.IsAny(), It.IsAny())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
+
+ waitAllRunner.ExecutionContext = stepContext.Object;
+ return waitAllRunner;
+ }
+
+ private CancelStepRunner CreateCancelStep(TestHostContext hc, string cancelStepId, Dictionary timelineVariables = null)
+ {
+ var cancelRunner = new CancelStepRunner
+ {
+ CancelStepId = cancelStepId,
+ DisplayName = "Cancel",
+ Condition = "success()",
+ };
+
+ var stepContext = new Mock();
+ stepContext.SetupAllProperties();
+ stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
+ stepContext.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
+ stepContext.Setup(x => x.ExpressionFunctions).Returns(new List());
+ stepContext.Setup(x => x.ContextName).Returns("__cancel");
+ stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
+ if (timelineVariables != null)
+ {
+ stepContext.Setup(x => x.SetTimelineRecordVariable(It.IsAny(), It.IsAny()))
+ .Callback((string name, string value) => timelineVariables[name] = value);
+ }
+ stepContext.Setup(x => x.Complete(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((TaskResult? r, string currentOperation, string resultCode) =>
+ {
+ if (r != null) stepContext.Object.Result = r;
+ });
+ var trace = hc.GetTrace();
+ stepContext.Setup(x => x.Write(It.IsAny(), It.IsAny())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
+
+ cancelRunner.ExecutionContext = stepContext.Object;
+ return cancelRunner;
+ }
+
+ #endregion
+ }
+}