Compare commits

..

2 Commits

Author SHA1 Message Date
Francesco Renzi
bdd5be59c3 Merge branch 'main' into feature/devtunnel-dap-runner 2026-03-27 16:00:58 +00:00
Francesco Renzi
a85b399779 Add devtunnel connection for debugger jobs 2026-03-27 08:49:17 -07:00
22 changed files with 477 additions and 306 deletions

View File

@@ -316,6 +316,7 @@ namespace GitHub.Runner.Worker
Schema = _actionManifestSchema,
// TODO: Switch to real tracewriter for cutover
TraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(),
AllowCaseFunction = false,
};
// Expression values from execution context

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
@@ -315,6 +315,7 @@ namespace GitHub.Runner.Worker
maxBytes: 10 * 1024 * 1024),
Schema = _actionManifestSchema,
TraceWriter = executionContext.ToTemplateTraceWriter(),
AllowCaseFunction = false,
};
// Expression values from execution context

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text;
using System.Threading;
@@ -9,6 +11,9 @@ using System.Threading.Tasks;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using Microsoft.DevTunnels.Connections;
using Microsoft.DevTunnels.Contracts;
using Microsoft.DevTunnels.Management;
using Newtonsoft.Json;
namespace GitHub.Runner.Worker.Dap
@@ -30,9 +35,7 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
public sealed class DapDebugger : RunnerService, IDapDebugger
{
private const int _defaultPort = 4711;
private const int _defaultTimeoutMinutes = 15;
private const string _portEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT";
private const string _timeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT";
private const string _contentLengthHeader = "Content-Length: ";
private const int _maxMessageSize = 10 * 1024 * 1024; // 10 MB
@@ -58,6 +61,12 @@ namespace GitHub.Runner.Worker.Dap
private CancellationTokenRegistration? _cancellationRegistration;
private bool _isFirstStep = true;
// Dev Tunnel relay host for remote debugging
private TunnelRelayTunnelHost _tunnelRelayHost;
// When true, skip tunnel relay startup (unit tests only)
internal bool SkipTunnelRelay { get; set; }
// Synchronization for step execution
private TaskCompletionSource<DapCommand> _commandTcs;
private readonly object _stateLock = new object();
@@ -101,11 +110,18 @@ namespace GitHub.Runner.Worker.Dap
Trace.Info("DapDebugger initialized");
}
public Task StartAsync(IExecutionContext jobContext)
public async Task StartAsync(IExecutionContext jobContext)
{
ArgUtil.NotNull(jobContext, nameof(jobContext));
var port = ResolvePort();
var debuggerConfig = jobContext.Global.Debugger;
if (debuggerConfig == null || !debuggerConfig.HasValidTunnel)
{
throw new InvalidOperationException(
"Debugger requires valid tunnel configuration (tunnelId, clusterId, hostToken, port).");
}
var port = debuggerConfig.Tunnel.Port;
Trace.Info($"Starting DAP debugger on port {port}");
_jobContext = jobContext;
@@ -115,6 +131,15 @@ namespace GitHub.Runner.Worker.Dap
_listener.Start();
Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}");
// Start Dev Tunnel relay so remote clients reach the local DAP port.
// The relay is torn down explicitly in StopAsync (after the DAP session
// is closed) so we do NOT pass the job cancellation token here — that
// would race with the DAP shutdown and drop the transport mid-protocol.
if (!SkipTunnelRelay)
{
await StartTunnelRelayAsync(debuggerConfig);
}
_state = DapSessionState.WaitingForConnection;
_connectionLoopTask = ConnectionLoopAsync(jobContext.CancellationToken);
@@ -126,7 +151,36 @@ namespace GitHub.Runner.Worker.Dap
});
Trace.Info($"DAP debugger started on port {port}");
return Task.CompletedTask;
}
private async Task StartTunnelRelayAsync(DebuggerConfig config)
{
Trace.Info($"Starting Dev Tunnel relay (tunnel={config.Tunnel.TunnelId}, cluster={config.Tunnel.ClusterId})");
var managementClient = new TunnelManagementClient(
new ProductInfoHeaderValue("actions-runner", "1.0"),
() => Task.FromResult(
(AuthenticationHeaderValue)
new AuthenticationHeaderValue("tunnel", config.Tunnel.HostToken)));
var tunnel = new Tunnel
{
TunnelId = config.Tunnel.TunnelId,
ClusterId = config.Tunnel.ClusterId,
AccessTokens = new Dictionary<string, string>
{
[TunnelAccessScopes.Host] = config.Tunnel.HostToken
},
Ports = new[]
{
new TunnelPort { PortNumber = (ushort)config.Tunnel.Port }
},
};
_tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, new TraceSource("DevTunnelRelay"));
await _tunnelRelayHost.StartAsync(tunnel, CancellationToken.None);
Trace.Info("Dev Tunnel relay started");
}
public async Task WaitUntilReadyAsync()
@@ -199,6 +253,25 @@ namespace GitHub.Runner.Worker.Dap
}
catch { /* best effort */ }
}
// Tear down Dev Tunnel relay
if (_tunnelRelayHost != null)
{
try
{
Trace.Info("Stopping Dev Tunnel relay");
await _tunnelRelayHost.DisposeAsync();
Trace.Info("Dev Tunnel relay stopped");
}
catch (Exception ex)
{
Trace.Warning($"Error stopping tunnel relay: {ex.Message}");
}
finally
{
_tunnelRelayHost = null;
}
}
}
catch (Exception ex)
{
@@ -1272,18 +1345,6 @@ namespace GitHub.Runner.Worker.Dap
};
}
internal int ResolvePort()
{
var portEnv = Environment.GetEnvironmentVariable(_portEnvironmentVariable);
if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 1024 && customPort <= 65535)
{
Trace.Info($"Using custom DAP port {customPort} from {_portEnvironmentVariable}");
return customPort;
}
return _defaultPort;
}
internal int ResolveTimeout()
{
var timeoutEnv = Environment.GetEnvironmentVariable(_timeoutEnvironmentVariable);

View File

@@ -0,0 +1,33 @@
using GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Consolidated runtime configuration for the job debugger.
/// Populated once from the acquire response and owned by <see cref="GlobalContext"/>.
/// </summary>
public sealed class DebuggerConfig
{
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
{
Enabled = enabled;
Tunnel = tunnel;
}
/// <summary>Whether the debugger is enabled for this job.</summary>
public bool Enabled { get; }
/// <summary>
/// Dev Tunnel details for remote debugging.
/// Required when <see cref="Enabled"/> is true.
/// </summary>
public DebuggerTunnelInfo Tunnel { get; }
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
public bool HasValidTunnel => Tunnel != null
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
&& !string.IsNullOrEmpty(Tunnel.ClusterId)
&& !string.IsNullOrEmpty(Tunnel.HostToken)
&& Tunnel.Port > 0;
}
}

View File

@@ -970,7 +970,7 @@ namespace GitHub.Runner.Worker
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
// Debugger enabled flag (from acquire response).
Global.EnableDebugger = message.EnableDebugger;
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;

View File

@@ -4,6 +4,7 @@ using GitHub.Actions.RunService.WebApi;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using Newtonsoft.Json.Linq;
using Sdk.RSWebApi.Contracts;
@@ -27,7 +28,7 @@ namespace GitHub.Runner.Worker
public StepsContext StepsContext { get; set; }
public Variables Variables { get; set; }
public bool WriteDebug { get; set; }
public bool EnableDebugger { get; set; }
public DebuggerConfig Debugger { get; set; }
public string InfrastructureFailureCategory { get; set; }
public JObject ContainerHookState { get; set; }
public bool HasTemplateEvaluatorMismatch { get; set; }

View File

@@ -182,7 +182,7 @@ namespace GitHub.Runner.Worker
_tempDirectoryManager.InitializeTempDirectory(jobContext);
// Setup the debugger
if (jobContext.Global.EnableDebugger)
if (jobContext.Global.Debugger?.Enabled == true)
{
Trace.Info("Debugger enabled for this job run");

View File

@@ -23,6 +23,7 @@
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.0.7317" />
</ItemGroup>
<ItemGroup>

View File

@@ -17,9 +17,10 @@ namespace GitHub.DistributedTask.Expressions2
String expression,
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions)
IEnumerable<IFunctionInfo> functions,
Boolean allowCaseFunction = true)
{
var context = new ParseContext(expression, trace, namedValues, functions);
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction);
context.Trace.Info($"Parsing expression: <{expression}>");
return CreateTree(context);
}
@@ -415,6 +416,12 @@ namespace GitHub.DistributedTask.Expressions2
String name,
out IFunctionInfo functionInfo)
{
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
{
functionInfo = null;
return false;
}
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
}
@@ -422,6 +429,7 @@ namespace GitHub.DistributedTask.Expressions2
private sealed class ParseContext
{
public Boolean AllowUnknownKeywords;
public Boolean AllowCaseFunction;
public readonly String Expression;
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
@@ -437,7 +445,8 @@ namespace GitHub.DistributedTask.Expressions2
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowUnknownKeywords = false)
Boolean allowUnknownKeywords = false,
Boolean allowCaseFunction = true)
{
Expression = expression ?? String.Empty;
if (Expression.Length > ExpressionConstants.MaxLength)
@@ -458,6 +467,7 @@ namespace GitHub.DistributedTask.Expressions2
LexicalAnalyzer = new LexicalAnalyzer(Expression);
AllowUnknownKeywords = allowUnknownKeywords;
AllowCaseFunction = allowCaseFunction;
}
private class NoOperationTraceWriter : ITraceWriter

View File

@@ -86,6 +86,12 @@ namespace GitHub.DistributedTask.ObjectTemplating
internal ITraceWriter TraceWriter { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the case expression function is allowed.
/// Defaults to true. Set to false to disable the case function.
/// </summary>
internal Boolean AllowCaseFunction { get; set; } = true;
private IDictionary<String, Int32> FileIds
{
get

View File

@@ -57,7 +57,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -94,7 +94,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -123,7 +123,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -152,7 +152,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,

View File

@@ -260,6 +260,13 @@ namespace GitHub.DistributedTask.Pipelines
set;
}
[DataMember(EmitDefaultValue = false)]
public DebuggerTunnelInfo DebuggerTunnel
{
get;
set;
}
/// <summary>
/// Gets the collection of variables associated with the current context.
/// </summary>

View File

@@ -0,0 +1,24 @@
using System.Runtime.Serialization;
namespace GitHub.DistributedTask.Pipelines
{
/// <summary>
/// Dev Tunnel information the runner needs to host the debugger tunnel.
/// Matches the run-service <c>DebuggerTunnel</c> contract.
/// </summary>
[DataContract]
public sealed class DebuggerTunnelInfo
{
[DataMember(EmitDefaultValue = false)]
public string TunnelId { get; set; }
[DataMember(EmitDefaultValue = false)]
public string ClusterId { get; set; }
[DataMember(EmitDefaultValue = false)]
public string HostToken { get; set; }
[DataMember(EmitDefaultValue = false)]
public int Port { get; set; }
}
}

View File

@@ -681,7 +681,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
var node = default(ExpressionNode);
try
{
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
}
catch (Exception ex)
{

View File

@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -17,9 +17,10 @@ namespace GitHub.Actions.Expressions
String expression,
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions)
IEnumerable<IFunctionInfo> functions,
Boolean allowCaseFunction = true)
{
var context = new ParseContext(expression, trace, namedValues, functions);
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction);
context.Trace.Info($"Parsing expression: <{expression}>");
return CreateTree(context);
}
@@ -321,7 +322,7 @@ namespace GitHub.Actions.Expressions
context.Operators.Pop();
}
var functionOperands = PopOperands(context, parameterCount);
// Node already exists on the operand stack
function = (Function)context.Operands.Peek();
@@ -415,6 +416,12 @@ namespace GitHub.Actions.Expressions
String name,
out IFunctionInfo functionInfo)
{
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
{
functionInfo = null;
return false;
}
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
}
@@ -422,6 +429,7 @@ namespace GitHub.Actions.Expressions
private sealed class ParseContext
{
public Boolean AllowUnknownKeywords;
public Boolean AllowCaseFunction;
public readonly String Expression;
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
@@ -437,7 +445,8 @@ namespace GitHub.Actions.Expressions
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowUnknownKeywords = false)
Boolean allowUnknownKeywords = false,
Boolean allowCaseFunction = true)
{
Expression = expression ?? String.Empty;
if (Expression.Length > ExpressionConstants.MaxLength)
@@ -458,6 +467,7 @@ namespace GitHub.Actions.Expressions
LexicalAnalyzer = new LexicalAnalyzer(Expression);
AllowUnknownKeywords = allowUnknownKeywords;
AllowCaseFunction = allowCaseFunction;
}
private class NoOperationTraceWriter : ITraceWriter

View File

@@ -1828,7 +1828,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
var node = default(ExpressionNode);
try
{
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
}
catch (Exception ex)
{

View File

@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -113,6 +113,12 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating
/// </summary>
internal Boolean StrictJsonParsing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the case expression function is allowed.
/// Defaults to true. Set to false to disable the case function.
/// </summary>
internal Boolean AllowCaseFunction { get; set; } = true;
internal ITraceWriter TraceWriter { get; set; }
private IDictionary<String, Int32> FileIds

View File

@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -55,7 +55,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -93,7 +93,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -123,7 +123,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -153,7 +153,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -289,4 +289,4 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
return result;
}
}
}
}

View File

@@ -1,4 +1,4 @@
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Expressions2.Sdk;
using GitHub.DistributedTask.ObjectTemplating;
using System;
@@ -9,7 +9,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
{
/// <summary>
/// Regression tests for ExpressionParser.CreateTree to verify that
/// the case function does not accidentally set allowUnknownKeywords.
/// allowCaseFunction does not accidentally set allowUnknownKeywords.
/// </summary>
public sealed class ExpressionParserL0
{
@@ -18,7 +18,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
[Trait("Category", "Sdk")]
public void CreateTree_RejectsUnrecognizedNamedValue()
{
// Regression: the case function parameter was passed positionally into
// Regression: allowCaseFunction was passed positionally into
// the allowUnknownKeywords parameter, causing all named values
// to be silently accepted.
var parser = new ExpressionParser();
@@ -52,7 +52,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionWorks()
public void CreateTree_CaseFunctionWorks_WhenAllowed()
{
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
@@ -60,17 +60,35 @@ namespace GitHub.Runner.Common.Tests.Sdk
new NamedValueInfo<ContextValueNode>("github"),
};
var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null);
var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: true);
Assert.NotNull(node);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionRejected_WhenDisallowed()
{
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("github"),
};
var ex = Assert.Throws<ParseException>(() =>
parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: false));
Assert.Contains("Unrecognized function", ex.Message);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionDoesNotAffectUnknownKeywords()
{
// The key regression test: unrecognized named values must still be rejected.
// The key regression test: with allowCaseFunction=true (default),
// unrecognized named values must still be rejected.
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
@@ -78,7 +96,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
};
var ex = Assert.Throws<ParseException>(() =>
parser.CreateTree("github.ref", null, namedValues, null));
parser.CreateTree("github.ref", null, namedValues, null, allowCaseFunction: true));
Assert.Contains("Unrecognized named-value", ex.Message);
}

View File

@@ -69,6 +69,56 @@ public sealed class AgentJobRequestMessageL0
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyDebuggerTunnelDeserialization_WithTunnel()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage), new DataContractJsonSerializerSettings
{
KnownTypes = new[] { typeof(DebuggerTunnelInfo) }
});
string json = DoubleQuotify(
"{'EnableDebugger': true, 'DebuggerTunnel': {'TunnelId': 'tun-123', 'ClusterId': 'use2', 'HostToken': 'tok-abc', 'Port': 4711}}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.True(recoveredMessage.EnableDebugger);
Assert.NotNull(recoveredMessage.DebuggerTunnel);
Assert.Equal("tun-123", recoveredMessage.DebuggerTunnel.TunnelId);
Assert.Equal("use2", recoveredMessage.DebuggerTunnel.ClusterId);
Assert.Equal("tok-abc", recoveredMessage.DebuggerTunnel.HostToken);
Assert.Equal(4711, recoveredMessage.DebuggerTunnel.Port);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyDebuggerTunnelDeserialization_WithoutTunnel()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'EnableDebugger': true}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.True(recoveredMessage.EnableDebugger);
Assert.Null(recoveredMessage.DebuggerTunnel);
}
private static string DoubleQuotify(string text)
{
return text.Replace('\'', '"');

View File

@@ -504,7 +504,7 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_Node24Action()
@@ -1006,45 +1006,6 @@ namespace GitHub.Runner.Common.Tests.Worker
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Evaluate_Default_Input_Case_Function()
{
try
{
//Arrange
Setup();
var actionManifest = new ActionManifestManager();
actionManifest.Initialize(_hc);
_ec.Object.ExpressionValues["github"] = new LegacyContextData.DictionaryContextData
{
{ "ref", new LegacyContextData.StringContextData("refs/heads/main") },
};
_ec.Object.ExpressionValues["strategy"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["matrix"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["steps"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["job"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["runner"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["env"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionFunctions.Add(new LegacyExpressions.FunctionInfo<GitHub.Runner.Worker.Expressions.HashFilesFunction>("hashFiles", 1, 255));
// Act — evaluate a case() expression as a default input value.
// The feature flag is set, so this should succeed.
var token = new BasicExpressionToken(null, null, null, "case(true, 'matched', 'default')");
var result = actionManifest.EvaluateDefaultInput(_ec.Object, "testInput", token);
// Assert — case() should evaluate successfully
Assert.Equal("matched", result);
}
finally
{
Teardown();
}
}
private void Teardown()
{
_hc?.Dispose();

View File

@@ -16,7 +16,6 @@ namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapDebuggerL0
{
private const string PortEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT";
private const string TimeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT";
private DapDebugger _debugger;
@@ -25,6 +24,7 @@ namespace GitHub.Runner.Common.Tests.Worker
var hc = new TestHostContext(this, testName);
_debugger = new DapDebugger();
_debugger.Initialize(hc);
_debugger.SkipTunnelRelay = true;
return hc;
}
@@ -144,6 +144,26 @@ namespace GitHub.Runner.Common.Tests.Worker
{
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
jobContext.Setup(x => x.Global).Returns(new GlobalContext());
jobContext
.Setup(x => x.GetGitHubContext(It.IsAny<string>()))
.Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null);
return jobContext;
}
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, int port, string jobName = null)
{
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = port
};
var debuggerConfig = new DebuggerConfig(true, tunnel);
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
jobContext
.Setup(x => x.GetGitHubContext(It.IsAny<string>()))
.Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null);
@@ -165,42 +185,36 @@ namespace GitHub.Runner.Common.Tests.Worker
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolvePortUsesCustomPortFromEnvironment()
public async Task StartAsyncFailsWithoutValidTunnelConfig()
{
using (CreateTestContext())
{
WithEnvironmentVariable(PortEnvironmentVariable, "9999", () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cts.Token);
jobContext.Setup(x => x.Global).Returns(new GlobalContext
{
Assert.Equal(9999, _debugger.ResolvePort());
Debugger = new DebuggerConfig(true, null)
});
await Assert.ThrowsAsync<InvalidOperationException>(() => _debugger.StartAsync(jobContext.Object));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolvePortIgnoresInvalidPortFromEnvironment()
public async Task StartAsyncUsesPortFromTunnelConfig()
{
using (CreateTestContext())
{
WithEnvironmentVariable(PortEnvironmentVariable, "not-a-number", () =>
{
Assert.Equal(4711, _debugger.ResolvePort());
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolvePortIgnoresOutOfRangePortFromEnvironment()
{
using (CreateTestContext())
{
WithEnvironmentVariable(PortEnvironmentVariable, "99999", () =>
{
Assert.Equal(4711, _debugger.ResolvePort());
});
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
Assert.True(client.Connected);
await _debugger.StopAsync();
}
}
@@ -254,15 +268,12 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
Assert.True(client.Connected);
await _debugger.StopAsync();
});
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
Assert.True(client.Connected);
await _debugger.StopAsync();
}
}
@@ -275,13 +286,10 @@ namespace GitHub.Runner.Common.Tests.Worker
{
foreach (var port in new[] { GetFreePort(), GetFreePort() })
{
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
await _debugger.StopAsync();
});
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
await _debugger.StopAsync();
}
}
}
@@ -294,25 +302,22 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
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);
await SendRequestAsync(client.GetStream(), new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
Assert.Equal(DapSessionState.Ready, _debugger.State);
await _debugger.StopAsync();
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
Assert.Equal(DapSessionState.Ready, _debugger.State);
await _debugger.StopAsync();
}
}
@@ -324,25 +329,22 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(client.GetStream(), new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token, "ci-job");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "threads"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", response);
Assert.Contains("\"name\":\"Job: ci-job\"", response);
await _debugger.StopAsync();
Seq = 1,
Type = "request",
Command = "threads"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", response);
Assert.Contains("\"name\":\"Job: ci-job\"", response);
await _debugger.StopAsync();
}
}
@@ -354,30 +356,27 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
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);
await SendRequestAsync(client.GetStream(), new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
cts.Cancel();
// In the real runner, JobRunner always calls OnJobCompletedAsync
// from a finally block. The cancellation callback only unblocks
// pending waits; OnJobCompletedAsync handles state + cleanup.
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
cts.Cancel();
// In the real runner, JobRunner always calls OnJobCompletedAsync
// from a finally block. The cancellation callback only unblocks
// pending waits; OnJobCompletedAsync handles state + cleanup.
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
}
}
@@ -400,25 +399,22 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
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);
await SendRequestAsync(client.GetStream(), new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
}
}
@@ -441,20 +437,17 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
await Task.Delay(50);
cts.Cancel();
var waitTask = _debugger.WaitUntilReadyAsync();
await Task.Delay(50);
cts.Cancel();
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask);
Assert.IsNotType<TimeoutException>(ex);
await _debugger.StopAsync();
});
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask);
Assert.IsNotType<TimeoutException>(ex);
await _debugger.StopAsync();
}
}
@@ -471,32 +464,29 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SecretMasker.AddValue("initialized");
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"response\"", response);
Assert.Contains("\"command\":\"initialize\"", response);
Assert.Contains("\"success\":true", response);
var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"event\"", initializedEvent);
Assert.Contains("\"event\":\"initialized\"", initializedEvent);
await _debugger.StopAsync();
Seq = 1,
Type = "request",
Command = "initialize"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"response\"", response);
Assert.Contains("\"command\":\"initialize\"", response);
Assert.Contains("\"success\":true", response);
var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"event\"", initializedEvent);
Assert.Contains("\"event\":\"initialized\"", initializedEvent);
await _debugger.StopAsync();
}
}
@@ -508,41 +498,38 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
// Complete handshake so session is ready
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
// Complete handshake so session is ready
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 waitTask;
// Simulate a step starting (which pauses)
var step = new Mock<IStep>();
step.Setup(s => s.DisplayName).Returns("Test Step");
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
var stepTask = _debugger.OnStepStartingAsync(step.Object);
// Give the step time to pause
await Task.Delay(50);
// Cancel the job — should release the step pause
cts.Cancel();
await stepTask;
// In the real runner, OnJobCompletedAsync always follows.
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
// Simulate a step starting (which pauses)
var step = new Mock<IStep>();
step.Setup(s => s.DisplayName).Returns("Test Step");
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
var stepTask = _debugger.OnStepStartingAsync(step.Object);
// Give the step time to pause
await Task.Delay(50);
// Cancel the job — should release the step pause
cts.Cancel();
await stepTask;
// In the real runner, OnJobCompletedAsync always follows.
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
}
}
@@ -558,13 +545,10 @@ namespace GitHub.Runner.Common.Tests.Worker
// Start then immediate stop (no connection, no WaitUntilReady)
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
await _debugger.StopAsync();
});
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
await _debugger.StopAsync();
// StopAsync after already stopped
await _debugger.StopAsync();
@@ -579,37 +563,34 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
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
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
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"
});
// Read the configurationDone response
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
// Complete the job — events are sent via OnJobCompletedAsync
await _debugger.OnJobCompletedAsync();
var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Both events should arrive (order may vary)
var combined = msg1 + msg2;
Assert.Contains("\"event\":\"terminated\"", combined);
Assert.Contains("\"event\":\"exited\"", combined);
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// Read the configurationDone response
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
// Complete the job — events are sent via OnJobCompletedAsync
await _debugger.OnJobCompletedAsync();
var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Both events should arrive (order may vary)
var combined = msg1 + msg2;
Assert.Contains("\"event\":\"terminated\"", combined);
Assert.Contains("\"event\":\"exited\"", combined);
}
}
}