Add devtunnel connection for debugger jobs (#4317)
This commit is contained in:
@@ -12,6 +12,13 @@ namespace GitHub.Runner.Common
|
||||
private ISecretMasker _secretMasker;
|
||||
private TraceSource _traceSource;
|
||||
|
||||
/// <summary>
|
||||
/// The underlying <see cref="System.Diagnostics.TraceSource"/> for this instance.
|
||||
/// Useful when third-party libraries require a <see cref="System.Diagnostics.TraceSource"/>
|
||||
/// to route their diagnostics into the runner's log infrastructure.
|
||||
/// </summary>
|
||||
public TraceSource Source => _traceSource;
|
||||
|
||||
public Tracing(string name, ISecretMasker secretMasker, SourceSwitch sourceSwitch, HostTraceListener traceListener, StdoutTraceListener stdoutTraceListener = null)
|
||||
{
|
||||
ArgUtil.NotNull(secretMasker, nameof(secretMasker));
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
@@ -9,6 +12,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,10 +36,10 @@ 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 int _defaultTunnelConnectTimeoutSeconds = 30;
|
||||
private const string _tunnelConnectTimeoutSeconds = "ACTIONS_RUNNER_DAP_TUNNEL_CONNECT_TIMEOUT_SECONDS";
|
||||
private const string _contentLengthHeader = "Content-Length: ";
|
||||
private const int _maxMessageSize = 10 * 1024 * 1024; // 10 MB
|
||||
private const int _maxHeaderLineLength = 8192; // 8 KB
|
||||
@@ -58,6 +64,16 @@ namespace GitHub.Runner.Worker.Dap
|
||||
private CancellationTokenRegistration? _cancellationRegistration;
|
||||
private bool _isFirstStep = true;
|
||||
|
||||
// Dev Tunnel relay host for remote debugging
|
||||
private TunnelRelayTunnelHost _tunnelRelayHost;
|
||||
|
||||
// Cancellation source for the connection loop, cancelled in StopAsync
|
||||
// so AcceptTcpClientAsync unblocks cleanly without relying on listener disposal.
|
||||
private CancellationTokenSource _loopCts;
|
||||
|
||||
// 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,22 +117,38 @@ 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;
|
||||
|
||||
Trace.Info($"Starting DAP debugger on port {port}");
|
||||
if (!debuggerConfig.HasValidTunnel)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Debugger requires valid tunnel configuration (tunnelId, clusterId, hostToken, port).");
|
||||
}
|
||||
|
||||
Trace.Info($"Starting DAP debugger on port {debuggerConfig.Tunnel.Port}");
|
||||
|
||||
_jobContext = jobContext;
|
||||
_readyTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||
_listener = new TcpListener(IPAddress.Loopback, debuggerConfig.Tunnel.Port);
|
||||
_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);
|
||||
_loopCts = CancellationTokenSource.CreateLinkedTokenSource(jobContext.CancellationToken);
|
||||
_connectionLoopTask = ConnectionLoopAsync(_loopCts.Token);
|
||||
|
||||
_cancellationRegistration = jobContext.CancellationToken.Register(() =>
|
||||
{
|
||||
@@ -125,8 +157,44 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||
});
|
||||
|
||||
Trace.Info($"DAP debugger started on port {port}");
|
||||
return Task.CompletedTask;
|
||||
Trace.Info($"DAP debugger started on port {debuggerConfig.Tunnel.Port}");
|
||||
}
|
||||
|
||||
private async Task StartTunnelRelayAsync(DebuggerConfig config)
|
||||
{
|
||||
Trace.Info($"Starting Dev Tunnel relay (tunnel={config.Tunnel.TunnelId}, cluster={config.Tunnel.ClusterId})");
|
||||
|
||||
var userAgents = HostContext.UserAgents.ToArray();
|
||||
var httpHandler = HostContext.CreateHttpClientHandler();
|
||||
httpHandler.AllowAutoRedirect = false;
|
||||
|
||||
var managementClient = new TunnelManagementClient(
|
||||
userAgents,
|
||||
() => Task.FromResult<AuthenticationHeaderValue>(new AuthenticationHeaderValue("tunnel", config.Tunnel.HostToken)),
|
||||
tunnelServiceUri: null,
|
||||
httpHandler);
|
||||
|
||||
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 = config.Tunnel.Port }
|
||||
},
|
||||
};
|
||||
|
||||
_tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, HostContext.GetTrace("DevTunnelRelay").Source);
|
||||
var tunnelConnectTimeoutSeconds = ResolveTunnelConnectTimeout();
|
||||
using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(tunnelConnectTimeoutSeconds));
|
||||
Trace.Info($"Connecting to Dev Tunnel relay (timeout: {tunnelConnectTimeoutSeconds}s)");
|
||||
await _tunnelRelayHost.ConnectAsync(tunnel, connectCts.Token);
|
||||
|
||||
Trace.Info("Dev Tunnel relay started");
|
||||
}
|
||||
|
||||
public async Task WaitUntilReadyAsync()
|
||||
@@ -180,31 +248,55 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_cancellationRegistration = null;
|
||||
}
|
||||
|
||||
if (_state != DapSessionState.NotStarted)
|
||||
try
|
||||
{
|
||||
try
|
||||
if (_listener != null || _tunnelRelayHost != null || _connectionLoopTask != null)
|
||||
{
|
||||
Trace.Info("Stopping DAP debugger");
|
||||
|
||||
CleanupConnection();
|
||||
|
||||
try { _listener?.Stop(); }
|
||||
catch { /* best effort */ }
|
||||
|
||||
if (_connectionLoopTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(_connectionLoopTask, Task.Delay(5000));
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// Tear down Dev Tunnel relay FIRST — it may hold connections to the
|
||||
// local port and must be fully disposed before we release the listener,
|
||||
// otherwise the next worker can't bind the same port.
|
||||
if (_tunnelRelayHost != null)
|
||||
{
|
||||
Trace.Error("Error stopping DAP debugger");
|
||||
Trace.Error(ex);
|
||||
Trace.Info("Stopping Dev Tunnel relay");
|
||||
var disposeTask = _tunnelRelayHost.DisposeAsync().AsTask();
|
||||
if (await Task.WhenAny(disposeTask, Task.Delay(10_000)) != disposeTask)
|
||||
{
|
||||
Trace.Warning("Dev Tunnel relay dispose timed out after 10s");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Dev Tunnel relay stopped");
|
||||
}
|
||||
|
||||
_tunnelRelayHost = null;
|
||||
}
|
||||
|
||||
CleanupConnection();
|
||||
|
||||
// Cancel the connection loop first so AcceptTcpClientAsync unblocks
|
||||
// cleanly, then stop the listener once nothing is using it.
|
||||
try { _loopCts?.Cancel(); }
|
||||
catch { /* best effort */ }
|
||||
|
||||
try { _listener?.Stop(); }
|
||||
catch { /* best effort */ }
|
||||
|
||||
if (_connectionLoopTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(_connectionLoopTask, Task.Delay(5000));
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error("Error stopping DAP debugger");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
@@ -221,6 +313,8 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_stream = null;
|
||||
_readyTcs = null;
|
||||
_connectionLoopTask = null;
|
||||
_loopCts?.Dispose();
|
||||
_loopCts = null;
|
||||
}
|
||||
|
||||
public async Task OnStepStartingAsync(IStep step)
|
||||
@@ -398,12 +492,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
try
|
||||
{
|
||||
Trace.Info("Waiting for debug client connection...");
|
||||
_client = await _listener.AcceptTcpClientAsync();
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
_client = await _listener.AcceptTcpClientAsync(cancellationToken);
|
||||
|
||||
_stream = _client.GetStream();
|
||||
var remoteEndPoint = _client.Client.RemoteEndPoint;
|
||||
@@ -418,6 +507,10 @@ namespace GitHub.Runner.Worker.Dap
|
||||
HandleClientDisconnected();
|
||||
CleanupConnection();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CleanupConnection();
|
||||
@@ -427,6 +520,13 @@ namespace GitHub.Runner.Worker.Dap
|
||||
break;
|
||||
}
|
||||
|
||||
// If the listener has been stopped, don't retry.
|
||||
if (_listener == null || !_listener.Server.IsBound)
|
||||
{
|
||||
Trace.Info("Listener stopped, exiting connection loop");
|
||||
break;
|
||||
}
|
||||
|
||||
Trace.Error("Debugger connection error");
|
||||
Trace.Error(ex);
|
||||
|
||||
@@ -1272,18 +1372,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);
|
||||
@@ -1295,5 +1383,17 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
return _defaultTimeoutMinutes;
|
||||
}
|
||||
|
||||
internal int ResolveTunnelConnectTimeout()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(_tunnelConnectTimeoutSeconds);
|
||||
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var customTimeout) && customTimeout > 0)
|
||||
{
|
||||
Trace.Info($"Using custom tunnel connect timeout {customTimeout}s from {_tunnelConnectTimeoutSeconds}");
|
||||
return customTimeout;
|
||||
}
|
||||
|
||||
return _defaultTunnelConnectTimeoutSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 >= 1024 && Tunnel.Port <= 65535;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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.3.16" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ushort Port { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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('\'', '"');
|
||||
|
||||
+239
-215
@@ -16,8 +16,8 @@ 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 const string TunnelConnectTimeoutVariable = "ACTIONS_RUNNER_DAP_TUNNEL_CONNECT_TIMEOUT_SECONDS";
|
||||
private DapDebugger _debugger;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||
@@ -25,6 +25,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
var hc = new TestHostContext(this, testName);
|
||||
_debugger = new DapDebugger();
|
||||
_debugger.Initialize(hc);
|
||||
_debugger.SkipTunnelRelay = true;
|
||||
return hc;
|
||||
}
|
||||
|
||||
@@ -56,11 +57,11 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
private static ushort GetFreePort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
return (ushort)((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
|
||||
private static async Task<TcpClient> ConnectClientAsync(int port)
|
||||
@@ -140,10 +141,19 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
return Encoding.UTF8.GetString(body);
|
||||
}
|
||||
|
||||
private static Mock<IExecutionContext> CreateJobContext(CancellationToken cancellationToken, string jobName = null)
|
||||
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort 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 +175,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<ArgumentException>(() => _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 +258,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 +276,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 +292,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 +319,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 +346,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 +389,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 +427,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 +454,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 +488,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 +535,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,36 +553,86 @@ 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);
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "configurationDone"
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// Read the configurationDone response
|
||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
await waitTask;
|
||||
// Complete the job — events are sent via OnJobCompletedAsync
|
||||
await _debugger.OnJobCompletedAsync();
|
||||
|
||||
// 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));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Both events should arrive (order may vary)
|
||||
var combined = msg1 + msg2;
|
||||
Assert.Contains("\"event\":\"terminated\"", combined);
|
||||
Assert.Contains("\"event\":\"exited\"", combined);
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResolveTunnelConnectTimeoutReturnsDefaultWhenNoVariable()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
Assert.Equal(30, _debugger.ResolveTunnelConnectTimeout());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResolveTunnelConnectTimeoutUsesCustomValue()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
WithEnvironmentVariable(TunnelConnectTimeoutVariable, "60", () =>
|
||||
{
|
||||
Assert.Equal(60, _debugger.ResolveTunnelConnectTimeout());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResolveTunnelConnectTimeoutIgnoresInvalidValue()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
WithEnvironmentVariable(TunnelConnectTimeoutVariable, "not-a-number", () =>
|
||||
{
|
||||
Assert.Equal(30, _debugger.ResolveTunnelConnectTimeout());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResolveTunnelConnectTimeoutIgnoresZeroValue()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
WithEnvironmentVariable(TunnelConnectTimeoutVariable, "0", () =>
|
||||
{
|
||||
Assert.Equal(30, _debugger.ResolveTunnelConnectTimeout());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user