Skip to content

Commit 40dd583

Browse files
authored
Fix cancellation token race during parser comparison (#4280)
1 parent 68f2e9a commit 40dd583

File tree

3 files changed

+60
-15
lines changed

3 files changed

+60
-15
lines changed

src/Runner.Worker/ExecutionContext.cs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,7 @@ public interface IExecutionContext : IRunnerService
7777

7878
List<string> StepEnvironmentOverrides { get; }
7979

80-
ExecutionContext Root { get; }
81-
ExecutionContext Parent { get; }
80+
IExecutionContext Root { get; }
8281

8382
// Initialize
8483
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
@@ -251,7 +250,9 @@ public string ResultCode
251250
}
252251
}
253252

254-
public ExecutionContext Root
253+
IExecutionContext IExecutionContext.Root => Root;
254+
255+
private ExecutionContext Root
255256
{
256257
get
257258
{
@@ -266,13 +267,7 @@ public ExecutionContext Root
266267
}
267268
}
268269

269-
public ExecutionContext Parent
270-
{
271-
get
272-
{
273-
return _parentExecutionContext;
274-
}
275-
}
270+
276271

277272
public JobContext JobContext
278273
{

src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Threading;
34
using GitHub.Actions.WorkflowParser;
45
using GitHub.DistributedTask.Expressions2;
56
using GitHub.DistributedTask.ObjectTemplating.Tokens;
@@ -226,8 +227,12 @@ internal TLegacy EvaluateAndCompare<TLegacy, TNew>(
226227
Func<TNew> newEvaluator,
227228
Func<TLegacy, TNew, bool> resultComparer)
228229
{
229-
// Capture cancellation state before evaluation
230-
var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested;
230+
// Use the root (job-level) cancellation token to detect cancellation race conditions.
231+
// The step-level token only fires on step timeout, not on job cancellation.
232+
// Job cancellation mutates JobContext.Status which expression functions read,
233+
// so we need the root token to properly detect cancellation between evaluator runs.
234+
var rootCancellationToken = _context.Root?.CancellationToken ?? CancellationToken.None;
235+
var cancellationRequestedBefore = rootCancellationToken.IsCancellationRequested;
231236

232237
// Legacy evaluator
233238
var legacyException = default(Exception);
@@ -261,7 +266,7 @@ internal TLegacy EvaluateAndCompare<TLegacy, TNew>(
261266
}
262267

263268
// Capture cancellation state after evaluation
264-
var cancellationRequestedAfter = _context.CancellationToken.IsCancellationRequested;
269+
var cancellationRequestedAfter = rootCancellationToken.IsCancellationRequested;
265270

266271
// Compare results or exceptions
267272
bool hasMismatch = false;

src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ namespace GitHub.Runner.Common.Tests.Worker
1919
public sealed class PipelineTemplateEvaluatorWrapperL0
2020
{
2121
private CancellationTokenSource _ecTokenSource;
22+
private CancellationTokenSource _rootTokenSource;
2223
private Mock<IExecutionContext> _ec;
2324
private TestHostContext _hc;
2425

@@ -65,7 +66,7 @@ public void EvaluateAndCompare_SkipsMismatchRecording_WhenCancellationOccursDuri
6566

6667
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
6768

68-
// Call EvaluateAndCompare directly: the new evaluator cancels the token
69+
// Call EvaluateAndCompare directly: the new evaluator cancels the root token
6970
// and returns a different value, forcing hasMismatch = true.
7071
// Because cancellation flipped during the evaluation window, the
7172
// mismatch should be skipped.
@@ -74,7 +75,7 @@ public void EvaluateAndCompare_SkipsMismatchRecording_WhenCancellationOccursDuri
7475
() => "legacy-value",
7576
() =>
7677
{
77-
_ecTokenSource.Cancel();
78+
_rootTokenSource.Cancel();
7879
return "different-value";
7980
},
8081
(legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal));
@@ -88,6 +89,43 @@ public void EvaluateAndCompare_SkipsMismatchRecording_WhenCancellationOccursDuri
8889
}
8990
}
9091

92+
[Fact]
93+
[Trait("Level", "L0")]
94+
[Trait("Category", "Worker")]
95+
public void EvaluateAndCompare_SkipsMismatchRecording_WhenRootCancellationOccursBetweenEvaluators()
96+
{
97+
// Simulates job-level cancellation firing between legacy and new evaluator runs.
98+
// Root is mocked with a separate CancellationTokenSource to exercise the
99+
// _context.Root?.CancellationToken path (the job-level token).
100+
try
101+
{
102+
Setup();
103+
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
104+
105+
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
106+
107+
// Legacy evaluator cancels the root token (simulating job cancel) and returns a value.
108+
// The new evaluator returns a different value. The mismatch should be skipped.
109+
var result = wrapper.EvaluateAndCompare<string, string>(
110+
"TestRootCancellationSkip",
111+
() =>
112+
{
113+
var legacyValue = "legacy-value";
114+
_rootTokenSource.Cancel();
115+
return legacyValue;
116+
},
117+
() => "different-value",
118+
(legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal));
119+
120+
Assert.Equal("legacy-value", result);
121+
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
122+
}
123+
finally
124+
{
125+
Teardown();
126+
}
127+
}
128+
91129
[Fact]
92130
[Trait("Level", "L0")]
93131
[Trait("Category", "Worker")]
@@ -862,6 +900,8 @@ private void Setup([CallerMemberName] string name = "")
862900
{
863901
_ecTokenSource?.Dispose();
864902
_ecTokenSource = new CancellationTokenSource();
903+
_rootTokenSource?.Dispose();
904+
_rootTokenSource = new CancellationTokenSource();
865905

866906
_hc = new TestHostContext(this, name);
867907

@@ -877,6 +917,9 @@ private void Setup([CallerMemberName] string name = "")
877917
WriteDebug = true,
878918
});
879919
_ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token);
920+
var rootEc = new Mock<IExecutionContext>();
921+
rootEc.Setup(x => x.CancellationToken).Returns(_rootTokenSource.Token);
922+
_ec.Setup(x => x.Root).Returns(rootEc.Object);
880923
_ec.Setup(x => x.ExpressionValues).Returns(expressionValues);
881924
_ec.Setup(x => x.ExpressionFunctions).Returns(expressionFunctions);
882925
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); });
@@ -885,6 +928,8 @@ private void Setup([CallerMemberName] string name = "")
885928

886929
private void Teardown()
887930
{
931+
_ecTokenSource?.Dispose();
932+
_rootTokenSource?.Dispose();
888933
_hc?.Dispose();
889934
}
890935
}

0 commit comments

Comments
 (0)