From 41d198590df31fa69ead2e5e114e8734fd6560d9 Mon Sep 17 00:00:00 2001 From: sebastiandero Date: Thu, 6 Aug 2026 11:36:26 -0700 Subject: [PATCH 1/2] fix(worker): use thread-safe atomic file write --- src/Runner.Worker/ExecutionContext.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 0f9410821c6..18ba83d88a1 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -1330,7 +1330,11 @@ public void WriteWebhookPayload() { var workflowFile = Path.Combine(workflowDirectory, "event.json"); Trace.Info($"Write event payload to {workflowFile}"); - File.WriteAllText(workflowFile, gitHubEvent, new UTF8Encoding(false)); + + // Ensure "parallel:" does not cause crashes here + var tempEventFile = Path.Combine(workflowDirectory, $"event.{Guid.NewGuid():N}.tmp"); + File.WriteAllText(tempEventFile, gitHubEvent, new UTF8Encoding(false)); + File.Move(tempEventFile, workflowFile, overwrite: true); SetGitHubContext("event_path", workflowFile); } } From f681cdd6b53c5a9a4449b3ef7b205b37a3265ca3 Mon Sep 17 00:00:00 2001 From: sebastiandero Date: Thu, 6 Aug 2026 15:16:42 -0700 Subject: [PATCH 2/2] tests(worker): add regression test --- src/Test/L0/Worker/ExecutionContextL0.cs | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/Test/L0/Worker/ExecutionContextL0.cs b/src/Test/L0/Worker/ExecutionContextL0.cs index 553c8b154ff..1f25c39c19c 100644 --- a/src/Test/L0/Worker/ExecutionContextL0.cs +++ b/src/Test/L0/Worker/ExecutionContextL0.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Runtime.CompilerServices; +using System.Text; using System.Threading; +using System.Threading.Tasks; using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Worker; @@ -1014,6 +1017,86 @@ public void PublishStepResult_EmbeddedStep_Legacy() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WriteWebhookPayload_ConcurrentReaderNeverObservesTruncatedFile() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange: Create a job request message with a github.event payload large enough + // that a non-atomic rewrite is observable while the stream flushes. + 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(), new List(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List(), null, null, null, null, null); + jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource() + { + Alias = Pipelines.PipelineConstants.SelfAlias, + Id = "github", + Version = "sha1" + }); + string gitHubEvent = $"{{\"filler\":\"{new string('a', 512 * 1024)}\"}}"; + var githubData = new Pipelines.ContextData.DictionaryContextData(); + githubData["event"] = new StringContextData(gitHubEvent); + jobRequest.ContextData["github"] = githubData; + + var pagingLogger = new Mock(); + hc.EnqueueInstance(pagingLogger.Object); + + var ec = new Runner.Worker.ExecutionContext(); + ec.Initialize(hc); + ec.InitializeJob(jobRequest, CancellationToken.None); + + ec.WriteWebhookPayload(); + string eventPath = ec.GetGitHubContext("event_path"); + Assert.Equal(gitHubEvent, File.ReadAllText(eventPath)); + + // Act: Rewrite the payload the way each starting action step does + // (ActionRunner.RunAsync), while a concurrent reader mimics a sibling parallel + // step's Node action parsing GITHUB_EVENT_PATH at module load. + using var writerDone = new CancellationTokenSource(); + var writer = Task.Run(() => + { + try + { + for (int i = 0; i < 100; i++) + { + ec.WriteWebhookPayload(); + } + } + finally + { + writerDone.Cancel(); + } + }); + + var truncatedReadLengths = new List(); + var reader = Task.Run(() => + { + while (!writerDone.IsCancellationRequested) + { + using var stream = new FileStream(eventPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var textReader = new StreamReader(stream, new UTF8Encoding(false)); + string observed = textReader.ReadToEnd(); + if (observed.Length != gitHubEvent.Length) + { + truncatedReadLengths.Add(observed.Length); + } + } + }); + + await writer; + await reader; + + // Assert + Assert.True(truncatedReadLengths.Count == 0, $"{truncatedReadLengths.Count} concurrent read(s) observed a truncated event.json (expected {gitHubEvent.Length} chars, observed lengths: {string.Join(", ", truncatedReadLengths.Take(5))}). The event.json write must be atomic."); + Assert.Equal(gitHubEvent, File.ReadAllText(eventPath)); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(eventPath), "*.tmp")); + } + } + private TestHostContext CreateTestContext([CallerMemberName] String testName = "") { var hc = new TestHostContext(this, testName);