Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Runner.Common/Tracing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ public sealed class Tracing : ITraceWriter, IDisposable
private ISecretMasker _secretMasker;
private TraceSource _traceSource;

/// <summary>
/// The underlying <see cref="System.Diagnostics.TraceSource"/> for this instance.
/// Useful when third-party libraries require a <see cref="System.Diagnostics.TraceSource"/>
/// to route their diagnostics into the runner's log infrastructure.
/// </summary>
public TraceSource Source => _traceSource;

public Tracing(string name, ISecretMasker secretMasker, SourceSwitch sourceSwitch, HostTraceListener traceListener, StdoutTraceListener stdoutTraceListener = null)
{
ArgUtil.NotNull(secretMasker, nameof(secretMasker));
Expand Down
155 changes: 121 additions & 34 deletions src/Runner.Worker/Dap/DapDebugger.cs
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
Expand All @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}

_state = DapSessionState.WaitingForConnection;
_connectionLoopTask = ConnectionLoopAsync(jobContext.CancellationToken);

Expand All @@ -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})");

var userAgents = HostContext.UserAgents.ToArray();
var httpHandler = HostContext.CreateHttpClientHandler();
httpHandler.AllowAutoRedirect = false;

var managementClient = new TunnelManagementClient(
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 }
},
};

_tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, HostContext.GetTrace("DevTunnelRelay").Source);
using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await _tunnelRelayHost.ConnectAsync(tunnel, connectCts.Token);

Trace.Info("Dev Tunnel relay started");
}

public async Task WaitUntilReadyAsync()
Expand Down Expand Up @@ -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)
{
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)
{
Expand Down Expand Up @@ -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();
Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions src/Runner.Worker/Dap/DebuggerConfig.cs
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;
}
}
2 changes: 1 addition & 1 deletion src/Runner.Worker/ExecutionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/Runner.Worker/GlobalContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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; }
Expand Down
2 changes: 1 addition & 1 deletion src/Runner.Worker/JobRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ public async Task<TaskResult> 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");

Expand Down
1 change: 1 addition & 0 deletions src/Runner.Worker/Runner.Worker.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.16" />
</ItemGroup>

<ItemGroup>
Expand Down
7 changes: 7 additions & 0 deletions src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,13 @@ public bool EnableDebugger
set;
}

[DataMember(EmitDefaultValue = false)]
public DebuggerTunnelInfo DebuggerTunnel
{
get;
set;
}

/// <summary>
/// Gets the collection of variables associated with the current context.
/// </summary>
Expand Down
24 changes: 24 additions & 0 deletions src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs
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; }
}
}
Loading
Loading