Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
123 changes: 92 additions & 31 deletions src/Runner.Worker/Dap/DapDebugger.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
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;
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 +35,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 +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<DapCommand> _commandTcs;
private readonly object _stateLock = new object();
Expand Down Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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<string, string>
{
[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()
Expand Down Expand Up @@ -180,32 +234,51 @@ 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 */ }
}
catch (Exception ex)

// Tear down Dev Tunnel relay
if (_tunnelRelayHost != null)
{
Trace.Error("Error stopping DAP debugger");
Trace.Error(ex);
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)
{
Trace.Error("Error stopping DAP debugger");
Trace.Error(ex);
}

lock (_stateLock)
{
Expand Down Expand Up @@ -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);
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.0.7317" />
</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 int Port { get; set; }
}
}
50 changes: 50 additions & 0 deletions src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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('\'', '"');
Expand Down
Loading
Loading