-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Add devtunnel connection for debugger jobs #4317
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 10 commits
a85b399
bdd5be5
b83d85d
dd6b4c5
4796916
5ba2f11
e079620
10c8505
476bc63
faa7856
9dd436c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,20 @@ | ||
| 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; | ||
| 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 +36,7 @@ internal sealed class CompletedStepInfo | |
| /// </summary> | ||
| public sealed class DapDebugger : RunnerService, IDapDebugger | ||
| { | ||
| private const int _defaultPort = 4711; | ||
| private const int _defaultTimeoutMinutes = 15; | ||
| private const string _portEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT"; | ||
| private const string _timeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT"; | ||
| private const string _contentLengthHeader = "Content-Length: "; | ||
| private const int _maxMessageSize = 10 * 1024 * 1024; // 10 MB | ||
|
|
@@ -58,6 +62,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<DapCommand> _commandTcs; | ||
| private readonly object _stateLock = new object(); | ||
|
|
@@ -101,20 +111,35 @@ 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.HasValidTunnel) | ||
| { | ||
| throw new ArgumentException( | ||
| "Debugger requires valid tunnel configuration (tunnelId, clusterId, hostToken, port)."); | ||
| } | ||
|
|
||
| Trace.Info($"Starting DAP debugger on port {port}"); | ||
| Trace.Info($"Starting DAP debugger on port {debuggerConfig.Tunnel.Port}"); | ||
|
|
||
| _jobContext = jobContext; | ||
| _readyTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); | ||
|
|
||
| _listener = new TcpListener(IPAddress.Loopback, port); | ||
| _listener = new TcpListener(IPAddress.Loopback, debuggerConfig.Tunnel.Port); | ||
| _listener.Start(); | ||
| Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}"); | ||
|
|
||
| // Start Dev Tunnel relay so remote clients reach the local DAP port. | ||
| // The relay is torn down explicitly in StopAsync (after the DAP session | ||
| // is closed) so we do NOT pass the job cancellation token here — that | ||
| // would race with the DAP shutdown and drop the transport mid-protocol. | ||
| if (!SkipTunnelRelay) | ||
| { | ||
| await StartTunnelRelayAsync(debuggerConfig); | ||
| } | ||
rentziass marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| _state = DapSessionState.WaitingForConnection; | ||
| _connectionLoopTask = ConnectionLoopAsync(jobContext.CancellationToken); | ||
|
|
||
|
|
@@ -125,8 +150,42 @@ 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})"); | ||
TingluoHuang marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| var userAgents = HostContext.UserAgents.ToArray(); | ||
| var httpHandler = HostContext.CreateHttpClientHandler(); | ||
| httpHandler.AllowAutoRedirect = false; | ||
|
|
||
| var managementClient = new TunnelManagementClient( | ||
rentziass marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| userAgents, | ||
| () => Task.FromResult<AuthenticationHeaderValue>(new AuthenticationHeaderValue("tunnel", config.Tunnel.HostToken)), | ||
| tunnelServiceUri: null, | ||
| httpHandler); | ||
|
|
||
| var tunnel = new Tunnel | ||
| { | ||
| TunnelId = config.Tunnel.TunnelId, | ||
| ClusterId = config.Tunnel.ClusterId, | ||
| AccessTokens = new Dictionary<string, string> | ||
| { | ||
| [TunnelAccessScopes.Host] = config.Tunnel.HostToken | ||
| }, | ||
| Ports = new[] | ||
| { | ||
| new TunnelPort { PortNumber = config.Tunnel.Port } | ||
| }, | ||
rentziass marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| _tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, HostContext.GetTrace("DevTunnelRelay").Source); | ||
| using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); | ||
rentziass marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| await _tunnelRelayHost.ConnectAsync(tunnel, connectCts.Token); | ||
|
|
||
| Trace.Info("Dev Tunnel relay started"); | ||
| } | ||
|
|
||
| public async Task WaitUntilReadyAsync() | ||
|
|
@@ -180,32 +239,60 @@ 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) | ||
| { | ||
| try | ||
| { | ||
| try | ||
| Trace.Info("Stopping Dev Tunnel relay"); | ||
| var disposeTask = _tunnelRelayHost.DisposeAsync().AsTask(); | ||
| if (await Task.WhenAny(disposeTask, Task.Delay(10_000)) != disposeTask) | ||
| { | ||
| await Task.WhenAny(_connectionLoopTask, Task.Delay(5000)); | ||
| Trace.Warning("Dev Tunnel relay dispose timed out after 10s"); | ||
| } | ||
| catch { /* best effort */ } | ||
| else | ||
| { | ||
| Trace.Info("Dev Tunnel relay stopped"); | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
rentziass marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| Trace.Warning($"Error stopping tunnel relay: {ex.Message}"); | ||
| } | ||
| finally | ||
| { | ||
| _tunnelRelayHost = null; | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
|
|
||
| CleanupConnection(); | ||
|
|
||
| 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) | ||
| { | ||
|
|
@@ -418,6 +505,11 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken) | |
| HandleClientDisconnected(); | ||
| CleanupConnection(); | ||
| } | ||
| catch (ObjectDisposedException) | ||
|
||
| { | ||
| // Listener was stopped/disposed by StopAsync — exit cleanly. | ||
| break; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| CleanupConnection(); | ||
|
|
@@ -427,6 +519,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,18 +1371,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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| using GitHub.DistributedTask.Pipelines; | ||
|
|
||
| namespace GitHub.Runner.Worker.Dap | ||
| { | ||
| /// <summary> | ||
| /// Consolidated runtime configuration for the job debugger. | ||
| /// Populated once from the acquire response and owned by <see cref="GlobalContext"/>. | ||
| /// </summary> | ||
| public sealed class DebuggerConfig | ||
| { | ||
| public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel) | ||
| { | ||
| Enabled = enabled; | ||
| Tunnel = tunnel; | ||
| } | ||
|
|
||
| /// <summary>Whether the debugger is enabled for this job.</summary> | ||
| public bool Enabled { get; } | ||
|
|
||
| /// <summary> | ||
| /// Dev Tunnel details for remote debugging. | ||
| /// Required when <see cref="Enabled"/> is true. | ||
| /// </summary> | ||
| public DebuggerTunnelInfo Tunnel { get; } | ||
|
|
||
| /// <summary>Whether the tunnel configuration is complete and valid.</summary> | ||
| public bool HasValidTunnel => Tunnel != null | ||
| && !string.IsNullOrEmpty(Tunnel.TunnelId) | ||
| && !string.IsNullOrEmpty(Tunnel.ClusterId) | ||
| && !string.IsNullOrEmpty(Tunnel.HostToken) | ||
| && Tunnel.Port >= 1024; | ||
rentziass marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| using System.Runtime.Serialization; | ||
|
|
||
| namespace GitHub.DistributedTask.Pipelines | ||
| { | ||
| /// <summary> | ||
| /// Dev Tunnel information the runner needs to host the debugger tunnel. | ||
| /// Matches the run-service <c>DebuggerTunnel</c> contract. | ||
| /// </summary> | ||
| [DataContract] | ||
| public sealed class DebuggerTunnelInfo | ||
| { | ||
| [DataMember(EmitDefaultValue = false)] | ||
| public string TunnelId { get; set; } | ||
|
|
||
| [DataMember(EmitDefaultValue = false)] | ||
| public string ClusterId { get; set; } | ||
|
|
||
| [DataMember(EmitDefaultValue = false)] | ||
| public string HostToken { get; set; } | ||
|
|
||
| [DataMember(EmitDefaultValue = false)] | ||
| public ushort Port { get; set; } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
does this work? are we able to get log from the AzureSDK into the runner diag log?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup: