mirror of
https://github.com/actions/runner.git
synced 2026-07-05 12:11:57 +08:00
Compare commits
7 Commits
copilot/fi
...
fix/normal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed0e5b75ee | ||
|
|
fffded93ac | ||
|
|
8307b8fe33 | ||
|
|
7585eb30aa | ||
|
|
0cdaa36d07 | ||
|
|
5ed0c52e21 | ||
|
|
16c8a91b21 |
@@ -64,6 +64,7 @@ namespace GitHub.Runner.Common
|
||||
private readonly List<ProductInfoHeaderValue> _userAgents = new() { new ProductInfoHeaderValue($"GitHubActionsRunner-{BuildConstants.RunnerPackage.PackageName}", BuildConstants.RunnerPackage.Version) };
|
||||
private CancellationTokenSource _runnerShutdownTokenSource = new();
|
||||
private object _perfLock = new();
|
||||
private string _canonicalRootDirectory;
|
||||
private Tracing _trace;
|
||||
private Tracing _actionsHttpTrace;
|
||||
private Tracing _netcoreHttpTrace;
|
||||
@@ -391,7 +392,12 @@ namespace GitHub.Runner.Common
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Root:
|
||||
path = new DirectoryInfo(GetDirectory(WellKnownDirectory.Bin)).Parent.FullName;
|
||||
if (_canonicalRootDirectory == null)
|
||||
{
|
||||
_canonicalRootDirectory = PathUtil.GetCanonicalPath(
|
||||
new DirectoryInfo(GetDirectory(WellKnownDirectory.Bin)).Parent.FullName);
|
||||
}
|
||||
path = _canonicalRootDirectory;
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Temp:
|
||||
|
||||
@@ -12,8 +12,6 @@ namespace GitHub.Runner.Plugins.Repository.v1_0
|
||||
{
|
||||
public class CheckoutTask : IRunnerActionPlugin
|
||||
{
|
||||
private readonly Regex _validSha1 = new(@"\b[0-9a-f]{40}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromSeconds(2));
|
||||
|
||||
public async Task RunAsync(RunnerActionPluginExecutionContext executionContext, CancellationToken token)
|
||||
{
|
||||
string runnerWorkspace = executionContext.GetRunnerContext("workspace");
|
||||
@@ -99,7 +97,7 @@ namespace GitHub.Runner.Plugins.Repository.v1_0
|
||||
{
|
||||
sourceBranch = refInput;
|
||||
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.CommitHash))
|
||||
{
|
||||
sourceVersion = sourceBranch;
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ namespace GitHub.Runner.Plugins.Repository.v1_1
|
||||
{
|
||||
sourceBranch = refInput;
|
||||
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.CommitHash))
|
||||
{
|
||||
sourceVersion = sourceBranch;
|
||||
// If Ref is a SHA and the repo is self, we need to use github.ref as source branch since it might be refs/pull/*
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace GitHub.Runner.Sdk
|
||||
{
|
||||
@@ -6,8 +9,98 @@ namespace GitHub.Runner.Sdk
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
public static readonly string PathVariable = "Path";
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern SafeFileHandle CreateFile(
|
||||
string lpFileName,
|
||||
uint dwDesiredAccess,
|
||||
uint dwShareMode,
|
||||
System.IntPtr lpSecurityAttributes,
|
||||
uint dwCreationDisposition,
|
||||
uint dwFlagsAndAttributes,
|
||||
System.IntPtr hTemplateFile);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern uint GetFinalPathNameByHandle(
|
||||
SafeFileHandle hFile,
|
||||
[Out] StringBuilder lpszFilePath,
|
||||
uint cchFilePath,
|
||||
uint dwFlags);
|
||||
|
||||
private const uint FILE_READ_ATTRIBUTES = 0x80;
|
||||
private const uint FILE_SHARE_READ = 0x1;
|
||||
private const uint FILE_SHARE_WRITE = 0x2;
|
||||
private const uint FILE_SHARE_DELETE = 0x4;
|
||||
private const uint OPEN_EXISTING = 3;
|
||||
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
|
||||
private const uint VOLUME_NAME_DOS = 0x0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the NTFS canonical path for a directory, resolving drive letter
|
||||
/// and folder name casing to match what is stored on disk.
|
||||
/// On non-Windows platforms, returns the path unchanged.
|
||||
/// </summary>
|
||||
public static string GetCanonicalPath(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
using var handle = CreateFile(
|
||||
path,
|
||||
FILE_READ_ATTRIBUTES,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
System.IntPtr.Zero,
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_BACKUP_SEMANTICS,
|
||||
System.IntPtr.Zero);
|
||||
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
var buffer = new StringBuilder(1024);
|
||||
var result = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS);
|
||||
if (result == 0)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
// Retry with a larger buffer if the path was longer than expected
|
||||
if (result >= buffer.Capacity)
|
||||
{
|
||||
buffer = new StringBuilder((int)result + 1);
|
||||
result = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS);
|
||||
if (result == 0 || result >= buffer.Capacity)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
var canonicalPath = buffer.ToString();
|
||||
|
||||
// Strip the \\?\UNC\ prefix and convert to standard UNC path
|
||||
if (canonicalPath.StartsWith(@"\\?\UNC\", System.StringComparison.Ordinal))
|
||||
{
|
||||
canonicalPath = @"\\" + canonicalPath.Substring(8);
|
||||
}
|
||||
// Strip the \\?\ prefix for local paths
|
||||
else if (canonicalPath.StartsWith(@"\\?\", System.StringComparison.Ordinal))
|
||||
{
|
||||
canonicalPath = canonicalPath.Substring(4);
|
||||
}
|
||||
|
||||
return canonicalPath;
|
||||
}
|
||||
#else
|
||||
public static readonly string PathVariable = "PATH";
|
||||
|
||||
public static string GetCanonicalPath(string path)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
#endif
|
||||
|
||||
public static string PrependPath(string path, string currentPath)
|
||||
|
||||
@@ -243,6 +243,26 @@ namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
if (_state != DapSessionState.NotStarted)
|
||||
{
|
||||
// Pause so the user can inspect final job state before we tear down,
|
||||
// but only if the user was stepping through (not if they hit continue).
|
||||
if (IsActive && _pauseOnNextStep)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_jobContext != null)
|
||||
{
|
||||
Trace.Info("Job completed — pausing for inspection");
|
||||
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
|
||||
|
||||
await WaitForCommandAsync(_jobContext.CancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP job-completed pause error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
OnJobCompleted();
|
||||
@@ -252,8 +272,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
Trace.Warning($"DAP OnJobCompleted error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await StopAsync();
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
@@ -1302,6 +1320,13 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
// If cancellation already fired before we created the new TCS,
|
||||
// the registration callback targeted the old one. Unblock now.
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_commandTcs.TrySetResult(DapCommand.Disconnect);
|
||||
}
|
||||
|
||||
Trace.Info("Waiting for debugger command...");
|
||||
|
||||
var command = await _commandTcs.Task;
|
||||
|
||||
@@ -22,5 +22,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
Task OnStepStartingAsync(IStep step);
|
||||
void OnStepCompleted(IStep step);
|
||||
Task OnJobCompletedAsync();
|
||||
Task StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Services.Common;
|
||||
using Newtonsoft.Json;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
@@ -50,6 +51,7 @@ namespace GitHub.Runner.Worker
|
||||
private Task _diskSpaceCheckTask = null;
|
||||
private CancellationTokenSource _serviceConnectivityCheckToken = new();
|
||||
private Task _serviceConnectivityCheckTask = null;
|
||||
private IDapDebugger _dapDebugger;
|
||||
|
||||
// Download all required actions.
|
||||
// Make sure all condition inputs are valid.
|
||||
@@ -67,6 +69,7 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
List<IStep> preJobSteps = new();
|
||||
List<IStep> jobSteps = new();
|
||||
var initSucceeded = false;
|
||||
using (var register = jobContext.CancellationToken.Register(() => { context.CancelToken(); }))
|
||||
{
|
||||
try
|
||||
@@ -77,20 +80,25 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
var setting = HostContext.GetService<IConfigurationStore>().GetSettings();
|
||||
var credFile = HostContext.GetConfigFile(WellKnownConfigFile.Credentials);
|
||||
if (File.Exists(credFile))
|
||||
var credData = File.Exists(credFile) ? IOUtil.LoadObject<CredentialData>(credFile) : null;
|
||||
// self-hosted runner is the only runner type using OAuth, can be identified via clientId
|
||||
if (credData != null &&
|
||||
credData.Data.TryGetValue("clientId", out _))
|
||||
{
|
||||
var credData = IOUtil.LoadObject<CredentialData>(credFile);
|
||||
if (credData != null &&
|
||||
credData.Data.TryGetValue("clientId", out var clientId))
|
||||
context.Output($"Runner name: '{setting.AgentName}'");
|
||||
// use system variable for group name since self-hosted runners can be renamed
|
||||
if (message.Variables.TryGetValue("system.runnerGroupName", out VariableValue runnerGroupName))
|
||||
{
|
||||
// print out HostName for self-hosted runner
|
||||
context.Output($"Runner name: '{setting.AgentName}'");
|
||||
if (message.Variables.TryGetValue("system.runnerGroupName", out VariableValue runnerGroupName))
|
||||
{
|
||||
context.Output($"Runner group name: '{runnerGroupName.Value}'");
|
||||
}
|
||||
context.Output($"Machine name: '{Environment.MachineName}'");
|
||||
context.Output($"Runner group name: '{runnerGroupName.Value}'");
|
||||
}
|
||||
// print out machine name for self-hosted runner
|
||||
context.Output($"Machine name: '{Environment.MachineName}'");
|
||||
}
|
||||
// print runner info for lhr runners, skips standard runners (PoolId = 0)
|
||||
else if (setting.PoolId > 0 && !string.IsNullOrEmpty(setting.PoolName) && !string.IsNullOrEmpty(setting.AgentName))
|
||||
{
|
||||
context.Output($"Runner name: '{setting.AgentName}'");
|
||||
context.Output($"Runner group name: '{setting.PoolName}'");
|
||||
}
|
||||
|
||||
var setupInfoFile = HostContext.GetConfigFile(WellKnownConfigFile.SetupInfo);
|
||||
@@ -476,6 +484,41 @@ namespace GitHub.Runner.Worker
|
||||
Trace.Info($"Start checking service connectivity in background.");
|
||||
_serviceConnectivityCheckTask = CheckServiceConnectivityAsync(context, _serviceConnectivityCheckToken.Token);
|
||||
|
||||
// Start the DAP debugger and wait for a client connection inside
|
||||
// "Set up job" so the step stays in-progress while we wait.
|
||||
if (jobContext.Global.Debugger?.Enabled == true)
|
||||
{
|
||||
Trace.Info("Debugger enabled — starting inside Set up job");
|
||||
context.Output("Starting debugger…");
|
||||
|
||||
try
|
||||
{
|
||||
_dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
await _dapDebugger.StartAsync(jobContext);
|
||||
|
||||
context.Output("Waiting for debugger client to connect…");
|
||||
|
||||
await _dapDebugger.WaitUntilReadyAsync();
|
||||
context.Output("Debugger connected.");
|
||||
AddDebuggerConnectionTelemetry(jobContext, "Connected");
|
||||
}
|
||||
catch (OperationCanceledException) when (jobContext.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Job was cancelled before debugger client connected.");
|
||||
AddDebuggerConnectionTelemetry(jobContext, "Canceled");
|
||||
context.Error("Job was cancelled before debugger client connected.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"DAP debugger failed: {ex.Message}");
|
||||
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.GetType().Name}");
|
||||
context.Error("The debugger failed to start or no debugger client connected in time.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
initSucceeded = true;
|
||||
return steps;
|
||||
}
|
||||
catch (OperationCanceledException ex) when (jobContext.CancellationToken.IsCancellationRequested)
|
||||
@@ -496,12 +539,36 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
finally
|
||||
{
|
||||
// If InitializeJob failed after the debugger was started,
|
||||
// tear down the transport here since FinalizeJob won't run.
|
||||
if (!initSucceeded && _dapDebugger != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dapDebugger.StopAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP debugger cleanup during failed init: {ex.Message}");
|
||||
}
|
||||
_dapDebugger = null;
|
||||
}
|
||||
|
||||
context.Debug("Finishing: Set up job");
|
||||
context.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
|
||||
{
|
||||
jobContext.Global.JobTelemetry.Add(new JobTelemetry
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"DebuggerConnectionResult: {result}"
|
||||
});
|
||||
}
|
||||
|
||||
private string GetWorkflowReference(IDictionary<string, VariableValue> variables)
|
||||
{
|
||||
var reference = "";
|
||||
@@ -777,6 +844,34 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Pause for debugger inspection, then tear down the DAP session.
|
||||
// OnJobCompletedAsync pauses first, then sends terminated/exited
|
||||
// events and stops the transport.
|
||||
if (_dapDebugger != null)
|
||||
{
|
||||
context.Output("Job completed — pausing for debugger inspection. Press continue to finish.");
|
||||
try
|
||||
{
|
||||
await _dapDebugger.OnJobCompletedAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP debugger completion error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dapDebugger.StopAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP debugger stop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
_dapDebugger = null;
|
||||
}
|
||||
|
||||
context.Debug("Finishing: Complete job");
|
||||
context.Complete();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ using GitHub.DistributedTask.WebApi;
|
||||
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;
|
||||
@@ -29,7 +28,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public sealed class JobRunner : RunnerService, IJobRunner
|
||||
{
|
||||
private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult";
|
||||
private IJobServerQueue _jobServerQueue;
|
||||
private RunnerSettings _runnerSettings;
|
||||
private ITempDirectoryManager _tempDirectoryManager;
|
||||
@@ -114,7 +112,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
IExecutionContext jobContext = null;
|
||||
CancellationTokenRegistration? runnerShutdownRegistration = null;
|
||||
IDapDebugger dapDebugger = null;
|
||||
try
|
||||
{
|
||||
// Create the job execution context.
|
||||
@@ -181,25 +178,6 @@ namespace GitHub.Runner.Worker
|
||||
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
|
||||
_tempDirectoryManager.InitializeTempDirectory(jobContext);
|
||||
|
||||
// Setup the debugger
|
||||
if (jobContext.Global.Debugger?.Enabled == true)
|
||||
{
|
||||
Trace.Info("Debugger enabled for this job run");
|
||||
|
||||
try
|
||||
{
|
||||
dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
await dapDebugger.StartAsync(jobContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Failed to start DAP debugger: {ex.Message}");
|
||||
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
|
||||
jobContext.Error("Failed to start debugger.");
|
||||
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get the job extension.
|
||||
Trace.Info("Getting job extension.");
|
||||
@@ -242,33 +220,6 @@ namespace GitHub.Runner.Worker
|
||||
await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000));
|
||||
}
|
||||
|
||||
// Wait for DAP debugger client connection and handshake after "Set up job"
|
||||
// so the job page shows the setup step before we block on the debugger
|
||||
if (dapDebugger != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await dapDebugger.WaitUntilReadyAsync();
|
||||
AddDebuggerConnectionTelemetry(jobContext, "Connected");
|
||||
}
|
||||
catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Job was cancelled before debugger client connected.");
|
||||
AddDebuggerConnectionTelemetry(jobContext, "Canceled");
|
||||
jobContext.Error("Job was cancelled before debugger client connected.");
|
||||
return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"DAP debugger failed to become ready: {ex.Message}");
|
||||
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
|
||||
|
||||
// If debugging was requested but the debugger is not available, fail the job
|
||||
jobContext.Error("The debugger failed to start or no debugger client connected in time.");
|
||||
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
|
||||
}
|
||||
}
|
||||
|
||||
// Run all job steps
|
||||
Trace.Info("Run all job steps.");
|
||||
var stepsRunner = HostContext.GetService<IStepsRunner>();
|
||||
@@ -309,11 +260,6 @@ namespace GitHub.Runner.Worker
|
||||
runnerShutdownRegistration = null;
|
||||
}
|
||||
|
||||
if (dapDebugger != null)
|
||||
{
|
||||
await dapDebugger.OnJobCompletedAsync();
|
||||
}
|
||||
|
||||
await ShutdownQueue(throwOnFailure: false);
|
||||
}
|
||||
}
|
||||
@@ -495,15 +441,6 @@ namespace GitHub.Runner.Worker
|
||||
throw new AggregateException(exceptions);
|
||||
}
|
||||
|
||||
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
|
||||
{
|
||||
jobContext.Global.JobTelemetry.Add(new JobTelemetry
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"{DebuggerConnectionTelemetryPrefix}: {result}"
|
||||
});
|
||||
}
|
||||
|
||||
private void MaskTelemetrySecrets(List<JobTelemetry> jobTelemetry)
|
||||
{
|
||||
foreach (var telemetryItem in jobTelemetry)
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
|
||||
public const String Email = nameof(Email);
|
||||
public const String IPv4Address = nameof(IPv4Address);
|
||||
public const String SHA1 = nameof(SHA1);
|
||||
public const String CommitHash = nameof(CommitHash);
|
||||
public const String Url = nameof(Url);
|
||||
|
||||
/// <summary>
|
||||
@@ -24,7 +25,8 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
|
||||
case IPv4Address:
|
||||
return s_validIPv4Address;
|
||||
case SHA1:
|
||||
return s_validSha1;
|
||||
case CommitHash:
|
||||
return s_validCommitHash;
|
||||
case Url:
|
||||
return s_validUrl;
|
||||
default:
|
||||
@@ -46,9 +48,9 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
|
||||
)
|
||||
);
|
||||
|
||||
// 40 hex characters
|
||||
private static readonly Lazy<Regex> s_validSha1 = new Lazy<Regex>(() => new Regex(
|
||||
@"\b[0-9a-f]{40}\b",
|
||||
// 40 or 64 hex characters (SHA-1 or SHA-256 commit hash)
|
||||
private static readonly Lazy<Regex> s_validCommitHash = new Lazy<Regex>(() => new Regex(
|
||||
@"\b(?:[0-9a-f]{40}|[0-9a-f]{64})\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, RegexUtility.GetRegexTimeOut()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -24,7 +24,10 @@ namespace GitHub.Runner.Common.Tests
|
||||
"osx-arm64"
|
||||
};
|
||||
|
||||
Assert.Equal(40, BuildConstants.Source.CommitHash.Length);
|
||||
Assert.True(
|
||||
BuildConstants.Source.CommitHash.Length == 40 || BuildConstants.Source.CommitHash.Length == 64,
|
||||
"CommitHash should be a 40-char SHA-1 or 64-char SHA-256 hex string");
|
||||
Assert.Matches("^[0-9a-f]+$", BuildConstants.Source.CommitHash);
|
||||
Assert.True(validPackageNames.Contains(BuildConstants.RunnerPackage.PackageName), $"PackageName should be one of the following '{string.Join(", ", validPackageNames)}', current PackageName is '{BuildConstants.RunnerPackage.PackageName}'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +299,52 @@ namespace GitHub.Runner.Common.Tests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetDirectoryRootReturnsCachedValue()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
|
||||
// Call GetDirectory(Root) twice — should return the same reference
|
||||
var root1 = _hc.GetDirectory(WellKnownDirectory.Root);
|
||||
var root2 = _hc.GetDirectory(WellKnownDirectory.Root);
|
||||
|
||||
Assert.NotNull(root1);
|
||||
Assert.Equal(root1, root2);
|
||||
Assert.True(Directory.Exists(root1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetDirectoryDerivedPathsUseRootCasing()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
|
||||
var root = _hc.GetDirectory(WellKnownDirectory.Root);
|
||||
var diag = _hc.GetDirectory(WellKnownDirectory.Diag);
|
||||
var externals = _hc.GetDirectory(WellKnownDirectory.Externals);
|
||||
|
||||
// Diag and Externals should start with the same Root prefix
|
||||
Assert.StartsWith(root, diag);
|
||||
Assert.StartsWith(root, externals);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
private void Setup([CallerMemberName] string testName = "")
|
||||
{
|
||||
_tokenSource = new CancellationTokenSource();
|
||||
|
||||
@@ -14,7 +14,7 @@ using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Listener
|
||||
{
|
||||
public sealed class RunnerL0
|
||||
public sealed class RunnerL0 : IDisposable
|
||||
{
|
||||
private Mock<IConfigurationManager> _configurationManager;
|
||||
private Mock<IJobNotification> _jobNotification;
|
||||
@@ -29,6 +29,7 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
private Mock<ICredentialManager> _credentialManager;
|
||||
private Mock<IActionsRunServer> _actionsRunServer;
|
||||
private Mock<IRunServer> _runServer;
|
||||
private readonly string _returnJobResultForHosted;
|
||||
|
||||
public RunnerL0()
|
||||
{
|
||||
@@ -45,6 +46,14 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
_credentialManager = new Mock<ICredentialManager>();
|
||||
_actionsRunServer = new Mock<IActionsRunServer>();
|
||||
_runServer = new Mock<IRunServer>();
|
||||
|
||||
_returnJobResultForHosted = Environment.GetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED");
|
||||
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED", null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED", _returnJobResultForHosted);
|
||||
}
|
||||
|
||||
private Pipelines.AgentJobRequestMessage CreateJobRequestMessage(string jobName)
|
||||
|
||||
100
src/Test/L0/Sdk/WellKnownRegularExpressionsL0.cs
Normal file
100
src/Test/L0/Sdk/WellKnownRegularExpressionsL0.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using GitHub.DistributedTask.Pipelines.Expressions;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Sdk
|
||||
{
|
||||
public sealed class WellKnownRegularExpressionsL0
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void SHA1_Key_Returns_CommitHash_Regex()
|
||||
{
|
||||
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.SHA1);
|
||||
|
||||
Assert.NotNull(regex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void CommitHash_Key_Returns_CommitHash_Regex()
|
||||
{
|
||||
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
|
||||
|
||||
Assert.NotNull(regex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void SHA1_And_CommitHash_Return_Same_Regex()
|
||||
{
|
||||
var sha1Regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.SHA1);
|
||||
var commitHashRegex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
|
||||
|
||||
Assert.Same(sha1Regex, commitHashRegex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void Matches_40_Char_Hex()
|
||||
{
|
||||
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
|
||||
|
||||
Assert.Matches(regex.Value, new string('a', 40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void Matches_64_Char_Hex()
|
||||
{
|
||||
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
|
||||
|
||||
Assert.Matches(regex.Value, new string('a', 64));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void Does_Not_Match_63_Char_Hex()
|
||||
{
|
||||
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
|
||||
|
||||
Assert.DoesNotMatch(regex.Value, new string('a', 63));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void Does_Not_Match_65_Char_Hex()
|
||||
{
|
||||
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
|
||||
|
||||
Assert.DoesNotMatch(regex.Value, new string('a', 65));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void Matches_Mixed_Case_64_Char()
|
||||
{
|
||||
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
|
||||
var value = new string('A', 32) + new string('b', 32);
|
||||
|
||||
Assert.Matches(regex.Value, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void Unknown_Key_Returns_Null()
|
||||
{
|
||||
var regex = WellKnownRegularExpressions.GetRegex("UnknownType");
|
||||
|
||||
Assert.Null(regex);
|
||||
}
|
||||
}
|
||||
}
|
||||
151
src/Test/L0/Util/PathUtilL0.cs
Normal file
151
src/Test/L0/Util/PathUtilL0.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using GitHub.Runner.Sdk;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Util
|
||||
{
|
||||
public sealed class PathUtilL0
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetCanonicalPath_ReturnsPath_WhenDirectoryDoesNotExist()
|
||||
{
|
||||
var fakePath = Path.Combine(Path.GetTempPath(), "nonexistent_" + Path.GetRandomFileName());
|
||||
var result = PathUtil.GetCanonicalPath(fakePath);
|
||||
Assert.Equal(fakePath, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetCanonicalPath_ReturnsPath_WhenNull()
|
||||
{
|
||||
Assert.Null(PathUtil.GetCanonicalPath(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetCanonicalPath_ReturnsEmpty_WhenEmpty()
|
||||
{
|
||||
Assert.Equal(string.Empty, PathUtil.GetCanonicalPath(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetCanonicalPath_ReturnsValidPath_ForExistingDirectory()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "pathutil_test_" + Path.GetRandomFileName());
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var result = PathUtil.GetCanonicalPath(tempDir);
|
||||
Assert.NotNull(result);
|
||||
Assert.True(Directory.Exists(result));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetCanonicalPath_NormalizesDriveLetter_OnWindows()
|
||||
{
|
||||
var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
|
||||
|
||||
// Skip if temp is a UNC path (no drive letter to normalize)
|
||||
if (tempDir.StartsWith(@"\\"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Force lowercase drive letter
|
||||
var lowerCased = char.ToLower(tempDir[0]) + tempDir.Substring(1);
|
||||
|
||||
var result = PathUtil.GetCanonicalPath(lowerCased);
|
||||
|
||||
// The canonical path should have an uppercase drive letter
|
||||
Assert.True(char.IsUpper(result[0]),
|
||||
$"Expected uppercase drive letter but got: {result}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetCanonicalPath_NormalizesFolderCasing_OnWindows()
|
||||
{
|
||||
// Create a directory with known casing, then query with wrong casing
|
||||
var basePath = Path.GetTempPath();
|
||||
if (basePath.StartsWith(@"\\"))
|
||||
{
|
||||
return; // Skip UNC
|
||||
}
|
||||
|
||||
var realName = "PathUtilTest_MiXeDcAsE_" + Path.GetRandomFileName();
|
||||
var realDir = Path.Combine(basePath, realName);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(realDir);
|
||||
|
||||
// Query with all-lowercase version
|
||||
var wrongCased = Path.Combine(basePath, realName.ToLowerInvariant());
|
||||
|
||||
var result = PathUtil.GetCanonicalPath(wrongCased);
|
||||
|
||||
// The canonical result should contain the original mixed-case name
|
||||
Assert.Contains(realName, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(realDir))
|
||||
{
|
||||
Directory.Delete(realDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetCanonicalPath_IsIdempotent_OnWindows()
|
||||
{
|
||||
// Calling GetCanonicalPath twice should return the same result
|
||||
var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
|
||||
var first = PathUtil.GetCanonicalPath(tempDir);
|
||||
var second = PathUtil.GetCanonicalPath(first);
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void GetCanonicalPath_ReturnsSameResult_RegardlessOfInputCasing_OnWindows()
|
||||
{
|
||||
var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
|
||||
if (tempDir.StartsWith(@"\\"))
|
||||
{
|
||||
return; // Skip UNC
|
||||
}
|
||||
|
||||
var upper = tempDir.ToUpperInvariant();
|
||||
var lower = tempDir.ToLowerInvariant();
|
||||
|
||||
var resultUpper = PathUtil.GetCanonicalPath(upper);
|
||||
var resultLower = PathUtil.GetCanonicalPath(lower);
|
||||
|
||||
// Both should resolve to the same canonical path
|
||||
Assert.Equal(resultUpper, resultLower);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
public sealed class ActionManagerL0
|
||||
{
|
||||
private const string TestDataFolderName = "TestData";
|
||||
private const string Sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private CancellationTokenSource _ecTokenSource;
|
||||
private Mock<IConfigurationStore> _configurationStore;
|
||||
private Mock<IDockerCommandManager> _dockerManager;
|
||||
@@ -334,7 +335,7 @@ runs:
|
||||
await File.WriteAllTextAsync(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact", "action.yml"), Content);
|
||||
|
||||
#if OS_WINDOWS
|
||||
ZipFile.CreateFromDirectory(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"), Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", "master-sha.zip"), CompressionLevel.Fastest, true);
|
||||
ZipFile.CreateFromDirectory(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"), Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", $"{Sha256}.zip"), CompressionLevel.Fastest, true);
|
||||
#else
|
||||
string tar = WhichUtil.Which("tar", require: true, trace: _hc.GetTrace());
|
||||
|
||||
@@ -360,7 +361,7 @@ runs:
|
||||
|
||||
string cwd = Path.GetDirectoryName(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"));
|
||||
string inputDirectory = Path.GetFileName(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"));
|
||||
string archiveFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", "master-sha.tar.gz");
|
||||
string archiveFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", $"{Sha256}.tar.gz");
|
||||
int exitCode = await processInvoker.ExecuteAsync(_hc.GetDirectory(WellKnownDirectory.Bin), tar, $"-czf \"{archiveFile}\" -C \"{cwd}\" \"{inputDirectory}\"", null, CancellationToken.None);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
@@ -368,6 +369,8 @@ runs:
|
||||
}
|
||||
}
|
||||
#endif
|
||||
MockResolvedSha("actions/download-artifact", "master", Sha256);
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
@@ -516,9 +519,10 @@ runs:
|
||||
|
||||
string actionsArchive = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions_archive", "action_checkout");
|
||||
Directory.CreateDirectory(actionsArchive);
|
||||
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha"));
|
||||
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content"));
|
||||
await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content", "action.yml"), Content);
|
||||
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", Sha256));
|
||||
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", Sha256, "content"));
|
||||
await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", Sha256, "content", "action.yml"), Content);
|
||||
MockResolvedSha("actions/checkout", "master", Sha256);
|
||||
Environment.SetEnvironmentVariable(Constants.Variables.Agent.ActionArchiveCacheDirectory, actionsArchive);
|
||||
|
||||
//Act
|
||||
@@ -3149,6 +3153,51 @@ runs:
|
||||
#endif
|
||||
}
|
||||
|
||||
private void MockResolvedSha(string nameWithOwner, string reference, string resolvedSha)
|
||||
{
|
||||
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.Is<ActionReferenceList>(actions => actions.Actions.Any(action => action.NameWithOwner == nameWithOwner && action.Ref == reference)), It.IsAny<CancellationToken>()))
|
||||
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
|
||||
foreach (var action in actions.Actions)
|
||||
{
|
||||
var key = $"{action.NameWithOwner}@{action.Ref}";
|
||||
result.Actions[key] = new ActionDownloadInfo
|
||||
{
|
||||
NameWithOwner = action.NameWithOwner,
|
||||
Ref = action.Ref,
|
||||
ResolvedNameWithOwner = action.NameWithOwner,
|
||||
ResolvedSha = resolvedSha,
|
||||
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
|
||||
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
|
||||
};
|
||||
}
|
||||
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
|
||||
_launchServer.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.Is<ActionReferenceList>(actions => actions.Actions.Any(action => action.NameWithOwner == nameWithOwner && action.Ref == reference)), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
|
||||
.Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken, bool displayHelpfulActionsDownloadErrors) =>
|
||||
{
|
||||
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
|
||||
foreach (var action in actions.Actions)
|
||||
{
|
||||
var key = $"{action.NameWithOwner}@{action.Ref}";
|
||||
result.Actions[key] = new ActionDownloadInfo
|
||||
{
|
||||
NameWithOwner = action.NameWithOwner,
|
||||
Ref = action.Ref,
|
||||
ResolvedNameWithOwner = action.NameWithOwner,
|
||||
ResolvedSha = resolvedSha,
|
||||
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
|
||||
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
|
||||
};
|
||||
}
|
||||
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
}
|
||||
|
||||
private void Setup([CallerMemberName] string name = "", bool enableComposite = true)
|
||||
{
|
||||
_ecTokenSource?.Dispose();
|
||||
|
||||
@@ -744,14 +744,32 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
await waitTask;
|
||||
|
||||
// Complete the job — events are sent via OnJobCompletedAsync
|
||||
await _debugger.OnJobCompletedAsync();
|
||||
// Complete the job — OnJobCompletedAsync pauses when stepping,
|
||||
// so run it in the background and send continue to unblock.
|
||||
var completedTask = _debugger.OnJobCompletedAsync();
|
||||
|
||||
var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
// Read the stopped event from the pause
|
||||
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
|
||||
|
||||
// Both events should arrive (order may vary)
|
||||
var combined = msg1 + msg2;
|
||||
// Send continue to unblock the pause
|
||||
await SendRequestAsync(stream, new Request
|
||||
{
|
||||
Seq = 2,
|
||||
Type = "request",
|
||||
Command = "continue"
|
||||
});
|
||||
|
||||
await completedTask;
|
||||
|
||||
// Read remaining messages — continue response + continued event + terminated + exited
|
||||
var allMessages = new System.Text.StringBuilder();
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
allMessages.Append(await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
var combined = allMessages.ToString();
|
||||
Assert.Contains("\"event\":\"terminated\"", combined);
|
||||
Assert.Contains("\"event\":\"exited\"", combined);
|
||||
}
|
||||
@@ -809,5 +827,45 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
});
|
||||
}
|
||||
}
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait()
|
||||
{
|
||||
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 waitTask;
|
||||
|
||||
// Start OnJobCompletedAsync — it will pause because _pauseOnNextStep is true
|
||||
var completedTask = _debugger.OnJobCompletedAsync();
|
||||
|
||||
// Read the stopped event
|
||||
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
|
||||
|
||||
// Cancel the job while waiting — should unblock the pause
|
||||
cts.Cancel();
|
||||
|
||||
// OnJobCompletedAsync should complete without hanging
|
||||
var finished = await Task.WhenAny(completedTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
Assert.Equal(completedTask, finished);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,5 +760,171 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Environment.SetEnvironmentVariable("RUNNER_ENVIRONMENT", null);
|
||||
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task DebuggerStartedInSetupJobWhenEnabled()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
// Enable debugger on the message
|
||||
_message.EnableDebugger = true;
|
||||
_message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo
|
||||
{
|
||||
TunnelId = "test-tunnel",
|
||||
ClusterId = "test-cluster",
|
||||
HostToken = "test-token",
|
||||
Port = 9229
|
||||
};
|
||||
|
||||
// Re-initialize the execution context so it picks up debugger config
|
||||
_jobEc = new Runner.Worker.ExecutionContext();
|
||||
_jobEc.Initialize(hc);
|
||||
_jobEc.InitializeJob(_message, _tokenSource.Token);
|
||||
|
||||
// Set up mock debugger
|
||||
var mockDebugger = new Mock<IDapDebugger>();
|
||||
mockDebugger.Setup(x => x.StartAsync(It.IsAny<IExecutionContext>())).Returns(Task.CompletedTask);
|
||||
mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask);
|
||||
hc.SetSingleton(mockDebugger.Object);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
List<IStep> result = await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
// Verify DAP debugger was started and waited on
|
||||
mockDebugger.Verify(x => x.StartAsync(It.IsAny<IExecutionContext>()), Times.Once);
|
||||
mockDebugger.Verify(x => x.WaitUntilReadyAsync(), Times.Once);
|
||||
|
||||
// Verify steps are still returned correctly
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task DebuggerNotStartedInSetupJobWhenDisabled()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
// Debugger NOT enabled on the message — should not be started
|
||||
|
||||
// Set up mock debugger (should NOT be called)
|
||||
var mockDebugger = new Mock<IDapDebugger>();
|
||||
hc.SetSingleton(mockDebugger.Object);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
List<IStep> result = await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
// Verify DAP debugger was NOT started during setup job
|
||||
mockDebugger.Verify(x => x.StartAsync(It.IsAny<IExecutionContext>()), Times.Never);
|
||||
mockDebugger.Verify(x => x.WaitUntilReadyAsync(), Times.Never);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task DebuggerCleanedUpInFinalizeJob()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
// Enable debugger on the message
|
||||
_message.EnableDebugger = true;
|
||||
_message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo
|
||||
{
|
||||
TunnelId = "test-tunnel",
|
||||
ClusterId = "test-cluster",
|
||||
HostToken = "test-token",
|
||||
Port = 9229
|
||||
};
|
||||
|
||||
// Re-initialize the execution context so it picks up debugger config
|
||||
_jobEc = new Runner.Worker.ExecutionContext();
|
||||
_jobEc.Initialize(hc);
|
||||
_jobEc.InitializeJob(_message, _tokenSource.Token);
|
||||
|
||||
// Set up mock debugger
|
||||
var mockDebugger = new Mock<IDapDebugger>();
|
||||
mockDebugger.Setup(x => x.StartAsync(It.IsAny<IExecutionContext>())).Returns(Task.CompletedTask);
|
||||
mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask);
|
||||
mockDebugger.Setup(x => x.OnJobCompletedAsync()).Returns(Task.CompletedTask);
|
||||
hc.SetSingleton(mockDebugger.Object);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
// Run InitializeJob to start the debugger
|
||||
await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
// Run FinalizeJob — should pause (inside OnJobCompletedAsync) then clean up
|
||||
await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow);
|
||||
|
||||
// Verify OnJobCompletedAsync was called (it handles pause + cleanup)
|
||||
mockDebugger.Verify(x => x.OnJobCompletedAsync(), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task FinalizeJobHandlesDebuggerCleanupException()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
// Enable debugger on the message
|
||||
_message.EnableDebugger = true;
|
||||
_message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo
|
||||
{
|
||||
TunnelId = "test-tunnel",
|
||||
ClusterId = "test-cluster",
|
||||
HostToken = "test-token",
|
||||
Port = 9229
|
||||
};
|
||||
|
||||
// Re-initialize the execution context so it picks up debugger config
|
||||
_jobEc = new Runner.Worker.ExecutionContext();
|
||||
_jobEc.Initialize(hc);
|
||||
_jobEc.InitializeJob(_message, _tokenSource.Token);
|
||||
|
||||
// Set up mock debugger — OnJobCompletedAsync throws
|
||||
var mockDebugger = new Mock<IDapDebugger>();
|
||||
mockDebugger.Setup(x => x.StartAsync(It.IsAny<IExecutionContext>())).Returns(Task.CompletedTask);
|
||||
mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask);
|
||||
mockDebugger.Setup(x => x.OnJobCompletedAsync()).ThrowsAsync(new InvalidOperationException("tunnel disposed"));
|
||||
mockDebugger.Setup(x => x.StopAsync()).Returns(Task.CompletedTask);
|
||||
hc.SetSingleton(mockDebugger.Object);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
// FinalizeJob should not throw even when OnJobCompletedAsync throws
|
||||
await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow);
|
||||
|
||||
mockDebugger.Verify(x => x.OnJobCompletedAsync(), Times.Once);
|
||||
mockDebugger.Verify(x => x.StopAsync(), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user