From a85b3997798f1ca50de4bfba4ca028da29e9d551 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Tue, 24 Mar 2026 09:29:35 -0700 Subject: [PATCH 1/7] Add devtunnel connection for debugger jobs --- src/Runner.Worker/Dap/DapDebugger.cs | 95 ++++- src/Runner.Worker/Dap/DebuggerConfig.cs | 33 ++ src/Runner.Worker/ExecutionContext.cs | 2 +- src/Runner.Worker/GlobalContext.cs | 3 +- src/Runner.Worker/JobRunner.cs | 2 +- src/Runner.Worker/Runner.Worker.csproj | 1 + .../Pipelines/AgentJobRequestMessage.cs | 7 + .../Pipelines/DebuggerTunnelInfo.cs | 24 ++ .../Sdk/RSWebApi/AgentJobRequestMessageL0.cs | 50 +++ src/Test/L0/Worker/DapDebuggerL0.cs | 403 +++++++++--------- 10 files changed, 389 insertions(+), 231 deletions(-) create mode 100644 src/Runner.Worker/Dap/DebuggerConfig.cs create mode 100644 src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 9d0acca680d..b9534f051a8 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Net; +using System.Net.Http.Headers; using System.Net.Sockets; using System.Text; using System.Threading; @@ -9,6 +11,9 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Sdk; +using Microsoft.DevTunnels.Connections; +using Microsoft.DevTunnels.Contracts; +using Microsoft.DevTunnels.Management; using Newtonsoft.Json; namespace GitHub.Runner.Worker.Dap @@ -30,9 +35,7 @@ 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 string _contentLengthHeader = "Content-Length: "; private const int _maxMessageSize = 10 * 1024 * 1024; // 10 MB @@ -58,6 +61,12 @@ public sealed class DapDebugger : RunnerService, IDapDebugger private CancellationTokenRegistration? _cancellationRegistration; private bool _isFirstStep = true; + // Dev Tunnel relay host for remote debugging + private TunnelRelayTunnelHost _tunnelRelayHost; + + // When true, skip tunnel relay startup (unit tests only) + internal bool SkipTunnelRelay { get; set; } + // Synchronization for step execution private TaskCompletionSource _commandTcs; private readonly object _stateLock = new object(); @@ -101,11 +110,18 @@ 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; + + if (debuggerConfig == null || !debuggerConfig.HasValidTunnel) + { + throw new InvalidOperationException( + "Debugger requires valid tunnel configuration (tunnelId, clusterId, hostToken, port)."); + } + var port = debuggerConfig.Tunnel.Port; Trace.Info($"Starting DAP debugger on port {port}"); _jobContext = jobContext; @@ -115,6 +131,15 @@ public Task StartAsync(IExecutionContext jobContext) _listener.Start(); Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}"); + // Start Dev Tunnel relay so remote clients reach the local DAP port. + // The relay is torn down explicitly in StopAsync (after the DAP session + // is closed) so we do NOT pass the job cancellation token here — that + // would race with the DAP shutdown and drop the transport mid-protocol. + if (!SkipTunnelRelay) + { + await StartTunnelRelayAsync(debuggerConfig); + } + _state = DapSessionState.WaitingForConnection; _connectionLoopTask = ConnectionLoopAsync(jobContext.CancellationToken); @@ -126,7 +151,36 @@ public Task StartAsync(IExecutionContext jobContext) }); Trace.Info($"DAP debugger started on port {port}"); - return Task.CompletedTask; + } + + private async Task StartTunnelRelayAsync(DebuggerConfig config) + { + Trace.Info($"Starting Dev Tunnel relay (tunnel={config.Tunnel.TunnelId}, cluster={config.Tunnel.ClusterId})"); + + var managementClient = new TunnelManagementClient( + new ProductInfoHeaderValue("actions-runner", "1.0"), + () => Task.FromResult( + (AuthenticationHeaderValue) + new AuthenticationHeaderValue("tunnel", config.Tunnel.HostToken))); + + var tunnel = new Tunnel + { + TunnelId = config.Tunnel.TunnelId, + ClusterId = config.Tunnel.ClusterId, + AccessTokens = new Dictionary + { + [TunnelAccessScopes.Host] = config.Tunnel.HostToken + }, + Ports = new[] + { + new TunnelPort { PortNumber = (ushort)config.Tunnel.Port } + }, + }; + + _tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, new TraceSource("DevTunnelRelay")); + await _tunnelRelayHost.StartAsync(tunnel, CancellationToken.None); + + Trace.Info("Dev Tunnel relay started"); } public async Task WaitUntilReadyAsync() @@ -199,6 +253,25 @@ public async Task StopAsync() } catch { /* best effort */ } } + + // Tear down Dev Tunnel relay + if (_tunnelRelayHost != null) + { + try + { + Trace.Info("Stopping Dev Tunnel relay"); + await _tunnelRelayHost.DisposeAsync(); + Trace.Info("Dev Tunnel relay stopped"); + } + catch (Exception ex) + { + Trace.Warning($"Error stopping tunnel relay: {ex.Message}"); + } + finally + { + _tunnelRelayHost = null; + } + } } catch (Exception ex) { @@ -1272,18 +1345,6 @@ private Response CreateResponse(Request request, bool success, string message = }; } - 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); diff --git a/src/Runner.Worker/Dap/DebuggerConfig.cs b/src/Runner.Worker/Dap/DebuggerConfig.cs new file mode 100644 index 00000000000..bea8f5a3e61 --- /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 > 0; + } +} 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..4b9a288fa3e 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..c99b01313b1 --- /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 int 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..aa02cd5d1bc 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -16,7 +16,6 @@ namespace GitHub.Runner.Common.Tests.Worker { public sealed class DapDebuggerL0 { - private const string PortEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT"; private const string TimeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT"; private DapDebugger _debugger; @@ -25,6 +24,7 @@ private TestHostContext CreateTestContext([CallerMemberName] string testName = " var hc = new TestHostContext(this, testName); _debugger = new DapDebugger(); _debugger.Initialize(hc); + _debugger.SkipTunnelRelay = true; return hc; } @@ -144,63 +144,77 @@ private static Mock CreateJobContext(CancellationToken cancel { var jobContext = new Mock(); jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken); + jobContext.Setup(x => x.Global).Returns(new GlobalContext()); jobContext .Setup(x => x.GetGitHubContext(It.IsAny())) .Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null); return jobContext; } - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void InitializeSucceeds() + private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, int port, string jobName = null) { - using (CreateTestContext()) + var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo { - Assert.NotNull(_debugger); - Assert.False(_debugger.IsActive); - } + 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); + return jobContext; } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public void ResolvePortUsesCustomPortFromEnvironment() + public void InitializeSucceeds() { using (CreateTestContext()) { - WithEnvironmentVariable(PortEnvironmentVariable, "9999", () => - { - Assert.Equal(9999, _debugger.ResolvePort()); - }); + Assert.NotNull(_debugger); + Assert.False(_debugger.IsActive); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public void ResolvePortIgnoresInvalidPortFromEnvironment() + public async Task StartAsyncFailsWithoutValidTunnelConfig() { using (CreateTestContext()) { - WithEnvironmentVariable(PortEnvironmentVariable, "not-a-number", () => + 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(4711, _debugger.ResolvePort()); + Debugger = new DebuggerConfig(true, null) }); + + 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 +268,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 +286,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 +302,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 +329,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 +356,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 +399,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 +437,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 +464,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 +498,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 +545,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,37 +563,34 @@ 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); - - 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" - }); + 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); } } } From b83d85d1484d394a7470257fe88b7188e3760cc3 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 30 Mar 2026 03:04:02 -0700 Subject: [PATCH 2/7] ccr --- src/Runner.Worker/Dap/DapDebugger.cs | 64 ++++++++++++------------- src/Runner.Worker/Dap/DebuggerConfig.cs | 2 +- src/Test/L0/Worker/DapDebuggerL0.cs | 11 ----- 3 files changed, 33 insertions(+), 44 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index b9534f051a8..d6128a9896b 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -234,50 +234,50 @@ 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(); + CleanupConnection(); - try { _listener?.Stop(); } - catch { /* best effort */ } + try { _listener?.Stop(); } + catch { /* best effort */ } - if (_connectionLoopTask != null) + if (_connectionLoopTask != null) + { + try { - try - { - await Task.WhenAny(_connectionLoopTask, Task.Delay(5000)); - } - catch { /* best effort */ } + await Task.WhenAny(_connectionLoopTask, Task.Delay(5000)); } + catch { /* best effort */ } + } - // Tear down Dev Tunnel relay - if (_tunnelRelayHost != null) + // Tear down Dev Tunnel relay + if (_tunnelRelayHost != null) + { + try { - try - { - Trace.Info("Stopping Dev Tunnel relay"); - await _tunnelRelayHost.DisposeAsync(); - Trace.Info("Dev Tunnel relay stopped"); - } - catch (Exception ex) - { - Trace.Warning($"Error stopping tunnel relay: {ex.Message}"); - } - finally - { - _tunnelRelayHost = null; - } + Trace.Info("Stopping Dev Tunnel relay"); + await _tunnelRelayHost.DisposeAsync(); + Trace.Info("Dev Tunnel relay stopped"); + } + catch (Exception ex) + { + Trace.Warning($"Error stopping tunnel relay: {ex.Message}"); + } + finally + { + _tunnelRelayHost = null; } } - catch (Exception ex) - { - Trace.Error("Error stopping DAP debugger"); - Trace.Error(ex); - } + } + catch (Exception ex) + { + Trace.Error("Error stopping DAP debugger"); + Trace.Error(ex); } lock (_stateLock) diff --git a/src/Runner.Worker/Dap/DebuggerConfig.cs b/src/Runner.Worker/Dap/DebuggerConfig.cs index bea8f5a3e61..94c24eb19b6 100644 --- a/src/Runner.Worker/Dap/DebuggerConfig.cs +++ b/src/Runner.Worker/Dap/DebuggerConfig.cs @@ -28,6 +28,6 @@ public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel) && !string.IsNullOrEmpty(Tunnel.TunnelId) && !string.IsNullOrEmpty(Tunnel.ClusterId) && !string.IsNullOrEmpty(Tunnel.HostToken) - && Tunnel.Port > 0; + && Tunnel.Port >= 1024; } } diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index aa02cd5d1bc..ee1a3f617d8 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -140,17 +140,6 @@ private static async Task ReadDapMessageAsync(NetworkStream stream, Time return Encoding.UTF8.GetString(body); } - private static Mock CreateJobContext(CancellationToken cancellationToken, string jobName = null) - { - var jobContext = new Mock(); - jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken); - jobContext.Setup(x => x.Global).Returns(new GlobalContext()); - jobContext - .Setup(x => x.GetGitHubContext(It.IsAny())) - .Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null); - return jobContext; - } - private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, int port, string jobName = null) { var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo From 4796916d6a6a53528566e6c8b2efa31bd02465a3 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 30 Mar 2026 07:43:12 -0700 Subject: [PATCH 3/7] dispose tunnel first --- src/Runner.Worker/Dap/DapDebugger.cs | 43 +++++++++++++++++----------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index d6128a9896b..888c02c3b97 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -241,28 +241,23 @@ public async Task StopAsync() 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 */ } - } - - // Tear down Dev Tunnel relay + // 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) { try { Trace.Info("Stopping Dev Tunnel relay"); - await _tunnelRelayHost.DisposeAsync(); - Trace.Info("Dev Tunnel relay stopped"); + 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"); + } } catch (Exception ex) { @@ -273,6 +268,20 @@ public async Task StopAsync() _tunnelRelayHost = null; } } + + CleanupConnection(); + + try { _listener?.Stop(); } + catch { /* best effort */ } + + if (_connectionLoopTask != null) + { + try + { + await Task.WhenAny(_connectionLoopTask, Task.Delay(5000)); + } + catch { /* best effort */ } + } } catch (Exception ex) { From 5ba2f11cb21aaa3b325023435bedfad9e67544a2 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 30 Mar 2026 09:20:54 -0700 Subject: [PATCH 4/7] pr feedback --- src/Runner.Worker/Dap/DapDebugger.cs | 38 +++++++++++++------ src/Runner.Worker/Runner.Worker.csproj | 2 +- .../Pipelines/DebuggerTunnelInfo.cs | 2 +- src/Test/L0/Worker/DapDebuggerL0.cs | 8 ++-- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 888c02c3b97..494ec731177 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -2,6 +2,7 @@ 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; @@ -115,19 +116,18 @@ public async Task StartAsync(IExecutionContext jobContext) ArgUtil.NotNull(jobContext, nameof(jobContext)); var debuggerConfig = jobContext.Global.Debugger; - if (debuggerConfig == null || !debuggerConfig.HasValidTunnel) + if (!debuggerConfig.HasValidTunnel) { - throw new InvalidOperationException( + throw new ArgumentException( "Debugger requires valid tunnel configuration (tunnelId, clusterId, hostToken, port)."); } - var port = debuggerConfig.Tunnel.Port; - Trace.Info($"Starting DAP debugger on port {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}"); @@ -150,18 +150,22 @@ public async Task StartAsync(IExecutionContext jobContext) _commandTcs?.TrySetResult(DapCommand.Disconnect); }); - Trace.Info($"DAP debugger started on port {port}"); + 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( - new ProductInfoHeaderValue("actions-runner", "1.0"), - () => Task.FromResult( - (AuthenticationHeaderValue) - new AuthenticationHeaderValue("tunnel", config.Tunnel.HostToken))); + userAgents, + () => Task.FromResult(new AuthenticationHeaderValue("tunnel", config.Tunnel.HostToken)), + tunnelServiceUri: null, + httpHandler); var tunnel = new Tunnel { @@ -173,7 +177,7 @@ private async Task StartTunnelRelayAsync(DebuggerConfig config) }, Ports = new[] { - new TunnelPort { PortNumber = (ushort)config.Tunnel.Port } + new TunnelPort { PortNumber = config.Tunnel.Port } }, }; @@ -500,6 +504,11 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken) HandleClientDisconnected(); CleanupConnection(); } + catch (ObjectDisposedException) + { + // Listener was stopped/disposed by StopAsync — exit cleanly. + break; + } catch (Exception ex) { CleanupConnection(); @@ -509,6 +518,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); diff --git a/src/Runner.Worker/Runner.Worker.csproj b/src/Runner.Worker/Runner.Worker.csproj index 4b9a288fa3e..ad8fbeb32ba 100644 --- a/src/Runner.Worker/Runner.Worker.csproj +++ b/src/Runner.Worker/Runner.Worker.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs b/src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs index c99b01313b1..a47c10cb646 100644 --- a/src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs +++ b/src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs @@ -19,6 +19,6 @@ public sealed class DebuggerTunnelInfo public string HostToken { get; set; } [DataMember(EmitDefaultValue = false)] - public int Port { get; set; } + public ushort Port { get; set; } } } diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index ee1a3f617d8..05657e83e32 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -56,11 +56,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,7 +140,7 @@ private static async Task ReadDapMessageAsync(NetworkStream stream, Time return Encoding.UTF8.GetString(body); } - private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, int port, string jobName = null) + private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null) { var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo { @@ -186,7 +186,7 @@ public async Task StartAsyncFailsWithoutValidTunnelConfig() Debugger = new DebuggerConfig(true, null) }); - await Assert.ThrowsAsync(() => _debugger.StartAsync(jobContext.Object)); + await Assert.ThrowsAsync(() => _debugger.StartAsync(jobContext.Object)); } } From e0796201069dcd683aef2486b5a0213d6b6a8b3a Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 30 Mar 2026 09:53:30 -0700 Subject: [PATCH 5/7] pr feedback --- src/Runner.Common/Tracing.cs | 7 +++++++ src/Runner.Worker/Dap/DapDebugger.cs | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) 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 494ec731177..2ecf9a419c4 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -181,8 +181,8 @@ private async Task StartTunnelRelayAsync(DebuggerConfig config) }, }; - _tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, new TraceSource("DevTunnelRelay")); - await _tunnelRelayHost.StartAsync(tunnel, CancellationToken.None); + _tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, HostContext.GetTrace("DevTunnelRelay").Source); + await _tunnelRelayHost.ConnectAsync(tunnel, CancellationToken.None); Trace.Info("Dev Tunnel relay started"); } From 476bc630ac4443261a0bc1dd1b91d7e41f999133 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Tue, 31 Mar 2026 03:20:05 -0700 Subject: [PATCH 6/7] add timeout for devtunnel connection --- src/Runner.Worker/Dap/DapDebugger.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 2ecf9a419c4..9da0a1cecbb 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -182,7 +182,8 @@ private async Task StartTunnelRelayAsync(DebuggerConfig config) }; _tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, HostContext.GetTrace("DevTunnelRelay").Source); - await _tunnelRelayHost.ConnectAsync(tunnel, CancellationToken.None); + using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await _tunnelRelayHost.ConnectAsync(tunnel, connectCts.Token); Trace.Info("Dev Tunnel relay started"); } From 9dd436c1f1dd712cc7fc938554dc1583f5e513ff Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 2 Apr 2026 07:19:49 -0700 Subject: [PATCH 7/7] feedback --- src/Runner.Worker/Dap/DapDebugger.cs | 67 +++++++++++++++---------- src/Runner.Worker/Dap/DebuggerConfig.cs | 2 +- src/Test/L0/Worker/DapDebuggerL0.cs | 54 ++++++++++++++++++++ 3 files changed, 95 insertions(+), 28 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 9da0a1cecbb..99b61e1b1a2 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -38,6 +38,8 @@ public sealed class DapDebugger : RunnerService, IDapDebugger { private const int _defaultTimeoutMinutes = 15; 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 @@ -65,6 +67,10 @@ public sealed class DapDebugger : RunnerService, IDapDebugger // 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; } @@ -141,7 +147,8 @@ public async Task StartAsync(IExecutionContext jobContext) } _state = DapSessionState.WaitingForConnection; - _connectionLoopTask = ConnectionLoopAsync(jobContext.CancellationToken); + _loopCts = CancellationTokenSource.CreateLinkedTokenSource(jobContext.CancellationToken); + _connectionLoopTask = ConnectionLoopAsync(_loopCts.Token); _cancellationRegistration = jobContext.CancellationToken.Register(() => { @@ -182,7 +189,9 @@ private async Task StartTunnelRelayAsync(DebuggerConfig config) }; _tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, HostContext.GetTrace("DevTunnelRelay").Source); - using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + 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"); @@ -251,31 +260,27 @@ public async Task StopAsync() // otherwise the next worker can't bind the same port. if (_tunnelRelayHost != null) { - try + Trace.Info("Stopping Dev Tunnel relay"); + var disposeTask = _tunnelRelayHost.DisposeAsync().AsTask(); + if (await Task.WhenAny(disposeTask, Task.Delay(10_000)) != disposeTask) { - 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"); - } + Trace.Warning("Dev Tunnel relay dispose timed out after 10s"); } - catch (Exception ex) + else { - Trace.Warning($"Error stopping tunnel relay: {ex.Message}"); - } - finally - { - _tunnelRelayHost = null; + 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 */ } @@ -308,6 +313,8 @@ public async Task StopAsync() _stream = null; _readyTcs = null; _connectionLoopTask = null; + _loopCts?.Dispose(); + _loopCts = null; } public async Task OnStepStartingAsync(IStep step) @@ -485,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; @@ -505,9 +507,8 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken) HandleClientDisconnected(); CleanupConnection(); } - catch (ObjectDisposedException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // Listener was stopped/disposed by StopAsync — exit cleanly. break; } catch (Exception ex) @@ -1382,5 +1383,17 @@ internal int ResolveTimeout() 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; + } } } diff --git a/src/Runner.Worker/Dap/DebuggerConfig.cs b/src/Runner.Worker/Dap/DebuggerConfig.cs index 94c24eb19b6..df139a15c18 100644 --- a/src/Runner.Worker/Dap/DebuggerConfig.cs +++ b/src/Runner.Worker/Dap/DebuggerConfig.cs @@ -28,6 +28,6 @@ public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel) && !string.IsNullOrEmpty(Tunnel.TunnelId) && !string.IsNullOrEmpty(Tunnel.ClusterId) && !string.IsNullOrEmpty(Tunnel.HostToken) - && Tunnel.Port >= 1024; + && Tunnel.Port >= 1024 && Tunnel.Port <= 65535; } } diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index 05657e83e32..3b7487dd934 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -17,6 +17,7 @@ namespace GitHub.Runner.Common.Tests.Worker public sealed class DapDebuggerL0 { 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 = "") @@ -582,5 +583,58 @@ public async Task OnJobCompletedSendsTerminatedAndExitedEvents() 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()); + }); + } + } } }