diff --git a/src/Runner.Common/Tracing.cs b/src/Runner.Common/Tracing.cs
index 3cc1d039385..9a2b035f6b1 100644
--- a/src/Runner.Common/Tracing.cs
+++ b/src/Runner.Common/Tracing.cs
@@ -12,6 +12,13 @@ public sealed class Tracing : ITraceWriter, IDisposable
private ISecretMasker _secretMasker;
private TraceSource _traceSource;
+ ///
+ /// The underlying for this instance.
+ /// Useful when third-party libraries require a
+ /// to route their diagnostics into the runner's log infrastructure.
+ ///
+ public TraceSource Source => _traceSource;
+
public Tracing(string name, ISecretMasker secretMasker, SourceSwitch sourceSwitch, HostTraceListener traceListener, StdoutTraceListener stdoutTraceListener = null)
{
ArgUtil.NotNull(secretMasker, nameof(secretMasker));
diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs
index 9d0acca680d..99b61e1b1a2 100644
--- a/src/Runner.Worker/Dap/DapDebugger.cs
+++ b/src/Runner.Worker/Dap/DapDebugger.cs
@@ -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 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 @@ internal sealed class CompletedStepInfo
///
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 @@ public sealed class DapDebugger : RunnerService, IDapDebugger
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 _commandTcs;
private readonly object _stateLock = new object();
@@ -101,22 +117,38 @@ public override void Initialize(IHostContext hostContext)
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(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 @@ public Task StartAsync(IExecutionContext jobContext)
_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(new AuthenticationHeaderValue("tunnel", config.Tunnel.HostToken)),
+ tunnelServiceUri: null,
+ httpHandler);
+
+ var tunnel = new Tunnel
+ {
+ TunnelId = config.Tunnel.TunnelId,
+ ClusterId = config.Tunnel.ClusterId,
+ AccessTokens = new Dictionary
+ {
+ [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,32 +248,56 @@ public async Task StopAsync()
_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)
+ // 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.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
{
- try
- {
- await Task.WhenAny(_connectionLoopTask, Task.Delay(5000));
- }
- catch { /* best effort */ }
+ Trace.Info("Dev Tunnel relay stopped");
}
+
+ _tunnelRelayHost = null;
}
- catch (Exception ex)
+
+ 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)
{
- Trace.Error("Error stopping DAP debugger");
- Trace.Error(ex);
+ 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 @@ public async Task StopAsync()
_stream = null;
_readyTcs = null;
_connectionLoopTask = null;
+ _loopCts?.Dispose();
+ _loopCts = null;
}
public async Task OnStepStartingAsync(IStep step)
@@ -398,12 +492,7 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken)
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 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken)
HandleClientDisconnected();
CleanupConnection();
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
catch (Exception ex)
{
CleanupConnection();
@@ -427,6 +520,13 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken)
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,28 +1372,28 @@ private Response CreateResponse(Request request, bool success, string message =
};
}
- internal int ResolvePort()
+ internal int ResolveTimeout()
{
- var portEnv = Environment.GetEnvironmentVariable(_portEnvironmentVariable);
- if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 1024 && customPort <= 65535)
+ var timeoutEnv = Environment.GetEnvironmentVariable(_timeoutEnvironmentVariable);
+ if (!string.IsNullOrEmpty(timeoutEnv) && int.TryParse(timeoutEnv, out var customTimeout) && customTimeout > 0)
{
- Trace.Info($"Using custom DAP port {customPort} from {_portEnvironmentVariable}");
- return customPort;
+ Trace.Info($"Using custom DAP timeout {customTimeout} minutes from {_timeoutEnvironmentVariable}");
+ return customTimeout;
}
- return _defaultPort;
+ return _defaultTimeoutMinutes;
}
- internal int ResolveTimeout()
+ internal int ResolveTunnelConnectTimeout()
{
- var timeoutEnv = Environment.GetEnvironmentVariable(_timeoutEnvironmentVariable);
- if (!string.IsNullOrEmpty(timeoutEnv) && int.TryParse(timeoutEnv, out var customTimeout) && customTimeout > 0)
+ var raw = Environment.GetEnvironmentVariable(_tunnelConnectTimeoutSeconds);
+ if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var customTimeout) && customTimeout > 0)
{
- Trace.Info($"Using custom DAP timeout {customTimeout} minutes from {_timeoutEnvironmentVariable}");
+ Trace.Info($"Using custom tunnel connect timeout {customTimeout}s from {_tunnelConnectTimeoutSeconds}");
return customTimeout;
}
- return _defaultTimeoutMinutes;
+ return _defaultTunnelConnectTimeoutSeconds;
}
}
}
diff --git a/src/Runner.Worker/Dap/DebuggerConfig.cs b/src/Runner.Worker/Dap/DebuggerConfig.cs
new file mode 100644
index 00000000000..df139a15c18
--- /dev/null
+++ b/src/Runner.Worker/Dap/DebuggerConfig.cs
@@ -0,0 +1,33 @@
+using GitHub.DistributedTask.Pipelines;
+
+namespace GitHub.Runner.Worker.Dap
+{
+ ///
+ /// Consolidated runtime configuration for the job debugger.
+ /// Populated once from the acquire response and owned by .
+ ///
+ public sealed class DebuggerConfig
+ {
+ public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
+ {
+ Enabled = enabled;
+ Tunnel = tunnel;
+ }
+
+ /// Whether the debugger is enabled for this job.
+ public bool Enabled { get; }
+
+ ///
+ /// Dev Tunnel details for remote debugging.
+ /// Required when is true.
+ ///
+ public DebuggerTunnelInfo Tunnel { get; }
+
+ /// Whether the tunnel configuration is complete and valid.
+ public bool HasValidTunnel => Tunnel != null
+ && !string.IsNullOrEmpty(Tunnel.TunnelId)
+ && !string.IsNullOrEmpty(Tunnel.ClusterId)
+ && !string.IsNullOrEmpty(Tunnel.HostToken)
+ && Tunnel.Port >= 1024 && Tunnel.Port <= 65535;
+ }
+}
diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs
index 70f2a47afb6..6acb3e385d5 100644
--- a/src/Runner.Worker/ExecutionContext.cs
+++ b/src/Runner.Worker/ExecutionContext.cs
@@ -970,7 +970,7 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation
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;
diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs
index 60b4ef1fea8..b22b9f8ad44 100644
--- a/src/Runner.Worker/GlobalContext.cs
+++ b/src/Runner.Worker/GlobalContext.cs
@@ -4,6 +4,7 @@
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 @@ public sealed class GlobalContext
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; }
diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs
index 10623bbef10..2ccad0c0ce2 100644
--- a/src/Runner.Worker/JobRunner.cs
+++ b/src/Runner.Worker/JobRunner.cs
@@ -182,7 +182,7 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat
_tempDirectoryManager.InitializeTempDirectory(jobContext);
// Setup the debugger
- if (jobContext.Global.EnableDebugger)
+ if (jobContext.Global.Debugger?.Enabled == true)
{
Trace.Info("Debugger enabled for this job run");
diff --git a/src/Runner.Worker/Runner.Worker.csproj b/src/Runner.Worker/Runner.Worker.csproj
index 4470920e10c..ad8fbeb32ba 100644
--- a/src/Runner.Worker/Runner.Worker.csproj
+++ b/src/Runner.Worker/Runner.Worker.csproj
@@ -23,6 +23,7 @@
+
diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs
index 328f6216081..465af8963fe 100644
--- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs
+++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs
@@ -260,6 +260,13 @@ public bool EnableDebugger
set;
}
+ [DataMember(EmitDefaultValue = false)]
+ public DebuggerTunnelInfo DebuggerTunnel
+ {
+ get;
+ set;
+ }
+
///
/// Gets the collection of variables associated with the current context.
///
diff --git a/src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs b/src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs
new file mode 100644
index 00000000000..a47c10cb646
--- /dev/null
+++ b/src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs
@@ -0,0 +1,24 @@
+using System.Runtime.Serialization;
+
+namespace GitHub.DistributedTask.Pipelines
+{
+ ///
+ /// Dev Tunnel information the runner needs to host the debugger tunnel.
+ /// Matches the run-service DebuggerTunnel contract.
+ ///
+ [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; }
+ }
+}
diff --git a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs
index 33b30d30836..4756d3de0d8 100644
--- a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs
+++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs
@@ -69,6 +69,56 @@ public void VerifyEnableDebuggerDeserialization_WithFalse()
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('\'', '"');
diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs
index f2c8557d15b..3b7487dd934 100644
--- a/src/Test/L0/Worker/DapDebuggerL0.cs
+++ b/src/Test/L0/Worker/DapDebuggerL0.cs
@@ -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 @@ private TestHostContext CreateTestContext([CallerMemberName] string testName = "
var hc = new TestHostContext(this, testName);
_debugger = new DapDebugger();
_debugger.Initialize(hc);
+ _debugger.SkipTunnelRelay = true;
return hc;
}
@@ -56,11 +57,11 @@ private static void WithEnvironmentVariable(string name, string value, Action ac
}
}
- 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 ConnectClientAsync(int port)
@@ -140,10 +141,19 @@ private static async Task ReadDapMessageAsync(NetworkStream stream, Time
return Encoding.UTF8.GetString(body);
}
- private static Mock CreateJobContext(CancellationToken cancellationToken, string jobName = null)
+ private static Mock 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();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
+ jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
jobContext
.Setup(x => x.GetGitHubContext(It.IsAny()))
.Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null);
@@ -165,42 +175,36 @@ public void InitializeSucceeds()
[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();
+ 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)
});
- }
- }
- [Fact]
- [Trait("Level", "L0")]
- [Trait("Category", "Worker")]
- public void ResolvePortIgnoresInvalidPortFromEnvironment()
- {
- using (CreateTestContext())
- {
- WithEnvironmentVariable(PortEnvironmentVariable, "not-a-number", () =>
- {
- Assert.Equal(4711, _debugger.ResolvePort());
- });
+ await Assert.ThrowsAsync(() => _debugger.StartAsync(jobContext.Object));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
- public void ResolvePortIgnoresOutOfRangePortFromEnvironment()
+ public async Task StartAsyncUsesPortFromTunnelConfig()
{
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 @@ public async Task StartAndStopLifecycle()
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 @@ public async Task StartAndStopMultipleTimesDoesNotThrow()
{
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 @@ public async Task WaitUntilReadyCompletesAfterClientConnectionAndConfigurationDo
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();
- 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();
+ 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();
}
}
@@ -324,25 +319,22 @@ public async Task StartStoresJobContextForThreadsRequest()
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 @@ public async Task CancellationUnblocksAndOnJobCompletedTerminates()
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();
- 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);
+ 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);
}
}
@@ -400,25 +389,22 @@ public async Task OnJobCompletedTerminatesSession()
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();
- 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);
+ 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);
}
}
@@ -441,20 +427,17 @@ public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledExc
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(() => waitTask);
- Assert.IsNotType(ex);
- await _debugger.StopAsync();
- });
+ var ex = await Assert.ThrowsAnyAsync(() => waitTask);
+ Assert.IsNotType(ex);
+ await _debugger.StopAsync();
}
}
@@ -471,32 +454,29 @@ public async Task InitializeRequestOverSocketPreservesProtocolMetadataWhenSecret
hc.SecretMasker.AddValue("initialized");
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);
- var stream = client.GetStream();
+ 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
- {
- Seq = 1,
- Type = "request",
- Command = "initialize"
- });
+ 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 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);
+ var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
+ Assert.Contains("\"type\":\"event\"", initializedEvent);
+ Assert.Contains("\"event\":\"initialized\"", initializedEvent);
- await _debugger.StopAsync();
- });
+ await _debugger.StopAsync();
}
}
@@ -508,41 +488,38 @@ public async Task CancellationDuringStepPauseReleasesWait()
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();
- 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();
+ 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 @@ public async Task StopAsyncSafeAtAnyLifecyclePoint()
// 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 @@ public async Task OnJobCompletedSendsTerminatedAndExitedEvents()
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());
});
}
}