diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 9d0acca68..b9534f051 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 System.Threading.Tasks; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Sdk; +using Microsoft.DevTunnels.Connections; +using Microsoft.DevTunnels.Contracts; +using Microsoft.DevTunnels.Management; using Newtonsoft.Json; namespace GitHub.Runner.Worker.Dap @@ -30,9 +35,7 @@ namespace GitHub.Runner.Worker.Dap /// public sealed class DapDebugger : RunnerService, IDapDebugger { - private const int _defaultPort = 4711; private const int _defaultTimeoutMinutes = 15; - private const string _portEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT"; private const string _timeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT"; private const string _contentLengthHeader = "Content-Length: "; private const int _maxMessageSize = 10 * 1024 * 1024; // 10 MB @@ -58,6 +61,12 @@ namespace GitHub.Runner.Worker.Dap private CancellationTokenRegistration? _cancellationRegistration; private bool _isFirstStep = true; + // Dev Tunnel relay host for remote debugging + private TunnelRelayTunnelHost _tunnelRelayHost; + + // When true, skip tunnel relay startup (unit tests only) + internal bool SkipTunnelRelay { get; set; } + // Synchronization for step execution private TaskCompletionSource _commandTcs; private readonly object _stateLock = new object(); @@ -101,11 +110,18 @@ namespace GitHub.Runner.Worker.Dap Trace.Info("DapDebugger initialized"); } - public Task StartAsync(IExecutionContext jobContext) + public async Task StartAsync(IExecutionContext jobContext) { ArgUtil.NotNull(jobContext, nameof(jobContext)); - var port = ResolvePort(); + var debuggerConfig = jobContext.Global.Debugger; + if (debuggerConfig == null || !debuggerConfig.HasValidTunnel) + { + throw new InvalidOperationException( + "Debugger requires valid tunnel configuration (tunnelId, clusterId, hostToken, port)."); + } + + var port = debuggerConfig.Tunnel.Port; Trace.Info($"Starting DAP debugger on port {port}"); _jobContext = jobContext; @@ -115,6 +131,15 @@ namespace GitHub.Runner.Worker.Dap _listener.Start(); Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}"); + // Start Dev Tunnel relay so remote clients reach the local DAP port. + // The relay is torn down explicitly in StopAsync (after the DAP session + // is closed) so we do NOT pass the job cancellation token here — that + // would race with the DAP shutdown and drop the transport mid-protocol. + if (!SkipTunnelRelay) + { + await StartTunnelRelayAsync(debuggerConfig); + } + _state = DapSessionState.WaitingForConnection; _connectionLoopTask = ConnectionLoopAsync(jobContext.CancellationToken); @@ -126,7 +151,36 @@ namespace GitHub.Runner.Worker.Dap }); Trace.Info($"DAP debugger started on port {port}"); - return Task.CompletedTask; + } + + private async Task StartTunnelRelayAsync(DebuggerConfig config) + { + Trace.Info($"Starting Dev Tunnel relay (tunnel={config.Tunnel.TunnelId}, cluster={config.Tunnel.ClusterId})"); + + var managementClient = new TunnelManagementClient( + new ProductInfoHeaderValue("actions-runner", "1.0"), + () => Task.FromResult( + (AuthenticationHeaderValue) + new AuthenticationHeaderValue("tunnel", config.Tunnel.HostToken))); + + var tunnel = new Tunnel + { + TunnelId = config.Tunnel.TunnelId, + ClusterId = config.Tunnel.ClusterId, + AccessTokens = new Dictionary + { + [TunnelAccessScopes.Host] = config.Tunnel.HostToken + }, + Ports = new[] + { + new TunnelPort { PortNumber = (ushort)config.Tunnel.Port } + }, + }; + + _tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, new TraceSource("DevTunnelRelay")); + await _tunnelRelayHost.StartAsync(tunnel, CancellationToken.None); + + Trace.Info("Dev Tunnel relay started"); } public async Task WaitUntilReadyAsync() @@ -199,6 +253,25 @@ namespace GitHub.Runner.Worker.Dap } catch { /* best effort */ } } + + // Tear down Dev Tunnel relay + if (_tunnelRelayHost != null) + { + try + { + Trace.Info("Stopping Dev Tunnel relay"); + await _tunnelRelayHost.DisposeAsync(); + Trace.Info("Dev Tunnel relay stopped"); + } + catch (Exception ex) + { + Trace.Warning($"Error stopping tunnel relay: {ex.Message}"); + } + finally + { + _tunnelRelayHost = null; + } + } } catch (Exception ex) { @@ -1272,18 +1345,6 @@ namespace GitHub.Runner.Worker.Dap }; } - internal int ResolvePort() - { - var portEnv = Environment.GetEnvironmentVariable(_portEnvironmentVariable); - if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 1024 && customPort <= 65535) - { - Trace.Info($"Using custom DAP port {customPort} from {_portEnvironmentVariable}"); - return customPort; - } - - return _defaultPort; - } - internal int ResolveTimeout() { var timeoutEnv = Environment.GetEnvironmentVariable(_timeoutEnvironmentVariable); diff --git a/src/Runner.Worker/Dap/DebuggerConfig.cs b/src/Runner.Worker/Dap/DebuggerConfig.cs new file mode 100644 index 000000000..bea8f5a3e --- /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 70f2a47af..6acb3e385 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -970,7 +970,7 @@ namespace GitHub.Runner.Worker Global.WriteDebug = Global.Variables.Step_Debug ?? false; // Debugger enabled flag (from acquire response). - Global.EnableDebugger = message.EnableDebugger; + Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel); // Hook up JobServerQueueThrottling event, we will log warning on server tarpit. _jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived; diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index 60b4ef1fe..b22b9f8ad 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -4,6 +4,7 @@ using GitHub.Actions.RunService.WebApi; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using GitHub.Runner.Worker.Container; +using GitHub.Runner.Worker.Dap; using Newtonsoft.Json.Linq; using Sdk.RSWebApi.Contracts; @@ -27,7 +28,7 @@ namespace GitHub.Runner.Worker public StepsContext StepsContext { get; set; } public Variables Variables { get; set; } public bool WriteDebug { get; set; } - public bool EnableDebugger { get; set; } + public DebuggerConfig Debugger { get; set; } public string InfrastructureFailureCategory { get; set; } public JObject ContainerHookState { get; set; } public bool HasTemplateEvaluatorMismatch { get; set; } diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 10623bbef..2ccad0c0c 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -182,7 +182,7 @@ namespace GitHub.Runner.Worker _tempDirectoryManager.InitializeTempDirectory(jobContext); // Setup the debugger - if (jobContext.Global.EnableDebugger) + if (jobContext.Global.Debugger?.Enabled == true) { Trace.Info("Debugger enabled for this job run"); diff --git a/src/Runner.Worker/Runner.Worker.csproj b/src/Runner.Worker/Runner.Worker.csproj index 4470920e1..4b9a288fa 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 328f62160..465af8963 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -260,6 +260,13 @@ namespace GitHub.DistributedTask.Pipelines 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 000000000..c99b01313 --- /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 33b30d308..4756d3de0 100644 --- a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs +++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs @@ -69,6 +69,56 @@ public sealed class AgentJobRequestMessageL0 Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false"); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyDebuggerTunnelDeserialization_WithTunnel() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage), new DataContractJsonSerializerSettings + { + KnownTypes = new[] { typeof(DebuggerTunnelInfo) } + }); + string json = DoubleQuotify( + "{'EnableDebugger': true, 'DebuggerTunnel': {'TunnelId': 'tun-123', 'ClusterId': 'use2', 'HostToken': 'tok-abc', 'Port': 4711}}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(json)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.True(recoveredMessage.EnableDebugger); + Assert.NotNull(recoveredMessage.DebuggerTunnel); + Assert.Equal("tun-123", recoveredMessage.DebuggerTunnel.TunnelId); + Assert.Equal("use2", recoveredMessage.DebuggerTunnel.ClusterId); + Assert.Equal("tok-abc", recoveredMessage.DebuggerTunnel.HostToken); + Assert.Equal(4711, recoveredMessage.DebuggerTunnel.Port); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyDebuggerTunnelDeserialization_WithoutTunnel() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string json = DoubleQuotify("{'EnableDebugger': true}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(json)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.True(recoveredMessage.EnableDebugger); + Assert.Null(recoveredMessage.DebuggerTunnel); + } + private static string DoubleQuotify(string text) { return text.Replace('\'', '"'); diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index f2c8557d1..aa02cd5d1 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 @@ namespace GitHub.Runner.Common.Tests.Worker var hc = new TestHostContext(this, testName); _debugger = new DapDebugger(); _debugger.Initialize(hc); + _debugger.SkipTunnelRelay = true; return hc; } @@ -144,6 +144,26 @@ namespace GitHub.Runner.Common.Tests.Worker { var jobContext = new Mock(); 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 + { + 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 +185,36 @@ namespace GitHub.Runner.Common.Tests.Worker [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public void ResolvePortUsesCustomPortFromEnvironment() + public async Task StartAsyncFailsWithoutValidTunnelConfig() { using (CreateTestContext()) { - WithEnvironmentVariable(PortEnvironmentVariable, "9999", () => + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = new Mock(); + jobContext.Setup(x => x.CancellationToken).Returns(cts.Token); + jobContext.Setup(x => x.Global).Returns(new GlobalContext { - Assert.Equal(9999, _debugger.ResolvePort()); + Debugger = new DebuggerConfig(true, null) }); + + await Assert.ThrowsAsync(() => _debugger.StartAsync(jobContext.Object)); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public void ResolvePortIgnoresInvalidPortFromEnvironment() + public async Task StartAsyncUsesPortFromTunnelConfig() { using (CreateTestContext()) { - WithEnvironmentVariable(PortEnvironmentVariable, "not-a-number", () => - { - Assert.Equal(4711, _debugger.ResolvePort()); - }); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void ResolvePortIgnoresOutOfRangePortFromEnvironment() - { - using (CreateTestContext()) - { - WithEnvironmentVariable(PortEnvironmentVariable, "99999", () => - { - Assert.Equal(4711, _debugger.ResolvePort()); - }); + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + Assert.True(client.Connected); + await _debugger.StopAsync(); } } @@ -254,15 +268,12 @@ namespace GitHub.Runner.Common.Tests.Worker using (CreateTestContext()) { var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); - using var client = await ConnectClientAsync(port); - Assert.True(client.Connected); - await _debugger.StopAsync(); - }); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + Assert.True(client.Connected); + await _debugger.StopAsync(); } } @@ -275,13 +286,10 @@ namespace GitHub.Runner.Common.Tests.Worker { foreach (var port in new[] { GetFreePort(), GetFreePort() }) { - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); - await _debugger.StopAsync(); - }); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + await _debugger.StopAsync(); } } } @@ -294,25 +302,22 @@ namespace GitHub.Runner.Common.Tests.Worker using (CreateTestContext()) { var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); - - var waitTask = _debugger.WaitUntilReadyAsync(); - using var client = await ConnectClientAsync(port); - await SendRequestAsync(client.GetStream(), new Request - { - Seq = 1, - Type = "request", - Command = "configurationDone" - }); - - await waitTask; - Assert.Equal(DapSessionState.Ready, _debugger.State); - await _debugger.StopAsync(); + Seq = 1, + Type = "request", + Command = "configurationDone" }); + + await waitTask; + Assert.Equal(DapSessionState.Ready, _debugger.State); + await _debugger.StopAsync(); } } @@ -324,25 +329,22 @@ namespace GitHub.Runner.Common.Tests.Worker using (CreateTestContext()) { var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(client.GetStream(), new Request { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token, "ci-job"); - await _debugger.StartAsync(jobContext.Object); - using var client = await ConnectClientAsync(port); - var stream = client.GetStream(); - await SendRequestAsync(client.GetStream(), new Request - { - Seq = 1, - Type = "request", - Command = "threads" - }); - - var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - Assert.Contains("\"command\":\"threads\"", response); - Assert.Contains("\"name\":\"Job: ci-job\"", response); - await _debugger.StopAsync(); + Seq = 1, + Type = "request", + Command = "threads" }); + + var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", response); + Assert.Contains("\"name\":\"Job: ci-job\"", response); + await _debugger.StopAsync(); } } @@ -354,30 +356,27 @@ namespace GitHub.Runner.Common.Tests.Worker using (CreateTestContext()) { var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); - - var waitTask = _debugger.WaitUntilReadyAsync(); - using var client = await ConnectClientAsync(port); - await SendRequestAsync(client.GetStream(), new Request - { - Seq = 1, - Type = "request", - Command = "configurationDone" - }); - - await waitTask; - cts.Cancel(); - - // In the real runner, JobRunner always calls OnJobCompletedAsync - // from a finally block. The cancellation callback only unblocks - // pending waits; OnJobCompletedAsync handles state + cleanup. - await _debugger.OnJobCompletedAsync(); - Assert.Equal(DapSessionState.Terminated, _debugger.State); + Seq = 1, + Type = "request", + Command = "configurationDone" }); + + await waitTask; + cts.Cancel(); + + // In the real runner, JobRunner always calls OnJobCompletedAsync + // from a finally block. The cancellation callback only unblocks + // pending waits; OnJobCompletedAsync handles state + cleanup. + await _debugger.OnJobCompletedAsync(); + Assert.Equal(DapSessionState.Terminated, _debugger.State); } } @@ -400,25 +399,22 @@ namespace GitHub.Runner.Common.Tests.Worker using (CreateTestContext()) { var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); - - var waitTask = _debugger.WaitUntilReadyAsync(); - using var client = await ConnectClientAsync(port); - await SendRequestAsync(client.GetStream(), new Request - { - Seq = 1, - Type = "request", - Command = "configurationDone" - }); - - await waitTask; - await _debugger.OnJobCompletedAsync(); - Assert.Equal(DapSessionState.Terminated, _debugger.State); + Seq = 1, + Type = "request", + Command = "configurationDone" }); + + await waitTask; + await _debugger.OnJobCompletedAsync(); + Assert.Equal(DapSessionState.Terminated, _debugger.State); } } @@ -441,20 +437,17 @@ namespace GitHub.Runner.Common.Tests.Worker using (CreateTestContext()) { var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); - var waitTask = _debugger.WaitUntilReadyAsync(); - await Task.Delay(50); - cts.Cancel(); + var waitTask = _debugger.WaitUntilReadyAsync(); + await Task.Delay(50); + cts.Cancel(); - var ex = await Assert.ThrowsAnyAsync(() => waitTask); - Assert.IsNotType(ex); - await _debugger.StopAsync(); - }); + var ex = await Assert.ThrowsAnyAsync(() => waitTask); + Assert.IsNotType(ex); + await _debugger.StopAsync(); } } @@ -471,32 +464,29 @@ namespace GitHub.Runner.Common.Tests.Worker hc.SecretMasker.AddValue("initialized"); var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); - using var client = await ConnectClientAsync(port); - var stream = client.GetStream(); - - await SendRequestAsync(stream, new Request - { - Seq = 1, - Type = "request", - Command = "initialize" - }); - - var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - Assert.Contains("\"type\":\"response\"", response); - Assert.Contains("\"command\":\"initialize\"", response); - Assert.Contains("\"success\":true", response); - - var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - Assert.Contains("\"type\":\"event\"", initializedEvent); - Assert.Contains("\"event\":\"initialized\"", initializedEvent); - - await _debugger.StopAsync(); + Seq = 1, + Type = "request", + Command = "initialize" }); + + var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"type\":\"response\"", response); + Assert.Contains("\"command\":\"initialize\"", response); + Assert.Contains("\"success\":true", response); + + var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"type\":\"event\"", initializedEvent); + Assert.Contains("\"event\":\"initialized\"", initializedEvent); + + await _debugger.StopAsync(); } } @@ -508,41 +498,38 @@ namespace GitHub.Runner.Common.Tests.Worker using (CreateTestContext()) { var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + // Complete handshake so session is ready + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); - - // Complete handshake so session is ready - var waitTask = _debugger.WaitUntilReadyAsync(); - using var client = await ConnectClientAsync(port); - var stream = client.GetStream(); - await SendRequestAsync(stream, new Request - { - Seq = 1, - Type = "request", - Command = "configurationDone" - }); - await waitTask; - - // Simulate a step starting (which pauses) - var step = new Mock(); - 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 @@ namespace GitHub.Runner.Common.Tests.Worker // Start then immediate stop (no connection, no WaitUntilReady) var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); - await _debugger.StopAsync(); - }); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + await _debugger.StopAsync(); // StopAsync after already stopped await _debugger.StopAsync(); @@ -579,37 +563,34 @@ namespace GitHub.Runner.Common.Tests.Worker using (CreateTestContext()) { var port = GetFreePort(); - await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var jobContext = CreateJobContext(cts.Token); - await _debugger.StartAsync(jobContext.Object); - - var waitTask = _debugger.WaitUntilReadyAsync(); - using var client = await ConnectClientAsync(port); - var stream = client.GetStream(); - await SendRequestAsync(stream, new Request - { - Seq = 1, - Type = "request", - Command = "configurationDone" - }); - - // Read the configurationDone response - await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - await waitTask; - - // Complete the job — events are sent via OnJobCompletedAsync - await _debugger.OnJobCompletedAsync(); - - var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - - // Both events should arrive (order may vary) - var combined = msg1 + msg2; - Assert.Contains("\"event\":\"terminated\"", combined); - Assert.Contains("\"event\":\"exited\"", combined); + Seq = 1, + Type = "request", + Command = "configurationDone" }); + + // Read the configurationDone response + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await waitTask; + + // Complete the job — events are sent via OnJobCompletedAsync + await _debugger.OnJobCompletedAsync(); + + var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + + // Both events should arrive (order may vary) + var combined = msg1 + msg2; + Assert.Contains("\"event\":\"terminated\"", combined); + Assert.Contains("\"event\":\"exited\"", combined); } } }