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()); }); } }