From 8b1b23b5ce8d6f1cab16be4c7054b17b5f7cc6b1 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Tue, 10 Mar 2026 04:13:39 -0700 Subject: [PATCH 01/42] Get EnableDebugger from job context --- src/Runner.Worker/ExecutionContext.cs | 3 + src/Runner.Worker/GlobalContext.cs | 1 + src/Runner.Worker/JobRunner.cs | 5 ++ .../Pipelines/AgentJobRequestMessage.cs | 7 ++ .../Sdk/RSWebApi/AgentJobRequestMessageL0.cs | 76 +++++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 3a3754fa7e7..6dd3c2adf7e 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -968,6 +968,9 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation // Verbosity (from GitHub.Step_Debug). Global.WriteDebug = Global.Variables.Step_Debug ?? false; + // Debugger enabled flag (from acquire response). + Global.EnableDebugger = message.EnableDebugger; + // 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 27c326d68f9..5ae7d4ae138 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -27,6 +27,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 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 72ee5a403ad..80f9caf6d5e 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -121,6 +121,11 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat jobContext.Start(); jobContext.Debug($"Starting: {message.JobDisplayName}"); + if (jobContext.Global.EnableDebugger) + { + Trace.Info("Debugger enabled for this job run"); + } + runnerShutdownRegistration = HostContext.RunnerShutdownToken.Register(() => { // log an issue, then runner get shutdown by Ctrl-C or Ctrl-Break. diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index e6ecbf4509d..328f6216081 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -253,6 +253,13 @@ public String BillingOwnerId set; } + [DataMember(EmitDefaultValue = false)] + public bool EnableDebugger + { + get; + set; + } + /// /// Gets the collection of variables associated with the current context. /// diff --git a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs new file mode 100644 index 00000000000..33b30d30836 --- /dev/null +++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.IO; +using System.Runtime.Serialization.Json; +using System.Text; +using Xunit; +using GitHub.DistributedTask.Pipelines; + +namespace GitHub.Actions.RunService.WebApi.Tests; + +public sealed class AgentJobRequestMessageL0 +{ + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyEnableDebuggerDeserialization_WithTrue() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true"); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyEnableDebuggerDeserialization_DefaultToFalse() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent"); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyEnableDebuggerDeserialization_WithFalse() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false"); + } + + private static string DoubleQuotify(string text) + { + return text.Replace('\'', '"'); + } +} From cca15de3b3929328ea200c997e327c215c129198 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 11 Mar 2026 08:55:17 -0700 Subject: [PATCH 02/42] Add DAP protocol message types and service interfaces --- src/Runner.Worker/Dap/DapMessages.cs | 1134 +++++++++++++++++++++ src/Runner.Worker/Dap/IDapDebugSession.cs | 32 + src/Runner.Worker/Dap/IDapServer.cs | 18 + 3 files changed, 1184 insertions(+) create mode 100644 src/Runner.Worker/Dap/DapMessages.cs create mode 100644 src/Runner.Worker/Dap/IDapDebugSession.cs create mode 100644 src/Runner.Worker/Dap/IDapServer.cs diff --git a/src/Runner.Worker/Dap/DapMessages.cs b/src/Runner.Worker/Dap/DapMessages.cs new file mode 100644 index 00000000000..bf868598194 --- /dev/null +++ b/src/Runner.Worker/Dap/DapMessages.cs @@ -0,0 +1,1134 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Runner.Worker.Dap +{ + public enum DapCommand + { + Continue, + Next, + StepIn, + StepOut, + Disconnect + } + + /// + /// Base class of requests, responses, and events per DAP specification. + /// + public class ProtocolMessage + { + /// + /// Sequence number of the message (also known as message ID). + /// The seq for the first message sent by a client or debug adapter is 1, + /// and for each subsequent message is 1 greater than the previous message. + /// + [JsonProperty("seq")] + public int Seq { get; set; } + + /// + /// Message type: 'request', 'response', 'event' + /// + [JsonProperty("type")] + public string Type { get; set; } + } + + /// + /// A client or debug adapter initiated request. + /// + public class Request : ProtocolMessage + { + /// + /// The command to execute. + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Object containing arguments for the command. + /// Using JObject for flexibility with different argument types. + /// + [JsonProperty("arguments")] + public JObject Arguments { get; set; } + } + + /// + /// Response for a request. + /// + public class Response : ProtocolMessage + { + /// + /// Sequence number of the corresponding request. + /// + [JsonProperty("request_seq")] + public int RequestSeq { get; set; } + + /// + /// Outcome of the request. If true, the request was successful. + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// The command requested. + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Contains the raw error in short form if success is false. + /// + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; set; } + + /// + /// Contains request result if success is true and error details if success is false. + /// + [JsonProperty("body", NullValueHandling = NullValueHandling.Ignore)] + public object Body { get; set; } + } + + /// + /// A debug adapter initiated event. + /// + public class Event : ProtocolMessage + { + public Event() + { + Type = "event"; + } + + /// + /// Type of event. + /// + [JsonProperty("event")] + public string EventType { get; set; } + + /// + /// Event-specific information. + /// + [JsonProperty("body", NullValueHandling = NullValueHandling.Ignore)] + public object Body { get; set; } + } + + #region Initialize Request/Response + + /// + /// Arguments for 'initialize' request. + /// + public class InitializeRequestArguments + { + /// + /// The ID of the client using this adapter. + /// + [JsonProperty("clientID")] + public string ClientId { get; set; } + + /// + /// The human-readable name of the client using this adapter. + /// + [JsonProperty("clientName")] + public string ClientName { get; set; } + + /// + /// The ID of the debug adapter. + /// + [JsonProperty("adapterID")] + public string AdapterId { get; set; } + + /// + /// The ISO-639 locale of the client using this adapter, e.g. en-US or de-CH. + /// + [JsonProperty("locale")] + public string Locale { get; set; } + + /// + /// If true all line numbers are 1-based (default). + /// + [JsonProperty("linesStartAt1")] + public bool LinesStartAt1 { get; set; } = true; + + /// + /// If true all column numbers are 1-based (default). + /// + [JsonProperty("columnsStartAt1")] + public bool ColumnsStartAt1 { get; set; } = true; + + /// + /// Determines in what format paths are specified. The default is 'path'. + /// + [JsonProperty("pathFormat")] + public string PathFormat { get; set; } = "path"; + + /// + /// Client supports the type attribute for variables. + /// + [JsonProperty("supportsVariableType")] + public bool SupportsVariableType { get; set; } + + /// + /// Client supports the paging of variables. + /// + [JsonProperty("supportsVariablePaging")] + public bool SupportsVariablePaging { get; set; } + + /// + /// Client supports the runInTerminal request. + /// + [JsonProperty("supportsRunInTerminalRequest")] + public bool SupportsRunInTerminalRequest { get; set; } + + /// + /// Client supports memory references. + /// + [JsonProperty("supportsMemoryReferences")] + public bool SupportsMemoryReferences { get; set; } + + /// + /// Client supports progress reporting. + /// + [JsonProperty("supportsProgressReporting")] + public bool SupportsProgressReporting { get; set; } + } + + /// + /// Debug adapter capabilities returned in InitializeResponse. + /// + public class Capabilities + { + /// + /// The debug adapter supports the configurationDone request. + /// + [JsonProperty("supportsConfigurationDoneRequest")] + public bool SupportsConfigurationDoneRequest { get; set; } + + /// + /// The debug adapter supports function breakpoints. + /// + [JsonProperty("supportsFunctionBreakpoints")] + public bool SupportsFunctionBreakpoints { get; set; } + + /// + /// The debug adapter supports conditional breakpoints. + /// + [JsonProperty("supportsConditionalBreakpoints")] + public bool SupportsConditionalBreakpoints { get; set; } + + /// + /// The debug adapter supports a (side effect free) evaluate request for data hovers. + /// + [JsonProperty("supportsEvaluateForHovers")] + public bool SupportsEvaluateForHovers { get; set; } + + /// + /// The debug adapter supports stepping back via the stepBack and reverseContinue requests. + /// + [JsonProperty("supportsStepBack")] + public bool SupportsStepBack { get; set; } + + /// + /// The debug adapter supports setting a variable to a value. + /// + [JsonProperty("supportsSetVariable")] + public bool SupportsSetVariable { get; set; } + + /// + /// The debug adapter supports restarting a frame. + /// + [JsonProperty("supportsRestartFrame")] + public bool SupportsRestartFrame { get; set; } + + /// + /// The debug adapter supports the gotoTargets request. + /// + [JsonProperty("supportsGotoTargetsRequest")] + public bool SupportsGotoTargetsRequest { get; set; } + + /// + /// The debug adapter supports the stepInTargets request. + /// + [JsonProperty("supportsStepInTargetsRequest")] + public bool SupportsStepInTargetsRequest { get; set; } + + /// + /// The debug adapter supports the completions request. + /// + [JsonProperty("supportsCompletionsRequest")] + public bool SupportsCompletionsRequest { get; set; } + + /// + /// The debug adapter supports the modules request. + /// + [JsonProperty("supportsModulesRequest")] + public bool SupportsModulesRequest { get; set; } + + /// + /// The debug adapter supports the terminate request. + /// + [JsonProperty("supportsTerminateRequest")] + public bool SupportsTerminateRequest { get; set; } + + /// + /// The debug adapter supports the terminateDebuggee attribute on the disconnect request. + /// + [JsonProperty("supportTerminateDebuggee")] + public bool SupportTerminateDebuggee { get; set; } + + /// + /// The debug adapter supports the delayed loading of parts of the stack. + /// + [JsonProperty("supportsDelayedStackTraceLoading")] + public bool SupportsDelayedStackTraceLoading { get; set; } + + /// + /// The debug adapter supports the loadedSources request. + /// + [JsonProperty("supportsLoadedSourcesRequest")] + public bool SupportsLoadedSourcesRequest { get; set; } + + /// + /// The debug adapter supports sending progress reporting events. + /// + [JsonProperty("supportsProgressReporting")] + public bool SupportsProgressReporting { get; set; } + + /// + /// The debug adapter supports the runInTerminal request. + /// + [JsonProperty("supportsRunInTerminalRequest")] + public bool SupportsRunInTerminalRequest { get; set; } + + /// + /// The debug adapter supports the cancel request. + /// + [JsonProperty("supportsCancelRequest")] + public bool SupportsCancelRequest { get; set; } + + /// + /// The debug adapter supports exception options. + /// + [JsonProperty("supportsExceptionOptions")] + public bool SupportsExceptionOptions { get; set; } + + /// + /// The debug adapter supports value formatting options. + /// + [JsonProperty("supportsValueFormattingOptions")] + public bool SupportsValueFormattingOptions { get; set; } + + /// + /// The debug adapter supports exception info request. + /// + [JsonProperty("supportsExceptionInfoRequest")] + public bool SupportsExceptionInfoRequest { get; set; } + } + + #endregion + + #region Attach Request + + /// + /// Arguments for 'attach' request. Additional attributes are implementation specific. + /// + public class AttachRequestArguments + { + /// + /// Arbitrary data from the previous, restarted session. + /// + [JsonProperty("__restart", NullValueHandling = NullValueHandling.Ignore)] + public object Restart { get; set; } + } + + #endregion + + #region Disconnect Request + + /// + /// Arguments for 'disconnect' request. + /// + public class DisconnectRequestArguments + { + /// + /// A value of true indicates that this disconnect request is part of a restart sequence. + /// + [JsonProperty("restart")] + public bool Restart { get; set; } + + /// + /// Indicates whether the debuggee should be terminated when the debugger is disconnected. + /// + [JsonProperty("terminateDebuggee")] + public bool TerminateDebuggee { get; set; } + + /// + /// Indicates whether the debuggee should stay suspended when the debugger is disconnected. + /// + [JsonProperty("suspendDebuggee")] + public bool SuspendDebuggee { get; set; } + } + + #endregion + + #region Threads Request/Response + + /// + /// A Thread in DAP represents a unit of execution. + /// For Actions runner, we have a single thread representing the job. + /// + public class Thread + { + /// + /// Unique identifier for the thread. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// The name of the thread. + /// + [JsonProperty("name")] + public string Name { get; set; } + } + + /// + /// Response body for 'threads' request. + /// + public class ThreadsResponseBody + { + /// + /// All threads. + /// + [JsonProperty("threads")] + public List Threads { get; set; } = new List(); + } + + #endregion + + #region StackTrace Request/Response + + /// + /// Arguments for 'stackTrace' request. + /// + public class StackTraceArguments + { + /// + /// Retrieve the stacktrace for this thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// The index of the first frame to return. + /// + [JsonProperty("startFrame")] + public int? StartFrame { get; set; } + + /// + /// The maximum number of frames to return. + /// + [JsonProperty("levels")] + public int? Levels { get; set; } + } + + /// + /// A Stackframe contains the source location. + /// For Actions runner, each step is a stack frame. + /// + public class StackFrame + { + /// + /// An identifier for the stack frame. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// The name of the stack frame, typically a method name. + /// For Actions, this is the step display name. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The source of the frame. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The line within the source of the frame. + /// + [JsonProperty("line")] + public int Line { get; set; } + + /// + /// Start position of the range covered by the stack frame. + /// + [JsonProperty("column")] + public int Column { get; set; } + + /// + /// The end line of the range covered by the stack frame. + /// + [JsonProperty("endLine", NullValueHandling = NullValueHandling.Ignore)] + public int? EndLine { get; set; } + + /// + /// End position of the range covered by the stack frame. + /// + [JsonProperty("endColumn", NullValueHandling = NullValueHandling.Ignore)] + public int? EndColumn { get; set; } + + /// + /// A hint for how to present this frame in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + } + + /// + /// A Source is a descriptor for source code. + /// + public class Source + { + /// + /// The short name of the source. + /// + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } + + /// + /// The path of the source to be shown in the UI. + /// + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + public string Path { get; set; } + + /// + /// If the value > 0 the contents of the source must be retrieved through + /// the 'source' request (even if a path is specified). + /// + [JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)] + public int? SourceReference { get; set; } + + /// + /// A hint for how to present the source in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + } + + /// + /// Response body for 'stackTrace' request. + /// + public class StackTraceResponseBody + { + /// + /// The frames of the stack frame. + /// + [JsonProperty("stackFrames")] + public List StackFrames { get; set; } = new List(); + + /// + /// The total number of frames available in the stack. + /// + [JsonProperty("totalFrames", NullValueHandling = NullValueHandling.Ignore)] + public int? TotalFrames { get; set; } + } + + #endregion + + #region Scopes Request/Response + + /// + /// Arguments for 'scopes' request. + /// + public class ScopesArguments + { + /// + /// Retrieve the scopes for the stack frame identified by frameId. + /// + [JsonProperty("frameId")] + public int FrameId { get; set; } + } + + /// + /// A Scope is a named container for variables. + /// For Actions runner, scopes are: github, env, inputs, steps, secrets, runner, job + /// + public class Scope + { + /// + /// Name of the scope such as 'Arguments', 'Locals', or 'Registers'. + /// For Actions: 'github', 'env', 'inputs', 'steps', 'secrets', 'runner', 'job' + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// A hint for how to present this scope in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + + /// + /// The variables of this scope can be retrieved by passing the value of + /// variablesReference to the variables request. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named variables in this scope. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed variables in this scope. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// If true, the number of variables in this scope is large or expensive to retrieve. + /// + [JsonProperty("expensive")] + public bool Expensive { get; set; } + + /// + /// The source for this scope. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The start line of the range covered by this scope. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + + /// + /// Start position of the range covered by this scope. + /// + [JsonProperty("column", NullValueHandling = NullValueHandling.Ignore)] + public int? Column { get; set; } + + /// + /// The end line of the range covered by this scope. + /// + [JsonProperty("endLine", NullValueHandling = NullValueHandling.Ignore)] + public int? EndLine { get; set; } + + /// + /// End position of the range covered by this scope. + /// + [JsonProperty("endColumn", NullValueHandling = NullValueHandling.Ignore)] + public int? EndColumn { get; set; } + } + + /// + /// Response body for 'scopes' request. + /// + public class ScopesResponseBody + { + /// + /// The scopes of the stack frame. + /// + [JsonProperty("scopes")] + public List Scopes { get; set; } = new List(); + } + + #endregion + + #region Variables Request/Response + + /// + /// Arguments for 'variables' request. + /// + public class VariablesArguments + { + /// + /// The variable for which to retrieve its children. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// Filter to limit the child variables to either named or indexed. + /// + [JsonProperty("filter", NullValueHandling = NullValueHandling.Ignore)] + public string Filter { get; set; } + + /// + /// The index of the first variable to return. + /// + [JsonProperty("start", NullValueHandling = NullValueHandling.Ignore)] + public int? Start { get; set; } + + /// + /// The number of variables to return. + /// + [JsonProperty("count", NullValueHandling = NullValueHandling.Ignore)] + public int? Count { get; set; } + } + + /// + /// A Variable is a name/value pair. + /// + public class Variable + { + /// + /// The variable's name. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The variable's value. + /// + [JsonProperty("value")] + public string Value { get; set; } + + /// + /// The type of the variable's value. + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// If variablesReference is > 0, the variable is structured and its children + /// can be retrieved by passing variablesReference to the variables request. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named child variables. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed child variables. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// A memory reference to a location appropriate for this result. + /// + [JsonProperty("memoryReference", NullValueHandling = NullValueHandling.Ignore)] + public string MemoryReference { get; set; } + + /// + /// A reference that allows the client to request the location where the + /// variable's value is declared. + /// + [JsonProperty("declarationLocationReference", NullValueHandling = NullValueHandling.Ignore)] + public int? DeclarationLocationReference { get; set; } + + /// + /// The evaluatable name of this variable which can be passed to the evaluate + /// request to fetch the variable's value. + /// + [JsonProperty("evaluateName", NullValueHandling = NullValueHandling.Ignore)] + public string EvaluateName { get; set; } + } + + /// + /// Response body for 'variables' request. + /// + public class VariablesResponseBody + { + /// + /// All (or a range) of variables for the given variable reference. + /// + [JsonProperty("variables")] + public List Variables { get; set; } = new List(); + } + + #endregion + + #region Continue Request/Response + + /// + /// Arguments for 'continue' request. + /// + public class ContinueArguments + { + /// + /// Specifies the active thread. If the debug adapter supports single thread + /// execution, setting this will resume only the specified thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// If this flag is true, execution is resumed only for the thread with given + /// threadId. If false, all threads are resumed. + /// + [JsonProperty("singleThread")] + public bool SingleThread { get; set; } + } + + /// + /// Response body for 'continue' request. + /// + public class ContinueResponseBody + { + /// + /// If true, all threads are resumed. If false, only the thread with the given + /// threadId is resumed. + /// + [JsonProperty("allThreadsContinued")] + public bool AllThreadsContinued { get; set; } = true; + } + + #endregion + + #region Next Request + + /// + /// Arguments for 'next' request. + /// + public class NextArguments + { + /// + /// Specifies the thread for which to resume execution for one step. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// Stepping granularity. + /// + [JsonProperty("granularity", NullValueHandling = NullValueHandling.Ignore)] + public string Granularity { get; set; } + + /// + /// If this flag is true, all other suspended threads are not resumed. + /// + [JsonProperty("singleThread")] + public bool SingleThread { get; set; } + } + + #endregion + + #region Evaluate Request/Response + + /// + /// Arguments for 'evaluate' request. + /// + public class EvaluateArguments + { + /// + /// The expression to evaluate. + /// + [JsonProperty("expression")] + public string Expression { get; set; } + + /// + /// Evaluate the expression in the scope of this stack frame. + /// + [JsonProperty("frameId", NullValueHandling = NullValueHandling.Ignore)] + public int? FrameId { get; set; } + + /// + /// The context in which the evaluate request is used. + /// Values: 'watch', 'repl', 'hover', 'clipboard', 'variables' + /// + [JsonProperty("context", NullValueHandling = NullValueHandling.Ignore)] + public string Context { get; set; } + } + + /// + /// Response body for 'evaluate' request. + /// + public class EvaluateResponseBody + { + /// + /// The result of the evaluate request. + /// + [JsonProperty("result")] + public string Result { get; set; } + + /// + /// The type of the evaluate result. + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// If variablesReference is > 0, the evaluate result is structured. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named child variables. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed child variables. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// A memory reference to a location appropriate for this result. + /// + [JsonProperty("memoryReference", NullValueHandling = NullValueHandling.Ignore)] + public string MemoryReference { get; set; } + } + + #endregion + + #region Events + + /// + /// Body for 'stopped' event. + /// The event indicates that the execution of the debuggee has stopped. + /// + public class StoppedEventBody + { + /// + /// The reason for the event. For backward compatibility this string is shown + /// in the UI if the description attribute is missing. + /// Values: 'step', 'breakpoint', 'exception', 'pause', 'entry', 'goto', + /// 'function breakpoint', 'data breakpoint', 'instruction breakpoint' + /// + [JsonProperty("reason")] + public string Reason { get; set; } + + /// + /// The full reason for the event, e.g. 'Paused on exception'. + /// This string is shown in the UI as is and can be translated. + /// + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + public string Description { get; set; } + + /// + /// The thread which was stopped. + /// + [JsonProperty("threadId", NullValueHandling = NullValueHandling.Ignore)] + public int? ThreadId { get; set; } + + /// + /// A value of true hints to the client that this event should not change the focus. + /// + [JsonProperty("preserveFocusHint", NullValueHandling = NullValueHandling.Ignore)] + public bool? PreserveFocusHint { get; set; } + + /// + /// Additional information. E.g. if reason is 'exception', text contains the + /// exception name. + /// + [JsonProperty("text", NullValueHandling = NullValueHandling.Ignore)] + public string Text { get; set; } + + /// + /// If allThreadsStopped is true, a debug adapter can announce that all threads + /// have stopped. + /// + [JsonProperty("allThreadsStopped", NullValueHandling = NullValueHandling.Ignore)] + public bool? AllThreadsStopped { get; set; } + + /// + /// Ids of the breakpoints that triggered the event. + /// + [JsonProperty("hitBreakpointIds", NullValueHandling = NullValueHandling.Ignore)] + public List HitBreakpointIds { get; set; } + } + + /// + /// Body for 'continued' event. + /// The event indicates that the execution of the debuggee has continued. + /// + public class ContinuedEventBody + { + /// + /// The thread which was continued. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// If true, all threads have been resumed. + /// + [JsonProperty("allThreadsContinued", NullValueHandling = NullValueHandling.Ignore)] + public bool? AllThreadsContinued { get; set; } + } + + /// + /// Body for 'terminated' event. + /// The event indicates that debugging of the debuggee has terminated. + /// + public class TerminatedEventBody + { + /// + /// A debug adapter may set restart to true to request that the client + /// restarts the session. + /// + [JsonProperty("restart", NullValueHandling = NullValueHandling.Ignore)] + public object Restart { get; set; } + } + + /// + /// Body for 'output' event. + /// The event indicates that the target has produced some output. + /// + public class OutputEventBody + { + /// + /// The output category. If not specified, 'console' is assumed. + /// Values: 'console', 'important', 'stdout', 'stderr', 'telemetry' + /// + [JsonProperty("category", NullValueHandling = NullValueHandling.Ignore)] + public string Category { get; set; } + + /// + /// The output to report. + /// + [JsonProperty("output")] + public string Output { get; set; } + + /// + /// Support for keeping an output log organized by grouping related messages. + /// Values: 'start', 'startCollapsed', 'end' + /// + [JsonProperty("group", NullValueHandling = NullValueHandling.Ignore)] + public string Group { get; set; } + + /// + /// If variablesReference is > 0, the output contains objects which can be + /// retrieved by passing variablesReference to the variables request. + /// + [JsonProperty("variablesReference", NullValueHandling = NullValueHandling.Ignore)] + public int? VariablesReference { get; set; } + + /// + /// The source location where the output was produced. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The source location's line where the output was produced. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + + /// + /// The position in line where the output was produced. + /// + [JsonProperty("column", NullValueHandling = NullValueHandling.Ignore)] + public int? Column { get; set; } + + /// + /// Additional data to report. + /// + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; set; } + } + + /// + /// Body for 'thread' event. + /// The event indicates that a thread has started or exited. + /// + public class ThreadEventBody + { + /// + /// The reason for the event. + /// Values: 'started', 'exited' + /// + [JsonProperty("reason")] + public string Reason { get; set; } + + /// + /// The identifier of the thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + } + + /// + /// Body for 'exited' event. + /// The event indicates that the debuggee has exited and returns its exit code. + /// + public class ExitedEventBody + { + /// + /// The exit code returned from the debuggee. + /// + [JsonProperty("exitCode")] + public int ExitCode { get; set; } + } + + #endregion + + #region Error Response + + /// + /// A structured error message. + /// + public class Message + { + /// + /// Unique identifier for the message. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// A format string for the message. + /// + [JsonProperty("format")] + public string Format { get; set; } + + /// + /// An object used as a dictionary for looking up the variables in the format string. + /// + [JsonProperty("variables", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Variables { get; set; } + + /// + /// If true send to telemetry. + /// + [JsonProperty("sendTelemetry", NullValueHandling = NullValueHandling.Ignore)] + public bool? SendTelemetry { get; set; } + + /// + /// If true show user. + /// + [JsonProperty("showUser", NullValueHandling = NullValueHandling.Ignore)] + public bool? ShowUser { get; set; } + + /// + /// A url where additional information about this message can be found. + /// + [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] + public string Url { get; set; } + + /// + /// A label that is presented to the user as the UI for opening the url. + /// + [JsonProperty("urlLabel", NullValueHandling = NullValueHandling.Ignore)] + public string UrlLabel { get; set; } + } + + /// + /// Body for error responses. + /// + public class ErrorResponseBody + { + /// + /// A structured error message. + /// + [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] + public Message Error { get; set; } + } + + #endregion +} diff --git a/src/Runner.Worker/Dap/IDapDebugSession.cs b/src/Runner.Worker/Dap/IDapDebugSession.cs new file mode 100644 index 00000000000..5a45d49dac2 --- /dev/null +++ b/src/Runner.Worker/Dap/IDapDebugSession.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Worker.Dap +{ + public enum DapSessionState + { + WaitingForConnection, + Initializing, + Ready, + Paused, + Running, + Terminated + } + + [ServiceLocator(Default = typeof(DapDebugSession))] + public interface IDapDebugSession : IRunnerService + { + bool IsActive { get; } + DapSessionState State { get; } + void SetDapServer(IDapServer server); + Task WaitForHandshakeAsync(CancellationToken cancellationToken); + Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken); + void OnStepCompleted(IStep step); + void OnJobCompleted(); + void CancelSession(); + void HandleClientConnected(); + void HandleClientDisconnected(); + Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken); + } +} diff --git a/src/Runner.Worker/Dap/IDapServer.cs b/src/Runner.Worker/Dap/IDapServer.cs new file mode 100644 index 00000000000..a5b879360aa --- /dev/null +++ b/src/Runner.Worker/Dap/IDapServer.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Worker.Dap +{ + [ServiceLocator(Default = typeof(DapServer))] + public interface IDapServer : IRunnerService + { + void SetSession(IDapDebugSession session); + Task StartAsync(int port, CancellationToken cancellationToken); + Task WaitForConnectionAsync(CancellationToken cancellationToken); + Task StopAsync(); + void SendMessage(ProtocolMessage message); + void SendEvent(Event evt); + void SendResponse(Response response); + } +} From 9737dfadd505ba2b1160677031ed6b66526a687a Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 11 Mar 2026 08:55:41 -0700 Subject: [PATCH 03/42] Add DAP TCP server with reconnection support --- src/Runner.Worker/Dap/DapServer.cs | 466 +++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 src/Runner.Worker/Dap/DapServer.cs diff --git a/src/Runner.Worker/Dap/DapServer.cs b/src/Runner.Worker/Dap/DapServer.cs new file mode 100644 index 00000000000..a51b47ea944 --- /dev/null +++ b/src/Runner.Worker/Dap/DapServer.cs @@ -0,0 +1,466 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Common; +using Newtonsoft.Json; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Production TCP server for the Debug Adapter Protocol. + /// Handles Content-Length message framing, JSON serialization, + /// client reconnection, and graceful shutdown. + /// + public sealed class DapServer : RunnerService, IDapServer + { + private const string ContentLengthHeader = "Content-Length: "; + + private TcpListener _listener; + private TcpClient _client; + private NetworkStream _stream; + private IDapDebugSession _session; + private CancellationTokenSource _cts; + private TaskCompletionSource _connectionTcs; + private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); + private int _nextSeq = 1; + private volatile bool _acceptConnections = true; + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + Trace.Info("DapServer initialized"); + } + + public void SetSession(IDapDebugSession session) + { + _session = session; + Trace.Info("Debug session set"); + } + + public async Task StartAsync(int port, CancellationToken cancellationToken) + { + Trace.Info($"Starting DAP server on port {port}"); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _connectionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _listener = new TcpListener(IPAddress.Loopback, port); + _listener.Start(); + Trace.Info($"DAP server listening on 127.0.0.1:{port}"); + + // Start the connection loop in the background + _ = ConnectionLoopAsync(_cts.Token); + + await Task.CompletedTask; + } + + /// + /// Accepts client connections in a loop, supporting reconnection. + /// When a client disconnects, the server waits for a new connection + /// without blocking step execution. + /// + private async Task ConnectionLoopAsync(CancellationToken cancellationToken) + { + while (_acceptConnections && !cancellationToken.IsCancellationRequested) + { + try + { + Trace.Info("Waiting for debug client connection..."); + + using (cancellationToken.Register(() => + { + try { _listener?.Stop(); } + catch { /* listener already stopped */ } + })) + { + _client = await _listener.AcceptTcpClientAsync(); + } + + if (cancellationToken.IsCancellationRequested) + { + break; + } + + _stream = _client.GetStream(); + var remoteEndPoint = _client.Client.RemoteEndPoint; + Trace.Info($"Debug client connected from {remoteEndPoint}"); + + // Signal first connection (no-op on subsequent connections) + _connectionTcs.TrySetResult(true); + + // Notify session of new client + _session?.HandleClientConnected(); + + // Process messages until client disconnects + await ProcessMessagesAsync(cancellationToken); + + // Client disconnected — notify session and clean up + Trace.Info("Client disconnected, waiting for reconnection..."); + _session?.HandleClientDisconnected(); + CleanupConnection(); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (SocketException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Trace.Warning($"Connection error: {ex.Message}"); + CleanupConnection(); + + if (!_acceptConnections || cancellationToken.IsCancellationRequested) + { + break; + } + + // Brief delay before accepting next connection + try + { + await Task.Delay(100, cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + _connectionTcs.TrySetCanceled(); + Trace.Info("Connection loop ended"); + } + + /// + /// Cleans up the current client connection without stopping the listener. + /// + private void CleanupConnection() + { + try { _stream?.Close(); } catch { /* best effort */ } + try { _client?.Close(); } catch { /* best effort */ } + _stream = null; + _client = null; + } + + public async Task WaitForConnectionAsync(CancellationToken cancellationToken) + { + Trace.Info("Waiting for debug client to connect..."); + + using (cancellationToken.Register(() => _connectionTcs.TrySetCanceled())) + { + await _connectionTcs.Task; + } + + Trace.Info("Debug client connected"); + } + + public async Task StopAsync() + { + Trace.Info("Stopping DAP server"); + + _acceptConnections = false; + _cts?.Cancel(); + + CleanupConnection(); + + try { _listener?.Stop(); } + catch { /* best effort */ } + + await Task.CompletedTask; + + Trace.Info("DAP server stopped"); + } + + private async Task ProcessMessagesAsync(CancellationToken cancellationToken) + { + Trace.Info("Starting DAP message processing loop"); + + try + { + while (!cancellationToken.IsCancellationRequested && _client?.Connected == true) + { + var json = await ReadMessageAsync(cancellationToken); + if (json == null) + { + Trace.Info("Client disconnected (end of stream)"); + break; + } + + await ProcessSingleMessageAsync(json, cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Trace.Info("Message processing cancelled"); + } + catch (IOException ex) + { + Trace.Info($"Connection closed: {ex.Message}"); + } + catch (Exception ex) + { + Trace.Error($"Error in message loop: {ex}"); + } + + Trace.Info("DAP message processing loop ended"); + } + + private async Task ProcessSingleMessageAsync(string json, CancellationToken cancellationToken) + { + Request request = null; + try + { + request = JsonConvert.DeserializeObject(json); + if (request == null || request.Type != "request") + { + Trace.Warning($"Received non-request message: {json}"); + return; + } + + Trace.Info($"Received request: seq={request.Seq}, command={request.Command}"); + + if (_session == null) + { + Trace.Error("No debug session configured"); + SendErrorResponse(request, "No debug session configured"); + return; + } + + // Pass raw JSON to session — session handles deserialization, dispatch, + // and calls back to SendResponse when done. + await _session.HandleMessageAsync(json, cancellationToken); + } + catch (JsonException ex) + { + Trace.Error($"Failed to parse request: {ex.Message}"); + } + catch (Exception ex) + { + Trace.Error($"Error processing request: {ex}"); + if (request != null) + { + SendErrorResponse(request, ex.Message); + } + } + } + + private void SendErrorResponse(Request request, string message) + { + var response = new Response + { + Type = "response", + RequestSeq = request.Seq, + Command = request.Command, + Success = false, + Message = message, + Body = new ErrorResponseBody + { + Error = new Message + { + Id = 1, + Format = message, + ShowUser = true + } + } + }; + + SendResponse(response); + } + + /// + /// Reads a DAP message using Content-Length framing. + /// Format: Content-Length: N\r\n\r\n{json} + /// + private async Task ReadMessageAsync(CancellationToken cancellationToken) + { + int contentLength = -1; + + while (true) + { + var line = await ReadLineAsync(cancellationToken); + if (line == null) + { + return null; + } + + if (line.Length == 0) + { + break; + } + + if (line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase)) + { + var lengthStr = line.Substring(ContentLengthHeader.Length).Trim(); + if (!int.TryParse(lengthStr, out contentLength)) + { + throw new InvalidDataException($"Invalid Content-Length: {lengthStr}"); + } + } + } + + if (contentLength < 0) + { + throw new InvalidDataException("Missing Content-Length header"); + } + + var buffer = new byte[contentLength]; + var totalRead = 0; + while (totalRead < contentLength) + { + var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading message body"); + } + totalRead += bytesRead; + } + + var json = Encoding.UTF8.GetString(buffer); + Trace.Verbose($"Received: {json}"); + return json; + } + + /// + /// Reads a line terminated by \r\n from the network stream. + /// + private async Task ReadLineAsync(CancellationToken cancellationToken) + { + var lineBuilder = new StringBuilder(); + var buffer = new byte[1]; + var previousWasCr = false; + + while (true) + { + var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken); + if (bytesRead == 0) + { + return lineBuilder.Length > 0 ? lineBuilder.ToString() : null; + } + + var c = (char)buffer[0]; + + if (c == '\n' && previousWasCr) + { + if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r') + { + lineBuilder.Length--; + } + return lineBuilder.ToString(); + } + + previousWasCr = (c == '\r'); + lineBuilder.Append(c); + } + } + + /// + /// Serializes and writes a DAP message with Content-Length framing. + /// Must be called within the _sendLock. + /// + private void SendMessageInternal(ProtocolMessage message) + { + var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }); + + var bodyBytes = Encoding.UTF8.GetBytes(json); + var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + + _stream.Write(headerBytes, 0, headerBytes.Length); + _stream.Write(bodyBytes, 0, bodyBytes.Length); + _stream.Flush(); + + Trace.Verbose($"Sent: {json}"); + } + + public void SendMessage(ProtocolMessage message) + { + if (_stream == null) + { + return; + } + + try + { + _sendLock.Wait(); + try + { + message.Seq = _nextSeq++; + SendMessageInternal(message); + } + finally + { + _sendLock.Release(); + } + } + catch (Exception ex) + { + Trace.Warning($"Failed to send message: {ex.Message}"); + } + } + + public void SendEvent(Event evt) + { + if (_stream == null) + { + Trace.Warning($"Cannot send event '{evt.EventType}': no client connected"); + return; + } + + try + { + _sendLock.Wait(); + try + { + evt.Seq = _nextSeq++; + SendMessageInternal(evt); + } + finally + { + _sendLock.Release(); + } + Trace.Info($"Sent event: {evt.EventType}"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to send event '{evt.EventType}': {ex.Message}"); + } + } + + public void SendResponse(Response response) + { + if (_stream == null) + { + Trace.Warning($"Cannot send response for '{response.Command}': no client connected"); + return; + } + + try + { + _sendLock.Wait(); + try + { + response.Seq = _nextSeq++; + SendMessageInternal(response); + } + finally + { + _sendLock.Release(); + } + Trace.Info($"Sent response: seq={response.Seq}, command={response.Command}, success={response.Success}"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to send response for '{response.Command}': {ex.Message}"); + } + } + } +} From 17b05ddaa4335979d380352463e9d7a23fc91b7a Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 11 Mar 2026 08:55:54 -0700 Subject: [PATCH 04/42] Add minimal DAP debug session with next/continue support --- src/Runner.Worker/Dap/DapDebugSession.cs | 644 +++++++++++++++++++++++ 1 file changed, 644 insertions(+) create mode 100644 src/Runner.Worker/Dap/DapDebugSession.cs diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs new file mode 100644 index 00000000000..edac98102c8 --- /dev/null +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -0,0 +1,644 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using Newtonsoft.Json; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Stores information about a completed step for stack trace display. + /// + internal sealed class CompletedStepInfo + { + public string DisplayName { get; set; } + public TaskResult? Result { get; set; } + public int FrameId { get; set; } + } + + /// + /// Minimal production DAP debug session. + /// Handles step-level breakpoints with next/continue flow control, + /// client reconnection, and cancellation signal propagation. + /// + /// Scope inspection, REPL, step manipulation, and time-travel debugging + /// are intentionally deferred to future iterations. + /// + public sealed class DapDebugSession : RunnerService, IDapDebugSession + { + // Thread ID for the single job execution thread + private const int JobThreadId = 1; + + // Frame ID for the current step (always 1) + private const int CurrentFrameId = 1; + + // Frame IDs for completed steps start at 1000 + private const int CompletedFrameIdBase = 1000; + + private IDapServer _server; + private DapSessionState _state = DapSessionState.WaitingForConnection; + + // Synchronization for step execution + private TaskCompletionSource _commandTcs; + private readonly object _stateLock = new object(); + + // Handshake completion — signaled when configurationDone is received + private readonly TaskCompletionSource _handshakeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Whether to pause before the next step (set by 'next' command) + private bool _pauseOnNextStep = true; + + // Current execution context + private IStep _currentStep; + private IExecutionContext _jobContext; + private int _currentStepIndex; + + // Track completed steps for stack trace + private readonly List _completedSteps = new List(); + private int _nextCompletedFrameId = CompletedFrameIdBase; + + // Client connection tracking for reconnection support + private volatile bool _isClientConnected; + + public bool IsActive => + _state == DapSessionState.Ready || + _state == DapSessionState.Paused || + _state == DapSessionState.Running; + + public DapSessionState State => _state; + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + Trace.Info("DapDebugSession initialized"); + } + + public void SetDapServer(IDapServer server) + { + _server = server; + Trace.Info("DAP server reference set"); + } + + public async Task WaitForHandshakeAsync(CancellationToken cancellationToken) + { + Trace.Info("Waiting for DAP handshake (configurationDone)..."); + + using (cancellationToken.Register(() => _handshakeTcs.TrySetCanceled())) + { + await _handshakeTcs.Task; + } + + Trace.Info("DAP handshake complete, session is ready"); + } + + #region Message Dispatch + + public async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken) + { + Request request = null; + try + { + request = JsonConvert.DeserializeObject(messageJson); + if (request == null) + { + Trace.Warning("Failed to deserialize DAP request"); + return; + } + + Trace.Info($"Handling DAP request: {request.Command}"); + + var response = request.Command switch + { + "initialize" => HandleInitialize(request), + "attach" => HandleAttach(request), + "configurationDone" => HandleConfigurationDone(request), + "disconnect" => HandleDisconnect(request), + "threads" => HandleThreads(request), + "stackTrace" => HandleStackTrace(request), + "scopes" => HandleScopes(request), + "variables" => HandleVariables(request), + "continue" => HandleContinue(request), + "next" => HandleNext(request), + "setBreakpoints" => HandleSetBreakpoints(request), + "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), + _ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null) + }; + + response.RequestSeq = request.Seq; + response.Command = request.Command; + + _server?.SendResponse(response); + } + catch (Exception ex) + { + Trace.Error($"Error handling request '{request?.Command}': {ex}"); + if (request != null) + { + var errorResponse = CreateResponse(request, false, ex.Message, body: null); + errorResponse.RequestSeq = request.Seq; + errorResponse.Command = request.Command; + _server?.SendResponse(errorResponse); + } + } + + await Task.CompletedTask; + } + + #endregion + + #region DAP Request Handlers + + private Response HandleInitialize(Request request) + { + if (request.Arguments != null) + { + try + { + var clientCaps = request.Arguments.ToObject(); + Trace.Info($"Client: {clientCaps?.ClientName ?? clientCaps?.ClientId ?? "unknown"}"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to parse initialize arguments: {ex.Message}"); + } + } + + _state = DapSessionState.Initializing; + + // Build capabilities — MVP only supports configurationDone + var capabilities = new Capabilities + { + SupportsConfigurationDoneRequest = true, + // All other capabilities are false for MVP + SupportsFunctionBreakpoints = false, + SupportsConditionalBreakpoints = false, + SupportsEvaluateForHovers = false, + SupportsStepBack = false, + SupportsSetVariable = false, + SupportsRestartFrame = false, + SupportsGotoTargetsRequest = false, + SupportsStepInTargetsRequest = false, + SupportsCompletionsRequest = false, + SupportsModulesRequest = false, + SupportsTerminateRequest = false, + SupportTerminateDebuggee = false, + SupportsDelayedStackTraceLoading = false, + SupportsLoadedSourcesRequest = false, + SupportsProgressReporting = false, + SupportsRunInTerminalRequest = false, + SupportsCancelRequest = false, + SupportsExceptionOptions = false, + SupportsValueFormattingOptions = false, + SupportsExceptionInfoRequest = false, + }; + + // Send initialized event after a brief delay to ensure the + // response is delivered first (DAP spec requirement) + _ = Task.Run(async () => + { + await Task.Delay(50); + _server?.SendEvent(new Event + { + EventType = "initialized" + }); + Trace.Info("Sent initialized event"); + }); + + Trace.Info("Initialize request handled, capabilities sent"); + return CreateResponse(request, true, body: capabilities); + } + + private Response HandleAttach(Request request) + { + Trace.Info("Attach request handled"); + return CreateResponse(request, true, body: null); + } + + private Response HandleConfigurationDone(Request request) + { + lock (_stateLock) + { + _state = DapSessionState.Ready; + } + + _handshakeTcs.TrySetResult(true); + + Trace.Info("Configuration done, debug session is ready"); + return CreateResponse(request, true, body: null); + } + + private Response HandleDisconnect(Request request) + { + Trace.Info("Disconnect request received"); + + lock (_stateLock) + { + _state = DapSessionState.Terminated; + + // Release any blocked step execution + _commandTcs?.TrySetResult(DapCommand.Disconnect); + } + + return CreateResponse(request, true, body: null); + } + + private Response HandleThreads(Request request) + { + var body = new ThreadsResponseBody + { + Threads = new List + { + new Thread + { + Id = JobThreadId, + Name = _jobContext != null + ? $"Job: {_jobContext.GetGitHubContext("job") ?? "workflow job"}" + : "Job Thread" + } + } + }; + + return CreateResponse(request, true, body: body); + } + + private Response HandleStackTrace(Request request) + { + var frames = new List(); + + // Add current step as the top frame + if (_currentStep != null) + { + var resultIndicator = _currentStep.ExecutionContext?.Result != null + ? $" [{_currentStep.ExecutionContext.Result}]" + : " [running]"; + + frames.Add(new StackFrame + { + Id = CurrentFrameId, + Name = $"{_currentStep.DisplayName ?? "Current Step"}{resultIndicator}", + Line = _currentStepIndex + 1, + Column = 1, + PresentationHint = "normal" + }); + } + else + { + frames.Add(new StackFrame + { + Id = CurrentFrameId, + Name = "(no step executing)", + Line = 0, + Column = 1, + PresentationHint = "subtle" + }); + } + + // Add completed steps as additional frames (most recent first) + for (int i = _completedSteps.Count - 1; i >= 0; i--) + { + var completedStep = _completedSteps[i]; + var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : ""; + frames.Add(new StackFrame + { + Id = completedStep.FrameId, + Name = $"{completedStep.DisplayName}{resultStr}", + Line = 1, + Column = 1, + PresentationHint = "subtle" + }); + } + + var body = new StackTraceResponseBody + { + StackFrames = frames, + TotalFrames = frames.Count + }; + + return CreateResponse(request, true, body: body); + } + + private Response HandleScopes(Request request) + { + // MVP: return empty scopes — scope inspection deferred + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = new List() + }); + } + + private Response HandleVariables(Request request) + { + // MVP: return empty variables — variable inspection deferred + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = new List() + }); + } + + private Response HandleContinue(Request request) + { + Trace.Info("Continue command received"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + _pauseOnNextStep = false; + _commandTcs?.TrySetResult(DapCommand.Continue); + } + } + + return CreateResponse(request, true, body: new ContinueResponseBody + { + AllThreadsContinued = true + }); + } + + private Response HandleNext(Request request) + { + Trace.Info("Next (step over) command received"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + _pauseOnNextStep = true; + _commandTcs?.TrySetResult(DapCommand.Next); + } + } + + return CreateResponse(request, true, body: null); + } + + private Response HandleSetBreakpoints(Request request) + { + // MVP: acknowledge but don't process breakpoints + // All steps pause automatically via _pauseOnNextStep + return CreateResponse(request, true, body: new { breakpoints = Array.Empty() }); + } + + private Response HandleSetExceptionBreakpoints(Request request) + { + // MVP: acknowledge but don't process exception breakpoints + return CreateResponse(request, true, body: null); + } + + #endregion + + #region Step Lifecycle + + public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken) + { + if (!IsActive) + { + return; + } + + _currentStep = step; + _jobContext = jobContext; + _currentStepIndex = _completedSteps.Count; + + // Determine if we should pause + bool shouldPause = isFirstStep || _pauseOnNextStep; + + if (!shouldPause) + { + Trace.Info($"Step starting (not pausing): {step.DisplayName}"); + return; + } + + var reason = isFirstStep ? "entry" : "step"; + var description = isFirstStep + ? $"Stopped at job entry: {step.DisplayName}" + : $"Stopped before step: {step.DisplayName}"; + + Trace.Info($"Step starting: {step.DisplayName} (reason: {reason})"); + + // Send stopped event to debugger (only if client is connected) + SendStoppedEvent(reason, description); + + // Wait for debugger command + await WaitForCommandAsync(cancellationToken); + } + + public void OnStepCompleted(IStep step) + { + if (!IsActive) + { + return; + } + + var result = step.ExecutionContext?.Result; + Trace.Info($"Step completed: {step.DisplayName}, result: {result}"); + + // Add to completed steps list for stack trace + _completedSteps.Add(new CompletedStepInfo + { + DisplayName = step.DisplayName, + Result = result, + FrameId = _nextCompletedFrameId++ + }); + } + + public void OnJobCompleted() + { + if (!IsActive) + { + return; + } + + Trace.Info("Job completed, sending terminated event"); + + lock (_stateLock) + { + _state = DapSessionState.Terminated; + } + + _server?.SendEvent(new Event + { + EventType = "terminated", + Body = new TerminatedEventBody() + }); + + var exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1; + _server?.SendEvent(new Event + { + EventType = "exited", + Body = new ExitedEventBody + { + ExitCode = exitCode + } + }); + } + + public void CancelSession() + { + Trace.Info("CancelSession called - terminating debug session"); + + lock (_stateLock) + { + if (_state == DapSessionState.Terminated) + { + Trace.Info("Session already terminated, ignoring CancelSession"); + return; + } + _state = DapSessionState.Terminated; + } + + // Send terminated event to debugger so it updates its UI + _server?.SendEvent(new Event + { + EventType = "terminated", + Body = new TerminatedEventBody() + }); + + // Send exited event with cancellation exit code (130 = SIGINT convention) + _server?.SendEvent(new Event + { + EventType = "exited", + Body = new ExitedEventBody { ExitCode = 130 } + }); + + // Release any pending command waits + _commandTcs?.TrySetResult(DapCommand.Disconnect); + + // Release handshake wait if still pending + _handshakeTcs.TrySetCanceled(); + + Trace.Info("Debug session cancelled"); + } + + #endregion + + #region Client Connection Tracking + + public void HandleClientConnected() + { + _isClientConnected = true; + Trace.Info("Client connected to debug session"); + + // If we're paused, re-send the stopped event so the new client + // knows the current state (important for reconnection) + lock (_stateLock) + { + if (_state == DapSessionState.Paused && _currentStep != null) + { + Trace.Info("Re-sending stopped event to reconnected client"); + var description = $"Stopped before step: {_currentStep.DisplayName}"; + SendStoppedEvent("step", description); + } + } + } + + public void HandleClientDisconnected() + { + _isClientConnected = false; + Trace.Info("Client disconnected from debug session"); + + // Intentionally do NOT release the command TCS here. + // The session stays paused, waiting for a client to reconnect. + // The server's connection loop will accept a new client and + // call HandleClientConnected, which re-sends the stopped event. + } + + #endregion + + #region Private Helpers + + /// + /// Blocks the step execution thread until a debugger command is received + /// or the job is cancelled. + /// + private async Task WaitForCommandAsync(CancellationToken cancellationToken) + { + lock (_stateLock) + { + _state = DapSessionState.Paused; + _commandTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + Trace.Info("Waiting for debugger command..."); + + using (cancellationToken.Register(() => + { + Trace.Info("Job cancellation detected, releasing debugger wait"); + _commandTcs?.TrySetResult(DapCommand.Disconnect); + })) + { + var command = await _commandTcs.Task; + + Trace.Info($"Received command: {command}"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + } + } + + // Send continued event for normal flow commands + if (!cancellationToken.IsCancellationRequested && + (command == DapCommand.Continue || command == DapCommand.Next)) + { + _server?.SendEvent(new Event + { + EventType = "continued", + Body = new ContinuedEventBody + { + ThreadId = JobThreadId, + AllThreadsContinued = true + } + }); + } + } + } + + /// + /// Sends a stopped event to the connected client. + /// Silently no-ops if no client is connected. + /// + private void SendStoppedEvent(string reason, string description) + { + if (!_isClientConnected) + { + Trace.Info($"No client connected, deferring stopped event: {description}"); + return; + } + + _server?.SendEvent(new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = reason, + Description = description, + ThreadId = JobThreadId, + AllThreadsStopped = true + } + }); + } + + /// + /// Creates a DAP response with common fields pre-populated. + /// + private Response CreateResponse(Request request, bool success, string message = null, object body = null) + { + return new Response + { + Type = "response", + RequestSeq = request.Seq, + Command = request.Command, + Success = success, + Message = success ? null : message, + Body = body + }; + } + + #endregion + } +} From 915e13c84227f71835a7455629ec5dc17f15e4cb Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 11 Mar 2026 08:56:08 -0700 Subject: [PATCH 05/42] Integrate DAP debugger into JobRunner and StepsRunner --- src/Runner.Worker/JobRunner.cs | 81 ++++ src/Runner.Worker/StepsRunner.cs | 49 ++ src/Test/L0/Worker/DapDebugSessionL0.cs | 611 ++++++++++++++++++++++++ src/Test/L0/Worker/DapMessagesL0.cs | 233 +++++++++ src/Test/L0/Worker/DapServerL0.cs | 170 +++++++ 5 files changed, 1144 insertions(+) create mode 100644 src/Test/L0/Worker/DapDebugSessionL0.cs create mode 100644 src/Test/L0/Worker/DapMessagesL0.cs create mode 100644 src/Test/L0/Worker/DapServerL0.cs diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 80f9caf6d5e..cea4771e880 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -13,6 +13,7 @@ using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Services.Common; using GitHub.Services.WebApi; using Sdk.RSWebApi.Contracts; @@ -112,6 +113,9 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat IExecutionContext jobContext = null; CancellationTokenRegistration? runnerShutdownRegistration = null; + IDapServer dapServer = null; + IDapDebugSession debugSession = null; + CancellationTokenRegistration? dapCancellationRegistration = null; try { // Create the job execution context. @@ -124,6 +128,31 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat if (jobContext.Global.EnableDebugger) { Trace.Info("Debugger enabled for this job run"); + + try + { + var port = 4711; + var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT"); + if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort)) + { + port = customPort; + } + + dapServer = HostContext.GetService(); + debugSession = HostContext.GetService(); + + dapServer.SetSession(debugSession); + debugSession.SetDapServer(dapServer); + + await dapServer.StartAsync(port, jobRequestCancellationToken); + Trace.Info($"DAP server started on port {port}, listening for debugger client"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to start DAP server: {ex.Message}. Job will continue without debugging."); + dapServer = null; + debugSession = null; + } } runnerShutdownRegistration = HostContext.RunnerShutdownToken.Register(() => @@ -224,6 +253,39 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000)); } + // Wait for DAP debugger client connection and handshake after "Set up job" + // so the job page shows the setup step before we block on the debugger + if (dapServer != null && debugSession != null) + { + try + { + Trace.Info("Waiting for debugger client connection..."); + await dapServer.WaitForConnectionAsync(jobRequestCancellationToken); + Trace.Info("Debugger client connected."); + + await debugSession.WaitForHandshakeAsync(jobRequestCancellationToken); + Trace.Info("DAP handshake complete."); + + dapCancellationRegistration = jobRequestCancellationToken.Register(() => + { + Trace.Info("Job cancellation requested, cancelling debug session."); + debugSession.CancelSession(); + }); + } + catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) + { + Trace.Info("Job was cancelled before debugger client connected. Continuing without debugger."); + dapServer = null; + debugSession = null; + } + catch (Exception ex) + { + Trace.Warning($"Failed to complete DAP handshake: {ex.Message}. Job will continue without debugging."); + dapServer = null; + debugSession = null; + } + } + // Run all job steps Trace.Info("Run all job steps."); var stepsRunner = HostContext.GetService(); @@ -264,6 +326,25 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat runnerShutdownRegistration = null; } + if (dapCancellationRegistration.HasValue) + { + dapCancellationRegistration.Value.Dispose(); + dapCancellationRegistration = null; + } + + if (dapServer != null) + { + try + { + Trace.Info("Stopping DAP server"); + await dapServer.StopAsync(); + } + catch (Exception ex) + { + Trace.Warning($"Error stopping DAP server: {ex.Message}"); + } + } + await ShutdownQueue(throwOnFailure: false); } } diff --git a/src/Runner.Worker/StepsRunner.cs b/src/Runner.Worker/StepsRunner.cs index 83ce87f6480..1c4894cb855 100644 --- a/src/Runner.Worker/StepsRunner.cs +++ b/src/Runner.Worker/StepsRunner.cs @@ -10,6 +10,7 @@ using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Runner.Worker.Expressions; namespace GitHub.Runner.Worker @@ -50,6 +51,16 @@ public async Task RunAsync(IExecutionContext jobContext) jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult(); var scopeInputs = new Dictionary(StringComparer.OrdinalIgnoreCase); bool checkPostJobActions = false; + IDapDebugSession debugSession = null; + try + { + debugSession = HostContext.GetService(); + } + catch + { + // Debug session not available — continue without debugging + } + bool isFirstStep = true; while (jobContext.JobSteps.Count > 0 || !checkPostJobActions) { if (jobContext.JobSteps.Count == 0 && !checkPostJobActions) @@ -226,9 +237,35 @@ public async Task RunAsync(IExecutionContext jobContext) } else { + // Pause for DAP debugger before step execution + if (debugSession?.IsActive == true) + { + try + { + await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken); + isFirstStep = false; + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepStarting error: {ex.Message}"); + } + } + // Run the step await RunStepAsync(step, jobContext.CancellationToken); CompleteStep(step); + + if (debugSession?.IsActive == true) + { + try + { + debugSession.OnStepCompleted(step); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepCompleted error: {ex.Message}"); + } + } } } finally @@ -255,6 +292,18 @@ public async Task RunAsync(IExecutionContext jobContext) Trace.Info($"Current state: job state = '{jobContext.Result}'"); } + + if (debugSession?.IsActive == true) + { + try + { + debugSession.OnJobCompleted(); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnJobCompleted error: {ex.Message}"); + } + } } private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken) diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs new file mode 100644 index 00000000000..ffff047b52c --- /dev/null +++ b/src/Test/L0/Worker/DapDebugSessionL0.cs @@ -0,0 +1,611 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Moq; +using Newtonsoft.Json; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapDebugSessionL0 + { + private DapDebugSession _session; + private Mock _mockServer; + private List _sentEvents; + private List _sentResponses; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + var hc = new TestHostContext(this, testName); + + _session = new DapDebugSession(); + _session.Initialize(hc); + + _sentEvents = new List(); + _sentResponses = new List(); + + _mockServer = new Mock(); + _mockServer.Setup(x => x.SendEvent(It.IsAny())) + .Callback(e => _sentEvents.Add(e)); + _mockServer.Setup(x => x.SendResponse(It.IsAny())) + .Callback(r => _sentResponses.Add(r)); + + _session.SetDapServer(_mockServer.Object); + + return hc; + } + + private Mock CreateMockStep(string displayName, TaskResult? result = null) + { + var mockEc = new Mock(); + mockEc.SetupAllProperties(); + mockEc.Object.Result = result; + + var mockStep = new Mock(); + mockStep.Setup(x => x.DisplayName).Returns(displayName); + mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); + + return mockStep; + } + + private Mock CreateMockJobContext() + { + var mockJobContext = new Mock(); + mockJobContext.Setup(x => x.GetGitHubContext("job")).Returns("test-job"); + return mockJobContext; + } + + private async Task InitializeSessionAsync() + { + var initJson = JsonConvert.SerializeObject(new Request + { + Seq = 1, + Type = "request", + Command = "initialize" + }); + await _session.HandleMessageAsync(initJson, CancellationToken.None); + + var attachJson = JsonConvert.SerializeObject(new Request + { + Seq = 2, + Type = "request", + Command = "attach" + }); + await _session.HandleMessageAsync(attachJson, CancellationToken.None); + + var configJson = JsonConvert.SerializeObject(new Request + { + Seq = 3, + Type = "request", + Command = "configurationDone" + }); + await _session.HandleMessageAsync(configJson, CancellationToken.None); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitialStateIsWaitingForConnection() + { + using (CreateTestContext()) + { + Assert.Equal(DapSessionState.WaitingForConnection, _session.State); + Assert.False(_session.IsActive); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task InitializeHandlerSetsInitializingState() + { + using (CreateTestContext()) + { + var json = JsonConvert.SerializeObject(new Request + { + Seq = 1, + Type = "request", + Command = "initialize" + }); + + await _session.HandleMessageAsync(json, CancellationToken.None); + + Assert.Equal(DapSessionState.Initializing, _session.State); + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ConfigurationDoneSetsReadyState() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + Assert.Equal(DapSessionState.Ready, _session.State); + Assert.True(_session.IsActive); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepStartingPausesAndSendsStoppedEvent() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + // Wait for the async initialized event to arrive, then clear + await Task.Delay(200); + _sentEvents.Clear(); + + var step = CreateMockStep("Checkout code"); + var jobContext = CreateMockJobContext(); + + var cts = new CancellationTokenSource(); + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); + + await Task.Delay(100); + Assert.False(stepTask.IsCompleted); + Assert.Equal(DapSessionState.Paused, _session.State); + + var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); + Assert.Single(stoppedEvents); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task NextCommandPausesOnFollowingStep() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + + var step1 = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + var nextJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "next" + }); + await _session.HandleMessageAsync(nextJson, CancellationToken.None); + await Task.WhenAny(step1Task, Task.Delay(5000)); + Assert.True(step1Task.IsCompleted); + + _session.OnStepCompleted(step1.Object); + _sentEvents.Clear(); + + var step2 = CreateMockStep("Step 2"); + var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); + + await Task.Delay(100); + Assert.False(step2Task.IsCompleted); + Assert.Equal(DapSessionState.Paused, _session.State); + + var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); + Assert.Single(stoppedEvents); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 11, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(step2Task, Task.Delay(5000)); + Assert.True(step2Task.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ContinueCommandSkipsNextPause() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + + var step1 = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(step1Task, Task.Delay(5000)); + Assert.True(step1Task.IsCompleted); + + _session.OnStepCompleted(step1.Object); + _sentEvents.Clear(); + + var step2 = CreateMockStep("Step 2"); + var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); + + await Task.WhenAny(step2Task, Task.Delay(5000)); + Assert.True(step2Task.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancellationUnblocksPausedStep() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + + var step = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var cts = new CancellationTokenSource(); + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); + + await Task.Delay(100); + Assert.False(stepTask.IsCompleted); + Assert.Equal(DapSessionState.Paused, _session.State); + + cts.Cancel(); + + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancelSessionSendsTerminatedAndExitedEvents() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _sentEvents.Clear(); + + _session.CancelSession(); + + Assert.Equal(DapSessionState.Terminated, _session.State); + Assert.False(_session.IsActive); + + var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); + var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); + Assert.Single(terminatedEvents); + Assert.Single(exitedEvents); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancelSessionReleasesBlockedStep() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + + var step = CreateMockStep("Blocked Step"); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + await Task.Delay(100); + Assert.False(stepTask.IsCompleted); + + _session.CancelSession(); + + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + Assert.Equal(DapSessionState.Terminated, _session.State); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ReconnectionResendStoppedEvent() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + // Wait for the async initialized event to arrive, then clear + await Task.Delay(200); + _sentEvents.Clear(); + + var step = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + await Task.Delay(100); + Assert.Equal(DapSessionState.Paused, _session.State); + var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); + Assert.Single(stoppedEvents); + + _session.HandleClientDisconnected(); + Assert.Equal(DapSessionState.Paused, _session.State); + + _sentEvents.Clear(); + _session.HandleClientConnected(); + + Assert.Single(_sentEvents); + Assert.Equal("stopped", _sentEvents[0].EventType); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DisconnectCommandTerminatesSession() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + var disconnectJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "disconnect" + }); + await _session.HandleMessageAsync(disconnectJson, CancellationToken.None); + + Assert.Equal(DapSessionState.Terminated, _session.State); + Assert.False(_session.IsActive); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepCompletedTracksCompletedSteps() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var step1 = CreateMockStep("Step 1"); + step1.Object.ExecutionContext.Result = TaskResult.Succeeded; + var jobContext = CreateMockJobContext(); + + var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(step1Task, Task.Delay(5000)); + + _session.OnStepCompleted(step1.Object); + + var stackTraceJson = JsonConvert.SerializeObject(new Request + { + Seq = 11, + Type = "request", + Command = "stackTrace" + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(stackTraceJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobCompletedSendsTerminatedAndExitedEvents() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _sentEvents.Clear(); + + _session.OnJobCompleted(); + + Assert.Equal(DapSessionState.Terminated, _session.State); + + var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); + var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); + Assert.Single(terminatedEvents); + Assert.Single(exitedEvents); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepStartingNoOpWhenNotActive() + { + using (CreateTestContext()) + { + var step = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var task = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + await Task.WhenAny(task, Task.Delay(5000)); + Assert.True(task.IsCompleted); + + _mockServer.Verify(x => x.SendEvent(It.IsAny()), Times.Never); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ThreadsCommandReturnsJobThread() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + var threadsJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "threads" + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(threadsJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task UnsupportedCommandReturnsErrorResponse() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + var json = JsonConvert.SerializeObject(new Request + { + Seq = 99, + Type = "request", + Command = "stepIn" + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(json, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.False(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task FullFlowInitAttachConfigStepContinueComplete() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + _sentResponses.Clear(); + + Assert.Equal(DapSessionState.Ready, _session.State); + + var step = CreateMockStep("Run tests"); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + await Task.Delay(100); + Assert.Equal(DapSessionState.Paused, _session.State); + + var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); + Assert.Single(stoppedEvents); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + + var continuedEvents = _sentEvents.FindAll(e => e.EventType == "continued"); + Assert.Single(continuedEvents); + + step.Object.ExecutionContext.Result = TaskResult.Succeeded; + _session.OnStepCompleted(step.Object); + + _sentEvents.Clear(); + _session.OnJobCompleted(); + + Assert.Equal(DapSessionState.Terminated, _session.State); + var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); + var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); + Assert.Single(terminatedEvents); + Assert.Single(exitedEvents); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DoubleCancelSessionIsIdempotent() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _sentEvents.Clear(); + + _session.CancelSession(); + _session.CancelSession(); + + Assert.Equal(DapSessionState.Terminated, _session.State); + + var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); + Assert.Single(terminatedEvents); + } + } + } +} diff --git a/src/Test/L0/Worker/DapMessagesL0.cs b/src/Test/L0/Worker/DapMessagesL0.cs new file mode 100644 index 00000000000..1b828571736 --- /dev/null +++ b/src/Test/L0/Worker/DapMessagesL0.cs @@ -0,0 +1,233 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; +using GitHub.Runner.Worker.Dap; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapMessagesL0 + { + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void RequestSerializesCorrectly() + { + var request = new Request + { + Seq = 1, + Type = "request", + Command = "initialize", + Arguments = JObject.FromObject(new { clientID = "test-client" }) + }; + + var json = JsonConvert.SerializeObject(request); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Seq); + Assert.Equal("request", deserialized.Type); + Assert.Equal("initialize", deserialized.Command); + Assert.Equal("test-client", deserialized.Arguments["clientID"].ToString()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResponseSerializesCorrectly() + { + var response = new Response + { + Seq = 2, + Type = "response", + RequestSeq = 1, + Success = true, + Command = "initialize", + Body = new Capabilities { SupportsConfigurationDoneRequest = true } + }; + + var json = JsonConvert.SerializeObject(response); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(2, deserialized.Seq); + Assert.Equal("response", deserialized.Type); + Assert.Equal(1, deserialized.RequestSeq); + Assert.True(deserialized.Success); + Assert.Equal("initialize", deserialized.Command); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EventSerializesWithCorrectType() + { + var evt = new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = "entry", + Description = "Stopped at entry", + ThreadId = 1, + AllThreadsStopped = true + } + }; + + Assert.Equal("event", evt.Type); + + var json = JsonConvert.SerializeObject(evt); + Assert.Contains("\"type\":\"event\"", json); + Assert.Contains("\"event\":\"stopped\"", json); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StoppedEventBodyOmitsNullFields() + { + var body = new StoppedEventBody + { + Reason = "step" + }; + + var json = JsonConvert.SerializeObject(body); + Assert.Contains("\"reason\":\"step\"", json); + Assert.DoesNotContain("\"threadId\"", json); + Assert.DoesNotContain("\"allThreadsStopped\"", json); + Assert.DoesNotContain("\"description\"", json); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CapabilitiesMvpDefaults() + { + var caps = new Capabilities + { + SupportsConfigurationDoneRequest = true, + SupportsFunctionBreakpoints = false, + SupportsStepBack = false + }; + + var json = JsonConvert.SerializeObject(caps); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.True(deserialized.SupportsConfigurationDoneRequest); + Assert.False(deserialized.SupportsFunctionBreakpoints); + Assert.False(deserialized.SupportsStepBack); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ContinueResponseBodySerialization() + { + var body = new ContinueResponseBody { AllThreadsContinued = true }; + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.True(deserialized.AllThreadsContinued); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ThreadsResponseBodySerialization() + { + var body = new ThreadsResponseBody + { + Threads = new List + { + new Thread { Id = 1, Name = "Job Thread" } + } + }; + + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Single(deserialized.Threads); + Assert.Equal(1, deserialized.Threads[0].Id); + Assert.Equal("Job Thread", deserialized.Threads[0].Name); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StackFrameSerialization() + { + var frame = new StackFrame + { + Id = 1, + Name = "Step: Checkout", + Line = 1, + Column = 1, + PresentationHint = "normal" + }; + + var json = JsonConvert.SerializeObject(frame); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Id); + Assert.Equal("Step: Checkout", deserialized.Name); + Assert.Equal("normal", deserialized.PresentationHint); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExitedEventBodySerialization() + { + var body = new ExitedEventBody { ExitCode = 130 }; + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(130, deserialized.ExitCode); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void DapCommandEnumValues() + { + Assert.Equal(0, (int)DapCommand.Continue); + Assert.Equal(1, (int)DapCommand.Next); + Assert.Equal(4, (int)DapCommand.Disconnect); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void RequestDeserializesFromRawJson() + { + var json = @"{""seq"":5,""type"":""request"",""command"":""continue"",""arguments"":{""threadId"":1}}"; + var request = JsonConvert.DeserializeObject(json); + + Assert.Equal(5, request.Seq); + Assert.Equal("request", request.Type); + Assert.Equal("continue", request.Command); + Assert.Equal(1, request.Arguments["threadId"].Value()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ErrorResponseBodySerialization() + { + var body = new ErrorResponseBody + { + Error = new Message + { + Id = 1, + Format = "Something went wrong", + ShowUser = true + } + }; + + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Error.Id); + Assert.Equal("Something went wrong", deserialized.Error.Format); + Assert.True(deserialized.Error.ShowUser); + } + } +} diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs new file mode 100644 index 00000000000..ffda39465fe --- /dev/null +++ b/src/Test/L0/Worker/DapServerL0.cs @@ -0,0 +1,170 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Worker.Dap; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapServerL0 + { + private DapServer _server; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + var hc = new TestHostContext(this, testName); + _server = new DapServer(); + _server.Initialize(hc); + return hc; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeSucceeds() + { + using (CreateTestContext()) + { + Assert.NotNull(_server); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetSessionAcceptsMock() + { + using (CreateTestContext()) + { + var mockSession = new Mock(); + _server.SetSession(mockSession.Object); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SendEventNoClientDoesNotThrow() + { + using (CreateTestContext()) + { + var evt = new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = "entry", + ThreadId = 1, + AllThreadsStopped = true + } + }; + + _server.SendEvent(evt); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SendResponseNoClientDoesNotThrow() + { + using (CreateTestContext()) + { + var response = new Response + { + Type = "response", + RequestSeq = 1, + Command = "initialize", + Success = true + }; + + _server.SendResponse(response); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SendMessageNoClientDoesNotThrow() + { + using (CreateTestContext()) + { + var msg = new ProtocolMessage + { + Type = "response", + Seq = 1 + }; + + _server.SendMessage(msg); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StopWithoutStartDoesNotThrow() + { + using (CreateTestContext()) + { + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartAndStopOnAvailablePort() + { + using (CreateTestContext()) + { + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitForConnectionCancelledByCancellationToken() + { + using (CreateTestContext()) + { + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + var waitTask = _server.WaitForConnectionAsync(cts.Token); + + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + { + await waitTask; + }); + + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartAndStopMultipleTimesDoesNotThrow() + { + using (CreateTestContext()) + { + var cts1 = new CancellationTokenSource(); + await _server.StartAsync(0, cts1.Token); + await _server.StopAsync(); + + _server = new DapServer(); + _server.Initialize(CreateTestContext()); + var cts2 = new CancellationTokenSource(); + await _server.StartAsync(0, cts2.Token); + await _server.StopAsync(); + } + } + } +} From 3d8c84488337c969370dcc5e9a9c2c5ba5537079 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 07:00:01 +0000 Subject: [PATCH 06/42] Add DapVariableProvider for scope inspection with centralized masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a reusable component that maps runner ExpressionValues and PipelineContextData into DAP scopes and variables. This is the single point where execution-context values are materialized for the debugger. Key design decisions: - Fixed scope reference IDs (1–100) for the 10 well-known scopes (github, env, runner, job, steps, secrets, inputs, vars, matrix, needs) - Dynamic reference IDs (101+) for lazy nested object/array expansion - All string values pass through HostContext.SecretMasker.MaskSecrets() - The secrets scope is intentionally opaque: keys shown, values replaced with a constant redaction marker - MaskSecrets() is public so future DAP features (evaluate, REPL) can reuse it without duplicating masking policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapVariableProvider.cs | 289 +++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 src/Runner.Worker/Dap/DapVariableProvider.cs diff --git a/src/Runner.Worker/Dap/DapVariableProvider.cs b/src/Runner.Worker/Dap/DapVariableProvider.cs new file mode 100644 index 00000000000..87f068c744e --- /dev/null +++ b/src/Runner.Worker/Dap/DapVariableProvider.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Maps runner execution context data to DAP scopes and variables. + /// + /// This is the single point where runner context values are materialized + /// for the debugger. All string values pass through the runner's existing + /// so the DAP + /// surface never exposes anything beyond what a normal CI log would show. + /// + /// The secrets scope is intentionally opaque: keys are visible but every + /// value is replaced with a constant redaction marker. + /// + /// Designed to be reusable by future DAP features (evaluate, hover, REPL) + /// so that masking policy is never duplicated. + /// + internal sealed class DapVariableProvider + { + // Well-known scope names that map to top-level expression contexts. + // Order matters: the index determines the stable variablesReference ID. + internal static readonly string[] ScopeNames = + { + "github", "env", "runner", "job", "steps", + "secrets", "inputs", "vars", "matrix", "needs" + }; + + // Scope references occupy the range [1, ScopeReferenceMax]. + private const int ScopeReferenceBase = 1; + private const int ScopeReferenceMax = 100; + + // Dynamic (nested) variable references start above the scope range. + private const int DynamicReferenceBase = 101; + + internal const string RedactedValue = "***"; + + private readonly IHostContext _hostContext; + + // Maps dynamic variable reference IDs to the backing data and its + // dot-separated path (e.g. "github.event.pull_request"). + private readonly Dictionary _variableReferences = new(); + private int _nextVariableReference = DynamicReferenceBase; + + public DapVariableProvider(IHostContext hostContext) + { + _hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext)); + } + + /// + /// Clears all dynamic variable references. + /// Call this whenever the paused execution context changes (e.g. new step) + /// so that stale nested references are not served to the client. + /// + public void Reset() + { + _variableReferences.Clear(); + _nextVariableReference = DynamicReferenceBase; + } + + /// + /// Returns the list of DAP scopes for the given execution context. + /// Each scope corresponds to a well-known runner expression context + /// (github, env, secrets, …) and carries a stable variablesReference + /// that the client can use to drill into variables. + /// + public List GetScopes(IExecutionContext context) + { + var scopes = new List(); + + if (context?.ExpressionValues == null) + { + return scopes; + } + + for (int i = 0; i < ScopeNames.Length; i++) + { + var scopeName = ScopeNames[i]; + if (!context.ExpressionValues.TryGetValue(scopeName, out var value) || value == null) + { + continue; + } + + var scope = new Scope + { + Name = scopeName, + VariablesReference = ScopeReferenceBase + i, + Expensive = false, + PresentationHint = scopeName == "secrets" ? "registers" : null + }; + + if (value is DictionaryContextData dict) + { + scope.NamedVariables = dict.Count; + } + else if (value is CaseSensitiveDictionaryContextData csDict) + { + scope.NamedVariables = csDict.Count; + } + + scopes.Add(scope); + } + + return scopes; + } + + /// + /// Returns the child variables for a given variablesReference. + /// The reference may point at a top-level scope (1–100) or a + /// dynamically registered nested container (101+). + /// + public List GetVariables(IExecutionContext context, int variablesReference) + { + var variables = new List(); + + if (context?.ExpressionValues == null) + { + return variables; + } + + PipelineContextData data = null; + string basePath = null; + bool isSecretsScope = false; + + if (variablesReference >= ScopeReferenceBase && variablesReference <= ScopeReferenceMax) + { + var scopeIndex = variablesReference - ScopeReferenceBase; + if (scopeIndex < ScopeNames.Length) + { + var scopeName = ScopeNames[scopeIndex]; + isSecretsScope = scopeName == "secrets"; + if (context.ExpressionValues.TryGetValue(scopeName, out data)) + { + basePath = scopeName; + } + } + } + else if (_variableReferences.TryGetValue(variablesReference, out var refData)) + { + data = refData.Data; + basePath = refData.Path; + isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true; + } + + if (data == null) + { + return variables; + } + + ConvertToVariables(data, basePath, isSecretsScope, variables); + return variables; + } + + /// + /// Applies the runner's secret masker to the given value. + /// This is the single masking entry-point for all DAP-visible strings + /// and is intentionally public so future DAP features (evaluate, REPL) + /// can reuse it without duplicating policy. + /// + public string MaskSecrets(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value ?? string.Empty; + } + + return _hostContext.SecretMasker.MaskSecrets(value); + } + + #region Private helpers + + private void ConvertToVariables( + PipelineContextData data, + string basePath, + bool isSecretsScope, + List variables) + { + switch (data) + { + case DictionaryContextData dict: + foreach (var pair in dict) + { + variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope)); + } + break; + + case CaseSensitiveDictionaryContextData csDict: + foreach (var pair in csDict) + { + variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope)); + } + break; + + case ArrayContextData array: + for (int i = 0; i < array.Count; i++) + { + var variable = CreateVariable($"[{i}]", array[i], basePath, isSecretsScope); + variables.Add(variable); + } + break; + } + } + + private Variable CreateVariable( + string name, + PipelineContextData value, + string basePath, + bool isSecretsScope) + { + var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}"; + var variable = new Variable + { + Name = name, + EvaluateName = $"${{{{ {childPath} }}}}" + }; + + if (value == null) + { + variable.Value = "null"; + variable.Type = "null"; + variable.VariablesReference = 0; + return variable; + } + + switch (value) + { + case StringContextData str: + variable.Value = isSecretsScope ? RedactedValue : MaskSecrets(str.Value); + variable.Type = "string"; + variable.VariablesReference = 0; + break; + + case NumberContextData num: + variable.Value = num.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture); + variable.Type = "number"; + variable.VariablesReference = 0; + break; + + case BooleanContextData boolVal: + variable.Value = boolVal.Value ? "true" : "false"; + variable.Type = "boolean"; + variable.VariablesReference = 0; + break; + + case DictionaryContextData dict: + variable.Value = $"Object ({dict.Count} properties)"; + variable.Type = "object"; + variable.VariablesReference = RegisterVariableReference(dict, childPath); + variable.NamedVariables = dict.Count; + break; + + case CaseSensitiveDictionaryContextData csDict: + variable.Value = $"Object ({csDict.Count} properties)"; + variable.Type = "object"; + variable.VariablesReference = RegisterVariableReference(csDict, childPath); + variable.NamedVariables = csDict.Count; + break; + + case ArrayContextData array: + variable.Value = $"Array ({array.Count} items)"; + variable.Type = "array"; + variable.VariablesReference = RegisterVariableReference(array, childPath); + variable.IndexedVariables = array.Count; + break; + + default: + var rawValue = value.ToJToken()?.ToString() ?? "unknown"; + variable.Value = isSecretsScope ? RedactedValue : MaskSecrets(rawValue); + variable.Type = value.GetType().Name; + variable.VariablesReference = 0; + break; + } + + return variable; + } + + private int RegisterVariableReference(PipelineContextData data, string path) + { + var reference = _nextVariableReference++; + _variableReferences[reference] = (data, path); + return reference; + } + + #endregion + } +} From 2c65db137a3b68eabb2b542a93df1379053a27e6 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 07:01:49 +0000 Subject: [PATCH 07/42] Wire DapVariableProvider into DapDebugSession for scope inspection Replace the stub HandleScopes/HandleVariables implementations that returned empty lists with real delegation to DapVariableProvider. Changes: - DapDebugSession now creates a DapVariableProvider on Initialize() - HandleScopes() resolves the execution context for the requested frame and delegates to the provider - HandleVariables() delegates to the provider for both top-level scope references and nested dynamic references - GetExecutionContextForFrame() maps frame IDs to contexts: frame 1 = current step, frames 1000+ = completed (no live context) - Provider is reset on each new step to invalidate stale nested refs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugSession.cs | 66 +++++++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index edac98102c8..31bf70337fe 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -19,12 +19,13 @@ internal sealed class CompletedStepInfo } /// - /// Minimal production DAP debug session. + /// Production DAP debug session. /// Handles step-level breakpoints with next/continue flow control, - /// client reconnection, and cancellation signal propagation. + /// scope/variable inspection, client reconnection, and cancellation + /// signal propagation. /// - /// Scope inspection, REPL, step manipulation, and time-travel debugging - /// are intentionally deferred to future iterations. + /// REPL, step manipulation, and time-travel debugging are intentionally + /// deferred to future iterations. /// public sealed class DapDebugSession : RunnerService, IDapDebugSession { @@ -62,6 +63,9 @@ public sealed class DapDebugSession : RunnerService, IDapDebugSession // Client connection tracking for reconnection support private volatile bool _isClientConnected; + // Scope/variable inspection provider — reusable by future DAP features + private DapVariableProvider _variableProvider; + public bool IsActive => _state == DapSessionState.Ready || _state == DapSessionState.Paused || @@ -72,6 +76,7 @@ public sealed class DapDebugSession : RunnerService, IDapDebugSession public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); + _variableProvider = new DapVariableProvider(hostContext); Trace.Info("DapDebugSession initialized"); } @@ -321,19 +326,43 @@ private Response HandleStackTrace(Request request) private Response HandleScopes(Request request) { - // MVP: return empty scopes — scope inspection deferred + var args = request.Arguments?.ToObject(); + var frameId = args?.FrameId ?? CurrentFrameId; + + var context = GetExecutionContextForFrame(frameId); + if (context == null) + { + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = new List() + }); + } + + var scopes = _variableProvider.GetScopes(context); return CreateResponse(request, true, body: new ScopesResponseBody { - Scopes = new List() + Scopes = scopes }); } private Response HandleVariables(Request request) { - // MVP: return empty variables — variable inspection deferred + var args = request.Arguments?.ToObject(); + var variablesRef = args?.VariablesReference ?? 0; + + var context = _currentStep?.ExecutionContext ?? _jobContext; + if (context == null) + { + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = new List() + }); + } + + var variables = _variableProvider.GetVariables(context, variablesRef); return CreateResponse(request, true, body: new VariablesResponseBody { - Variables = new List() + Variables = variables }); } @@ -402,6 +431,10 @@ public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, _jobContext = jobContext; _currentStepIndex = _completedSteps.Count; + // Reset variable references so stale nested refs from the + // previous step are not served to the client. + _variableProvider?.Reset(); + // Determine if we should pause bool shouldPause = isFirstStep || _pauseOnNextStep; @@ -598,6 +631,23 @@ private async Task WaitForCommandAsync(CancellationToken cancellationToken) } } + /// + /// Resolves the execution context for a given stack frame ID. + /// Frame 1 = current step; frames 1000+ = completed steps (no + /// context available — those steps have already finished). + /// Falls back to the job-level context when no step is active. + /// + private IExecutionContext GetExecutionContextForFrame(int frameId) + { + if (frameId == CurrentFrameId) + { + return _currentStep?.ExecutionContext ?? _jobContext; + } + + // Completed-step frames don't carry a live execution context. + return null; + } + /// /// Sends a stopped event to the connected client. /// Silently no-ops if no client is connected. From 0d33fd1930a573f3de7bfa02b293d080a5a9b714 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 07:13:15 +0000 Subject: [PATCH 08/42] Add L0 tests for DAP scope inspection and secret masking Provider tests (DapVariableProviderL0): - Scope discovery: empty context, populated scopes, variable count, stable reference IDs, secrets presentation hint - Variable types: string, boolean, number, null handling - Nested expansion: dictionaries and arrays with child drilling - Secret masking: redacted values in secrets scope, SecretMasker integration for non-secret scopes, MaskSecrets delegation - Reset: stale nested references invalidated after Reset() - EvaluateName: dot-path expression syntax Session integration tests (DapDebugSessionL0): - Scopes request returns scopes from step execution context - Variables request returns variables from step execution context - Scopes request returns empty when no step is active - Secrets values are redacted through the full request path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Test/L0/Worker/DapDebugSessionL0.cs | 198 +++++++- src/Test/L0/Worker/DapVariableProviderL0.cs | 504 ++++++++++++++++++++ 2 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 src/Test/L0/Worker/DapVariableProviderL0.cs diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs index ffff047b52c..0962970ebec 100644 --- a/src/Test/L0/Worker/DapDebugSessionL0.cs +++ b/src/Test/L0/Worker/DapDebugSessionL0.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; @@ -607,5 +607,201 @@ public async Task DoubleCancelSessionIsIdempotent() Assert.Single(terminatedEvents); } } + + #region Scope inspection integration tests + + private Mock CreateMockStepWithContext( + string displayName, + DictionaryContextData expressionValues, + TaskResult? result = null) + { + var mockEc = new Mock(); + mockEc.SetupAllProperties(); + mockEc.Object.Result = result; + mockEc.Setup(x => x.ExpressionValues).Returns(expressionValues); + + var mockStep = new Mock(); + mockStep.Setup(x => x.DisplayName).Returns(displayName); + mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); + + return mockStep; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ScopesRequestReturnsScopesFromExecutionContext() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") } + }; + + var step = CreateMockStepWithContext("Run tests", exprValues); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await Task.Delay(100); + + var scopesJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "scopes", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new ScopesArguments { FrameId = 1 }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(scopesJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + + // Resume to unblock + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 21, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task VariablesRequestReturnsVariablesFromExecutionContext() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") }, + { "HOME", new StringContextData("/home/runner") } + }; + + var step = CreateMockStepWithContext("Run tests", exprValues); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await Task.Delay(100); + + // "env" is at ScopeNames index 1 → variablesReference = 2 + var variablesJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "variables", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new VariablesArguments { VariablesReference = 2 }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(variablesJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + + // Resume to unblock + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 21, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ScopesRequestReturnsEmptyWhenNoStepActive() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + var scopesJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "scopes", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new ScopesArguments { FrameId = 1 }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(scopesJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task SecretsValuesAreRedactedThroughSession() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "MY_TOKEN", new StringContextData("ghp_verysecret") } + }; + + var step = CreateMockStepWithContext("Run tests", exprValues); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await Task.Delay(100); + + // "secrets" is at ScopeNames index 5 → variablesReference = 6 + var variablesJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "variables", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new VariablesArguments { VariablesReference = 6 }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(variablesJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + // The response body is serialized — we can't easily inspect it from + // the mock, but the important thing is it succeeded without exposing + // raw secrets (which is tested in DapVariableProviderL0). + + // Resume to unblock + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 21, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + } + } + + #endregion } } diff --git a/src/Test/L0/Worker/DapVariableProviderL0.cs b/src/Test/L0/Worker/DapVariableProviderL0.cs new file mode 100644 index 00000000000..fd63dccfc66 --- /dev/null +++ b/src/Test/L0/Worker/DapVariableProviderL0.cs @@ -0,0 +1,504 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker.Dap; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapVariableProviderL0 + { + private TestHostContext _hc; + private DapVariableProvider _provider; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + _hc = new TestHostContext(this, testName); + _provider = new DapVariableProvider(_hc); + return _hc; + } + + private Moq.Mock CreateMockContext(DictionaryContextData expressionValues) + { + var mock = new Moq.Mock(); + mock.Setup(x => x.ExpressionValues).Returns(expressionValues); + return mock; + } + + #region GetScopes tests + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_ReturnsEmptyWhenContextIsNull() + { + using (CreateTestContext()) + { + var scopes = _provider.GetScopes(null); + Assert.Empty(scopes); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_ReturnsOnlyPopulatedScopes() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") }, + { "HOME", new StringContextData("/home/runner") } + }; + // "runner" is not set — should not appear in scopes + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + Assert.Equal(2, scopes.Count); + Assert.Equal("github", scopes[0].Name); + Assert.Equal("env", scopes[1].Name); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_ReportsNamedVariableCount() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "A", new StringContextData("1") }, + { "B", new StringContextData("2") }, + { "C", new StringContextData("3") } + }; + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + Assert.Single(scopes); + Assert.Equal(3, scopes[0].NamedVariables); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_SecretsGetSpecialPresentationHint() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "MY_SECRET", new StringContextData("super-secret") } + }; + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") } + }; + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + var envScope = scopes.Find(s => s.Name == "env"); + var secretsScope = scopes.Find(s => s.Name == "secrets"); + + Assert.NotNull(envScope); + Assert.Null(envScope.PresentationHint); + + Assert.NotNull(secretsScope); + Assert.Equal("registers", secretsScope.PresentationHint); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_StableVariablesReferenceIds() + { + using (CreateTestContext()) + { + // Populate all 10 scopes and verify their reference IDs + // are stable and based on array position + var exprValues = new DictionaryContextData(); + foreach (var name in DapVariableProvider.ScopeNames) + { + exprValues[name] = new DictionaryContextData(); + } + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + Assert.Equal(DapVariableProvider.ScopeNames.Length, scopes.Count); + for (int i = 0; i < scopes.Count; i++) + { + Assert.Equal(DapVariableProvider.ScopeNames[i], scopes[i].Name); + // Reference IDs are 1-based: index 0 -> ref 1, index 1 -> ref 2, etc. + Assert.Equal(i + 1, scopes[i].VariablesReference); + } + } + } + + #endregion + + #region GetVariables — basic types + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsEmptyWhenContextIsNull() + { + using (CreateTestContext()) + { + var variables = _provider.GetVariables(null, 1); + Assert.Empty(variables); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsStringVariables() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") }, + { "HOME", new StringContextData("/home/runner") } + }; + + var ctx = CreateMockContext(exprValues); + // "env" is at ScopeNames index 1 → variablesReference = 2 + var variables = _provider.GetVariables(ctx.Object, 2); + + Assert.Equal(2, variables.Count); + + var ciVar = variables.Find(v => v.Name == "CI"); + Assert.NotNull(ciVar); + Assert.Equal("true", ciVar.Value); + Assert.Equal("string", ciVar.Type); + Assert.Equal(0, ciVar.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsBooleanVariables() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event_name", new StringContextData("push") }, + }; + // Use a nested dict with boolean to test + var jobDict = new DictionaryContextData(); + // BooleanContextData is a valid PipelineContextData type + // but job context typically has strings. Use env scope instead. + exprValues["env"] = new DictionaryContextData + { + { "flag", new BooleanContextData(true) } + }; + + var ctx = CreateMockContext(exprValues); + // "env" is at index 1 → ref 2 + var variables = _provider.GetVariables(ctx.Object, 2); + + var flagVar = variables.Find(v => v.Name == "flag"); + Assert.NotNull(flagVar); + Assert.Equal("true", flagVar.Value); + Assert.Equal("boolean", flagVar.Type); + Assert.Equal(0, flagVar.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsNumberVariables() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "count", new NumberContextData(42) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var countVar = variables.Find(v => v.Name == "count"); + Assert.NotNull(countVar); + Assert.Equal("42", countVar.Value); + Assert.Equal("number", countVar.Type); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_HandlesNullValues() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var dict = new DictionaryContextData(); + dict["present"] = new StringContextData("yes"); + dict["missing"] = null; + exprValues["env"] = dict; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var nullVar = variables.Find(v => v.Name == "missing"); + Assert.NotNull(nullVar); + Assert.Equal("null", nullVar.Value); + Assert.Equal("null", nullVar.Type); + Assert.Equal(0, nullVar.VariablesReference); + } + } + + #endregion + + #region GetVariables — nested expansion + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_NestedDictionaryIsExpandable() + { + using (CreateTestContext()) + { + var innerDict = new DictionaryContextData + { + { "name", new StringContextData("push") }, + { "ref", new StringContextData("refs/heads/main") } + }; + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event", innerDict } + }; + + var ctx = CreateMockContext(exprValues); + // "github" is at index 0 → ref 1 + var variables = _provider.GetVariables(ctx.Object, 1); + + var eventVar = variables.Find(v => v.Name == "event"); + Assert.NotNull(eventVar); + Assert.Equal("object", eventVar.Type); + Assert.True(eventVar.VariablesReference > 0, "Nested dict should have a non-zero variablesReference"); + Assert.Equal(2, eventVar.NamedVariables); + + // Now expand it + var children = _provider.GetVariables(ctx.Object, eventVar.VariablesReference); + Assert.Equal(2, children.Count); + + var nameVar = children.Find(v => v.Name == "name"); + Assert.NotNull(nameVar); + Assert.Equal("push", nameVar.Value); + Assert.Equal("${{ github.event.name }}", nameVar.EvaluateName); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_NestedArrayIsExpandable() + { + using (CreateTestContext()) + { + var array = new ArrayContextData(); + array.Add(new StringContextData("item0")); + array.Add(new StringContextData("item1")); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "list", array } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var listVar = variables.Find(v => v.Name == "list"); + Assert.NotNull(listVar); + Assert.Equal("array", listVar.Type); + Assert.True(listVar.VariablesReference > 0); + Assert.Equal(2, listVar.IndexedVariables); + + // Expand the array + var items = _provider.GetVariables(ctx.Object, listVar.VariablesReference); + Assert.Equal(2, items.Count); + Assert.Equal("[0]", items[0].Name); + Assert.Equal("item0", items[0].Value); + Assert.Equal("[1]", items[1].Name); + Assert.Equal("item1", items[1].Value); + } + } + + #endregion + + #region Secret masking + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeValuesAreRedacted() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "MY_TOKEN", new StringContextData("ghp_abc123secret") }, + { "DB_PASSWORD", new StringContextData("p@ssword!") } + }; + + var ctx = CreateMockContext(exprValues); + // "secrets" is at index 5 → ref 6 + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Equal(2, variables.Count); + foreach (var v in variables) + { + Assert.Equal(DapVariableProvider.RedactedValue, v.Value); + Assert.Equal("string", v.Type); + } + + // Keys should still be visible + Assert.Contains(variables, v => v.Name == "MY_TOKEN"); + Assert.Contains(variables, v => v.Name == "DB_PASSWORD"); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_NonSecretScopeValuesMaskedBySecretMasker() + { + using (var hc = CreateTestContext()) + { + // Register a known secret value with the masker + hc.SecretMasker.AddValue("super-secret-token"); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "SAFE", new StringContextData("hello world") }, + { "LEAKED", new StringContextData("prefix-super-secret-token-suffix") } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var safeVar = variables.Find(v => v.Name == "SAFE"); + Assert.NotNull(safeVar); + Assert.Equal("hello world", safeVar.Value); + + var leakedVar = variables.Find(v => v.Name == "LEAKED"); + Assert.NotNull(leakedVar); + Assert.DoesNotContain("super-secret-token", leakedVar.Value); + Assert.Contains("***", leakedVar.Value); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void MaskSecrets_DelegatesToHostContextSecretMasker() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("my-secret"); + + Assert.Equal("before-***-after", _provider.MaskSecrets("before-my-secret-after")); + Assert.Equal("no secrets here", _provider.MaskSecrets("no secrets here")); + Assert.Equal(string.Empty, _provider.MaskSecrets(null)); + Assert.Equal(string.Empty, _provider.MaskSecrets(string.Empty)); + } + } + + #endregion + + #region Reset + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Reset_InvalidatesNestedReferences() + { + using (CreateTestContext()) + { + var innerDict = new DictionaryContextData + { + { "name", new StringContextData("push") } + }; + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event", innerDict } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 1); + var eventVar = variables.Find(v => v.Name == "event"); + Assert.True(eventVar.VariablesReference > 0); + + var savedRef = eventVar.VariablesReference; + + // Reset should clear all dynamic references + _provider.Reset(); + + var children = _provider.GetVariables(ctx.Object, savedRef); + Assert.Empty(children); + } + } + + #endregion + + #region EvaluateName + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SetsEvaluateNameWithDotPath() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 1); + + var repoVar = variables.Find(v => v.Name == "repository"); + Assert.NotNull(repoVar); + Assert.Equal("${{ github.repository }}", repoVar.EvaluateName); + } + } + + #endregion + } +} From 1573e36a44c7c58dbbd20ff9ef1be6178b8d0c8b Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 09:50:39 +0000 Subject: [PATCH 09/42] Add expression evaluation to DapVariableProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add EvaluateExpression() that evaluates GitHub Actions expressions using the runner's existing PipelineTemplateEvaluator infrastructure. How it works: - Strips ${{ }} wrapper if present - Creates a BasicExpressionToken and evaluates via EvaluateStepDisplayName (supports the full expression language: functions, operators, context access) - Masks the result through MaskSecrets() — same masking path used by scope inspection - Returns a structured EvaluateResponseBody with type inference - Catches evaluation errors and returns masked error messages Also adds InferResultType() helper for DAP type hints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapVariableProvider.cs | 87 ++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/Runner.Worker/Dap/DapVariableProvider.cs b/src/Runner.Worker/Dap/DapVariableProvider.cs index 87f068c744e..ea1fa591a1d 100644 --- a/src/Runner.Worker/Dap/DapVariableProvider.cs +++ b/src/Runner.Worker/Dap/DapVariableProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using GitHub.DistributedTask.ObjectTemplating.Tokens; using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.Runner.Common; @@ -170,6 +171,92 @@ public string MaskSecrets(string value) return _hostContext.SecretMasker.MaskSecrets(value); } + /// + /// Evaluates a GitHub Actions expression (e.g. "github.repository", + /// "${{ github.event_name }}") in the context of the current step and + /// returns a masked result suitable for the DAP evaluate response. + /// + /// Uses the runner's standard + /// so the full expression language is available (functions, operators, + /// context access). + /// + public EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context) + { + if (context?.ExpressionValues == null) + { + return new EvaluateResponseBody + { + Result = "(no execution context available)", + Type = "string", + VariablesReference = 0 + }; + } + + // Strip ${{ }} wrapper if present + var expr = expression?.Trim() ?? string.Empty; + if (expr.StartsWith("${{") && expr.EndsWith("}}")) + { + expr = expr.Substring(3, expr.Length - 5).Trim(); + } + + if (string.IsNullOrEmpty(expr)) + { + return new EvaluateResponseBody + { + Result = string.Empty, + Type = "string", + VariablesReference = 0 + }; + } + + try + { + var templateEvaluator = context.ToPipelineTemplateEvaluator(); + var token = new BasicExpressionToken(null, null, null, expr); + + var result = templateEvaluator.EvaluateStepDisplayName( + token, + context.ExpressionValues, + context.ExpressionFunctions); + + result = MaskSecrets(result ?? "null"); + + return new EvaluateResponseBody + { + Result = result, + Type = InferResultType(result), + VariablesReference = 0 + }; + } + catch (Exception ex) + { + var errorMessage = MaskSecrets($"Evaluation error: {ex.Message}"); + return new EvaluateResponseBody + { + Result = errorMessage, + Type = "string", + VariablesReference = 0 + }; + } + } + + /// + /// Infers a simple DAP type hint from the string representation of a result. + /// + internal static string InferResultType(string value) + { + if (value == null || value == "null") + return "null"; + if (value == "true" || value == "false") + return "boolean"; + if (double.TryParse(value, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out _)) + return "number"; + if (value.StartsWith("{") || value.StartsWith("[")) + return "object"; + return "string"; + } + #region Private helpers private void ConvertToVariables( From f31e1c7c431b4ec4b3726d06ef0063c1cf0ae9bd Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 09:51:45 +0000 Subject: [PATCH 10/42] Wire evaluate request into DapDebugSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HandleEvaluate() that delegates expression evaluation to the DapVariableProvider, keeping all masking centralized. Changes: - Register 'evaluate' in the command dispatch switch - HandleEvaluate resolves frame context and delegates to DapVariableProvider.EvaluateExpression() - Set SupportsEvaluateForHovers = true in capabilities so DAP clients enable hover tooltips and the Watch pane No separate feature flag — the debugger is already gated by EnableDebugger on the job context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugSession.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index 31bf70337fe..74f5545a310 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -124,6 +124,7 @@ public async Task HandleMessageAsync(string messageJson, CancellationToken cance "stackTrace" => HandleStackTrace(request), "scopes" => HandleScopes(request), "variables" => HandleVariables(request), + "evaluate" => HandleEvaluate(request), "continue" => HandleContinue(request), "next" => HandleNext(request), "setBreakpoints" => HandleSetBreakpoints(request), @@ -179,7 +180,7 @@ private Response HandleInitialize(Request request) // All other capabilities are false for MVP SupportsFunctionBreakpoints = false, SupportsConditionalBreakpoints = false, - SupportsEvaluateForHovers = false, + SupportsEvaluateForHovers = true, SupportsStepBack = false, SupportsSetVariable = false, SupportsRestartFrame = false, @@ -366,6 +367,20 @@ private Response HandleVariables(Request request) }); } + private Response HandleEvaluate(Request request) + { + var args = request.Arguments?.ToObject(); + var expression = args?.Expression ?? string.Empty; + var frameId = args?.FrameId ?? CurrentFrameId; + + Trace.Info($"Evaluate request: '{expression}' (frame: {frameId}, context: {args?.Context ?? "unknown"})"); + + var context = GetExecutionContextForFrame(frameId); + var result = _variableProvider.EvaluateExpression(expression, context); + + return CreateResponse(request, true, body: result); + } + private Response HandleContinue(Request request) { Trace.Info("Continue command received"); From 2a98a8c9550c941b719bb7208b7f575e073c2799 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 09:56:28 +0000 Subject: [PATCH 11/42] Add L0 tests for DAP expression evaluation Provider tests (DapVariableProviderL0): - Simple expression evaluation (github.repository) - ${{ }} wrapper stripping - Secret masking in evaluation results - Graceful error for invalid expressions - No-context returns descriptive message - Empty expression returns empty string - InferResultType classifies null/bool/number/object/string Session integration tests (DapDebugSessionL0): - evaluate request returns result when paused with context - evaluate request returns graceful error when no step active - evaluate request handles ${{ }} wrapper syntax Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Test/L0/Worker/DapDebugSessionL0.cs | 165 ++++++++++++++++++++ src/Test/L0/Worker/DapVariableProviderL0.cs | 165 ++++++++++++++++++++ 2 files changed, 330 insertions(+) diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs index 0962970ebec..a26ee871627 100644 --- a/src/Test/L0/Worker/DapDebugSessionL0.cs +++ b/src/Test/L0/Worker/DapDebugSessionL0.cs @@ -803,5 +803,170 @@ public async Task SecretsValuesAreRedactedThroughSession() } #endregion + + #region Evaluate request integration tests + + private Mock CreateMockStepWithEvaluatableContext( + TestHostContext hc, + string displayName, + DictionaryContextData expressionValues, + TaskResult? result = null) + { + var mockEc = new Mock(); + mockEc.SetupAllProperties(); + mockEc.Object.Result = result; + mockEc.Setup(x => x.ExpressionValues).Returns(expressionValues); + mockEc.Setup(x => x.ExpressionFunctions) + .Returns(new List()); + mockEc.Setup(x => x.Global).Returns(new GlobalContext + { + FileTable = new List(), + Variables = new Variables(hc, new Dictionary()), + }); + mockEc.Setup(x => x.Write(It.IsAny(), It.IsAny())); + + var mockStep = new Mock(); + mockStep.Setup(x => x.DisplayName).Returns(displayName); + mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); + + return mockStep; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task EvaluateRequestReturnsResult() + { + using (var hc = CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await Task.Delay(100); + + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "github.repository", + FrameId = 1, + Context = "watch" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + + // Resume to unblock + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 21, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task EvaluateRequestReturnsGracefulErrorWhenNoContext() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + // No step is active — evaluate should still succeed with + // a descriptive "no context" message, not an error response. + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "github.repository", + FrameId = 1, + Context = "hover" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task EvaluateRequestWithWrapperSyntax() + { + using (var hc = CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event_name", new StringContextData("push") } + }; + + var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await Task.Delay(100); + + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "${{ github.event_name }}", + FrameId = 1, + Context = "watch" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + + // Resume to unblock + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 21, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + } + } + + #endregion } } diff --git a/src/Test/L0/Worker/DapVariableProviderL0.cs b/src/Test/L0/Worker/DapVariableProviderL0.cs index fd63dccfc66..3dfb83f57b4 100644 --- a/src/Test/L0/Worker/DapVariableProviderL0.cs +++ b/src/Test/L0/Worker/DapVariableProviderL0.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker; using GitHub.Runner.Worker.Dap; using Xunit; @@ -500,5 +502,168 @@ public void GetVariables_SetsEvaluateNameWithDotPath() } #endregion + + #region EvaluateExpression + + /// + /// Creates a mock execution context with Global set up so that + /// ToPipelineTemplateEvaluator() works for real expression evaluation. + /// + private Moq.Mock CreateEvaluatableContext( + TestHostContext hc, + DictionaryContextData expressionValues) + { + var mock = new Moq.Mock(); + mock.Setup(x => x.ExpressionValues).Returns(expressionValues); + mock.Setup(x => x.ExpressionFunctions) + .Returns(new List()); + mock.Setup(x => x.Global).Returns(new GlobalContext + { + FileTable = new List(), + Variables = new Variables(hc, new Dictionary()), + }); + // ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls + // context.Write — provide a no-op so it doesn't NRE. + mock.Setup(x => x.Write(Moq.It.IsAny(), Moq.It.IsAny())); + return mock; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsValueForSimpleExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("github.repository", ctx.Object); + + Assert.Equal("owner/repo", result.Result); + Assert.Equal("string", result.Type); + Assert.Equal(0, result.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_StripsWrapperSyntax() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event_name", new StringContextData("push") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("${{ github.event_name }}", ctx.Object); + + Assert.Equal("push", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_MasksSecretInResult() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("super-secret"); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "TOKEN", new StringContextData("super-secret") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("env.TOKEN", ctx.Object); + + Assert.DoesNotContain("super-secret", result.Result); + Assert.Contains("***", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsErrorForInvalidExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData(); + + var ctx = CreateEvaluatableContext(hc, exprValues); + // An invalid expression syntax should not throw — it should + // return an error result. + var result = _provider.EvaluateExpression("!!!invalid[[", ctx.Object); + + Assert.Contains("error", result.Result, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsMessageWhenNoContext() + { + using (CreateTestContext()) + { + var result = _provider.EvaluateExpression("github.repository", null); + + Assert.Contains("no execution context", result.Result, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsEmptyForEmptyExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("", ctx.Object); + + Assert.Equal(string.Empty, result.Result); + } + } + + #endregion + + #region InferResultType + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InferResultType_ClassifiesCorrectly() + { + using (CreateTestContext()) + { + Assert.Equal("null", DapVariableProvider.InferResultType(null)); + Assert.Equal("null", DapVariableProvider.InferResultType("null")); + Assert.Equal("boolean", DapVariableProvider.InferResultType("true")); + Assert.Equal("boolean", DapVariableProvider.InferResultType("false")); + Assert.Equal("number", DapVariableProvider.InferResultType("42")); + Assert.Equal("number", DapVariableProvider.InferResultType("3.14")); + Assert.Equal("object", DapVariableProvider.InferResultType("{\"key\":\"val\"}")); + Assert.Equal("object", DapVariableProvider.InferResultType("[1,2,3]")); + Assert.Equal("string", DapVariableProvider.InferResultType("hello world")); + Assert.Equal("string", DapVariableProvider.InferResultType("owner/repo")); + } + } + + #endregion } } From 852e8721d0807ec9c13990d474042bb026f39e33 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 10:58:35 +0000 Subject: [PATCH 12/42] Add DAP REPL command model and parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a typed command model and hand-rolled parser for the debug console DSL. The parser turns REPL input into HelpCommand or RunCommand objects, keeping parsing separate from execution. Ruby-like DSL syntax: help → general help help("run") → command-specific help run("echo hello") → run with default shell run("echo $X", shell: "bash", env: { X: "1" }) → run with explicit shell and env Parser features: - Handles escaped quotes, nested braces, and mixed arguments - Keyword arguments: shell, env, working_directory - Env blocks parsed as { KEY: "value", KEY2: "value2" } - Returns null for non-DSL input (falls through to expression eval) - Descriptive error messages for malformed input - Help text scaffolding for discoverability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapReplParser.cs | 409 +++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/Runner.Worker/Dap/DapReplParser.cs diff --git a/src/Runner.Worker/Dap/DapReplParser.cs b/src/Runner.Worker/Dap/DapReplParser.cs new file mode 100644 index 00000000000..b6385c34df2 --- /dev/null +++ b/src/Runner.Worker/Dap/DapReplParser.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Base type for all REPL DSL commands. + /// + internal abstract class DapReplCommand + { + } + + /// + /// help or help("run") + /// + internal sealed class HelpCommand : DapReplCommand + { + public string Topic { get; set; } + } + + /// + /// run("echo hello") or + /// run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp") + /// + internal sealed class RunCommand : DapReplCommand + { + public string Script { get; set; } + public string Shell { get; set; } + public Dictionary Env { get; set; } + public string WorkingDirectory { get; set; } + } + + /// + /// Parses REPL input into typed objects. + /// + /// Grammar (intentionally minimal — extend as the DSL grows): + /// + /// help → HelpCommand { Topic = null } + /// help("run") → HelpCommand { Topic = "run" } + /// run("script body") → RunCommand { Script = "script body" } + /// run("script", shell: "bash") → RunCommand { Shell = "bash" } + /// run("script", env: { K: "V" }) → RunCommand { Env = { K → V } } + /// run("script", working_directory: "p")→ RunCommand { WorkingDirectory = "p" } + /// + /// + /// Parsing is intentionally hand-rolled rather than regex-based so it can + /// handle nested braces, quoted strings with escapes, and grow to support + /// future commands without accumulating regex complexity. + /// + internal static class DapReplParser + { + /// + /// Attempts to parse REPL input into a command. Returns null if the + /// input does not match any known DSL command (i.e. it should be + /// treated as an expression instead). + /// + internal static DapReplCommand TryParse(string input, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + var trimmed = input.Trim(); + + // help / help("topic") + if (trimmed.Equals("help", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("help(", StringComparison.OrdinalIgnoreCase)) + { + return ParseHelp(trimmed, out error); + } + + // run("...") + if (trimmed.StartsWith("run(", StringComparison.OrdinalIgnoreCase)) + { + return ParseRun(trimmed, out error); + } + + // Not a DSL command + return null; + } + + internal static string GetGeneralHelp() + { + var sb = new StringBuilder(); + sb.AppendLine("Actions Debug Console"); + sb.AppendLine(); + sb.AppendLine("Commands:"); + sb.AppendLine(" help Show this help"); + sb.AppendLine(" help(\"run\") Show help for the run command"); + sb.AppendLine(" run(\"script\") Execute a script (like a workflow run step)"); + sb.AppendLine(); + sb.AppendLine("Anything else is evaluated as a GitHub Actions expression."); + sb.AppendLine(" Example: github.repository"); + sb.AppendLine(" Example: ${{ github.event_name }}"); + return sb.ToString(); + } + + internal static string GetRunHelp() + { + var sb = new StringBuilder(); + sb.AppendLine("run command — execute a script in the job context"); + sb.AppendLine(); + sb.AppendLine("Usage:"); + sb.AppendLine(" run(\"echo hello\")"); + sb.AppendLine(" run(\"echo $FOO\", shell: \"bash\")"); + sb.AppendLine(" run(\"echo $FOO\", env: { FOO: \"bar\" })"); + sb.AppendLine(" run(\"ls\", working_directory: \"/tmp\")"); + sb.AppendLine(" run(\"echo $X\", shell: \"bash\", env: { X: \"1\" }, working_directory: \"/tmp\")"); + sb.AppendLine(); + sb.AppendLine("Options:"); + sb.AppendLine(" shell: Shell to use (default: job default, e.g. bash)"); + sb.AppendLine(" env: Extra environment variables as { KEY: \"value\" }"); + sb.AppendLine(" working_directory: Working directory for the command"); + sb.AppendLine(); + sb.AppendLine("Behavior:"); + sb.AppendLine(" - Equivalent to a workflow `run:` step"); + sb.AppendLine(" - Expressions in the script body are expanded (${{ ... }})"); + sb.AppendLine(" - Output is streamed in real time and secrets are masked"); + return sb.ToString(); + } + + #region Parsers + + private static HelpCommand ParseHelp(string input, out string error) + { + error = null; + if (input.Equals("help", StringComparison.OrdinalIgnoreCase)) + { + return new HelpCommand(); + } + + // help("topic") + var inner = ExtractParenthesizedArgs(input, "help", out error); + if (error != null) return null; + + var topic = ExtractQuotedString(inner.Trim(), out error); + if (error != null) return null; + + return new HelpCommand { Topic = topic }; + } + + private static RunCommand ParseRun(string input, out string error) + { + error = null; + + var inner = ExtractParenthesizedArgs(input, "run", out error); + if (error != null) return null; + + // Split into argument list respecting quotes and braces + var args = SplitArguments(inner, out error); + if (error != null) return null; + if (args.Count == 0) + { + error = "run() requires a script argument. Example: run(\"echo hello\")"; + return null; + } + + // First arg must be the script body (a quoted string) + var script = ExtractQuotedString(args[0].Trim(), out error); + if (error != null) + { + error = $"First argument to run() must be a quoted string. {error}"; + return null; + } + + var cmd = new RunCommand { Script = script }; + + // Parse remaining keyword arguments + for (int i = 1; i < args.Count; i++) + { + var kv = args[i].Trim(); + var colonIdx = kv.IndexOf(':'); + if (colonIdx <= 0) + { + error = $"Expected keyword argument (e.g. shell: \"bash\"), got: {kv}"; + return null; + } + + var key = kv.Substring(0, colonIdx).Trim(); + var value = kv.Substring(colonIdx + 1).Trim(); + + switch (key.ToLowerInvariant()) + { + case "shell": + cmd.Shell = ExtractQuotedString(value, out error); + if (error != null) { error = $"shell: {error}"; return null; } + break; + + case "working_directory": + cmd.WorkingDirectory = ExtractQuotedString(value, out error); + if (error != null) { error = $"working_directory: {error}"; return null; } + break; + + case "env": + cmd.Env = ParseEnvBlock(value, out error); + if (error != null) { error = $"env: {error}"; return null; } + break; + + default: + error = $"Unknown option: {key}. Valid options: shell, env, working_directory"; + return null; + } + } + + return cmd; + } + + #endregion + + #region Low-level parsing helpers + + /// + /// Given "cmd(...)" returns the inner content between the outer parens. + /// + private static string ExtractParenthesizedArgs(string input, string prefix, out string error) + { + error = null; + var start = prefix.Length; // skip "cmd" + if (start >= input.Length || input[start] != '(') + { + error = $"Expected '(' after {prefix}"; + return null; + } + + if (input[input.Length - 1] != ')') + { + error = $"Expected ')' at end of {prefix}(...)"; + return null; + } + + return input.Substring(start + 1, input.Length - start - 2); + } + + /// + /// Extracts a double-quoted string value, handling escaped quotes. + /// + internal static string ExtractQuotedString(string input, out string error) + { + error = null; + if (string.IsNullOrEmpty(input)) + { + error = "Expected a quoted string, got empty input"; + return null; + } + + if (input[0] != '"') + { + error = $"Expected a quoted string starting with \", got: {Truncate(input, 40)}"; + return null; + } + + var sb = new StringBuilder(); + for (int i = 1; i < input.Length; i++) + { + if (input[i] == '\\' && i + 1 < input.Length) + { + sb.Append(input[i + 1]); + i++; + } + else if (input[i] == '"') + { + // Check nothing meaningful follows the closing quote + var rest = input.Substring(i + 1).Trim(); + if (rest.Length > 0) + { + error = $"Unexpected content after closing quote: {Truncate(rest, 40)}"; + return null; + } + return sb.ToString(); + } + else + { + sb.Append(input[i]); + } + } + + error = "Unterminated string (missing closing \")"; + return null; + } + + /// + /// Splits a comma-separated argument list, respecting quoted strings + /// and nested braces so that "a, b", env: { K: "V, W" } is + /// correctly split into two arguments. + /// + internal static List SplitArguments(string input, out string error) + { + error = null; + var result = new List(); + var current = new StringBuilder(); + int depth = 0; + bool inQuote = false; + + for (int i = 0; i < input.Length; i++) + { + var ch = input[i]; + + if (ch == '\\' && inQuote && i + 1 < input.Length) + { + current.Append(ch); + current.Append(input[++i]); + continue; + } + + if (ch == '"') + { + inQuote = !inQuote; + current.Append(ch); + continue; + } + + if (!inQuote) + { + if (ch == '{') + { + depth++; + current.Append(ch); + continue; + } + if (ch == '}') + { + depth--; + current.Append(ch); + continue; + } + if (ch == ',' && depth == 0) + { + result.Add(current.ToString()); + current.Clear(); + continue; + } + } + + current.Append(ch); + } + + if (inQuote) + { + error = "Unterminated string in arguments"; + return null; + } + if (depth != 0) + { + error = "Unmatched braces in arguments"; + return null; + } + + if (current.Length > 0) + { + result.Add(current.ToString()); + } + + return result; + } + + /// + /// Parses { KEY: "value", KEY2: "value2" } into a dictionary. + /// + internal static Dictionary ParseEnvBlock(string input, out string error) + { + error = null; + var trimmed = input.Trim(); + if (!trimmed.StartsWith("{") || !trimmed.EndsWith("}")) + { + error = "Expected env block in the form { KEY: \"value\" }"; + return null; + } + + var inner = trimmed.Substring(1, trimmed.Length - 2).Trim(); + if (string.IsNullOrEmpty(inner)) + { + return new Dictionary(); + } + + var pairs = SplitArguments(inner, out error); + if (error != null) return null; + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in pairs) + { + var colonIdx = pair.IndexOf(':'); + if (colonIdx <= 0) + { + error = $"Expected KEY: \"value\" pair, got: {Truncate(pair.Trim(), 40)}"; + return null; + } + + var key = pair.Substring(0, colonIdx).Trim(); + var val = ExtractQuotedString(pair.Substring(colonIdx + 1).Trim(), out error); + if (error != null) return null; + + result[key] = val; + } + + return result; + } + + private static string Truncate(string value, int maxLength) + { + if (value == null) return "(null)"; + return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "..."; + } + + #endregion + } +} From 735dd69833c5716b1ea0dfc8eb5a4d7fd2974ef6 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 11:01:15 +0000 Subject: [PATCH 13/42] Add DapReplExecutor for run command execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the run command executor that makes REPL `run(...)` behave like a real workflow `run:` step by reusing the runner's existing infrastructure. Key design choices: - Shell resolution mirrors ScriptHandler: job defaults → explicit shell from DSL → platform default (bash→sh on Unix, pwsh→powershell on Windows) - Script fixup via ScriptHandlerHelpers.FixUpScriptContents() adds the same error-handling preamble as a real step - Environment is built from ExecutionContext.ExpressionValues[`env`] plus runtime context variables (GITHUB_*, RUNNER_*, etc.), with DSL-provided env overrides applied last - Working directory defaults to $GITHUB_WORKSPACE - Output is streamed in real time via DAP output events with secrets masked before emission through HostContext.SecretMasker - Only the exit code is returned in the evaluate response (avoiding the prototype's double-output bug) - Temp script files are cleaned up after execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapReplExecutor.cs | 308 +++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 src/Runner.Worker/Dap/DapReplExecutor.cs diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs new file mode 100644 index 00000000000..e7d9866d08a --- /dev/null +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Handlers; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Executes objects in the job's runtime context. + /// + /// Mirrors the behavior of a normal workflow run: step as closely + /// as possible by reusing the runner's existing shell-resolution logic, + /// script fixup helpers, and process execution infrastructure. + /// + /// Output is streamed to the debugger via DAP output events with + /// secrets masked before emission. + /// + internal sealed class DapReplExecutor + { + private readonly IHostContext _hostContext; + private readonly IDapServer _server; + private readonly Tracing _trace; + + public DapReplExecutor(IHostContext hostContext, IDapServer server) + { + _hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext)); + _server = server; + _trace = hostContext.GetTrace(nameof(DapReplExecutor)); + } + + /// + /// Executes a and returns the exit code as a + /// formatted . + /// + public async Task ExecuteRunCommandAsync( + RunCommand command, + IExecutionContext context, + CancellationToken cancellationToken) + { + if (context == null) + { + return ErrorResult("No execution context available. The debugger must be paused at a step to run commands."); + } + + try + { + return await ExecuteScriptAsync(command, context, cancellationToken); + } + catch (Exception ex) + { + _trace.Error($"REPL run command failed: {ex}"); + var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message); + return ErrorResult($"Command failed: {maskedError}"); + } + } + + private async Task ExecuteScriptAsync( + RunCommand command, + IExecutionContext context, + CancellationToken cancellationToken) + { + // 1. Resolve shell — same logic as ScriptHandler + string shellCommand; + string argFormat; + + if (!string.IsNullOrEmpty(command.Shell)) + { + // Explicit shell from the DSL + var parsed = ScriptHandlerHelpers.ParseShellOptionString(command.Shell); + shellCommand = parsed.shellCommand; + argFormat = string.IsNullOrEmpty(parsed.shellArgs) + ? ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand) + : parsed.shellArgs; + } + else + { + // Default shell — mirrors ScriptHandler platform defaults + shellCommand = ResolveDefaultShell(context); + argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand); + } + + _trace.Info($"REPL shell: {shellCommand}, argFormat: {argFormat}"); + + // 2. Prepare the script content + var contents = command.Script; + contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents); + + // Write to a temp file (same pattern as ScriptHandler) + var extension = ScriptHandlerHelpers.GetScriptFileExtension(shellCommand); + var scriptFilePath = Path.Combine( + _hostContext.GetDirectory(WellKnownDirectory.Temp), + $"dap_repl_{Guid.NewGuid()}{extension}"); + + var encoding = new UTF8Encoding(false); +#if OS_WINDOWS + contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n"); + encoding = Console.InputEncoding.CodePage != 65001 + ? Console.InputEncoding + : encoding; +#endif + File.WriteAllText(scriptFilePath, contents, encoding); + + try + { + // 3. Format arguments with script path + var resolvedPath = scriptFilePath.Replace("\"", "\\\""); + if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}")) + { + return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'"); + } + var arguments = string.Format(argFormat, resolvedPath); + + // 4. Resolve shell command path + string prependPath = string.Join( + Path.PathSeparator.ToString(), + Enumerable.Reverse(context.Global.PrependPath)); + var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath) + ?? shellCommand; + + // 5. Build environment — merge from execution context like a real step + var environment = BuildEnvironment(context, command.Env); + + // 6. Resolve working directory + var workingDirectory = command.WorkingDirectory; + if (string.IsNullOrEmpty(workingDirectory)) + { + var githubContext = context.ExpressionValues.TryGetValue("github", out var gh) + ? gh as DictionaryContextData + : null; + var workspace = githubContext?.TryGetValue("workspace", out var ws) == true + ? (ws as StringContextData)?.Value + : null; + workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work); + } + + _trace.Info($"REPL executing: {commandPath} {arguments} (cwd: {workingDirectory})"); + + // Stream execution info to debugger + SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n"); + + // 7. Execute via IProcessInvoker (same as DefaultStepHost) + int exitCode; + using (var processInvoker = _hostContext.CreateService()) + { + processInvoker.OutputDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); + SendOutput("stdout", masked + "\n"); + } + }; + + processInvoker.ErrorDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); + SendOutput("stderr", masked + "\n"); + } + }; + + exitCode = await processInvoker.ExecuteAsync( + workingDirectory: workingDirectory, + fileName: commandPath, + arguments: arguments, + environment: environment, + requireExitCodeZero: false, + outputEncoding: null, + killProcessOnCancel: true, + cancellationToken: cancellationToken); + } + + _trace.Info($"REPL command exited with code {exitCode}"); + + // 8. Return only the exit code summary (output was already streamed) + return new EvaluateResponseBody + { + Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.", + Type = exitCode == 0 ? "string" : "error", + VariablesReference = 0 + }; + } + finally + { + // Clean up temp script file + try { File.Delete(scriptFilePath); } + catch { /* best effort */ } + } + } + + /// + /// Resolves the default shell the same way + /// does: check job defaults, then fall back to platform default. + /// + private string ResolveDefaultShell(IExecutionContext context) + { + // Check job defaults + if (context.Global?.JobDefaults != null && + context.Global.JobDefaults.TryGetValue("run", out var runDefaults) && + runDefaults.TryGetValue("shell", out var defaultShell) && + !string.IsNullOrEmpty(defaultShell)) + { + _trace.Info($"Using job default shell: {defaultShell}"); + return defaultShell; + } + +#if OS_WINDOWS + string prependPath = string.Join( + Path.PathSeparator.ToString(), + context.Global?.PrependPath != null ? Enumerable.Reverse(context.Global.PrependPath) : Array.Empty()); + var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath); + return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell"; +#else + return "sh"; +#endif + } + + /// + /// Merges the job context environment with any REPL-specific overrides. + /// + private Dictionary BuildEnvironment( + IExecutionContext context, + Dictionary replEnv) + { + var env = new Dictionary(VarUtil.EnvironmentVariableKeyComparer); + + // Pull environment from the execution context (same as ActionRunner) + if (context.ExpressionValues.TryGetValue("env", out var envData)) + { + if (envData is DictionaryContextData dictEnv) + { + foreach (var pair in dictEnv) + { + if (pair.Value is StringContextData str) + { + env[pair.Key] = str.Value; + } + } + } + else if (envData is CaseSensitiveDictionaryContextData csEnv) + { + foreach (var pair in csEnv) + { + if (pair.Value is StringContextData str) + { + env[pair.Key] = str.Value; + } + } + } + } + + // Expose runtime context variables to the environment (GITHUB_*, RUNNER_*, etc.) + foreach (var ctxPair in context.ExpressionValues) + { + if (ctxPair.Value is IEnvironmentContextData runtimeContext && runtimeContext != null) + { + foreach (var rtEnv in runtimeContext.GetRuntimeEnvironmentVariables()) + { + env[rtEnv.Key] = rtEnv.Value; + } + } + } + + // Apply REPL-specific overrides last (so they win) + if (replEnv != null) + { + foreach (var pair in replEnv) + { + env[pair.Key] = pair.Value; + } + } + + return env; + } + + private void SendOutput(string category, string text) + { + _server?.SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = category, + Output = text + } + }); + } + + private static EvaluateResponseBody ErrorResult(string message) + { + return new EvaluateResponseBody + { + Result = message, + Type = "error", + VariablesReference = 0 + }; + } + } +} From 165fb90296040387f4a2e7d03ec5458dac6651e0 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 11:03:10 +0000 Subject: [PATCH 14/42] Wire REPL routing into DapDebugSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route `evaluate` requests by context: - `repl` context → DSL parser → command dispatch (help/run) - All other contexts (watch, hover, etc.) → expression evaluation If REPL input doesn't match any DSL command, it falls through to expression evaluation so the Debug Console also works for ad-hoc `github.repository`-style queries. Changes: - HandleEvaluateAsync replaces the sync HandleEvaluate - HandleReplInputAsync parses input through DapReplParser.TryParse - DispatchReplCommandAsync dispatches HelpCommand and RunCommand - DapReplExecutor is created alongside the DAP server reference - Remove vestigial `await Task.CompletedTask` from HandleMessageAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugSession.cs | 129 +++++++++++++++++++---- 1 file changed, 107 insertions(+), 22 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index 74f5545a310..e37b8542c69 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -66,6 +66,9 @@ public sealed class DapDebugSession : RunnerService, IDapDebugSession // Scope/variable inspection provider — reusable by future DAP features private DapVariableProvider _variableProvider; + // REPL command executor for run() commands + private DapReplExecutor _replExecutor; + public bool IsActive => _state == DapSessionState.Ready || _state == DapSessionState.Paused || @@ -83,6 +86,7 @@ public override void Initialize(IHostContext hostContext) public void SetDapServer(IDapServer server) { _server = server; + _replExecutor = new DapReplExecutor(HostContext, server); Trace.Info("DAP server reference set"); } @@ -114,23 +118,30 @@ public async Task HandleMessageAsync(string messageJson, CancellationToken cance Trace.Info($"Handling DAP request: {request.Command}"); - var response = request.Command switch + Response response; + if (request.Command == "evaluate") { - "initialize" => HandleInitialize(request), - "attach" => HandleAttach(request), - "configurationDone" => HandleConfigurationDone(request), - "disconnect" => HandleDisconnect(request), - "threads" => HandleThreads(request), - "stackTrace" => HandleStackTrace(request), - "scopes" => HandleScopes(request), - "variables" => HandleVariables(request), - "evaluate" => HandleEvaluate(request), - "continue" => HandleContinue(request), - "next" => HandleNext(request), - "setBreakpoints" => HandleSetBreakpoints(request), - "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), - _ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null) - }; + response = await HandleEvaluateAsync(request, cancellationToken); + } + else + { + response = request.Command switch + { + "initialize" => HandleInitialize(request), + "attach" => HandleAttach(request), + "configurationDone" => HandleConfigurationDone(request), + "disconnect" => HandleDisconnect(request), + "threads" => HandleThreads(request), + "stackTrace" => HandleStackTrace(request), + "scopes" => HandleScopes(request), + "variables" => HandleVariables(request), + "continue" => HandleContinue(request), + "next" => HandleNext(request), + "setBreakpoints" => HandleSetBreakpoints(request), + "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), + _ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null) + }; + } response.RequestSeq = request.Seq; response.Command = request.Command; @@ -148,8 +159,6 @@ public async Task HandleMessageAsync(string messageJson, CancellationToken cance _server?.SendResponse(errorResponse); } } - - await Task.CompletedTask; } #endregion @@ -367,18 +376,94 @@ private Response HandleVariables(Request request) }); } - private Response HandleEvaluate(Request request) + private async Task HandleEvaluateAsync(Request request, CancellationToken cancellationToken) { var args = request.Arguments?.ToObject(); var expression = args?.Expression ?? string.Empty; var frameId = args?.FrameId ?? CurrentFrameId; + var evalContext = args?.Context ?? "hover"; + + Trace.Info($"Evaluate request: '{expression}' (frame: {frameId}, context: {evalContext})"); + + // REPL context → route through the DSL dispatcher + if (string.Equals(evalContext, "repl", StringComparison.OrdinalIgnoreCase)) + { + var result = await HandleReplInputAsync(expression, frameId, cancellationToken); + return CreateResponse(request, true, body: result); + } + + // Watch/hover/variables/clipboard → expression evaluation only + var context = GetExecutionContextForFrame(frameId); + var evalResult = _variableProvider.EvaluateExpression(expression, context); + return CreateResponse(request, true, body: evalResult); + } + + /// + /// Routes REPL input through the DSL parser. If the input matches a + /// known command it is dispatched; otherwise it falls through to + /// expression evaluation. + /// + private async Task HandleReplInputAsync( + string input, + int frameId, + CancellationToken cancellationToken) + { + // Try to parse as a DSL command + var command = DapReplParser.TryParse(input, out var parseError); + + if (parseError != null) + { + return new EvaluateResponseBody + { + Result = parseError, + Type = "error", + VariablesReference = 0 + }; + } - Trace.Info($"Evaluate request: '{expression}' (frame: {frameId}, context: {args?.Context ?? "unknown"})"); + if (command != null) + { + return await DispatchReplCommandAsync(command, frameId, cancellationToken); + } + // Not a DSL command → evaluate as a GitHub Actions expression + // (this lets the REPL console also work for ad-hoc expression queries) var context = GetExecutionContextForFrame(frameId); - var result = _variableProvider.EvaluateExpression(expression, context); + return _variableProvider.EvaluateExpression(input, context); + } + + private async Task DispatchReplCommandAsync( + DapReplCommand command, + int frameId, + CancellationToken cancellationToken) + { + switch (command) + { + case HelpCommand help: + var helpText = string.IsNullOrEmpty(help.Topic) + ? DapReplParser.GetGeneralHelp() + : help.Topic.Equals("run", StringComparison.OrdinalIgnoreCase) + ? DapReplParser.GetRunHelp() + : $"Unknown help topic: {help.Topic}. Try: help or help(\"run\")"; + return new EvaluateResponseBody + { + Result = helpText, + Type = "string", + VariablesReference = 0 + }; + + case RunCommand run: + var context = GetExecutionContextForFrame(frameId); + return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken); - return CreateResponse(request, true, body: result); + default: + return new EvaluateResponseBody + { + Result = $"Unknown command type: {command.GetType().Name}", + Type = "error", + VariablesReference = 0 + }; + } } private Response HandleContinue(Request request) From b76917a8a02d4213acb9bc6f707e5e72773c2061 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 11:06:26 +0000 Subject: [PATCH 15/42] Add L0 tests for REPL parser and session routing Parser tests (DapReplParserL0, 22 tests): - help: bare, case-insensitive, with topic - run: simple script, with shell, env, working_directory, all options - Edge cases: escaped quotes, commas in env values - Errors: empty args, unquoted arg, unknown option, missing paren - Non-DSL input falls through: expressions, wrapped expressions, empty - Help text contains expected commands and options - Internal helpers: SplitArguments with nested braces, empty env block Session integration tests (DapDebugSessionL0, 4 tests): - REPL help returns help text - REPL non-DSL input falls through to expression evaluation - REPL parse error returns error result (not a DAP error response) - watch context still evaluates expressions (not routed through REPL) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Test/L0/Worker/DapDebugSessionL0.cs | 166 +++++++++++++ src/Test/L0/Worker/DapReplParserL0.cs | 314 ++++++++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 src/Test/L0/Worker/DapReplParserL0.cs diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs index a26ee871627..1f0b2f8aa29 100644 --- a/src/Test/L0/Worker/DapDebugSessionL0.cs +++ b/src/Test/L0/Worker/DapDebugSessionL0.cs @@ -968,5 +968,171 @@ public async Task EvaluateRequestWithWrapperSyntax() } #endregion + + #region REPL routing tests + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ReplHelpReturnsHelpText() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "help", + Context = "repl" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ReplExpressionFallsThroughToEvaluation() + { + using (var hc = CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await Task.Delay(100); + + // In REPL context, a non-DSL expression should still evaluate + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "github.repository", + Context = "repl" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 21, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ReplParseErrorReturnsErrorResult() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + // Malformed run() command + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "run()", + Context = "repl" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + // The response is successful at the DAP level (not an error + // response), but the result body conveys the parse error + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WatchContextStillEvaluatesExpressions() + { + using (var hc = CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await Task.Delay(100); + + // watch context should NOT route through REPL even if input + // looks like a DSL command — it should evaluate as expression + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "github.repository", + Context = "watch" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 21, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + } + } + + #endregion } } diff --git a/src/Test/L0/Worker/DapReplParserL0.cs b/src/Test/L0/Worker/DapReplParserL0.cs new file mode 100644 index 00000000000..0a15a37f400 --- /dev/null +++ b/src/Test/L0/Worker/DapReplParserL0.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker.Dap; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapReplParserL0 + { + #region help command + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_HelpReturnsHelpCommand() + { + var cmd = DapReplParser.TryParse("help", out var error); + + Assert.Null(error); + var help = Assert.IsType(cmd); + Assert.Null(help.Topic); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_HelpCaseInsensitive() + { + var cmd = DapReplParser.TryParse("Help", out var error); + Assert.Null(error); + Assert.IsType(cmd); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_HelpWithTopic() + { + var cmd = DapReplParser.TryParse("help(\"run\")", out var error); + + Assert.Null(error); + var help = Assert.IsType(cmd); + Assert.Equal("run", help.Topic); + } + + #endregion + + #region run command — basic + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunSimpleScript() + { + var cmd = DapReplParser.TryParse("run(\"echo hello\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo hello", run.Script); + Assert.Null(run.Shell); + Assert.Null(run.Env); + Assert.Null(run.WorkingDirectory); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithShell() + { + var cmd = DapReplParser.TryParse("run(\"echo hello\", shell: \"bash\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo hello", run.Script); + Assert.Equal("bash", run.Shell); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithWorkingDirectory() + { + var cmd = DapReplParser.TryParse("run(\"ls\", working_directory: \"/tmp\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("ls", run.Script); + Assert.Equal("/tmp", run.WorkingDirectory); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithEnv() + { + var cmd = DapReplParser.TryParse("run(\"echo $FOO\", env: { FOO: \"bar\" })", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo $FOO", run.Script); + Assert.NotNull(run.Env); + Assert.Equal("bar", run.Env["FOO"]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithMultipleEnvVars() + { + var cmd = DapReplParser.TryParse("run(\"echo\", env: { A: \"1\", B: \"2\" })", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal(2, run.Env.Count); + Assert.Equal("1", run.Env["A"]); + Assert.Equal("2", run.Env["B"]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithAllOptions() + { + var input = "run(\"echo $X\", shell: \"zsh\", env: { X: \"1\" }, working_directory: \"/tmp\")"; + var cmd = DapReplParser.TryParse(input, out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo $X", run.Script); + Assert.Equal("zsh", run.Shell); + Assert.Equal("1", run.Env["X"]); + Assert.Equal("/tmp", run.WorkingDirectory); + } + + #endregion + + #region run command — edge cases + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithEscapedQuotes() + { + var cmd = DapReplParser.TryParse("run(\"echo \\\"hello\\\"\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo \"hello\"", run.Script); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithCommaInEnvValue() + { + var cmd = DapReplParser.TryParse("run(\"echo\", env: { CSV: \"a,b,c\" })", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("a,b,c", run.Env["CSV"]); + } + + #endregion + + #region error cases + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunEmptyArgsReturnsError() + { + var cmd = DapReplParser.TryParse("run()", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + Assert.Contains("requires a script argument", error); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunUnquotedArgReturnsError() + { + var cmd = DapReplParser.TryParse("run(echo hello)", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + Assert.Contains("quoted string", error); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunUnknownOptionReturnsError() + { + var cmd = DapReplParser.TryParse("run(\"echo\", timeout: \"10\")", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + Assert.Contains("Unknown option", error); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunMissingClosingParenReturnsError() + { + var cmd = DapReplParser.TryParse("run(\"echo\"", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + } + + #endregion + + #region non-DSL input falls through + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_ExpressionReturnsNull() + { + var cmd = DapReplParser.TryParse("github.repository", out var error); + + Assert.Null(error); + Assert.Null(cmd); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_WrappedExpressionReturnsNull() + { + var cmd = DapReplParser.TryParse("${{ github.event_name }}", out var error); + + Assert.Null(error); + Assert.Null(cmd); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_EmptyInputReturnsNull() + { + var cmd = DapReplParser.TryParse("", out var error); + Assert.Null(error); + Assert.Null(cmd); + + cmd = DapReplParser.TryParse(null, out error); + Assert.Null(error); + Assert.Null(cmd); + } + + #endregion + + #region help text + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetGeneralHelp_ContainsCommands() + { + var help = DapReplParser.GetGeneralHelp(); + + Assert.Contains("help", help); + Assert.Contains("run", help); + Assert.Contains("expression", help, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetRunHelp_ContainsOptions() + { + var help = DapReplParser.GetRunHelp(); + + Assert.Contains("shell", help); + Assert.Contains("env", help); + Assert.Contains("working_directory", help); + } + + #endregion + + #region internal parser helpers + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SplitArguments_HandlesNestedBraces() + { + var args = DapReplParser.SplitArguments("\"hello\", env: { A: \"1\", B: \"2\" }", out var error); + + Assert.Null(error); + Assert.Equal(2, args.Count); + Assert.Equal("\"hello\"", args[0].Trim()); + Assert.Contains("A:", args[1]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ParseEnvBlock_HandlesEmptyBlock() + { + var result = DapReplParser.ParseEnvBlock("{ }", out var error); + + Assert.Null(error); + Assert.NotNull(result); + Assert.Empty(result); + } + + #endregion + } +} From 860a9190816982db57d0755d003372884ebce6bb Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 11:16:12 +0000 Subject: [PATCH 16/42] Fix expression expansion in REPL run command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run() command was passing ${{ }} expressions literally to the shell instead of evaluating them first. This caused scripts like `run("echo ${{ github.job }}")` to fail with 'bad substitution'. Fix: add ExpandExpressions() that finds each ${{ expr }} occurrence, evaluates it individually via PipelineTemplateEvaluator, masks the result through SecretMasker, and substitutes it into the script body before writing the temp file — matching how ActionRunner evaluates step inputs before ScriptHandler sees them. Also expands expressions in DSL-provided env values so that `env: { TOKEN: "${{ secrets.MY_TOKEN }}" }` works correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapReplExecutor.cs | 79 ++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index e7d9866d08a..6977c3be37d 100644 --- a/src/Runner.Worker/Dap/DapReplExecutor.cs +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -89,8 +89,9 @@ private async Task ExecuteScriptAsync( _trace.Info($"REPL shell: {shellCommand}, argFormat: {argFormat}"); - // 2. Prepare the script content - var contents = command.Script; + // 2. Expand ${{ }} expressions in the script body, just like + // ActionRunner evaluates step inputs before ScriptHandler sees them + var contents = ExpandExpressions(command.Script, context); contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents); // Write to a temp file (same pattern as ScriptHandler) @@ -197,6 +198,73 @@ private async Task ExecuteScriptAsync( } } + /// + /// Expands ${{ }} expressions in the input string using the + /// runner's template evaluator — the same evaluation path that processes + /// step inputs before runs them. + /// + /// Each ${{ expr }} occurrence is individually evaluated and + /// replaced with its masked string result, mirroring the semantics of + /// expression interpolation in a workflow run: step body. + /// + private string ExpandExpressions(string input, IExecutionContext context) + { + if (string.IsNullOrEmpty(input) || !input.Contains("${{")) + { + return input ?? string.Empty; + } + + var result = new StringBuilder(); + int pos = 0; + + while (pos < input.Length) + { + var start = input.IndexOf("${{", pos, StringComparison.Ordinal); + if (start < 0) + { + result.Append(input, pos, input.Length - pos); + break; + } + + // Append the literal text before the expression + result.Append(input, pos, start - pos); + + var end = input.IndexOf("}}", start + 3, StringComparison.Ordinal); + if (end < 0) + { + // Unterminated expression — keep literal + result.Append(input, start, input.Length - start); + break; + } + + var expr = input.Substring(start + 3, end - start - 3).Trim(); + end += 2; // skip past "}}" + + // Evaluate the expression + try + { + var templateEvaluator = context.ToPipelineTemplateEvaluator(); + var token = new GitHub.DistributedTask.ObjectTemplating.Tokens.BasicExpressionToken( + null, null, null, expr); + var evaluated = templateEvaluator.EvaluateStepDisplayName( + token, + context.ExpressionValues, + context.ExpressionFunctions); + result.Append(_hostContext.SecretMasker.MaskSecrets(evaluated ?? string.Empty)); + } + catch (Exception ex) + { + _trace.Warning($"Expression expansion failed for '{expr}': {ex.Message}"); + // Keep the original expression literal on failure + result.Append(input, start, end - start); + } + + pos = end; + } + + return result.ToString(); + } + /// /// Resolves the default shell the same way /// does: check job defaults, then fall back to platform default. @@ -270,12 +338,13 @@ private Dictionary BuildEnvironment( } } - // Apply REPL-specific overrides last (so they win) + // Apply REPL-specific overrides last (so they win), + // expanding any ${{ }} expressions in the values if (replEnv != null) { foreach (var pair in replEnv) { - env[pair.Key] = pair.Value; + env[pair.Key] = ExpandExpressions(pair.Value, context); } } From 8d6b38a428eceb019e91bb81e0e841c9771e08b8 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 11:38:20 +0000 Subject: [PATCH 17/42] Add completions support and friendly errors for unsupported commands Completions (SupportsCompletionsRequest = true): - Respond to DAP 'completions' requests with our DSL commands (help, help("run"), run(...)) so they appear in the debug console autocomplete across all DAP clients - Add CompletionsArguments, CompletionItem, and CompletionsResponseBody to DapMessages Friendly error messages for unsupported stepping commands: - stepIn: explain that Actions debug at the step level - stepOut: suggest using 'continue' - stepBack/reverseContinue: note 'not yet supported' - pause: explain automatic pausing at step boundaries The DAP spec does not provide a capability to hide stepIn/stepOut buttons (they are considered fundamental operations). The best server-side UX is clear error messages when clients send them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugSession.cs | 56 +++++++++++++- src/Runner.Worker/Dap/DapMessages.cs | 99 +++++++++++++++++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index e37b8542c69..65697e274c7 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -139,6 +139,12 @@ public async Task HandleMessageAsync(string messageJson, CancellationToken cance "next" => HandleNext(request), "setBreakpoints" => HandleSetBreakpoints(request), "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), + "completions" => HandleCompletions(request), + "stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level — use 'next' to advance to the next step.", body: null), + "stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level — use 'continue' to resume.", body: null), + "stepBack" => CreateResponse(request, false, "Step Back is not yet supported.", body: null), + "reverseContinue" => CreateResponse(request, false, "Reverse Continue is not yet supported.", body: null), + "pause" => CreateResponse(request, false, "Pause is not supported. The debugger pauses automatically at step boundaries.", body: null), _ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null) }; } @@ -195,7 +201,7 @@ private Response HandleInitialize(Request request) SupportsRestartFrame = false, SupportsGotoTargetsRequest = false, SupportsStepInTargetsRequest = false, - SupportsCompletionsRequest = false, + SupportsCompletionsRequest = true, SupportsModulesRequest = false, SupportsTerminateRequest = false, SupportTerminateDebuggee = false, @@ -466,6 +472,52 @@ private async Task DispatchReplCommandAsync( } } + private Response HandleCompletions(Request request) + { + var args = request.Arguments?.ToObject(); + var text = args?.Text ?? string.Empty; + + var items = new List(); + + // Offer DSL commands when the user is starting to type + if (string.IsNullOrEmpty(text) || "help".StartsWith(text, System.StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "help", + Text = "help", + Detail = "Show available debug console commands", + Type = "function" + }); + } + if (string.IsNullOrEmpty(text) || "help(\"run\")".StartsWith(text, System.StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "help(\"run\")", + Text = "help(\"run\")", + Detail = "Show help for the run command", + Type = "function" + }); + } + if (string.IsNullOrEmpty(text) || "run(".StartsWith(text, System.StringComparison.OrdinalIgnoreCase) + || text.StartsWith("run(", System.StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "run(\"...\")", + Text = "run(\"", + Detail = "Execute a script (like a workflow run step)", + Type = "function" + }); + } + + return CreateResponse(request, true, body: new CompletionsResponseBody + { + Targets = items + }); + } + private Response HandleContinue(Request request) { Trace.Info("Continue command received"); diff --git a/src/Runner.Worker/Dap/DapMessages.cs b/src/Runner.Worker/Dap/DapMessages.cs index bf868598194..53cd7a436b8 100644 --- a/src/Runner.Worker/Dap/DapMessages.cs +++ b/src/Runner.Worker/Dap/DapMessages.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -885,6 +885,103 @@ public class EvaluateResponseBody #endregion + #region Completions Request/Response + + /// + /// Arguments for 'completions' request. + /// + public class CompletionsArguments + { + /// + /// Returns completions in the scope of this stack frame. + /// + [JsonProperty("frameId", NullValueHandling = NullValueHandling.Ignore)] + public int? FrameId { get; set; } + + /// + /// One or more source lines. Typically this is the text users have typed + /// in the debug console (REPL). + /// + [JsonProperty("text")] + public string Text { get; set; } + + /// + /// The position within 'text' for which to determine the completion proposals. + /// It is measured in UTF-16 code units. + /// + [JsonProperty("column")] + public int Column { get; set; } + + /// + /// A line for which to determine the completion proposals. + /// If missing the first line of the text is assumed. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + } + + /// + /// A completion item in the debug console. + /// + public class CompletionItem + { + /// + /// The label of this completion item. + /// + [JsonProperty("label")] + public string Label { get; set; } + + /// + /// If text is returned and not an empty string, then it is inserted instead + /// of the label. + /// + [JsonProperty("text", NullValueHandling = NullValueHandling.Ignore)] + public string Text { get; set; } + + /// + /// A human-readable string with additional information about this item. + /// + [JsonProperty("detail", NullValueHandling = NullValueHandling.Ignore)] + public string Detail { get; set; } + + /// + /// The item's type. Typically the client uses this information to render the item + /// in the UI with an icon. + /// Values: 'method', 'function', 'constructor', 'field', 'variable', 'class', + /// 'interface', 'module', 'property', 'unit', 'value', 'enum', 'keyword', + /// 'snippet', 'text', 'color', 'file', 'reference', 'customcolor' + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// Start position (0-based) within 'text' that should be replaced + /// by the completion text. + /// + [JsonProperty("start", NullValueHandling = NullValueHandling.Ignore)] + public int? Start { get; set; } + + /// + /// Length of the text that should be replaced by the completion text. + /// + [JsonProperty("length", NullValueHandling = NullValueHandling.Ignore)] + public int? Length { get; set; } + } + + /// + /// Response body for 'completions' request. + /// + public class CompletionsResponseBody + { + /// + /// The possible completions. + /// + [JsonProperty("targets")] + public List Targets { get; set; } = new List(); + } + + #endregion + #region Events /// From a8f3b9195d9dd37f39ecd7b245f6ccf20e8080fd Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 17:15:34 +0000 Subject: [PATCH 18/42] Harden DAP server Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapServer.cs | 77 ++++++++++++++++++++---------- src/Runner.Worker/JobRunner.cs | 2 + 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/Runner.Worker/Dap/DapServer.cs b/src/Runner.Worker/Dap/DapServer.cs index a51b47ea944..56b0f0c4200 100644 --- a/src/Runner.Worker/Dap/DapServer.cs +++ b/src/Runner.Worker/Dap/DapServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Net; using System.Net.Sockets; @@ -18,6 +18,8 @@ namespace GitHub.Runner.Worker.Dap public sealed class DapServer : RunnerService, IDapServer { private const string ContentLengthHeader = "Content-Length: "; + private const int MaxMessageSize = 10 * 1024 * 1024; // 10 MB + private const int MaxHeaderLineLength = 8192; // 8 KB private TcpListener _listener; private TcpClient _client; @@ -27,6 +29,7 @@ public sealed class DapServer : RunnerService, IDapServer private TaskCompletionSource _connectionTcs; private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); private int _nextSeq = 1; + private Task _connectionLoopTask; private volatile bool _acceptConnections = true; public override void Initialize(IHostContext hostContext) @@ -41,7 +44,7 @@ public void SetSession(IDapDebugSession session) Trace.Info("Debug session set"); } - public async Task StartAsync(int port, CancellationToken cancellationToken) + public Task StartAsync(int port, CancellationToken cancellationToken) { Trace.Info($"Starting DAP server on port {port}"); @@ -53,9 +56,9 @@ public async Task StartAsync(int port, CancellationToken cancellationToken) Trace.Info($"DAP server listening on 127.0.0.1:{port}"); // Start the connection loop in the background - _ = ConnectionLoopAsync(_cts.Token); + _connectionLoopTask = ConnectionLoopAsync(_cts.Token); - await Task.CompletedTask; + return Task.CompletedTask; } /// @@ -142,10 +145,18 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken) /// private void CleanupConnection() { - try { _stream?.Close(); } catch { /* best effort */ } - try { _client?.Close(); } catch { /* best effort */ } - _stream = null; - _client = null; + _sendLock.Wait(); + try + { + try { _stream?.Close(); } catch { /* best effort */ } + try { _client?.Close(); } catch { /* best effort */ } + _stream = null; + _client = null; + } + finally + { + _sendLock.Release(); + } } public async Task WaitForConnectionAsync(CancellationToken cancellationToken) @@ -172,7 +183,14 @@ public async Task StopAsync() try { _listener?.Stop(); } catch { /* best effort */ } - await Task.CompletedTask; + if (_connectionLoopTask != null) + { + try + { + await Task.WhenAny(_connectionLoopTask, Task.Delay(5000)); + } + catch { /* best effort */ } + } Trace.Info("DAP server stopped"); } @@ -309,6 +327,11 @@ private async Task ReadMessageAsync(CancellationToken cancellationToken) throw new InvalidDataException("Missing Content-Length header"); } + if (contentLength > MaxMessageSize) + { + throw new InvalidDataException($"Message size {contentLength} exceeds maximum allowed size of {MaxMessageSize}"); + } + var buffer = new byte[contentLength]; var totalRead = 0; while (totalRead < contentLength) @@ -356,6 +379,11 @@ private async Task ReadLineAsync(CancellationToken cancellationToken) previousWasCr = (c == '\r'); lineBuilder.Append(c); + + if (lineBuilder.Length > MaxHeaderLineLength) + { + throw new InvalidDataException($"Header line exceeds maximum length of {MaxHeaderLineLength}"); + } } } @@ -383,16 +411,15 @@ private void SendMessageInternal(ProtocolMessage message) public void SendMessage(ProtocolMessage message) { - if (_stream == null) - { - return; - } - try { _sendLock.Wait(); try { + if (_stream == null) + { + return; + } message.Seq = _nextSeq++; SendMessageInternal(message); } @@ -409,17 +436,16 @@ public void SendMessage(ProtocolMessage message) public void SendEvent(Event evt) { - if (_stream == null) - { - Trace.Warning($"Cannot send event '{evt.EventType}': no client connected"); - return; - } - try { _sendLock.Wait(); try { + if (_stream == null) + { + Trace.Warning($"Cannot send event '{evt.EventType}': no client connected"); + return; + } evt.Seq = _nextSeq++; SendMessageInternal(evt); } @@ -437,17 +463,16 @@ public void SendEvent(Event evt) public void SendResponse(Response response) { - if (_stream == null) - { - Trace.Warning($"Cannot send response for '{response.Command}': no client connected"); - return; - } - try { _sendLock.Wait(); try { + if (_stream == null) + { + Trace.Warning($"Cannot send response for '{response.Command}': no client connected"); + return; + } response.Seq = _nextSeq++; SendMessageInternal(response); } diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index cea4771e880..944f2c4aeae 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -275,12 +275,14 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) { Trace.Info("Job was cancelled before debugger client connected. Continuing without debugger."); + try { await dapServer.StopAsync(); } catch { } dapServer = null; debugSession = null; } catch (Exception ex) { Trace.Warning($"Failed to complete DAP handshake: {ex.Message}. Job will continue without debugging."); + try { await dapServer.StopAsync(); } catch { } dapServer = null; debugSession = null; } From e4406e035e0671ec21d4ccd6f2ca709171034509 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 17:16:47 +0000 Subject: [PATCH 19/42] Fix debug session race conditions and step-flow bugs - Guard WaitForCommandAsync against resurrecting terminated sessions (H1) - Mask exception messages in top-level DAP error responses (M1) - Move isFirstStep=false outside try block to prevent continue breakage (M5) - Guard OnJobCompleted with lock-internal state check to prevent duplicate events (M6) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugSession.cs | 17 +++++++++++------ src/Runner.Worker/StepsRunner.cs | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index 65697e274c7..cb19548714c 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -159,7 +159,8 @@ public async Task HandleMessageAsync(string messageJson, CancellationToken cance Trace.Error($"Error handling request '{request?.Command}': {ex}"); if (request != null) { - var errorResponse = CreateResponse(request, false, ex.Message, body: null); + var maskedMessage = HostContext?.SecretMasker?.MaskSecrets(ex.Message) ?? ex.Message; + var errorResponse = CreateResponse(request, false, maskedMessage, body: null); errorResponse.RequestSeq = request.Seq; errorResponse.Command = request.Command; _server?.SendResponse(errorResponse); @@ -631,15 +632,15 @@ public void OnStepCompleted(IStep step) public void OnJobCompleted() { - if (!IsActive) - { - return; - } - Trace.Info("Job completed, sending terminated event"); lock (_stateLock) { + if (_state == DapSessionState.Terminated) + { + Trace.Info("Session already terminated, skipping OnJobCompleted events"); + return; + } _state = DapSessionState.Terminated; } @@ -742,6 +743,10 @@ private async Task WaitForCommandAsync(CancellationToken cancellationToken) { lock (_stateLock) { + if (_state == DapSessionState.Terminated) + { + return; + } _state = DapSessionState.Paused; _commandTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } diff --git a/src/Runner.Worker/StepsRunner.cs b/src/Runner.Worker/StepsRunner.cs index 1c4894cb855..7e639789bee 100644 --- a/src/Runner.Worker/StepsRunner.cs +++ b/src/Runner.Worker/StepsRunner.cs @@ -243,12 +243,12 @@ public async Task RunAsync(IExecutionContext jobContext) try { await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken); - isFirstStep = false; } catch (Exception ex) { Trace.Warning($"DAP OnStepStarting error: {ex.Message}"); } + isFirstStep = false; } // Run the step From 75760d1f348414d82ff2d09235e9ab788c18b76b Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 17:20:38 +0000 Subject: [PATCH 20/42] Centralize outbound DAP masking and harden secrets scope - Add centralized secret masking in DapServer.SendMessageInternal so all outbound DAP payloads (responses, events) are masked before serialization, creating a single egress funnel that catches secrets regardless of caller. - Redact the entire secrets scope in DapVariableProvider regardless of PipelineContextData type (NumberContextData, BooleanContextData, containers) not just StringContextData, closing the defense-in-depth gap. - Null values under secrets scope are now also redacted. - Existing per-call-site masking retained as defense-in-depth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapServer.cs | 9 +++++++++ src/Runner.Worker/Dap/DapVariableProvider.cs | 19 +++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/Runner.Worker/Dap/DapServer.cs b/src/Runner.Worker/Dap/DapServer.cs index 56b0f0c4200..f91d56c680a 100644 --- a/src/Runner.Worker/Dap/DapServer.cs +++ b/src/Runner.Worker/Dap/DapServer.cs @@ -390,6 +390,11 @@ private async Task ReadLineAsync(CancellationToken cancellationToken) /// /// Serializes and writes a DAP message with Content-Length framing. /// Must be called within the _sendLock. + /// + /// This is the single egress point for all DAP output. The serialized + /// JSON is run through the runner's + /// so that callers do not need to mask individually — any secret that + /// appears anywhere in a response or event body is caught here. /// private void SendMessageInternal(ProtocolMessage message) { @@ -398,6 +403,10 @@ private void SendMessageInternal(ProtocolMessage message) NullValueHandling = NullValueHandling.Ignore }); + // Centralized masking: every outbound DAP payload is sanitized + // before hitting the wire, regardless of what callers did. + json = HostContext?.SecretMasker?.MaskSecrets(json) ?? json; + var bodyBytes = Encoding.UTF8.GetBytes(json); var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n"; var headerBytes = Encoding.ASCII.GetBytes(header); diff --git a/src/Runner.Worker/Dap/DapVariableProvider.cs b/src/Runner.Worker/Dap/DapVariableProvider.cs index ea1fa591a1d..1ee49d5f1b7 100644 --- a/src/Runner.Worker/Dap/DapVariableProvider.cs +++ b/src/Runner.Worker/Dap/DapVariableProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using GitHub.DistributedTask.ObjectTemplating.Tokens; using GitHub.DistributedTask.Pipelines.ContextData; @@ -306,16 +306,27 @@ private Variable CreateVariable( if (value == null) { - variable.Value = "null"; + variable.Value = isSecretsScope ? RedactedValue : "null"; variable.Type = "null"; variable.VariablesReference = 0; return variable; } + // Secrets scope: redact ALL values regardless of underlying type. + // Keys are visible but values are always replaced with the + // redaction marker, and nested containers are not drillable. + if (isSecretsScope) + { + variable.Value = RedactedValue; + variable.Type = "string"; + variable.VariablesReference = 0; + return variable; + } + switch (value) { case StringContextData str: - variable.Value = isSecretsScope ? RedactedValue : MaskSecrets(str.Value); + variable.Value = MaskSecrets(str.Value); variable.Type = "string"; variable.VariablesReference = 0; break; @@ -355,7 +366,7 @@ private Variable CreateVariable( default: var rawValue = value.ToJToken()?.ToString() ?? "unknown"; - variable.Value = isSecretsScope ? RedactedValue : MaskSecrets(rawValue); + variable.Value = MaskSecrets(rawValue); variable.Type = value.GetType().Name; variable.VariablesReference = 0; break; From 649dc74be337ea018e2a2a899972fc05290cd160 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 13 Mar 2026 08:34:24 +0000 Subject: [PATCH 21/42] More tests --- src/Runner.Worker/Dap/DapReplExecutor.cs | 6 +- src/Test/L0/Worker/DapDebugSessionL0.cs | 14 +- src/Test/L0/Worker/DapReplExecutorL0.cs | 226 ++++++++++++++++++++ src/Test/L0/Worker/DapServerL0.cs | 174 ++++++++++++++- src/Test/L0/Worker/DapVariableProviderL0.cs | 103 +++++++++ 5 files changed, 516 insertions(+), 7 deletions(-) create mode 100644 src/Test/L0/Worker/DapReplExecutorL0.cs diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index 6977c3be37d..fa432a8f6c8 100644 --- a/src/Runner.Worker/Dap/DapReplExecutor.cs +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -207,7 +207,7 @@ private async Task ExecuteScriptAsync( /// replaced with its masked string result, mirroring the semantics of /// expression interpolation in a workflow run: step body. /// - private string ExpandExpressions(string input, IExecutionContext context) + internal string ExpandExpressions(string input, IExecutionContext context) { if (string.IsNullOrEmpty(input) || !input.Contains("${{")) { @@ -269,7 +269,7 @@ private string ExpandExpressions(string input, IExecutionContext context) /// Resolves the default shell the same way /// does: check job defaults, then fall back to platform default. /// - private string ResolveDefaultShell(IExecutionContext context) + internal string ResolveDefaultShell(IExecutionContext context) { // Check job defaults if (context.Global?.JobDefaults != null && @@ -295,7 +295,7 @@ private string ResolveDefaultShell(IExecutionContext context) /// /// Merges the job context environment with any REPL-specific overrides. /// - private Dictionary BuildEnvironment( + internal Dictionary BuildEnvironment( IExecutionContext context, Dictionary replEnv) { diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs index 1f0b2f8aa29..2bb27be24e4 100644 --- a/src/Test/L0/Worker/DapDebugSessionL0.cs +++ b/src/Test/L0/Worker/DapDebugSessionL0.cs @@ -786,9 +786,17 @@ public async Task SecretsValuesAreRedactedThroughSession() Assert.Single(_sentResponses); Assert.True(_sentResponses[0].Success); - // The response body is serialized — we can't easily inspect it from - // the mock, but the important thing is it succeeded without exposing - // raw secrets (which is tested in DapVariableProviderL0). + + // Verify the response body actually contains redacted values + var body = _sentResponses[0].Body; + Assert.NotNull(body); + var varsBody = Assert.IsType(body); + Assert.NotEmpty(varsBody.Variables); + foreach (var variable in varsBody.Variables) + { + Assert.Equal(DapVariableProvider.RedactedValue, variable.Value); + Assert.DoesNotContain("ghp_verysecret", variable.Value); + } // Resume to unblock var continueJson = JsonConvert.SerializeObject(new Request diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs new file mode 100644 index 00000000000..63e6779fb5d --- /dev/null +++ b/src/Test/L0/Worker/DapReplExecutorL0.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.Expressions2; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapReplExecutorL0 + { + private TestHostContext _hc; + private DapReplExecutor _executor; + private Mock _mockServer; + private List _sentEvents; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + _hc = new TestHostContext(this, testName); + _sentEvents = new List(); + _mockServer = new Mock(); + _mockServer.Setup(x => x.SendEvent(It.IsAny())) + .Callback(e => _sentEvents.Add(e)); + _executor = new DapReplExecutor(_hc, _mockServer.Object); + return _hc; + } + + private Mock CreateMockContext( + DictionaryContextData exprValues = null, + IDictionary> jobDefaults = null) + { + var mock = new Mock(); + mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData()); + mock.Setup(x => x.ExpressionFunctions).Returns(new List()); + + var global = new GlobalContext + { + PrependPath = new List(), + JobDefaults = jobDefaults + ?? new Dictionary>(StringComparer.OrdinalIgnoreCase), + }; + mock.Setup(x => x.Global).Returns(global); + + return mock; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ExecuteRunCommand_NullContext_ReturnsError() + { + using (CreateTestContext()) + { + var command = new RunCommand { Script = "echo hello" }; + var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None); + + Assert.Equal("error", result.Type); + Assert.Contains("No execution context available", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_NoExpressions_ReturnsInput() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("echo hello", context.Object); + + Assert.Equal("echo hello", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_NullInput_ReturnsEmpty() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions(null, context.Object); + + Assert.Equal(string.Empty, result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_EmptyInput_ReturnsEmpty() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("", context.Object); + + Assert.Equal(string.Empty, result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_UnterminatedExpression_KeepsLiteral() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object); + + Assert.Equal("echo ${{ github.repo", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveDefaultShell_NoJobDefaults_ReturnsSh() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ResolveDefaultShell(context.Object); + + Assert.Equal("sh", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault() + { + using (CreateTestContext()) + { + var jobDefaults = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["run"] = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["shell"] = "bash" + } + }; + var context = CreateMockContext(jobDefaults: jobDefaults); + var result = _executor.ResolveDefaultShell(context.Object); + + Assert.Equal("bash", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_MergesEnvContextAndReplOverrides() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("bar"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var replEnv = new Dictionary { { "BAZ", "qux" } }; + var result = _executor.BuildEnvironment(context.Object, replEnv); + + Assert.Equal("bar", result["FOO"]); + Assert.Equal("qux", result["BAZ"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_ReplOverridesWin() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("original"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var replEnv = new Dictionary { { "FOO", "override" } }; + var result = _executor.BuildEnvironment(context.Object, replEnv); + + Assert.Equal("override", result["FOO"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("bar"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var result = _executor.BuildEnvironment(context.Object, null); + + Assert.Equal("bar", result["FOO"]); + Assert.False(result.ContainsKey("BAZ")); + } + } + } +} diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs index ffda39465fe..2bc4f5fffed 100644 --- a/src/Test/L0/Worker/DapServerL0.cs +++ b/src/Test/L0/Worker/DapServerL0.cs @@ -1,5 +1,10 @@ -using System; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Reflection; using System.Runtime.CompilerServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using GitHub.Runner.Worker.Dap; @@ -166,5 +171,172 @@ public async Task StartAndStopMultipleTimesDoesNotThrow() await _server.StopAsync(); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task MessageFraming_ValidMessage_ProcessedSuccessfully() + { + using (var hc = CreateTestContext()) + { + var receivedMessages = new List(); + var mockSession = new Mock(); + mockSession.Setup(x => x.HandleMessageAsync(It.IsAny(), It.IsAny())) + .Callback((json, ct) => receivedMessages.Add(json)) + .Returns(Task.CompletedTask); + _server.SetSession(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); + var listener = (TcpListener)listenerField.GetValue(_server); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + var stream = client.GetStream(); + + // Wait for server to accept connection + await Task.Delay(100); + + // Send a valid DAP request with Content-Length framing + var requestJson = "{\"seq\":1,\"type\":\"request\",\"command\":\"initialize\"}"; + var body = Encoding.UTF8.GetBytes(requestJson); + var header = $"Content-Length: {body.Length}\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + + await stream.WriteAsync(headerBytes, 0, headerBytes.Length); + await stream.WriteAsync(body, 0, body.Length); + await stream.FlushAsync(); + + // Wait for processing + await Task.Delay(500); + + Assert.Single(receivedMessages); + Assert.Contains("initialize", receivedMessages[0]); + + cts.Cancel(); + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CentralizedMasking_SecretsInResponseAreMasked() + { + using (var hc = CreateTestContext()) + { + // Register a secret + hc.SecretMasker.AddValue("super-secret-token"); + + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); + var listener = (TcpListener)listenerField.GetValue(_server); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + var stream = client.GetStream(); + + await Task.Delay(100); + + // Send a response that contains the secret (through the server API) + var response = new Response + { + Type = "response", + RequestSeq = 1, + Command = "evaluate", + Success = true, + Body = new EvaluateResponseBody + { + Result = "The value is super-secret-token here", + Type = "string", + VariablesReference = 0 + } + }; + + _server.SendResponse(response); + + // Read what the client received + await Task.Delay(200); + var buffer = new byte[4096]; + var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); + var received = Encoding.UTF8.GetString(buffer, 0, bytesRead); + + // The response should NOT contain the raw secret + Assert.DoesNotContain("super-secret-token", received); + // It should contain the masked version + Assert.Contains("***", received); + + cts.Cancel(); + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CentralizedMasking_SecretsInEventsAreMasked() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("event-secret-value"); + + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); + var listener = (TcpListener)listenerField.GetValue(_server); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + var stream = client.GetStream(); + + await Task.Delay(100); + + _server.SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = "stdout", + Output = "Output contains event-secret-value here" + } + }); + + await Task.Delay(200); + var buffer = new byte[4096]; + var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); + var received = Encoding.UTF8.GetString(buffer, 0, bytesRead); + + Assert.DoesNotContain("event-secret-value", received); + Assert.Contains("***", received); + + cts.Cancel(); + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StopAsync_AwaitsConnectionLoopShutdown() + { + using (CreateTestContext()) + { + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + // Stop should complete within a reasonable time + var stopTask = _server.StopAsync(); + var completed = await Task.WhenAny(stopTask, Task.Delay(10000)); + Assert.Equal(stopTask, completed); + } + } } } diff --git a/src/Test/L0/Worker/DapVariableProviderL0.cs b/src/Test/L0/Worker/DapVariableProviderL0.cs index 3dfb83f57b4..2f84b9f34f1 100644 --- a/src/Test/L0/Worker/DapVariableProviderL0.cs +++ b/src/Test/L0/Worker/DapVariableProviderL0.cs @@ -665,5 +665,108 @@ public void InferResultType_ClassifiesCorrectly() } #endregion + + #region Non-string secret type redaction + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNumberContextData() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "NUMERIC_SECRET", new NumberContextData(12345) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NUMERIC_SECRET", variables[0].Name); + Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value); + Assert.Equal("string", variables[0].Type); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsBooleanContextData() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "BOOL_SECRET", new BooleanContextData(true) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("BOOL_SECRET", variables[0].Name); + Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value); + Assert.Equal("string", variables[0].Type); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNestedDictionary() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "NESTED_SECRET", new DictionaryContextData + { + { "inner_key", new StringContextData("inner_value") } + } + } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NESTED_SECRET", variables[0].Name); + Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value); + Assert.Equal("string", variables[0].Type); + // Nested container should NOT be drillable under secrets + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNullValue() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var secrets = new DictionaryContextData(); + secrets["NULL_SECRET"] = null; + exprValues["secrets"] = secrets; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NULL_SECRET", variables[0].Name); + Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + #endregion } } From 8d1e06f43629fa9ad7932248279eb2e0491cd469 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 13 Mar 2026 08:41:45 +0000 Subject: [PATCH 22/42] Remove centralized masking --- src/Runner.Worker/Dap/DapServer.cs | 15 ++-- src/Test/L0/Worker/DapServerL0.cs | 134 ++++++++++++++++++++--------- 2 files changed, 101 insertions(+), 48 deletions(-) diff --git a/src/Runner.Worker/Dap/DapServer.cs b/src/Runner.Worker/Dap/DapServer.cs index f91d56c680a..f45de1ca485 100644 --- a/src/Runner.Worker/Dap/DapServer.cs +++ b/src/Runner.Worker/Dap/DapServer.cs @@ -391,10 +391,13 @@ private async Task ReadLineAsync(CancellationToken cancellationToken) /// Serializes and writes a DAP message with Content-Length framing. /// Must be called within the _sendLock. /// - /// This is the single egress point for all DAP output. The serialized - /// JSON is run through the runner's - /// so that callers do not need to mask individually — any secret that - /// appears anywhere in a response or event body is caught here. + /// Secret masking is intentionally NOT applied here at the serialization + /// layer. Masking the raw JSON would corrupt protocol envelope fields + /// (type, event, command, seq) if a secret collides with those strings. + /// Instead, each DAP producer masks user-visible text at the point of + /// construction via or the + /// runner's SecretMasker directly. See DapVariableProvider, DapReplExecutor, + /// and DapDebugSession for the call sites. /// private void SendMessageInternal(ProtocolMessage message) { @@ -403,10 +406,6 @@ private void SendMessageInternal(ProtocolMessage message) NullValueHandling = NullValueHandling.Ignore }); - // Centralized masking: every outbound DAP payload is sanitized - // before hitting the wire, regardless of what callers did. - json = HostContext?.SecretMasker?.MaskSecrets(json) ?? json; - var bodyBytes = Encoding.UTF8.GetBytes(json); var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n"; var headerBytes = Encoding.ASCII.GetBytes(header); diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs index 2bc4f5fffed..903389e5561 100644 --- a/src/Test/L0/Worker/DapServerL0.cs +++ b/src/Test/L0/Worker/DapServerL0.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net; using System.Net.Sockets; using System.Reflection; @@ -179,14 +180,14 @@ public async Task MessageFraming_ValidMessage_ProcessedSuccessfully() { using (var hc = CreateTestContext()) { - var receivedMessages = new List(); + var messageReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var mockSession = new Mock(); mockSession.Setup(x => x.HandleMessageAsync(It.IsAny(), It.IsAny())) - .Callback((json, ct) => receivedMessages.Add(json)) + .Callback((json, ct) => messageReceived.TrySetResult(json)) .Returns(Task.CompletedTask); _server.SetSession(mockSession.Object); - var cts = new CancellationTokenSource(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await _server.StartAsync(0, cts.Token); var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); @@ -197,9 +198,6 @@ public async Task MessageFraming_ValidMessage_ProcessedSuccessfully() await client.ConnectAsync(IPAddress.Loopback, port); var stream = client.GetStream(); - // Wait for server to accept connection - await Task.Delay(100); - // Send a valid DAP request with Content-Length framing var requestJson = "{\"seq\":1,\"type\":\"request\",\"command\":\"initialize\"}"; var body = Encoding.UTF8.GetBytes(requestJson); @@ -210,11 +208,10 @@ public async Task MessageFraming_ValidMessage_ProcessedSuccessfully() await stream.WriteAsync(body, 0, body.Length); await stream.FlushAsync(); - // Wait for processing - await Task.Delay(500); - - Assert.Single(receivedMessages); - Assert.Contains("initialize", receivedMessages[0]); + // Wait for session to receive the message (deterministic, bounded) + var completed = await Task.WhenAny(messageReceived.Task, Task.Delay(5000)); + Assert.Equal(messageReceived.Task, completed); + Assert.Contains("initialize", await messageReceived.Task); cts.Cancel(); await _server.StopAsync(); @@ -224,14 +221,16 @@ public async Task MessageFraming_ValidMessage_ProcessedSuccessfully() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task CentralizedMasking_SecretsInResponseAreMasked() + public async Task ProtocolMetadata_PreservedWhenSecretCollidesWithKeywords() { using (var hc = CreateTestContext()) { - // Register a secret - hc.SecretMasker.AddValue("super-secret-token"); + // Register secrets that match DAP protocol keywords + hc.SecretMasker.AddValue("response"); + hc.SecretMasker.AddValue("output"); + hc.SecretMasker.AddValue("evaluate"); - var cts = new CancellationTokenSource(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await _server.StartAsync(0, cts.Token); var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); @@ -242,9 +241,7 @@ public async Task CentralizedMasking_SecretsInResponseAreMasked() await client.ConnectAsync(IPAddress.Loopback, port); var stream = client.GetStream(); - await Task.Delay(100); - - // Send a response that contains the secret (through the server API) + // Send a response whose protocol fields collide with secrets var response = new Response { Type = "response", @@ -253,7 +250,7 @@ public async Task CentralizedMasking_SecretsInResponseAreMasked() Success = true, Body = new EvaluateResponseBody { - Result = "The value is super-secret-token here", + Result = "some result", Type = "string", VariablesReference = 0 } @@ -261,16 +258,13 @@ public async Task CentralizedMasking_SecretsInResponseAreMasked() _server.SendResponse(response); - // Read what the client received - await Task.Delay(200); - var buffer = new byte[4096]; - var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); - var received = Encoding.UTF8.GetString(buffer, 0, bytesRead); + // Read a full framed DAP message with timeout + var received = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - // The response should NOT contain the raw secret - Assert.DoesNotContain("super-secret-token", received); - // It should contain the masked version - Assert.Contains("***", received); + // Protocol metadata MUST be preserved even when secrets collide + Assert.Contains("\"type\":\"response\"", received); + Assert.Contains("\"command\":\"evaluate\"", received); + Assert.Contains("\"success\":true", received); cts.Cancel(); await _server.StopAsync(); @@ -280,13 +274,14 @@ public async Task CentralizedMasking_SecretsInResponseAreMasked() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task CentralizedMasking_SecretsInEventsAreMasked() + public async Task ProtocolMetadata_EventFieldsPreservedWhenSecretCollidesWithKeywords() { using (var hc = CreateTestContext()) { - hc.SecretMasker.AddValue("event-secret-value"); + hc.SecretMasker.AddValue("output"); + hc.SecretMasker.AddValue("stdout"); - var cts = new CancellationTokenSource(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await _server.StartAsync(0, cts.Token); var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); @@ -297,25 +292,23 @@ public async Task CentralizedMasking_SecretsInEventsAreMasked() await client.ConnectAsync(IPAddress.Loopback, port); var stream = client.GetStream(); - await Task.Delay(100); - _server.SendEvent(new Event { EventType = "output", Body = new OutputEventBody { Category = "stdout", - Output = "Output contains event-secret-value here" + Output = "hello world" } }); - await Task.Delay(200); - var buffer = new byte[4096]; - var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); - var received = Encoding.UTF8.GetString(buffer, 0, bytesRead); + // Read a full framed DAP message with timeout + var received = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - Assert.DoesNotContain("event-secret-value", received); - Assert.Contains("***", received); + // Protocol fields MUST be preserved + Assert.Contains("\"type\":\"event\"", received); + Assert.Contains("\"event\":\"output\"", received); + Assert.Contains("\"category\":\"stdout\"", received); cts.Cancel(); await _server.StopAsync(); @@ -338,5 +331,66 @@ public async Task StopAsync_AwaitsConnectionLoopShutdown() Assert.Equal(stopTask, completed); } } + + /// + /// Reads a single DAP-framed message from a stream with a timeout. + /// Parses the Content-Length header, reads exactly that many bytes, + /// and returns the JSON body. Fails with a clear error on timeout. + /// + private static async Task ReadDapMessageAsync(NetworkStream stream, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + var token = cts.Token; + + // Read headers byte-by-byte until we see \r\n\r\n + var headerBuilder = new StringBuilder(); + var buffer = new byte[1]; + var contentLength = -1; + + while (true) + { + var readTask = stream.ReadAsync(buffer, 0, 1, token); + var bytesRead = await readTask; + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading DAP headers"); + } + + headerBuilder.Append((char)buffer[0]); + var headers = headerBuilder.ToString(); + if (headers.EndsWith("\r\n\r\n")) + { + // Parse Content-Length + foreach (var line in headers.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("Content-Length: ", StringComparison.OrdinalIgnoreCase)) + { + contentLength = int.Parse(line.Substring("Content-Length: ".Length).Trim()); + } + } + break; + } + } + + if (contentLength < 0) + { + throw new InvalidOperationException("No Content-Length header found in DAP message"); + } + + // Read exactly contentLength bytes + var body = new byte[contentLength]; + var totalRead = 0; + while (totalRead < contentLength) + { + var bytesRead = await stream.ReadAsync(body, totalRead, contentLength - totalRead, token); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading DAP body"); + } + totalRead += bytesRead; + } + + return Encoding.UTF8.GetString(body); + } } } From 5bad8cb3598f613f9a046c9d51628076d596ab72 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 13 Mar 2026 09:10:14 +0000 Subject: [PATCH 23/42] Mask step display names --- src/Runner.Worker/Dap/DapDebugSession.cs | 26 ++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index cb19548714c..9d4be096c3f 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -268,6 +268,10 @@ private Response HandleDisconnect(Request request) private Response HandleThreads(Request request) { + var threadName = _jobContext != null + ? MaskUserVisibleText($"Job: {_jobContext.GetGitHubContext("job") ?? "workflow job"}") + : "Job Thread"; + var body = new ThreadsResponseBody { Threads = new List @@ -275,9 +279,7 @@ private Response HandleThreads(Request request) new Thread { Id = JobThreadId, - Name = _jobContext != null - ? $"Job: {_jobContext.GetGitHubContext("job") ?? "workflow job"}" - : "Job Thread" + Name = threadName } } }; @@ -299,7 +301,7 @@ private Response HandleStackTrace(Request request) frames.Add(new StackFrame { Id = CurrentFrameId, - Name = $"{_currentStep.DisplayName ?? "Current Step"}{resultIndicator}", + Name = MaskUserVisibleText($"{_currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), Line = _currentStepIndex + 1, Column = 1, PresentationHint = "normal" @@ -325,7 +327,7 @@ private Response HandleStackTrace(Request request) frames.Add(new StackFrame { Id = completedStep.FrameId, - Name = $"{completedStep.DisplayName}{resultStr}", + Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"), Line = 1, Column = 1, PresentationHint = "subtle" @@ -823,13 +825,25 @@ private void SendStoppedEvent(string reason, string description) Body = new StoppedEventBody { Reason = reason, - Description = description, + Description = MaskUserVisibleText(description), ThreadId = JobThreadId, AllThreadsStopped = true } }); } + private string MaskUserVisibleText(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value ?? string.Empty; + } + + return _variableProvider?.MaskSecrets(value) + ?? HostContext?.SecretMasker?.MaskSecrets(value) + ?? value; + } + /// /// Creates a DAP response with common fields pre-populated. /// From 00bde9001822798a8fa5f2fbd5c4d8f4d321df43 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 13 Mar 2026 09:10:35 +0000 Subject: [PATCH 24/42] remove waits --- src/Test/L0/Worker/DapDebugSessionL0.cs | 272 ++++++++++++++++++------ 1 file changed, 210 insertions(+), 62 deletions(-) diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs index 2bb27be24e4..c11a5f834f0 100644 --- a/src/Test/L0/Worker/DapDebugSessionL0.cs +++ b/src/Test/L0/Worker/DapDebugSessionL0.cs @@ -16,10 +16,14 @@ namespace GitHub.Runner.Common.Tests.Worker { public sealed class DapDebugSessionL0 { + private static readonly TimeSpan DefaultAsyncTimeout = TimeSpan.FromSeconds(5); + private DapDebugSession _session; private Mock _mockServer; private List _sentEvents; private List _sentResponses; + private readonly object _eventWaitersLock = new object(); + private List<(Predicate Predicate, TaskCompletionSource Completion)> _eventWaiters; private TestHostContext CreateTestContext([CallerMemberName] string testName = "") { @@ -30,10 +34,40 @@ private TestHostContext CreateTestContext([CallerMemberName] string testName = " _sentEvents = new List(); _sentResponses = new List(); + _eventWaiters = new List<(Predicate, TaskCompletionSource)>(); _mockServer = new Mock(); _mockServer.Setup(x => x.SendEvent(It.IsAny())) - .Callback(e => _sentEvents.Add(e)); + .Callback(e => + { + List> matchedWaiters = null; + lock (_eventWaitersLock) + { + _sentEvents.Add(e); + for (int i = _eventWaiters.Count - 1; i >= 0; i--) + { + var waiter = _eventWaiters[i]; + if (!waiter.Predicate(e)) + { + continue; + } + + matchedWaiters ??= new List>(); + matchedWaiters.Add(waiter.Completion); + _eventWaiters.RemoveAt(i); + } + } + + if (matchedWaiters == null) + { + return; + } + + foreach (var waiter in matchedWaiters) + { + waiter.TrySetResult(e); + } + }); _mockServer.Setup(x => x.SendResponse(It.IsAny())) .Callback(r => _sentResponses.Add(r)); @@ -55,15 +89,17 @@ private Mock CreateMockStep(string displayName, TaskResult? result = null return mockStep; } - private Mock CreateMockJobContext() + private Mock CreateMockJobContext(string jobName = "test-job") { var mockJobContext = new Mock(); - mockJobContext.Setup(x => x.GetGitHubContext("job")).Returns("test-job"); + mockJobContext.Setup(x => x.GetGitHubContext("job")).Returns(jobName); return mockJobContext; } private async Task InitializeSessionAsync() { + var initializedEventTask = WaitForEventAsync(e => e.EventType == "initialized"); + var initJson = JsonConvert.SerializeObject(new Request { Seq = 1, @@ -87,6 +123,48 @@ private async Task InitializeSessionAsync() Command = "configurationDone" }); await _session.HandleMessageAsync(configJson, CancellationToken.None); + await WaitForTaskAsync(initializedEventTask); + } + + private Task WaitForEventAsync(Predicate predicate) + { + lock (_eventWaitersLock) + { + foreach (var sentEvent in _sentEvents) + { + if (predicate(sentEvent)) + { + return Task.FromResult(sentEvent); + } + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _eventWaiters.Add((predicate, completion)); + return completion.Task; + } + } + + private Task WaitForEventAsync(string eventType) + { + return WaitForEventAsync(e => string.Equals(e.EventType, eventType, StringComparison.Ordinal)); + } + + private static async Task WaitForTaskAsync(Task task) + { + await task.WaitAsync(DefaultAsyncTimeout); + } + + private static async Task WaitForTaskAsync(Task task) + { + return await task.WaitAsync(DefaultAsyncTimeout); + } + + private async Task WaitForStepPauseAsync(Task stepTask) + { + var stoppedEvent = await WaitForTaskAsync(WaitForEventAsync("stopped")); + Assert.False(stepTask.IsCompleted); + Assert.Equal(DapSessionState.Paused, _session.State); + return stoppedEvent; } [Fact] @@ -146,9 +224,6 @@ public async Task OnStepStartingPausesAndSendsStoppedEvent() { await InitializeSessionAsync(); _session.HandleClientConnected(); - - // Wait for the async initialized event to arrive, then clear - await Task.Delay(200); _sentEvents.Clear(); var step = CreateMockStep("Checkout code"); @@ -157,9 +232,7 @@ public async Task OnStepStartingPausesAndSendsStoppedEvent() var cts = new CancellationTokenSource(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); - await Task.Delay(100); - Assert.False(stepTask.IsCompleted); - Assert.Equal(DapSessionState.Paused, _session.State); + await WaitForStepPauseAsync(stepTask); var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); Assert.Single(stoppedEvents); @@ -172,8 +245,7 @@ public async Task OnStepStartingPausesAndSendsStoppedEvent() }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); - Assert.True(stepTask.IsCompleted); + await WaitForTaskAsync(stepTask); } } @@ -192,6 +264,7 @@ public async Task NextCommandPausesOnFollowingStep() var jobContext = CreateMockJobContext(); var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await WaitForStepPauseAsync(step1Task); var nextJson = JsonConvert.SerializeObject(new Request { @@ -200,8 +273,7 @@ public async Task NextCommandPausesOnFollowingStep() Command = "next" }); await _session.HandleMessageAsync(nextJson, CancellationToken.None); - await Task.WhenAny(step1Task, Task.Delay(5000)); - Assert.True(step1Task.IsCompleted); + await WaitForTaskAsync(step1Task); _session.OnStepCompleted(step1.Object); _sentEvents.Clear(); @@ -209,9 +281,7 @@ public async Task NextCommandPausesOnFollowingStep() var step2 = CreateMockStep("Step 2"); var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); - await Task.Delay(100); - Assert.False(step2Task.IsCompleted); - Assert.Equal(DapSessionState.Paused, _session.State); + await WaitForStepPauseAsync(step2Task); var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); Assert.Single(stoppedEvents); @@ -223,8 +293,7 @@ public async Task NextCommandPausesOnFollowingStep() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(step2Task, Task.Delay(5000)); - Assert.True(step2Task.IsCompleted); + await WaitForTaskAsync(step2Task); } } @@ -243,6 +312,7 @@ public async Task ContinueCommandSkipsNextPause() var jobContext = CreateMockJobContext(); var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await WaitForStepPauseAsync(step1Task); var continueJson = JsonConvert.SerializeObject(new Request { @@ -251,8 +321,7 @@ public async Task ContinueCommandSkipsNextPause() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(step1Task, Task.Delay(5000)); - Assert.True(step1Task.IsCompleted); + await WaitForTaskAsync(step1Task); _session.OnStepCompleted(step1.Object); _sentEvents.Clear(); @@ -260,8 +329,7 @@ public async Task ContinueCommandSkipsNextPause() var step2 = CreateMockStep("Step 2"); var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); - await Task.WhenAny(step2Task, Task.Delay(5000)); - Assert.True(step2Task.IsCompleted); + await WaitForTaskAsync(step2Task); } } @@ -282,14 +350,11 @@ public async Task CancellationUnblocksPausedStep() var cts = new CancellationTokenSource(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); - await Task.Delay(100); - Assert.False(stepTask.IsCompleted); - Assert.Equal(DapSessionState.Paused, _session.State); + await WaitForStepPauseAsync(stepTask); cts.Cancel(); - await Task.WhenAny(stepTask, Task.Delay(5000)); - Assert.True(stepTask.IsCompleted); + await WaitForTaskAsync(stepTask); } } @@ -330,14 +395,11 @@ public async Task CancelSessionReleasesBlockedStep() var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - - await Task.Delay(100); - Assert.False(stepTask.IsCompleted); + await WaitForStepPauseAsync(stepTask); _session.CancelSession(); - await Task.WhenAny(stepTask, Task.Delay(5000)); - Assert.True(stepTask.IsCompleted); + await WaitForTaskAsync(stepTask); Assert.Equal(DapSessionState.Terminated, _session.State); } } @@ -351,18 +413,13 @@ public async Task ReconnectionResendStoppedEvent() { await InitializeSessionAsync(); _session.HandleClientConnected(); - - // Wait for the async initialized event to arrive, then clear - await Task.Delay(200); _sentEvents.Clear(); var step = CreateMockStep("Step 1"); var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - - await Task.Delay(100); - Assert.Equal(DapSessionState.Paused, _session.State); + await WaitForStepPauseAsync(stepTask); var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); Assert.Single(stoppedEvents); @@ -382,8 +439,7 @@ public async Task ReconnectionResendStoppedEvent() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); - Assert.True(stepTask.IsCompleted); + await WaitForTaskAsync(stepTask); } } @@ -424,6 +480,7 @@ public async Task OnStepCompletedTracksCompletedSteps() var jobContext = CreateMockJobContext(); var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await WaitForStepPauseAsync(step1Task); var continueJson = JsonConvert.SerializeObject(new Request { @@ -432,7 +489,7 @@ public async Task OnStepCompletedTracksCompletedSteps() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(step1Task, Task.Delay(5000)); + await WaitForTaskAsync(step1Task); _session.OnStepCompleted(step1.Object); @@ -450,6 +507,56 @@ public async Task OnStepCompletedTracksCompletedSteps() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StoppedEventAndStackTraceMaskSecretStepDisplayName() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("ghs_step_secret"); + + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + + var step = CreateMockStep("Deploy ghs_step_secret"); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + var stoppedEvent = await WaitForStepPauseAsync(stepTask); + + var stoppedBody = Assert.IsType(stoppedEvent.Body); + Assert.Contains(DapVariableProvider.RedactedValue, stoppedBody.Description); + Assert.DoesNotContain("ghs_step_secret", stoppedBody.Description); + + var stackTraceJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "stackTrace" + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(stackTraceJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + var stackTraceBody = Assert.IsType(_sentResponses[0].Body); + Assert.Single(stackTraceBody.StackFrames); + Assert.Contains(DapVariableProvider.RedactedValue, stackTraceBody.StackFrames[0].Name); + Assert.DoesNotContain("ghs_step_secret", stackTraceBody.StackFrames[0].Name); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 11, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await WaitForTaskAsync(stepTask); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -483,8 +590,7 @@ public async Task OnStepStartingNoOpWhenNotActive() var task = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await Task.WhenAny(task, Task.Delay(5000)); - Assert.True(task.IsCompleted); + await WaitForTaskAsync(task); _mockServer.Verify(x => x.SendEvent(It.IsAny()), Times.Never); } @@ -513,6 +619,51 @@ public async Task ThreadsCommandReturnsJobThread() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ThreadsCommandMasksSecretJobName() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("very-secret-job"); + + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var step = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext("very-secret-job"); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await WaitForStepPauseAsync(stepTask); + + var threadsJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "threads" + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(threadsJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + var threadsBody = Assert.IsType(_sentResponses[0].Body); + Assert.Single(threadsBody.Threads); + Assert.Contains(DapVariableProvider.RedactedValue, threadsBody.Threads[0].Name); + Assert.DoesNotContain("very-secret-job", threadsBody.Threads[0].Name); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 11, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await WaitForTaskAsync(stepTask); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -554,9 +705,7 @@ public async Task FullFlowInitAttachConfigStepContinueComplete() var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - - await Task.Delay(100); - Assert.Equal(DapSessionState.Paused, _session.State); + await WaitForStepPauseAsync(stepTask); var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); Assert.Single(stoppedEvents); @@ -568,8 +717,7 @@ public async Task FullFlowInitAttachConfigStepContinueComplete() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); - Assert.True(stepTask.IsCompleted); + await WaitForTaskAsync(stepTask); var continuedEvents = _sentEvents.FindAll(e => e.EventType == "continued"); Assert.Single(continuedEvents); @@ -651,7 +799,7 @@ public async Task ScopesRequestReturnsScopesFromExecutionContext() var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await Task.Delay(100); + await WaitForStepPauseAsync(stepTask); var scopesJson = JsonConvert.SerializeObject(new Request { @@ -674,7 +822,7 @@ public async Task ScopesRequestReturnsScopesFromExecutionContext() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); + await WaitForTaskAsync(stepTask); } } @@ -699,7 +847,7 @@ public async Task VariablesRequestReturnsVariablesFromExecutionContext() var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await Task.Delay(100); + await WaitForStepPauseAsync(stepTask); // "env" is at ScopeNames index 1 → variablesReference = 2 var variablesJson = JsonConvert.SerializeObject(new Request @@ -723,7 +871,7 @@ public async Task VariablesRequestReturnsVariablesFromExecutionContext() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); + await WaitForTaskAsync(stepTask); } } @@ -771,7 +919,7 @@ public async Task SecretsValuesAreRedactedThroughSession() var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await Task.Delay(100); + await WaitForStepPauseAsync(stepTask); // "secrets" is at ScopeNames index 5 → variablesReference = 6 var variablesJson = JsonConvert.SerializeObject(new Request @@ -806,7 +954,7 @@ public async Task SecretsValuesAreRedactedThroughSession() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); + await WaitForTaskAsync(stepTask); } } @@ -860,7 +1008,7 @@ public async Task EvaluateRequestReturnsResult() var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await Task.Delay(100); + await WaitForStepPauseAsync(stepTask); var evaluateJson = JsonConvert.SerializeObject(new Request { @@ -888,7 +1036,7 @@ public async Task EvaluateRequestReturnsResult() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); + await WaitForTaskAsync(stepTask); } } @@ -943,7 +1091,7 @@ public async Task EvaluateRequestWithWrapperSyntax() var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await Task.Delay(100); + await WaitForStepPauseAsync(stepTask); var evaluateJson = JsonConvert.SerializeObject(new Request { @@ -971,7 +1119,7 @@ public async Task EvaluateRequestWithWrapperSyntax() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); + await WaitForTaskAsync(stepTask); } } @@ -1027,7 +1175,7 @@ public async Task ReplExpressionFallsThroughToEvaluation() var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await Task.Delay(100); + await WaitForStepPauseAsync(stepTask); // In REPL context, a non-DSL expression should still evaluate var evaluateJson = JsonConvert.SerializeObject(new Request @@ -1054,7 +1202,7 @@ public async Task ReplExpressionFallsThroughToEvaluation() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); + await WaitForTaskAsync(stepTask); } } @@ -1109,7 +1257,7 @@ public async Task WatchContextStillEvaluatesExpressions() var jobContext = CreateMockJobContext(); var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await Task.Delay(100); + await WaitForStepPauseAsync(stepTask); // watch context should NOT route through REPL even if input // looks like a DSL command — it should evaluate as expression @@ -1137,7 +1285,7 @@ public async Task WatchContextStillEvaluatesExpressions() Command = "continue" }); await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await Task.WhenAny(stepTask, Task.Delay(5000)); + await WaitForTaskAsync(stepTask); } } From e11d6cfa59392d9aa7b8fa14fd84f7c7c52f6ecb Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 13 Mar 2026 09:30:09 +0000 Subject: [PATCH 25/42] lock state --- src/Runner.Worker/Dap/DapDebugSession.cs | 106 ++++++++++++++++------- 1 file changed, 74 insertions(+), 32 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index 9d4be096c3f..570e2a3ee08 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -268,8 +268,14 @@ private Response HandleDisconnect(Request request) private Response HandleThreads(Request request) { - var threadName = _jobContext != null - ? MaskUserVisibleText($"Job: {_jobContext.GetGitHubContext("job") ?? "workflow job"}") + IExecutionContext jobContext; + lock (_stateLock) + { + jobContext = _jobContext; + } + + var threadName = jobContext != null + ? MaskUserVisibleText($"Job: {jobContext.GetGitHubContext("job") ?? "workflow job"}") : "Job Thread"; var body = new ThreadsResponseBody @@ -289,20 +295,30 @@ private Response HandleThreads(Request request) private Response HandleStackTrace(Request request) { + IStep currentStep; + int currentStepIndex; + CompletedStepInfo[] completedSteps; + lock (_stateLock) + { + currentStep = _currentStep; + currentStepIndex = _currentStepIndex; + completedSteps = _completedSteps.ToArray(); + } + var frames = new List(); // Add current step as the top frame - if (_currentStep != null) + if (currentStep != null) { - var resultIndicator = _currentStep.ExecutionContext?.Result != null - ? $" [{_currentStep.ExecutionContext.Result}]" + var resultIndicator = currentStep.ExecutionContext?.Result != null + ? $" [{currentStep.ExecutionContext.Result}]" : " [running]"; frames.Add(new StackFrame { Id = CurrentFrameId, - Name = MaskUserVisibleText($"{_currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), - Line = _currentStepIndex + 1, + Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), + Line = currentStepIndex + 1, Column = 1, PresentationHint = "normal" }); @@ -320,9 +336,9 @@ private Response HandleStackTrace(Request request) } // Add completed steps as additional frames (most recent first) - for (int i = _completedSteps.Count - 1; i >= 0; i--) + for (int i = completedSteps.Length - 1; i >= 0; i--) { - var completedStep = _completedSteps[i]; + var completedStep = completedSteps[i]; var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : ""; frames.Add(new StackFrame { @@ -369,7 +385,7 @@ private Response HandleVariables(Request request) var args = request.Arguments?.ToObject(); var variablesRef = args?.VariablesReference ?? 0; - var context = _currentStep?.ExecutionContext ?? _jobContext; + var context = GetCurrentExecutionContext(); if (context == null) { return CreateResponse(request, true, body: new VariablesResponseBody @@ -577,21 +593,28 @@ private Response HandleSetExceptionBreakpoints(Request request) public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken) { - if (!IsActive) + bool pauseOnNextStep; + lock (_stateLock) { - return; - } + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return; + } - _currentStep = step; - _jobContext = jobContext; - _currentStepIndex = _completedSteps.Count; + _currentStep = step; + _jobContext = jobContext; + _currentStepIndex = _completedSteps.Count; + pauseOnNextStep = _pauseOnNextStep; + } // Reset variable references so stale nested refs from the // previous step are not served to the client. _variableProvider?.Reset(); // Determine if we should pause - bool shouldPause = isFirstStep || _pauseOnNextStep; + bool shouldPause = isFirstStep || pauseOnNextStep; if (!shouldPause) { @@ -615,27 +638,33 @@ public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, public void OnStepCompleted(IStep step) { - if (!IsActive) - { - return; - } - var result = step.ExecutionContext?.Result; Trace.Info($"Step completed: {step.DisplayName}, result: {result}"); // Add to completed steps list for stack trace - _completedSteps.Add(new CompletedStepInfo + lock (_stateLock) { - DisplayName = step.DisplayName, - Result = result, - FrameId = _nextCompletedFrameId++ - }); + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return; + } + + _completedSteps.Add(new CompletedStepInfo + { + DisplayName = step.DisplayName, + Result = result, + FrameId = _nextCompletedFrameId++ + }); + } } public void OnJobCompleted() { Trace.Info("Job completed, sending terminated event"); + int exitCode; lock (_stateLock) { if (_state == DapSessionState.Terminated) @@ -644,6 +673,7 @@ public void OnJobCompleted() return; } _state = DapSessionState.Terminated; + exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1; } _server?.SendEvent(new Event @@ -652,7 +682,6 @@ public void OnJobCompleted() Body = new TerminatedEventBody() }); - var exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1; _server?.SendEvent(new Event { EventType = "exited", @@ -711,15 +740,20 @@ public void HandleClientConnected() // If we're paused, re-send the stopped event so the new client // knows the current state (important for reconnection) + string description = null; lock (_stateLock) { if (_state == DapSessionState.Paused && _currentStep != null) { - Trace.Info("Re-sending stopped event to reconnected client"); - var description = $"Stopped before step: {_currentStep.DisplayName}"; - SendStoppedEvent("step", description); + description = $"Stopped before step: {_currentStep.DisplayName}"; } } + + if (description != null) + { + Trace.Info("Re-sending stopped event to reconnected client"); + SendStoppedEvent("step", description); + } } public void HandleClientDisconnected() @@ -800,13 +834,21 @@ private IExecutionContext GetExecutionContextForFrame(int frameId) { if (frameId == CurrentFrameId) { - return _currentStep?.ExecutionContext ?? _jobContext; + return GetCurrentExecutionContext(); } // Completed-step frames don't carry a live execution context. return null; } + private IExecutionContext GetCurrentExecutionContext() + { + lock (_stateLock) + { + return _currentStep?.ExecutionContext ?? _jobContext; + } + } + /// /// Sends a stopped event to the connected client. /// Silently no-ops if no client is connected. From 7d0f26a5572ca10d34dc435aa8564180c388d125 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 13 Mar 2026 11:08:43 +0000 Subject: [PATCH 26/42] encoding casting --- src/Runner.Worker/Dap/DapReplExecutor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index fa432a8f6c8..2a8e634240a 100644 --- a/src/Runner.Worker/Dap/DapReplExecutor.cs +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -100,7 +100,7 @@ private async Task ExecuteScriptAsync( _hostContext.GetDirectory(WellKnownDirectory.Temp), $"dap_repl_{Guid.NewGuid()}{extension}"); - var encoding = new UTF8Encoding(false); + Encoding encoding = new UTF8Encoding(false); #if OS_WINDOWS contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n"); encoding = Console.InputEncoding.CodePage != 65001 From 9d33c82d611da9d7ea154253e7de5ace94814bf1 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 13 Mar 2026 11:11:09 +0000 Subject: [PATCH 27/42] volatile state --- src/Runner.Worker/Dap/DapDebugSession.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index 570e2a3ee08..b9eb00e2f9a 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -39,7 +39,7 @@ public sealed class DapDebugSession : RunnerService, IDapDebugSession private const int CompletedFrameIdBase = 1000; private IDapServer _server; - private DapSessionState _state = DapSessionState.WaitingForConnection; + private volatile DapSessionState _state = DapSessionState.WaitingForConnection; // Synchronization for step execution private TaskCompletionSource _commandTcs; From 9cd74b0f26ff7e2562e31e5326be2d29e8371cf1 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 13 Mar 2026 11:32:56 +0000 Subject: [PATCH 28/42] ci --- src/Runner.Worker/Dap/DapDebugSession.cs | 23 ++++++++-------- src/Runner.Worker/Dap/DapReplExecutor.cs | 10 +++---- src/Runner.Worker/Dap/DapServer.cs | 34 ++++++++++++------------ src/Test/L0/Worker/DapReplExecutorL0.cs | 6 ++++- src/Test/L0/Worker/DapServerL0.cs | 11 ++++++-- 5 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index b9eb00e2f9a..bdf5e85d219 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -19,7 +19,6 @@ internal sealed class CompletedStepInfo } /// - /// Production DAP debug session. /// Handles step-level breakpoints with next/continue flow control, /// scope/variable inspection, client reconnection, and cancellation /// signal propagation. @@ -116,7 +115,7 @@ public async Task HandleMessageAsync(string messageJson, CancellationToken cance return; } - Trace.Info($"Handling DAP request: {request.Command}"); + Trace.Info("Handling DAP request"); Response response; if (request.Command == "evaluate") @@ -156,7 +155,7 @@ public async Task HandleMessageAsync(string messageJson, CancellationToken cance } catch (Exception ex) { - Trace.Error($"Error handling request '{request?.Command}': {ex}"); + Trace.Error($"Error handling DAP request ({ex.GetType().Name})"); if (request != null) { var maskedMessage = HostContext?.SecretMasker?.MaskSecrets(ex.Message) ?? ex.Message; @@ -178,12 +177,12 @@ private Response HandleInitialize(Request request) { try { - var clientCaps = request.Arguments.ToObject(); - Trace.Info($"Client: {clientCaps?.ClientName ?? clientCaps?.ClientId ?? "unknown"}"); + request.Arguments.ToObject(); + Trace.Info("Initialize arguments received"); } catch (Exception ex) { - Trace.Warning($"Failed to parse initialize arguments: {ex.Message}"); + Trace.Warning($"Failed to parse initialize arguments ({ex.GetType().Name})"); } } @@ -408,7 +407,7 @@ private async Task HandleEvaluateAsync(Request request, CancellationTo var frameId = args?.FrameId ?? CurrentFrameId; var evalContext = args?.Context ?? "hover"; - Trace.Info($"Evaluate request: '{expression}' (frame: {frameId}, context: {evalContext})"); + Trace.Info("Evaluate request received"); // REPL context → route through the DSL dispatcher if (string.Equals(evalContext, "repl", StringComparison.OrdinalIgnoreCase)) @@ -618,7 +617,7 @@ public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, if (!shouldPause) { - Trace.Info($"Step starting (not pausing): {step.DisplayName}"); + Trace.Info("Step starting without debugger pause"); return; } @@ -627,7 +626,7 @@ public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, ? $"Stopped at job entry: {step.DisplayName}" : $"Stopped before step: {step.DisplayName}"; - Trace.Info($"Step starting: {step.DisplayName} (reason: {reason})"); + Trace.Info("Step starting with debugger pause"); // Send stopped event to debugger (only if client is connected) SendStoppedEvent(reason, description); @@ -639,7 +638,7 @@ public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, public void OnStepCompleted(IStep step) { var result = step.ExecutionContext?.Result; - Trace.Info($"Step completed: {step.DisplayName}, result: {result}"); + Trace.Info("Step completed"); // Add to completed steps list for stack trace lock (_stateLock) @@ -797,7 +796,7 @@ private async Task WaitForCommandAsync(CancellationToken cancellationToken) { var command = await _commandTcs.Task; - Trace.Info($"Received command: {command}"); + Trace.Info("Received debugger command"); lock (_stateLock) { @@ -857,7 +856,7 @@ private void SendStoppedEvent(string reason, string description) { if (!_isClientConnected) { - Trace.Info($"No client connected, deferring stopped event: {description}"); + Trace.Info("No client connected, deferring stopped event"); return; } diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index 2a8e634240a..e0a746f7ea0 100644 --- a/src/Runner.Worker/Dap/DapReplExecutor.cs +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -56,7 +56,7 @@ public async Task ExecuteRunCommandAsync( } catch (Exception ex) { - _trace.Error($"REPL run command failed: {ex}"); + _trace.Error($"REPL run command failed ({ex.GetType().Name})"); var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message); return ErrorResult($"Command failed: {maskedError}"); } @@ -87,7 +87,7 @@ private async Task ExecuteScriptAsync( argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand); } - _trace.Info($"REPL shell: {shellCommand}, argFormat: {argFormat}"); + _trace.Info("Resolved REPL shell"); // 2. Expand ${{ }} expressions in the script body, just like // ActionRunner evaluates step inputs before ScriptHandler sees them @@ -142,7 +142,7 @@ private async Task ExecuteScriptAsync( workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work); } - _trace.Info($"REPL executing: {commandPath} {arguments} (cwd: {workingDirectory})"); + _trace.Info("Executing REPL command"); // Stream execution info to debugger SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n"); @@ -254,7 +254,7 @@ internal string ExpandExpressions(string input, IExecutionContext context) } catch (Exception ex) { - _trace.Warning($"Expression expansion failed for '{expr}': {ex.Message}"); + _trace.Warning($"Expression expansion failed ({ex.GetType().Name})"); // Keep the original expression literal on failure result.Append(input, start, end - start); } @@ -277,7 +277,7 @@ internal string ResolveDefaultShell(IExecutionContext context) runDefaults.TryGetValue("shell", out var defaultShell) && !string.IsNullOrEmpty(defaultShell)) { - _trace.Info($"Using job default shell: {defaultShell}"); + _trace.Info("Using job default shell"); return defaultShell; } diff --git a/src/Runner.Worker/Dap/DapServer.cs b/src/Runner.Worker/Dap/DapServer.cs index f45de1ca485..c10094501c8 100644 --- a/src/Runner.Worker/Dap/DapServer.cs +++ b/src/Runner.Worker/Dap/DapServer.cs @@ -11,7 +11,7 @@ namespace GitHub.Runner.Worker.Dap { /// - /// Production TCP server for the Debug Adapter Protocol. + /// TCP server for the Debug Adapter Protocol. /// Handles Content-Length message framing, JSON serialization, /// client reconnection, and graceful shutdown. /// @@ -116,7 +116,7 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken) } catch (Exception ex) { - Trace.Warning($"Connection error: {ex.Message}"); + Trace.Warning($"Connection error ({ex.GetType().Name})"); CleanupConnection(); if (!_acceptConnections || cancellationToken.IsCancellationRequested) @@ -219,11 +219,11 @@ private async Task ProcessMessagesAsync(CancellationToken cancellationToken) } catch (IOException ex) { - Trace.Info($"Connection closed: {ex.Message}"); + Trace.Info($"Connection closed ({ex.GetType().Name})"); } catch (Exception ex) { - Trace.Error($"Error in message loop: {ex}"); + Trace.Error($"Error in message loop ({ex.GetType().Name})"); } Trace.Info("DAP message processing loop ended"); @@ -237,11 +237,11 @@ private async Task ProcessSingleMessageAsync(string json, CancellationToken canc request = JsonConvert.DeserializeObject(json); if (request == null || request.Type != "request") { - Trace.Warning($"Received non-request message: {json}"); + Trace.Warning("Received DAP message that was not a request"); return; } - Trace.Info($"Received request: seq={request.Seq}, command={request.Command}"); + Trace.Info("Received DAP request"); if (_session == null) { @@ -256,11 +256,11 @@ private async Task ProcessSingleMessageAsync(string json, CancellationToken canc } catch (JsonException ex) { - Trace.Error($"Failed to parse request: {ex.Message}"); + Trace.Error($"Failed to parse request ({ex.GetType().Name})"); } catch (Exception ex) { - Trace.Error($"Error processing request: {ex}"); + Trace.Error($"Error processing request ({ex.GetType().Name})"); if (request != null) { SendErrorResponse(request, ex.Message); @@ -345,7 +345,7 @@ private async Task ReadMessageAsync(CancellationToken cancellationToken) } var json = Encoding.UTF8.GetString(buffer); - Trace.Verbose($"Received: {json}"); + Trace.Verbose("Received DAP message body"); return json; } @@ -414,7 +414,7 @@ private void SendMessageInternal(ProtocolMessage message) _stream.Write(bodyBytes, 0, bodyBytes.Length); _stream.Flush(); - Trace.Verbose($"Sent: {json}"); + Trace.Verbose("Sent DAP message"); } public void SendMessage(ProtocolMessage message) @@ -438,7 +438,7 @@ public void SendMessage(ProtocolMessage message) } catch (Exception ex) { - Trace.Warning($"Failed to send message: {ex.Message}"); + Trace.Warning($"Failed to send message ({ex.GetType().Name})"); } } @@ -451,7 +451,7 @@ public void SendEvent(Event evt) { if (_stream == null) { - Trace.Warning($"Cannot send event '{evt.EventType}': no client connected"); + Trace.Warning("Cannot send event: no client connected"); return; } evt.Seq = _nextSeq++; @@ -461,11 +461,11 @@ public void SendEvent(Event evt) { _sendLock.Release(); } - Trace.Info($"Sent event: {evt.EventType}"); + Trace.Info("Sent event"); } catch (Exception ex) { - Trace.Warning($"Failed to send event '{evt.EventType}': {ex.Message}"); + Trace.Warning($"Failed to send event ({ex.GetType().Name})"); } } @@ -478,7 +478,7 @@ public void SendResponse(Response response) { if (_stream == null) { - Trace.Warning($"Cannot send response for '{response.Command}': no client connected"); + Trace.Warning("Cannot send response: no client connected"); return; } response.Seq = _nextSeq++; @@ -488,11 +488,11 @@ public void SendResponse(Response response) { _sendLock.Release(); } - Trace.Info($"Sent response: seq={response.Seq}, command={response.Command}, success={response.Success}"); + Trace.Info("Sent response"); } catch (Exception ex) { - Trace.Warning($"Failed to send response for '{response.Command}': {ex.Message}"); + Trace.Warning($"Failed to send response ({ex.GetType().Name})"); } } } diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs index 63e6779fb5d..bd8950e92e7 100644 --- a/src/Test/L0/Worker/DapReplExecutorL0.cs +++ b/src/Test/L0/Worker/DapReplExecutorL0.cs @@ -124,14 +124,18 @@ public void ExpandExpressions_UnterminatedExpression_KeepsLiteral() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public void ResolveDefaultShell_NoJobDefaults_ReturnsSh() + public void ResolveDefaultShell_NoJobDefaults_ReturnsPlatformDefault() { using (CreateTestContext()) { var context = CreateMockContext(); var result = _executor.ResolveDefaultShell(context.Object); +#if OS_WINDOWS + Assert.True(result == "pwsh" || result == "powershell"); +#else Assert.Equal("sh", result); +#endif } } diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs index 903389e5561..f91e5fa997a 100644 --- a/src/Test/L0/Worker/DapServerL0.cs +++ b/src/Test/L0/Worker/DapServerL0.cs @@ -164,9 +164,10 @@ public async Task StartAndStopMultipleTimesDoesNotThrow() var cts1 = new CancellationTokenSource(); await _server.StartAsync(0, cts1.Token); await _server.StopAsync(); + } - _server = new DapServer(); - _server.Initialize(CreateTestContext()); + using (CreateTestContext($"{nameof(StartAndStopMultipleTimesDoesNotThrow)}_SecondStart")) + { var cts2 = new CancellationTokenSource(); await _server.StartAsync(0, cts2.Token); await _server.StopAsync(); @@ -194,8 +195,10 @@ public async Task MessageFraming_ValidMessage_ProcessedSuccessfully() var listener = (TcpListener)listenerField.GetValue(_server); var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var connectionTask = _server.WaitForConnectionAsync(cts.Token); using var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, port); + await connectionTask; var stream = client.GetStream(); // Send a valid DAP request with Content-Length framing @@ -237,8 +240,10 @@ public async Task ProtocolMetadata_PreservedWhenSecretCollidesWithKeywords() var listener = (TcpListener)listenerField.GetValue(_server); var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var connectionTask = _server.WaitForConnectionAsync(cts.Token); using var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, port); + await connectionTask; var stream = client.GetStream(); // Send a response whose protocol fields collide with secrets @@ -288,8 +293,10 @@ public async Task ProtocolMetadata_EventFieldsPreservedWhenSecretCollidesWithKey var listener = (TcpListener)listenerField.GetValue(_server); var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var connectionTask = _server.WaitForConnectionAsync(cts.Token); using var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, port); + await connectionTask; var stream = client.GetStream(); _server.SendEvent(new Event From 7f39d40e383f792f778e1ba74edc601c1ea42734 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 16 Mar 2026 09:18:06 +0000 Subject: [PATCH 29/42] Update src/Runner.Worker/JobRunner.cs Co-authored-by: Tingluo Huang --- src/Runner.Worker/JobRunner.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 944f2c4aeae..db716839347 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -343,7 +343,8 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat } catch (Exception ex) { - Trace.Warning($"Error stopping DAP server: {ex.Message}"); + Trace.Error($"Error stopping DAP server"); + Trace.Error(ex); } } From 4bf2b29367a46377042b12a4b7d05ba5d4c76c17 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 16 Mar 2026 10:48:54 +0000 Subject: [PATCH 30/42] Add DapDebugger facade --- src/Runner.Worker/Dap/DapDebugger.cs | 161 ++++++++++ src/Runner.Worker/Dap/IDapDebugger.cs | 19 ++ src/Runner.Worker/JobRunner.cs | 99 ++---- src/Runner.Worker/StepsRunner.cs | 39 +-- src/Test/L0/Worker/DapDebuggerL0.cs | 442 ++++++++++++++++++++++++++ 5 files changed, 661 insertions(+), 99 deletions(-) create mode 100644 src/Runner.Worker/Dap/DapDebugger.cs create mode 100644 src/Runner.Worker/Dap/IDapDebugger.cs create mode 100644 src/Test/L0/Worker/DapDebuggerL0.cs diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs new file mode 100644 index 00000000000..bf8f5cf925c --- /dev/null +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -0,0 +1,161 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Single public facade for the Debug Adapter Protocol subsystem. + /// Owns the DapServer and DapDebugSession internally; external callers + /// (JobRunner, StepsRunner) interact only with this class. + /// + public sealed class DapDebugger : RunnerService, IDapDebugger + { + private const int DefaultPort = 4711; + private const string PortEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT"; + + private IDapServer _server; + private IDapDebugSession _session; + private CancellationTokenRegistration? _cancellationRegistration; + private volatile bool _started; + + public bool IsActive => _session?.IsActive == true; + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + Trace.Info("DapDebugger initialized"); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var port = ResolvePort(); + + _server = HostContext.GetService(); + _session = HostContext.GetService(); + + _server.SetSession(_session); + _session.SetDapServer(_server); + + await _server.StartAsync(port, cancellationToken); + _started = true; + + Trace.Info($"DAP debugger started on port {port}"); + } + + public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) + { + if (!_started || _server == null || _session == null) + { + return; + } + + Trace.Info("Waiting for debugger client connection..."); + await _server.WaitForConnectionAsync(cancellationToken); + Trace.Info("Debugger client connected."); + + await _session.WaitForHandshakeAsync(cancellationToken); + Trace.Info("DAP handshake complete."); + + _cancellationRegistration = cancellationToken.Register(() => + { + Trace.Info("Job cancellation requested, cancelling debug session."); + _session.CancelSession(); + }); + } + + public async Task StopAsync() + { + if (_cancellationRegistration.HasValue) + { + _cancellationRegistration.Value.Dispose(); + _cancellationRegistration = null; + } + + if (_server != null && _started) + { + try + { + Trace.Info("Stopping DAP debugger"); + await _server.StopAsync(); + } + catch (Exception ex) + { + Trace.Error("Error stopping DAP debugger"); + Trace.Error(ex); + } + } + + _started = false; + } + + public void CancelSession() + { + _session?.CancelSession(); + } + + public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken) + { + if (!IsActive) + { + return; + } + + try + { + await _session.OnStepStartingAsync(step, jobContext, isFirstStep, cancellationToken); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepStarting error: {ex.Message}"); + } + } + + public void OnStepCompleted(IStep step) + { + if (!IsActive) + { + return; + } + + try + { + _session.OnStepCompleted(step); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepCompleted error: {ex.Message}"); + } + } + + public void OnJobCompleted() + { + if (!IsActive) + { + return; + } + + try + { + _session.OnJobCompleted(); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnJobCompleted error: {ex.Message}"); + } + } + + private 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; + } + } +} diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs new file mode 100644 index 00000000000..f753b6f7ce8 --- /dev/null +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Worker.Dap +{ + [ServiceLocator(Default = typeof(DapDebugger))] + public interface IDapDebugger : IRunnerService + { + bool IsActive { get; } + Task StartAsync(CancellationToken cancellationToken); + Task WaitUntilReadyAsync(CancellationToken cancellationToken); + Task StopAsync(); + void CancelSession(); + Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken); + void OnStepCompleted(IStep step); + void OnJobCompleted(); + } +} diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index db716839347..4abf403172e 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -113,9 +113,7 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat IExecutionContext jobContext = null; CancellationTokenRegistration? runnerShutdownRegistration = null; - IDapServer dapServer = null; - IDapDebugSession debugSession = null; - CancellationTokenRegistration? dapCancellationRegistration = null; + IDapDebugger dapDebugger = null; try { // Create the job execution context. @@ -125,36 +123,6 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat jobContext.Start(); jobContext.Debug($"Starting: {message.JobDisplayName}"); - if (jobContext.Global.EnableDebugger) - { - Trace.Info("Debugger enabled for this job run"); - - try - { - var port = 4711; - var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT"); - if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort)) - { - port = customPort; - } - - dapServer = HostContext.GetService(); - debugSession = HostContext.GetService(); - - dapServer.SetSession(debugSession); - debugSession.SetDapServer(dapServer); - - await dapServer.StartAsync(port, jobRequestCancellationToken); - Trace.Info($"DAP server started on port {port}, listening for debugger client"); - } - catch (Exception ex) - { - Trace.Warning($"Failed to start DAP server: {ex.Message}. Job will continue without debugging."); - dapServer = null; - debugSession = null; - } - } - runnerShutdownRegistration = HostContext.RunnerShutdownToken.Register(() => { // log an issue, then runner get shutdown by Ctrl-C or Ctrl-Break. @@ -212,6 +180,27 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat _tempDirectoryManager = HostContext.GetService(); _tempDirectoryManager.InitializeTempDirectory(jobContext); + // Setup the debugger + if (jobContext.Global.EnableDebugger) + { + Trace.Info("Debugger enabled for this job run"); + + try + { + dapDebugger = HostContext.GetService(); + await dapDebugger.StartAsync(jobRequestCancellationToken); + } + catch (Exception ex) + { + Trace.Warning($"Failed to start DAP debugger: {ex.Message}. Job will continue without debugging."); + + // cleanup if debugger failed to start + try { await dapDebugger?.StopAsync(); } catch { } + dapDebugger = null; + } + } + + // Get the job extension. Trace.Info("Getting job extension."); IJobExtension jobExtension = HostContext.CreateService(); @@ -255,36 +244,23 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat // Wait for DAP debugger client connection and handshake after "Set up job" // so the job page shows the setup step before we block on the debugger - if (dapServer != null && debugSession != null) + if (dapDebugger != null) { try { - Trace.Info("Waiting for debugger client connection..."); - await dapServer.WaitForConnectionAsync(jobRequestCancellationToken); - Trace.Info("Debugger client connected."); - - await debugSession.WaitForHandshakeAsync(jobRequestCancellationToken); - Trace.Info("DAP handshake complete."); - - dapCancellationRegistration = jobRequestCancellationToken.Register(() => - { - Trace.Info("Job cancellation requested, cancelling debug session."); - debugSession.CancelSession(); - }); + await dapDebugger.WaitUntilReadyAsync(jobRequestCancellationToken); } catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) { Trace.Info("Job was cancelled before debugger client connected. Continuing without debugger."); - try { await dapServer.StopAsync(); } catch { } - dapServer = null; - debugSession = null; + try { await dapDebugger.StopAsync(); } catch { } + dapDebugger = null; } catch (Exception ex) { Trace.Warning($"Failed to complete DAP handshake: {ex.Message}. Job will continue without debugging."); - try { await dapServer.StopAsync(); } catch { } - dapServer = null; - debugSession = null; + try { await dapDebugger.StopAsync(); } catch { } + dapDebugger = null; } } @@ -328,24 +304,9 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat runnerShutdownRegistration = null; } - if (dapCancellationRegistration.HasValue) + if (dapDebugger != null) { - dapCancellationRegistration.Value.Dispose(); - dapCancellationRegistration = null; - } - - if (dapServer != null) - { - try - { - Trace.Info("Stopping DAP server"); - await dapServer.StopAsync(); - } - catch (Exception ex) - { - Trace.Error($"Error stopping DAP server"); - Trace.Error(ex); - } + await dapDebugger.StopAsync(); } await ShutdownQueue(throwOnFailure: false); diff --git a/src/Runner.Worker/StepsRunner.cs b/src/Runner.Worker/StepsRunner.cs index 7e639789bee..c7309be82fb 100644 --- a/src/Runner.Worker/StepsRunner.cs +++ b/src/Runner.Worker/StepsRunner.cs @@ -51,14 +51,14 @@ public async Task RunAsync(IExecutionContext jobContext) jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult(); var scopeInputs = new Dictionary(StringComparer.OrdinalIgnoreCase); bool checkPostJobActions = false; - IDapDebugSession debugSession = null; + IDapDebugger dapDebugger = null; try { - debugSession = HostContext.GetService(); + dapDebugger = HostContext.GetService(); } catch { - // Debug session not available — continue without debugging + // Debugger not available — continue without debugging } bool isFirstStep = true; while (jobContext.JobSteps.Count > 0 || !checkPostJobActions) @@ -238,16 +238,9 @@ public async Task RunAsync(IExecutionContext jobContext) else { // Pause for DAP debugger before step execution - if (debugSession?.IsActive == true) + if (dapDebugger != null) { - try - { - await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken); - } - catch (Exception ex) - { - Trace.Warning($"DAP OnStepStarting error: {ex.Message}"); - } + await dapDebugger.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken); isFirstStep = false; } @@ -255,16 +248,9 @@ public async Task RunAsync(IExecutionContext jobContext) await RunStepAsync(step, jobContext.CancellationToken); CompleteStep(step); - if (debugSession?.IsActive == true) + if (dapDebugger != null) { - try - { - debugSession.OnStepCompleted(step); - } - catch (Exception ex) - { - Trace.Warning($"DAP OnStepCompleted error: {ex.Message}"); - } + dapDebugger.OnStepCompleted(step); } } } @@ -293,16 +279,9 @@ public async Task RunAsync(IExecutionContext jobContext) Trace.Info($"Current state: job state = '{jobContext.Result}'"); } - if (debugSession?.IsActive == true) + if (dapDebugger != null) { - try - { - debugSession.OnJobCompleted(); - } - catch (Exception ex) - { - Trace.Warning($"DAP OnJobCompleted error: {ex.Message}"); - } + dapDebugger.OnJobCompleted(); } } diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs new file mode 100644 index 00000000000..61bc470c95a --- /dev/null +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -0,0 +1,442 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapDebuggerL0 + { + private DapDebugger _debugger; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + var hc = new TestHostContext(this, testName); + _debugger = new DapDebugger(); + _debugger.Initialize(hc); + return hc; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeSucceeds() + { + using (CreateTestContext()) + { + Assert.NotNull(_debugger); + Assert.False(_debugger.IsActive); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartAndStopLifecycle() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + mockServer.Verify(x => x.SetSession(mockSession.Object), Times.Once); + mockSession.Verify(x => x.SetDapServer(mockServer.Object), Times.Once); + mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once); + + await _debugger.StopAsync(); + mockServer.Verify(x => x.StopAsync(), Times.Once); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartUsesCustomPortFromEnvironment() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "9999"); + try + { + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + mockServer.Verify(x => x.StartAsync(9999, cts.Token), Times.Once); + + await _debugger.StopAsync(); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", null); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartIgnoresInvalidPortFromEnvironment() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "not-a-number"); + try + { + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + // Falls back to default port + mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once); + + await _debugger.StopAsync(); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", null); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartIgnoresOutOfRangePortFromEnvironment() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "99999"); + try + { + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + // Falls back to default port + mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once); + + await _debugger.StopAsync(); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", null); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyCallsServerAndSession() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + await _debugger.WaitUntilReadyAsync(cts.Token); + + mockServer.Verify(x => x.WaitForConnectionAsync(cts.Token), Times.Once); + mockSession.Verify(x => x.WaitForHandshakeAsync(cts.Token), Times.Once); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyRegistersCancellation() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + await _debugger.WaitUntilReadyAsync(cts.Token); + + // Trigger cancellation — should call CancelSession on the session + cts.Cancel(); + mockSession.Verify(x => x.CancelSession(), Times.Once); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StopWithoutStartDoesNotThrow() + { + using (CreateTestContext()) + { + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepStartingDelegatesWhenActive() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.IsActive).Returns(true); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + var mockStep = new Mock(); + var mockJobContext = new Mock(); + + await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None); + + mockSession.Verify(x => x.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None), Times.Once); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepStartingSkipsWhenNotActive() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.IsActive).Returns(false); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + var mockStep = new Mock(); + var mockJobContext = new Mock(); + + await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None); + + mockSession.Verify(x => x.OnStepStartingAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepCompletedDelegatesWhenActive() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.IsActive).Returns(true); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + var mockStep = new Mock(); + _debugger.OnStepCompleted(mockStep.Object); + + mockSession.Verify(x => x.OnStepCompleted(mockStep.Object), Times.Once); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobCompletedDelegatesWhenActive() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.IsActive).Returns(true); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + _debugger.OnJobCompleted(); + + mockSession.Verify(x => x.OnJobCompleted(), Times.Once); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepStartingSwallowsSessionException() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.IsActive).Returns(true); + mockSession.Setup(x => x.OnStepStartingAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("test error")); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + var mockStep = new Mock(); + var mockJobContext = new Mock(); + + // Should not throw + await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CancelSessionDelegatesToSession() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + var mockSession = new Mock(); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + // CancelSession before start should not throw + _debugger.CancelSession(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyBeforeStartIsNoOp() + { + using (CreateTestContext()) + { + // Should not throw or block + await _debugger.WaitUntilReadyAsync(CancellationToken.None); + } + } + } +} From b36d9a6c926f7ca06618079fbf3d8c8e556b4fde Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 16 Mar 2026 11:17:24 +0000 Subject: [PATCH 31/42] PR feedback --- src/Runner.Worker/Dap/DapDebugSession.cs | 7 +++--- src/Runner.Worker/JobRunner.cs | 31 ++++++++++++++++-------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index bdf5e85d219..9f6b044a168 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -192,10 +192,11 @@ private Response HandleInitialize(Request request) var capabilities = new Capabilities { SupportsConfigurationDoneRequest = true, + SupportsEvaluateForHovers = true, + // All other capabilities are false for MVP SupportsFunctionBreakpoints = false, SupportsConditionalBreakpoints = false, - SupportsEvaluateForHovers = true, SupportsStepBack = false, SupportsSetVariable = false, SupportsRestartFrame = false, @@ -880,9 +881,7 @@ private string MaskUserVisibleText(string value) return value ?? string.Empty; } - return _variableProvider?.MaskSecrets(value) - ?? HostContext?.SecretMasker?.MaskSecrets(value) - ?? value; + return HostContext?.SecretMasker?.MaskSecrets(value); } /// diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 4abf403172e..9867eb251cd 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -195,7 +195,13 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat Trace.Warning($"Failed to start DAP debugger: {ex.Message}. Job will continue without debugging."); // cleanup if debugger failed to start - try { await dapDebugger?.StopAsync(); } catch { } + try { + await dapDebugger.StopAsync(); + } + catch { + Trace.Error("Failed to stop debugger server") + Trace.Error(ex); + } dapDebugger = null; } } @@ -250,16 +256,15 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat { await dapDebugger.WaitUntilReadyAsync(jobRequestCancellationToken); } - catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) - { - Trace.Info("Job was cancelled before debugger client connected. Continuing without debugger."); - try { await dapDebugger.StopAsync(); } catch { } - dapDebugger = null; - } catch (Exception ex) { - Trace.Warning($"Failed to complete DAP handshake: {ex.Message}. Job will continue without debugging."); - try { await dapDebugger.StopAsync(); } catch { } + try { + await dapDebugger.StopAsync(); + } + catch { + Trace.Error("Failed to stop debugger server") + Trace.Error(ex); + } dapDebugger = null; } } @@ -306,7 +311,13 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat if (dapDebugger != null) { - await dapDebugger.StopAsync(); + try { + await dapDebugger.StopAsync(); + } + catch { + Trace.Error("Failed to stop debugger server") + Trace.Error(ex); + } } await ShutdownQueue(throwOnFailure: false); From 437e20db7d7aec949a014685e918f9dc0e4cde13 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 16 Mar 2026 11:48:46 +0000 Subject: [PATCH 32/42] Fail the job if no connection in 15 minutes --- src/Runner.Worker/Dap/DapDebugger.cs | 37 +++- src/Runner.Worker/JobRunner.cs | 45 +++-- src/Test/L0/Worker/DapDebuggerL0.cs | 248 ++++++++++++++++++++++++++- 3 files changed, 299 insertions(+), 31 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index bf8f5cf925c..b4d515865eb 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -13,7 +13,9 @@ 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 IDapServer _server; private IDapDebugSession _session; @@ -51,12 +53,23 @@ public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) return; } - Trace.Info("Waiting for debugger client connection..."); - await _server.WaitForConnectionAsync(cancellationToken); - Trace.Info("Debugger client connected."); + var timeoutMinutes = ResolveTimeout(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(timeoutMinutes)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - await _session.WaitForHandshakeAsync(cancellationToken); - Trace.Info("DAP handshake complete."); + try + { + Trace.Info($"Waiting for debugger client connection (timeout: {timeoutMinutes} minutes)..."); + await _server.WaitForConnectionAsync(linkedCts.Token); + Trace.Info("Debugger client connected."); + + await _session.WaitForHandshakeAsync(linkedCts.Token); + Trace.Info("DAP handshake complete."); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"No debugger client connected within {timeoutMinutes} minutes."); + } _cancellationRegistration = cancellationToken.Register(() => { @@ -149,7 +162,7 @@ public void OnJobCompleted() private int ResolvePort() { var portEnv = Environment.GetEnvironmentVariable(PortEnvironmentVariable); - if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 1024 && customPort <= 65535) + if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 0 && customPort <= 65535) { Trace.Info($"Using custom DAP port {customPort} from {PortEnvironmentVariable}"); return customPort; @@ -157,5 +170,17 @@ private int ResolvePort() return DefaultPort; } + + private int ResolveTimeout() + { + var timeoutEnv = Environment.GetEnvironmentVariable(TimeoutEnvironmentVariable); + if (!string.IsNullOrEmpty(timeoutEnv) && int.TryParse(timeoutEnv, out var customTimeout) && customTimeout > 0) + { + Trace.Info($"Using custom DAP timeout {customTimeout} minutes from {TimeoutEnvironmentVariable}"); + return customTimeout; + } + + return DefaultTimeoutMinutes; + } } } diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 9867eb251cd..4d7ac04a5f4 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -192,15 +192,10 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat } catch (Exception ex) { - Trace.Warning($"Failed to start DAP debugger: {ex.Message}. Job will continue without debugging."); - - // cleanup if debugger failed to start - try { - await dapDebugger.StopAsync(); - } - catch { - Trace.Error("Failed to stop debugger server") - Trace.Error(ex); + Trace.Error($"Failed to start DAP debugger: {ex.Message}"); + if (dapDebugger != null) + { + try { await dapDebugger.StopAsync(); } catch { } } dapDebugger = null; } @@ -256,19 +251,29 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat { await dapDebugger.WaitUntilReadyAsync(jobRequestCancellationToken); } + catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) + { + Trace.Info("Job was cancelled before debugger client connected."); + try { await dapDebugger.StopAsync(); } catch { } + dapDebugger = null; + return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled); + } catch (Exception ex) { - try { - await dapDebugger.StopAsync(); - } - catch { - Trace.Error("Failed to stop debugger server") - Trace.Error(ex); - } + Trace.Error($"DAP debugger failed to become ready: {ex.Message}"); + try { await dapDebugger.StopAsync(); } catch { } dapDebugger = null; } } + // If debugging was requested but the debugger is not available, fail the job + if (jobContext.Global.EnableDebugger && dapDebugger == null) + { + var errorMessage = "The debugger failed to start or no debugger client connected in time."; + jobContext.Error(errorMessage); + return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); + } + // Run all job steps Trace.Info("Run all job steps."); var stepsRunner = HostContext.GetService(); @@ -311,13 +316,7 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat if (dapDebugger != null) { - try { - await dapDebugger.StopAsync(); - } - catch { - Trace.Error("Failed to stop debugger server") - Trace.Error(ex); - } + await dapDebugger.StopAsync(); } await ShutdownQueue(throwOnFailure: false); diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index 61bc470c95a..4838aaf4620 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -196,8 +196,8 @@ public async Task WaitUntilReadyCallsServerAndSession() await _debugger.StartAsync(cts.Token); await _debugger.WaitUntilReadyAsync(cts.Token); - mockServer.Verify(x => x.WaitForConnectionAsync(cts.Token), Times.Once); - mockSession.Verify(x => x.WaitForHandshakeAsync(cts.Token), Times.Once); + mockServer.Verify(x => x.WaitForConnectionAsync(It.IsAny()), Times.Once); + mockSession.Verify(x => x.WaitForHandshakeAsync(It.IsAny()), Times.Once); await _debugger.StopAsync(); } @@ -438,5 +438,249 @@ public async Task WaitUntilReadyBeforeStartIsNoOp() await _debugger.WaitUntilReadyAsync(CancellationToken.None); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyPassesLinkedTokenNotOriginal() + { + using (var hc = CreateTestContext()) + { + CancellationToken capturedToken = default; + + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Callback(ct => capturedToken = ct) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + await _debugger.WaitUntilReadyAsync(cts.Token); + + // The token passed to WaitForConnectionAsync should be a linked token + // (combines job cancellation + internal timeout), not the raw job token + Assert.NotEqual(cts.Token, capturedToken); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyTimeoutSurfacesAsTimeoutException() + { + using (var hc = CreateTestContext()) + { + // Mock WaitForConnectionAsync to block until its cancellation token fires, + // then throw OperationCanceledException — simulating "no client connected" + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Returns(async ct => + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ct.Register(() => tcs.TrySetCanceled(ct)); + await tcs.Task; + }); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var jobCts = new CancellationTokenSource(); + await _debugger.StartAsync(jobCts.Token); + + // Start wait in background + var waitTask = _debugger.WaitUntilReadyAsync(jobCts.Token); + await Task.Delay(50); + Assert.False(waitTask.IsCompleted); + + // The linked token includes the internal timeout CTS. + // We can't easily make it fire fast (it uses minutes), but we can + // verify the contract: cancelling the job token produces OCE, not TimeoutException. + jobCts.Cancel(); + var ex = await Assert.ThrowsAnyAsync(() => waitTask); + Assert.IsNotType(ex); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyUsesCustomTimeoutFromEnvironment() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "30"); + try + { + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + // The timeout is applied internally — we can verify it worked + // by checking the trace output contains the custom value + await _debugger.WaitUntilReadyAsync(cts.Token); + + // If we got here without exception, the custom timeout was accepted + // (it didn't default to something that would fail) + await _debugger.StopAsync(); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", null); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyIgnoresInvalidTimeoutFromEnvironment() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "not-a-number"); + try + { + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + await _debugger.WaitUntilReadyAsync(cts.Token); + + // Should succeed with default timeout (no crash from bad env var) + await _debugger.StopAsync(); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", null); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyIgnoresZeroTimeoutFromEnvironment() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "0"); + try + { + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + await _debugger.WaitUntilReadyAsync(cts.Token); + + // Zero is not > 0, so falls back to default (should succeed, not throw) + await _debugger.StopAsync(); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", null); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledException() + { + using (var hc = CreateTestContext()) + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Returns(ct => + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ct.Register(() => tcs.TrySetCanceled(ct)); + return tcs.Task; + }); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + var mockSession = new Mock(); + + hc.SetSingleton(mockServer.Object); + hc.SetSingleton(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _debugger.StartAsync(cts.Token); + + var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); + await Task.Delay(50); + + // Cancel the job token — should surface as OperationCanceledException, NOT TimeoutException + cts.Cancel(); + var ex = await Assert.ThrowsAnyAsync(() => waitTask); + Assert.IsNotType(ex); + + await _debugger.StopAsync(); + } + } } } From f9919b2ba4cb91a8710729e46c43a32da06b00e6 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 16 Mar 2026 13:51:18 +0000 Subject: [PATCH 33/42] PR Feedback --- src/Runner.Worker/Dap/DapReplParser.cs | 66 ++++++++++---------- src/Runner.Worker/Dap/DapVariableProvider.cs | 6 +- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/src/Runner.Worker/Dap/DapReplParser.cs b/src/Runner.Worker/Dap/DapReplParser.cs index b6385c34df2..c23f4c181a2 100644 --- a/src/Runner.Worker/Dap/DapReplParser.cs +++ b/src/Runner.Worker/Dap/DapReplParser.cs @@ -84,42 +84,44 @@ internal static DapReplCommand TryParse(string input, out string error) internal static string GetGeneralHelp() { - var sb = new StringBuilder(); - sb.AppendLine("Actions Debug Console"); - sb.AppendLine(); - sb.AppendLine("Commands:"); - sb.AppendLine(" help Show this help"); - sb.AppendLine(" help(\"run\") Show help for the run command"); - sb.AppendLine(" run(\"script\") Execute a script (like a workflow run step)"); - sb.AppendLine(); - sb.AppendLine("Anything else is evaluated as a GitHub Actions expression."); - sb.AppendLine(" Example: github.repository"); - sb.AppendLine(" Example: ${{ github.event_name }}"); - return sb.ToString(); + return """ + Actions Debug Console + + Commands: + help Show this help + help("run") Show help for the run command + run("script") Execute a script (like a workflow run step) + + Anything else is evaluated as a GitHub Actions expression. + Example: github.repository + Example: ${{ github.event_name }} + + """; } internal static string GetRunHelp() { - var sb = new StringBuilder(); - sb.AppendLine("run command — execute a script in the job context"); - sb.AppendLine(); - sb.AppendLine("Usage:"); - sb.AppendLine(" run(\"echo hello\")"); - sb.AppendLine(" run(\"echo $FOO\", shell: \"bash\")"); - sb.AppendLine(" run(\"echo $FOO\", env: { FOO: \"bar\" })"); - sb.AppendLine(" run(\"ls\", working_directory: \"/tmp\")"); - sb.AppendLine(" run(\"echo $X\", shell: \"bash\", env: { X: \"1\" }, working_directory: \"/tmp\")"); - sb.AppendLine(); - sb.AppendLine("Options:"); - sb.AppendLine(" shell: Shell to use (default: job default, e.g. bash)"); - sb.AppendLine(" env: Extra environment variables as { KEY: \"value\" }"); - sb.AppendLine(" working_directory: Working directory for the command"); - sb.AppendLine(); - sb.AppendLine("Behavior:"); - sb.AppendLine(" - Equivalent to a workflow `run:` step"); - sb.AppendLine(" - Expressions in the script body are expanded (${{ ... }})"); - sb.AppendLine(" - Output is streamed in real time and secrets are masked"); - return sb.ToString(); + return """ + run command — execute a script in the job context + + Usage: + run("echo hello") + run("echo $FOO", shell: "bash") + run("echo $FOO", env: { FOO: "bar" }) + run("ls", working_directory: "/tmp") + run("echo $X", shell: "bash", env: { X: "1" }, working_directory: "/tmp") + + Options: + shell: Shell to use (default: job default, e.g. bash) + env: Extra environment variables as { KEY: "value" } + working_directory: Working directory for the command + + Behavior: + - Equivalent to a workflow `run:` step + - Expressions in the script body are expanded (${{ ... }}) + - Output is streamed in real time and secrets are masked + + """; } #region Parsers diff --git a/src/Runner.Worker/Dap/DapVariableProvider.cs b/src/Runner.Worker/Dap/DapVariableProvider.cs index 1ee49d5f1b7..ced6c9d5fff 100644 --- a/src/Runner.Worker/Dap/DapVariableProvider.cs +++ b/src/Runner.Worker/Dap/DapVariableProvider.cs @@ -10,7 +10,7 @@ namespace GitHub.Runner.Worker.Dap /// Maps runner execution context data to DAP scopes and variables. /// /// This is the single point where runner context values are materialized - /// for the debugger. All string values pass through the runner's existing + /// for the debugger. All values pass through the runner's existing /// so the DAP /// surface never exposes anything beyond what a normal CI log would show. /// @@ -332,13 +332,13 @@ private Variable CreateVariable( break; case NumberContextData num: - variable.Value = num.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture); + variable.Value = MaskSecrets(num.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture)); variable.Type = "number"; variable.VariablesReference = 0; break; case BooleanContextData boolVal: - variable.Value = boolVal.Value ? "true" : "false"; + variable.Value = MaskSecrets(boolVal.Value ? "true" : "false"); variable.Type = "boolean"; variable.VariablesReference = 0; break; From e3c0a6e1191732c1aebdbdffc047e0f4dd663c8d Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Tue, 17 Mar 2026 08:55:27 +0000 Subject: [PATCH 34/42] PR Feedback --- src/Runner.Worker/Dap/DapDebugger.cs | 41 ++++++++++++++------------- src/Runner.Worker/Dap/IDapDebugger.cs | 4 +-- src/Runner.Worker/JobRunner.cs | 30 ++++++-------------- src/Runner.Worker/StepsRunner.cs | 26 ++--------------- src/Test/L0/Worker/DapDebuggerL0.cs | 11 ++++--- src/Test/L0/Worker/JobExtensionL0.cs | 6 ++++ src/Test/L0/Worker/StepsRunnerL0.cs | 6 ++++ 7 files changed, 53 insertions(+), 71 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index b4d515865eb..aa2048e7d84 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -21,6 +21,7 @@ public sealed class DapDebugger : RunnerService, IDapDebugger private IDapDebugSession _session; private CancellationTokenRegistration? _cancellationRegistration; private volatile bool _started; + private bool _isFirstStep = true; public bool IsActive => _session?.IsActive == true; @@ -78,6 +79,23 @@ public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) }); } + public async Task OnJobCompletedAsync() + { + if (_session != null && _started) + { + try + { + _session.OnJobCompleted(); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnJobCompleted error: {ex.Message}"); + } + } + + await StopAsync(); + } + public async Task StopAsync() { if (_cancellationRegistration.HasValue) @@ -108,7 +126,7 @@ public void CancelSession() _session?.CancelSession(); } - public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken) + public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken) { if (!IsActive) { @@ -117,7 +135,9 @@ public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, try { - await _session.OnStepStartingAsync(step, jobContext, isFirstStep, cancellationToken); + bool isFirst = _isFirstStep; + _isFirstStep = false; + await _session.OnStepStartingAsync(step, jobContext, isFirst, cancellationToken); } catch (Exception ex) { @@ -142,23 +162,6 @@ public void OnStepCompleted(IStep step) } } - public void OnJobCompleted() - { - if (!IsActive) - { - return; - } - - try - { - _session.OnJobCompleted(); - } - catch (Exception ex) - { - Trace.Warning($"DAP OnJobCompleted error: {ex.Message}"); - } - } - private int ResolvePort() { var portEnv = Environment.GetEnvironmentVariable(PortEnvironmentVariable); diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs index f753b6f7ce8..45a85e8fc52 100644 --- a/src/Runner.Worker/Dap/IDapDebugger.cs +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -12,8 +12,8 @@ public interface IDapDebugger : IRunnerService Task WaitUntilReadyAsync(CancellationToken cancellationToken); Task StopAsync(); void CancelSession(); - Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken); + Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken); void OnStepCompleted(IStep step); - void OnJobCompleted(); + Task OnJobCompletedAsync(); } } diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 4d7ac04a5f4..d5ecdeffd81 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -193,11 +193,8 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat catch (Exception ex) { Trace.Error($"Failed to start DAP debugger: {ex.Message}"); - if (dapDebugger != null) - { - try { await dapDebugger.StopAsync(); } catch { } - } - dapDebugger = null; + jobContext.Error("Failed to start debugger."); + return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); } } @@ -254,24 +251,18 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) { Trace.Info("Job was cancelled before debugger client connected."); - try { await dapDebugger.StopAsync(); } catch { } - dapDebugger = null; + jobContext.Error("Job was cancelled before debugger client connected."); return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled); } catch (Exception ex) { Trace.Error($"DAP debugger failed to become ready: {ex.Message}"); - try { await dapDebugger.StopAsync(); } catch { } - dapDebugger = null; - } - } - // If debugging was requested but the debugger is not available, fail the job - if (jobContext.Global.EnableDebugger && dapDebugger == null) - { - var errorMessage = "The debugger failed to start or no debugger client connected in time."; - jobContext.Error(errorMessage); - return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); + // If debugging was requested but the debugger is not available, fail the job + var errorMessage = "The debugger failed to start or no debugger client connected in time."; + jobContext.Error(errorMessage); + return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); + } } // Run all job steps @@ -314,10 +305,7 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat runnerShutdownRegistration = null; } - if (dapDebugger != null) - { - await dapDebugger.StopAsync(); - } + await dapDebugger?.OnJobCompletedAsync(); await ShutdownQueue(throwOnFailure: false); } diff --git a/src/Runner.Worker/StepsRunner.cs b/src/Runner.Worker/StepsRunner.cs index c7309be82fb..a109012002e 100644 --- a/src/Runner.Worker/StepsRunner.cs +++ b/src/Runner.Worker/StepsRunner.cs @@ -51,16 +51,7 @@ public async Task RunAsync(IExecutionContext jobContext) jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult(); var scopeInputs = new Dictionary(StringComparer.OrdinalIgnoreCase); bool checkPostJobActions = false; - IDapDebugger dapDebugger = null; - try - { - dapDebugger = HostContext.GetService(); - } - catch - { - // Debugger not available — continue without debugging - } - bool isFirstStep = true; + var dapDebugger = HostContext.GetService(); while (jobContext.JobSteps.Count > 0 || !checkPostJobActions) { if (jobContext.JobSteps.Count == 0 && !checkPostJobActions) @@ -238,20 +229,13 @@ public async Task RunAsync(IExecutionContext jobContext) else { // Pause for DAP debugger before step execution - if (dapDebugger != null) - { - await dapDebugger.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken); - isFirstStep = false; - } + await dapDebugger?.OnStepStartingAsync(step, jobContext, jobContext.CancellationToken); // Run the step await RunStepAsync(step, jobContext.CancellationToken); CompleteStep(step); - if (dapDebugger != null) - { - dapDebugger.OnStepCompleted(step); - } + dapDebugger?.OnStepCompleted(step); } } finally @@ -279,10 +263,6 @@ public async Task RunAsync(IExecutionContext jobContext) Trace.Info($"Current state: job state = '{jobContext.Result}'"); } - if (dapDebugger != null) - { - dapDebugger.OnJobCompleted(); - } } private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken) diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index 4838aaf4620..ddf6f0153e1 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -273,7 +273,7 @@ public async Task OnStepStartingDelegatesWhenActive() var mockStep = new Mock(); var mockJobContext = new Mock(); - await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None); + await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None); mockSession.Verify(x => x.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None), Times.Once); @@ -306,7 +306,7 @@ public async Task OnStepStartingSkipsWhenNotActive() var mockStep = new Mock(); var mockJobContext = new Mock(); - await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None); + await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None); mockSession.Verify(x => x.OnStepStartingAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -367,11 +367,10 @@ public async Task OnJobCompletedDelegatesWhenActive() var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); - _debugger.OnJobCompleted(); + await _debugger.OnJobCompletedAsync(); mockSession.Verify(x => x.OnJobCompleted(), Times.Once); - - await _debugger.StopAsync(); + mockServer.Verify(x => x.StopAsync(), Times.Once); } } @@ -403,7 +402,7 @@ public async Task OnStepStartingSwallowsSessionException() var mockJobContext = new Mock(); // Should not throw - await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None); + await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None); await _debugger.StopAsync(); } diff --git a/src/Test/L0/Worker/JobExtensionL0.cs b/src/Test/L0/Worker/JobExtensionL0.cs index 60814998ef3..34413552c5f 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -8,6 +8,7 @@ using GitHub.DistributedTask.Pipelines.ObjectTemplating; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; using Moq; using Xunit; using Pipelines = GitHub.DistributedTask.Pipelines; @@ -547,6 +548,11 @@ private async Task EnsureSnapshotPostJobStepForToken(TemplateToken snapshotToken var _stepsRunner = new StepsRunner(); _stepsRunner.Initialize(hc); + + var mockDapDebugger = new Mock(); + mockDapDebugger.Setup(x => x.IsActive).Returns(false); + hc.SetSingleton(mockDapDebugger.Object); + await _stepsRunner.RunAsync(_jobEc); Assert.Equal("Create custom image", snapshotStep.DisplayName); diff --git a/src/Test/L0/Worker/StepsRunnerL0.cs b/src/Test/L0/Worker/StepsRunnerL0.cs index a22dc618f8d..e9e99d82ce5 100644 --- a/src/Test/L0/Worker/StepsRunnerL0.cs +++ b/src/Test/L0/Worker/StepsRunnerL0.cs @@ -12,6 +12,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; namespace GitHub.Runner.Common.Tests.Worker { @@ -61,6 +62,11 @@ private TestHostContext CreateTestContext([CallerMemberName] String testName = " _stepsRunner = new StepsRunner(); _stepsRunner.Initialize(hc); + + var mockDapDebugger = new Mock(); + mockDapDebugger.Setup(x => x.IsActive).Returns(false); + hc.SetSingleton(mockDapDebugger.Object); + return hc; } From da35402572cf0df3471edc8ac73e7313c166d375 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Tue, 17 Mar 2026 10:49:14 +0000 Subject: [PATCH 35/42] Merge DapDebugger and DapDebugSession --- src/Runner.Worker/Dap/DapDebugSession.cs | 905 -------------- src/Runner.Worker/Dap/DapDebugger.cs | 932 ++++++++++++++- src/Runner.Worker/Dap/DapServer.cs | 33 +- src/Runner.Worker/Dap/IDapDebugSession.cs | 32 - src/Runner.Worker/Dap/IDapDebugger.cs | 10 + src/Runner.Worker/Dap/IDapServer.cs | 13 +- src/Runner.Worker/InternalsVisibleTo.cs | 1 + src/Runner.Worker/JobRunner.cs | 5 +- src/Test/L0/ServiceInterfacesL0.cs | 2 + src/Test/L0/Worker/DapDebugSessionL0.cs | 1294 --------------------- src/Test/L0/Worker/DapDebuggerL0.cs | 351 +----- src/Test/L0/Worker/DapServerL0.cs | 8 +- 12 files changed, 1014 insertions(+), 2572 deletions(-) delete mode 100644 src/Runner.Worker/Dap/DapDebugSession.cs delete mode 100644 src/Runner.Worker/Dap/IDapDebugSession.cs delete mode 100644 src/Test/L0/Worker/DapDebugSessionL0.cs diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs deleted file mode 100644 index 9f6b044a168..00000000000 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ /dev/null @@ -1,905 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using GitHub.DistributedTask.WebApi; -using GitHub.Runner.Common; -using Newtonsoft.Json; - -namespace GitHub.Runner.Worker.Dap -{ - /// - /// Stores information about a completed step for stack trace display. - /// - internal sealed class CompletedStepInfo - { - public string DisplayName { get; set; } - public TaskResult? Result { get; set; } - public int FrameId { get; set; } - } - - /// - /// Handles step-level breakpoints with next/continue flow control, - /// scope/variable inspection, client reconnection, and cancellation - /// signal propagation. - /// - /// REPL, step manipulation, and time-travel debugging are intentionally - /// deferred to future iterations. - /// - public sealed class DapDebugSession : RunnerService, IDapDebugSession - { - // Thread ID for the single job execution thread - private const int JobThreadId = 1; - - // Frame ID for the current step (always 1) - private const int CurrentFrameId = 1; - - // Frame IDs for completed steps start at 1000 - private const int CompletedFrameIdBase = 1000; - - private IDapServer _server; - private volatile DapSessionState _state = DapSessionState.WaitingForConnection; - - // Synchronization for step execution - private TaskCompletionSource _commandTcs; - private readonly object _stateLock = new object(); - - // Handshake completion — signaled when configurationDone is received - private readonly TaskCompletionSource _handshakeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - // Whether to pause before the next step (set by 'next' command) - private bool _pauseOnNextStep = true; - - // Current execution context - private IStep _currentStep; - private IExecutionContext _jobContext; - private int _currentStepIndex; - - // Track completed steps for stack trace - private readonly List _completedSteps = new List(); - private int _nextCompletedFrameId = CompletedFrameIdBase; - - // Client connection tracking for reconnection support - private volatile bool _isClientConnected; - - // Scope/variable inspection provider — reusable by future DAP features - private DapVariableProvider _variableProvider; - - // REPL command executor for run() commands - private DapReplExecutor _replExecutor; - - public bool IsActive => - _state == DapSessionState.Ready || - _state == DapSessionState.Paused || - _state == DapSessionState.Running; - - public DapSessionState State => _state; - - public override void Initialize(IHostContext hostContext) - { - base.Initialize(hostContext); - _variableProvider = new DapVariableProvider(hostContext); - Trace.Info("DapDebugSession initialized"); - } - - public void SetDapServer(IDapServer server) - { - _server = server; - _replExecutor = new DapReplExecutor(HostContext, server); - Trace.Info("DAP server reference set"); - } - - public async Task WaitForHandshakeAsync(CancellationToken cancellationToken) - { - Trace.Info("Waiting for DAP handshake (configurationDone)..."); - - using (cancellationToken.Register(() => _handshakeTcs.TrySetCanceled())) - { - await _handshakeTcs.Task; - } - - Trace.Info("DAP handshake complete, session is ready"); - } - - #region Message Dispatch - - public async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken) - { - Request request = null; - try - { - request = JsonConvert.DeserializeObject(messageJson); - if (request == null) - { - Trace.Warning("Failed to deserialize DAP request"); - return; - } - - Trace.Info("Handling DAP request"); - - Response response; - if (request.Command == "evaluate") - { - response = await HandleEvaluateAsync(request, cancellationToken); - } - else - { - response = request.Command switch - { - "initialize" => HandleInitialize(request), - "attach" => HandleAttach(request), - "configurationDone" => HandleConfigurationDone(request), - "disconnect" => HandleDisconnect(request), - "threads" => HandleThreads(request), - "stackTrace" => HandleStackTrace(request), - "scopes" => HandleScopes(request), - "variables" => HandleVariables(request), - "continue" => HandleContinue(request), - "next" => HandleNext(request), - "setBreakpoints" => HandleSetBreakpoints(request), - "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), - "completions" => HandleCompletions(request), - "stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level — use 'next' to advance to the next step.", body: null), - "stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level — use 'continue' to resume.", body: null), - "stepBack" => CreateResponse(request, false, "Step Back is not yet supported.", body: null), - "reverseContinue" => CreateResponse(request, false, "Reverse Continue is not yet supported.", body: null), - "pause" => CreateResponse(request, false, "Pause is not supported. The debugger pauses automatically at step boundaries.", body: null), - _ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null) - }; - } - - response.RequestSeq = request.Seq; - response.Command = request.Command; - - _server?.SendResponse(response); - } - catch (Exception ex) - { - Trace.Error($"Error handling DAP request ({ex.GetType().Name})"); - if (request != null) - { - var maskedMessage = HostContext?.SecretMasker?.MaskSecrets(ex.Message) ?? ex.Message; - var errorResponse = CreateResponse(request, false, maskedMessage, body: null); - errorResponse.RequestSeq = request.Seq; - errorResponse.Command = request.Command; - _server?.SendResponse(errorResponse); - } - } - } - - #endregion - - #region DAP Request Handlers - - private Response HandleInitialize(Request request) - { - if (request.Arguments != null) - { - try - { - request.Arguments.ToObject(); - Trace.Info("Initialize arguments received"); - } - catch (Exception ex) - { - Trace.Warning($"Failed to parse initialize arguments ({ex.GetType().Name})"); - } - } - - _state = DapSessionState.Initializing; - - // Build capabilities — MVP only supports configurationDone - var capabilities = new Capabilities - { - SupportsConfigurationDoneRequest = true, - SupportsEvaluateForHovers = true, - - // All other capabilities are false for MVP - SupportsFunctionBreakpoints = false, - SupportsConditionalBreakpoints = false, - SupportsStepBack = false, - SupportsSetVariable = false, - SupportsRestartFrame = false, - SupportsGotoTargetsRequest = false, - SupportsStepInTargetsRequest = false, - SupportsCompletionsRequest = true, - SupportsModulesRequest = false, - SupportsTerminateRequest = false, - SupportTerminateDebuggee = false, - SupportsDelayedStackTraceLoading = false, - SupportsLoadedSourcesRequest = false, - SupportsProgressReporting = false, - SupportsRunInTerminalRequest = false, - SupportsCancelRequest = false, - SupportsExceptionOptions = false, - SupportsValueFormattingOptions = false, - SupportsExceptionInfoRequest = false, - }; - - // Send initialized event after a brief delay to ensure the - // response is delivered first (DAP spec requirement) - _ = Task.Run(async () => - { - await Task.Delay(50); - _server?.SendEvent(new Event - { - EventType = "initialized" - }); - Trace.Info("Sent initialized event"); - }); - - Trace.Info("Initialize request handled, capabilities sent"); - return CreateResponse(request, true, body: capabilities); - } - - private Response HandleAttach(Request request) - { - Trace.Info("Attach request handled"); - return CreateResponse(request, true, body: null); - } - - private Response HandleConfigurationDone(Request request) - { - lock (_stateLock) - { - _state = DapSessionState.Ready; - } - - _handshakeTcs.TrySetResult(true); - - Trace.Info("Configuration done, debug session is ready"); - return CreateResponse(request, true, body: null); - } - - private Response HandleDisconnect(Request request) - { - Trace.Info("Disconnect request received"); - - lock (_stateLock) - { - _state = DapSessionState.Terminated; - - // Release any blocked step execution - _commandTcs?.TrySetResult(DapCommand.Disconnect); - } - - return CreateResponse(request, true, body: null); - } - - private Response HandleThreads(Request request) - { - IExecutionContext jobContext; - lock (_stateLock) - { - jobContext = _jobContext; - } - - var threadName = jobContext != null - ? MaskUserVisibleText($"Job: {jobContext.GetGitHubContext("job") ?? "workflow job"}") - : "Job Thread"; - - var body = new ThreadsResponseBody - { - Threads = new List - { - new Thread - { - Id = JobThreadId, - Name = threadName - } - } - }; - - return CreateResponse(request, true, body: body); - } - - private Response HandleStackTrace(Request request) - { - IStep currentStep; - int currentStepIndex; - CompletedStepInfo[] completedSteps; - lock (_stateLock) - { - currentStep = _currentStep; - currentStepIndex = _currentStepIndex; - completedSteps = _completedSteps.ToArray(); - } - - var frames = new List(); - - // Add current step as the top frame - if (currentStep != null) - { - var resultIndicator = currentStep.ExecutionContext?.Result != null - ? $" [{currentStep.ExecutionContext.Result}]" - : " [running]"; - - frames.Add(new StackFrame - { - Id = CurrentFrameId, - Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), - Line = currentStepIndex + 1, - Column = 1, - PresentationHint = "normal" - }); - } - else - { - frames.Add(new StackFrame - { - Id = CurrentFrameId, - Name = "(no step executing)", - Line = 0, - Column = 1, - PresentationHint = "subtle" - }); - } - - // Add completed steps as additional frames (most recent first) - for (int i = completedSteps.Length - 1; i >= 0; i--) - { - var completedStep = completedSteps[i]; - var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : ""; - frames.Add(new StackFrame - { - Id = completedStep.FrameId, - Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"), - Line = 1, - Column = 1, - PresentationHint = "subtle" - }); - } - - var body = new StackTraceResponseBody - { - StackFrames = frames, - TotalFrames = frames.Count - }; - - return CreateResponse(request, true, body: body); - } - - private Response HandleScopes(Request request) - { - var args = request.Arguments?.ToObject(); - var frameId = args?.FrameId ?? CurrentFrameId; - - var context = GetExecutionContextForFrame(frameId); - if (context == null) - { - return CreateResponse(request, true, body: new ScopesResponseBody - { - Scopes = new List() - }); - } - - var scopes = _variableProvider.GetScopes(context); - return CreateResponse(request, true, body: new ScopesResponseBody - { - Scopes = scopes - }); - } - - private Response HandleVariables(Request request) - { - var args = request.Arguments?.ToObject(); - var variablesRef = args?.VariablesReference ?? 0; - - var context = GetCurrentExecutionContext(); - if (context == null) - { - return CreateResponse(request, true, body: new VariablesResponseBody - { - Variables = new List() - }); - } - - var variables = _variableProvider.GetVariables(context, variablesRef); - return CreateResponse(request, true, body: new VariablesResponseBody - { - Variables = variables - }); - } - - private async Task HandleEvaluateAsync(Request request, CancellationToken cancellationToken) - { - var args = request.Arguments?.ToObject(); - var expression = args?.Expression ?? string.Empty; - var frameId = args?.FrameId ?? CurrentFrameId; - var evalContext = args?.Context ?? "hover"; - - Trace.Info("Evaluate request received"); - - // REPL context → route through the DSL dispatcher - if (string.Equals(evalContext, "repl", StringComparison.OrdinalIgnoreCase)) - { - var result = await HandleReplInputAsync(expression, frameId, cancellationToken); - return CreateResponse(request, true, body: result); - } - - // Watch/hover/variables/clipboard → expression evaluation only - var context = GetExecutionContextForFrame(frameId); - var evalResult = _variableProvider.EvaluateExpression(expression, context); - return CreateResponse(request, true, body: evalResult); - } - - /// - /// Routes REPL input through the DSL parser. If the input matches a - /// known command it is dispatched; otherwise it falls through to - /// expression evaluation. - /// - private async Task HandleReplInputAsync( - string input, - int frameId, - CancellationToken cancellationToken) - { - // Try to parse as a DSL command - var command = DapReplParser.TryParse(input, out var parseError); - - if (parseError != null) - { - return new EvaluateResponseBody - { - Result = parseError, - Type = "error", - VariablesReference = 0 - }; - } - - if (command != null) - { - return await DispatchReplCommandAsync(command, frameId, cancellationToken); - } - - // Not a DSL command → evaluate as a GitHub Actions expression - // (this lets the REPL console also work for ad-hoc expression queries) - var context = GetExecutionContextForFrame(frameId); - return _variableProvider.EvaluateExpression(input, context); - } - - private async Task DispatchReplCommandAsync( - DapReplCommand command, - int frameId, - CancellationToken cancellationToken) - { - switch (command) - { - case HelpCommand help: - var helpText = string.IsNullOrEmpty(help.Topic) - ? DapReplParser.GetGeneralHelp() - : help.Topic.Equals("run", StringComparison.OrdinalIgnoreCase) - ? DapReplParser.GetRunHelp() - : $"Unknown help topic: {help.Topic}. Try: help or help(\"run\")"; - return new EvaluateResponseBody - { - Result = helpText, - Type = "string", - VariablesReference = 0 - }; - - case RunCommand run: - var context = GetExecutionContextForFrame(frameId); - return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken); - - default: - return new EvaluateResponseBody - { - Result = $"Unknown command type: {command.GetType().Name}", - Type = "error", - VariablesReference = 0 - }; - } - } - - private Response HandleCompletions(Request request) - { - var args = request.Arguments?.ToObject(); - var text = args?.Text ?? string.Empty; - - var items = new List(); - - // Offer DSL commands when the user is starting to type - if (string.IsNullOrEmpty(text) || "help".StartsWith(text, System.StringComparison.OrdinalIgnoreCase)) - { - items.Add(new CompletionItem - { - Label = "help", - Text = "help", - Detail = "Show available debug console commands", - Type = "function" - }); - } - if (string.IsNullOrEmpty(text) || "help(\"run\")".StartsWith(text, System.StringComparison.OrdinalIgnoreCase)) - { - items.Add(new CompletionItem - { - Label = "help(\"run\")", - Text = "help(\"run\")", - Detail = "Show help for the run command", - Type = "function" - }); - } - if (string.IsNullOrEmpty(text) || "run(".StartsWith(text, System.StringComparison.OrdinalIgnoreCase) - || text.StartsWith("run(", System.StringComparison.OrdinalIgnoreCase)) - { - items.Add(new CompletionItem - { - Label = "run(\"...\")", - Text = "run(\"", - Detail = "Execute a script (like a workflow run step)", - Type = "function" - }); - } - - return CreateResponse(request, true, body: new CompletionsResponseBody - { - Targets = items - }); - } - - private Response HandleContinue(Request request) - { - Trace.Info("Continue command received"); - - lock (_stateLock) - { - if (_state == DapSessionState.Paused) - { - _state = DapSessionState.Running; - _pauseOnNextStep = false; - _commandTcs?.TrySetResult(DapCommand.Continue); - } - } - - return CreateResponse(request, true, body: new ContinueResponseBody - { - AllThreadsContinued = true - }); - } - - private Response HandleNext(Request request) - { - Trace.Info("Next (step over) command received"); - - lock (_stateLock) - { - if (_state == DapSessionState.Paused) - { - _state = DapSessionState.Running; - _pauseOnNextStep = true; - _commandTcs?.TrySetResult(DapCommand.Next); - } - } - - return CreateResponse(request, true, body: null); - } - - private Response HandleSetBreakpoints(Request request) - { - // MVP: acknowledge but don't process breakpoints - // All steps pause automatically via _pauseOnNextStep - return CreateResponse(request, true, body: new { breakpoints = Array.Empty() }); - } - - private Response HandleSetExceptionBreakpoints(Request request) - { - // MVP: acknowledge but don't process exception breakpoints - return CreateResponse(request, true, body: null); - } - - #endregion - - #region Step Lifecycle - - public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken) - { - bool pauseOnNextStep; - lock (_stateLock) - { - if (_state != DapSessionState.Ready && - _state != DapSessionState.Paused && - _state != DapSessionState.Running) - { - return; - } - - _currentStep = step; - _jobContext = jobContext; - _currentStepIndex = _completedSteps.Count; - pauseOnNextStep = _pauseOnNextStep; - } - - // Reset variable references so stale nested refs from the - // previous step are not served to the client. - _variableProvider?.Reset(); - - // Determine if we should pause - bool shouldPause = isFirstStep || pauseOnNextStep; - - if (!shouldPause) - { - Trace.Info("Step starting without debugger pause"); - return; - } - - var reason = isFirstStep ? "entry" : "step"; - var description = isFirstStep - ? $"Stopped at job entry: {step.DisplayName}" - : $"Stopped before step: {step.DisplayName}"; - - Trace.Info("Step starting with debugger pause"); - - // Send stopped event to debugger (only if client is connected) - SendStoppedEvent(reason, description); - - // Wait for debugger command - await WaitForCommandAsync(cancellationToken); - } - - public void OnStepCompleted(IStep step) - { - var result = step.ExecutionContext?.Result; - Trace.Info("Step completed"); - - // Add to completed steps list for stack trace - lock (_stateLock) - { - if (_state != DapSessionState.Ready && - _state != DapSessionState.Paused && - _state != DapSessionState.Running) - { - return; - } - - _completedSteps.Add(new CompletedStepInfo - { - DisplayName = step.DisplayName, - Result = result, - FrameId = _nextCompletedFrameId++ - }); - } - } - - public void OnJobCompleted() - { - Trace.Info("Job completed, sending terminated event"); - - int exitCode; - lock (_stateLock) - { - if (_state == DapSessionState.Terminated) - { - Trace.Info("Session already terminated, skipping OnJobCompleted events"); - return; - } - _state = DapSessionState.Terminated; - exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1; - } - - _server?.SendEvent(new Event - { - EventType = "terminated", - Body = new TerminatedEventBody() - }); - - _server?.SendEvent(new Event - { - EventType = "exited", - Body = new ExitedEventBody - { - ExitCode = exitCode - } - }); - } - - public void CancelSession() - { - Trace.Info("CancelSession called - terminating debug session"); - - lock (_stateLock) - { - if (_state == DapSessionState.Terminated) - { - Trace.Info("Session already terminated, ignoring CancelSession"); - return; - } - _state = DapSessionState.Terminated; - } - - // Send terminated event to debugger so it updates its UI - _server?.SendEvent(new Event - { - EventType = "terminated", - Body = new TerminatedEventBody() - }); - - // Send exited event with cancellation exit code (130 = SIGINT convention) - _server?.SendEvent(new Event - { - EventType = "exited", - Body = new ExitedEventBody { ExitCode = 130 } - }); - - // Release any pending command waits - _commandTcs?.TrySetResult(DapCommand.Disconnect); - - // Release handshake wait if still pending - _handshakeTcs.TrySetCanceled(); - - Trace.Info("Debug session cancelled"); - } - - #endregion - - #region Client Connection Tracking - - public void HandleClientConnected() - { - _isClientConnected = true; - Trace.Info("Client connected to debug session"); - - // If we're paused, re-send the stopped event so the new client - // knows the current state (important for reconnection) - string description = null; - lock (_stateLock) - { - if (_state == DapSessionState.Paused && _currentStep != null) - { - description = $"Stopped before step: {_currentStep.DisplayName}"; - } - } - - if (description != null) - { - Trace.Info("Re-sending stopped event to reconnected client"); - SendStoppedEvent("step", description); - } - } - - public void HandleClientDisconnected() - { - _isClientConnected = false; - Trace.Info("Client disconnected from debug session"); - - // Intentionally do NOT release the command TCS here. - // The session stays paused, waiting for a client to reconnect. - // The server's connection loop will accept a new client and - // call HandleClientConnected, which re-sends the stopped event. - } - - #endregion - - #region Private Helpers - - /// - /// Blocks the step execution thread until a debugger command is received - /// or the job is cancelled. - /// - private async Task WaitForCommandAsync(CancellationToken cancellationToken) - { - lock (_stateLock) - { - if (_state == DapSessionState.Terminated) - { - return; - } - _state = DapSessionState.Paused; - _commandTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - - Trace.Info("Waiting for debugger command..."); - - using (cancellationToken.Register(() => - { - Trace.Info("Job cancellation detected, releasing debugger wait"); - _commandTcs?.TrySetResult(DapCommand.Disconnect); - })) - { - var command = await _commandTcs.Task; - - Trace.Info("Received debugger command"); - - lock (_stateLock) - { - if (_state == DapSessionState.Paused) - { - _state = DapSessionState.Running; - } - } - - // Send continued event for normal flow commands - if (!cancellationToken.IsCancellationRequested && - (command == DapCommand.Continue || command == DapCommand.Next)) - { - _server?.SendEvent(new Event - { - EventType = "continued", - Body = new ContinuedEventBody - { - ThreadId = JobThreadId, - AllThreadsContinued = true - } - }); - } - } - } - - /// - /// Resolves the execution context for a given stack frame ID. - /// Frame 1 = current step; frames 1000+ = completed steps (no - /// context available — those steps have already finished). - /// Falls back to the job-level context when no step is active. - /// - private IExecutionContext GetExecutionContextForFrame(int frameId) - { - if (frameId == CurrentFrameId) - { - return GetCurrentExecutionContext(); - } - - // Completed-step frames don't carry a live execution context. - return null; - } - - private IExecutionContext GetCurrentExecutionContext() - { - lock (_stateLock) - { - return _currentStep?.ExecutionContext ?? _jobContext; - } - } - - /// - /// Sends a stopped event to the connected client. - /// Silently no-ops if no client is connected. - /// - private void SendStoppedEvent(string reason, string description) - { - if (!_isClientConnected) - { - Trace.Info("No client connected, deferring stopped event"); - return; - } - - _server?.SendEvent(new Event - { - EventType = "stopped", - Body = new StoppedEventBody - { - Reason = reason, - Description = MaskUserVisibleText(description), - ThreadId = JobThreadId, - AllThreadsStopped = true - } - }); - } - - private string MaskUserVisibleText(string value) - { - if (string.IsNullOrEmpty(value)) - { - return value ?? string.Empty; - } - - return HostContext?.SecretMasker?.MaskSecrets(value); - } - - /// - /// Creates a DAP response with common fields pre-populated. - /// - private Response CreateResponse(Request request, bool success, string message = null, object body = null) - { - return new Response - { - Type = "response", - RequestSeq = request.Seq, - Command = request.Command, - Success = success, - Message = success ? null : message, - Body = body - }; - } - - #endregion - } -} diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index aa2048e7d84..a10bda19a86 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -1,45 +1,99 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; +using Newtonsoft.Json; namespace GitHub.Runner.Worker.Dap { + /// + /// Stores information about a completed step for stack trace display. + /// + internal sealed class CompletedStepInfo + { + public string DisplayName { get; set; } + public TaskResult? Result { get; set; } + public int FrameId { get; set; } + } + /// /// Single public facade for the Debug Adapter Protocol subsystem. - /// Owns the DapServer and DapDebugSession internally; external callers - /// (JobRunner, StepsRunner) interact only with this class. + /// Owns the DapServer internally and handles handshake, step-level + /// pauses, variable inspection, reconnection, and cancellation. /// - public sealed class DapDebugger : RunnerService, IDapDebugger + public sealed class DapDebugger : RunnerService, IDapDebugger, IDapDebuggerCallbacks { 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"; + // Thread ID for the single job execution thread + private const int JobThreadId = 1; + + // Frame ID for the current step (always 1) + private const int CurrentFrameId = 1; + + // Frame IDs for completed steps start at 1000 + private const int CompletedFrameIdBase = 1000; + private IDapServer _server; - private IDapDebugSession _session; + private volatile DapSessionState _state = DapSessionState.WaitingForConnection; private CancellationTokenRegistration? _cancellationRegistration; private volatile bool _started; private bool _isFirstStep = true; - public bool IsActive => _session?.IsActive == true; + // Synchronization for step execution + private TaskCompletionSource _commandTcs; + private readonly object _stateLock = new object(); + + // Handshake completion — signaled when configurationDone is received + private TaskCompletionSource _handshakeTcs = CreateHandshakeCompletionSource(); + + // Whether to pause before the next step (set by 'next' command) + private bool _pauseOnNextStep = true; + + // Current execution context + private IStep _currentStep; + private IExecutionContext _jobContext; + private int _currentStepIndex; + + // Track completed steps for stack trace + private readonly List _completedSteps = new List(); + private int _nextCompletedFrameId = CompletedFrameIdBase; + + // Client connection tracking for reconnection support + private volatile bool _isClientConnected; + + // Scope/variable inspection provider — reusable by future DAP features + private DapVariableProvider _variableProvider; + + // REPL command executor for run() commands + private DapReplExecutor _replExecutor; + + public bool IsActive => + _state == DapSessionState.Ready || + _state == DapSessionState.Paused || + _state == DapSessionState.Running; + + internal DapSessionState State => _state; public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); + _variableProvider = new DapVariableProvider(hostContext); Trace.Info("DapDebugger initialized"); } public async Task StartAsync(CancellationToken cancellationToken) { + ResetSessionState(); var port = ResolvePort(); - _server = HostContext.GetService(); - _session = HostContext.GetService(); - - _server.SetSession(_session); - _session.SetDapServer(_server); + SetDapServer(HostContext.GetService()); + _server.SetDebugger(this); await _server.StartAsync(port, cancellationToken); _started = true; @@ -49,7 +103,7 @@ public async Task StartAsync(CancellationToken cancellationToken) public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) { - if (!_started || _server == null || _session == null) + if (!_started || _server == null) { return; } @@ -64,7 +118,7 @@ public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) await _server.WaitForConnectionAsync(linkedCts.Token); Trace.Info("Debugger client connected."); - await _session.WaitForHandshakeAsync(linkedCts.Token); + await WaitForHandshakeAsync(linkedCts.Token); Trace.Info("DAP handshake complete."); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) @@ -75,17 +129,17 @@ public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) _cancellationRegistration = cancellationToken.Register(() => { Trace.Info("Job cancellation requested, cancelling debug session."); - _session.CancelSession(); + CancelSession(); }); } public async Task OnJobCompletedAsync() { - if (_session != null && _started) + if (_started) { try { - _session.OnJobCompleted(); + OnJobCompleted(); } catch (Exception ex) { @@ -118,12 +172,55 @@ public async Task StopAsync() } } + lock (_stateLock) + { + if (_started && _state != DapSessionState.Terminated) + { + _state = DapSessionState.Terminated; + } + } + + _isClientConnected = false; + _server = null; + _replExecutor = null; _started = false; } public void CancelSession() { - _session?.CancelSession(); + Trace.Info("CancelSession called - terminating debug session"); + + lock (_stateLock) + { + if (_state == DapSessionState.Terminated) + { + Trace.Info("Session already terminated, ignoring CancelSession"); + return; + } + _state = DapSessionState.Terminated; + } + + // Send terminated event to debugger so it updates its UI + _server?.SendEvent(new Event + { + EventType = "terminated", + Body = new TerminatedEventBody() + }); + + // Send exited event with cancellation exit code (130 = SIGINT convention) + _server?.SendEvent(new Event + { + EventType = "exited", + Body = new ExitedEventBody { ExitCode = 130 } + }); + + // Release any pending command waits + _commandTcs?.TrySetResult(DapCommand.Disconnect); + + // Release handshake wait if still pending + _handshakeTcs.TrySetCanceled(); + + Trace.Info("Debug session cancelled"); } public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken) @@ -137,7 +234,7 @@ public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, { bool isFirst = _isFirstStep; _isFirstStep = false; - await _session.OnStepStartingAsync(step, jobContext, isFirst, cancellationToken); + await OnStepStartingAsync(step, jobContext, isFirst, cancellationToken); } catch (Exception ex) { @@ -154,7 +251,26 @@ public void OnStepCompleted(IStep step) try { - _session.OnStepCompleted(step); + var result = step.ExecutionContext?.Result; + Trace.Info("Step completed"); + + // Add to completed steps list for stack trace + lock (_stateLock) + { + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return; + } + + _completedSteps.Add(new CompletedStepInfo + { + DisplayName = step.DisplayName, + Result = result, + FrameId = _nextCompletedFrameId++ + }); + } } catch (Exception ex) { @@ -162,6 +278,786 @@ public void OnStepCompleted(IStep step) } } + Task IDapDebuggerCallbacks.HandleMessageAsync(string messageJson, CancellationToken cancellationToken) + { + return HandleMessageAsync(messageJson, cancellationToken); + } + + void IDapDebuggerCallbacks.HandleClientConnected() + { + HandleClientConnected(); + } + + void IDapDebuggerCallbacks.HandleClientDisconnected() + { + HandleClientDisconnected(); + } + + internal async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken) + { + Request request = null; + try + { + request = JsonConvert.DeserializeObject(messageJson); + if (request == null) + { + Trace.Warning("Failed to deserialize DAP request"); + return; + } + + Trace.Info("Handling DAP request"); + + Response response; + if (request.Command == "evaluate") + { + response = await HandleEvaluateAsync(request, cancellationToken); + } + else + { + response = request.Command switch + { + "initialize" => HandleInitialize(request), + "attach" => HandleAttach(request), + "configurationDone" => HandleConfigurationDone(request), + "disconnect" => HandleDisconnect(request), + "threads" => HandleThreads(request), + "stackTrace" => HandleStackTrace(request), + "scopes" => HandleScopes(request), + "variables" => HandleVariables(request), + "continue" => HandleContinue(request), + "next" => HandleNext(request), + "setBreakpoints" => HandleSetBreakpoints(request), + "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), + "completions" => HandleCompletions(request), + "stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null), + "stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null), + "stepBack" => CreateResponse(request, false, "Step Back is not yet supported.", body: null), + "reverseContinue" => CreateResponse(request, false, "Reverse Continue is not yet supported.", body: null), + "pause" => CreateResponse(request, false, "Pause is not supported. The debugger pauses automatically at step boundaries.", body: null), + _ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null) + }; + } + + response.RequestSeq = request.Seq; + response.Command = request.Command; + + _server?.SendResponse(response); + } + catch (Exception ex) + { + Trace.Error($"Error handling DAP request ({ex.GetType().Name})"); + if (request != null) + { + var maskedMessage = HostContext?.SecretMasker?.MaskSecrets(ex.Message) ?? ex.Message; + var errorResponse = CreateResponse(request, false, maskedMessage, body: null); + errorResponse.RequestSeq = request.Seq; + errorResponse.Command = request.Command; + _server?.SendResponse(errorResponse); + } + } + } + + internal void HandleClientConnected() + { + _isClientConnected = true; + Trace.Info("Client connected to debug session"); + + // If we're paused, re-send the stopped event so the new client + // knows the current state (important for reconnection) + string description = null; + lock (_stateLock) + { + if (_state == DapSessionState.Paused && _currentStep != null) + { + description = $"Stopped before step: {_currentStep.DisplayName}"; + } + } + + if (description != null) + { + Trace.Info("Re-sending stopped event to reconnected client"); + SendStoppedEvent("step", description); + } + } + + internal void HandleClientDisconnected() + { + _isClientConnected = false; + Trace.Info("Client disconnected from debug session"); + + // Intentionally do NOT release the command TCS here. + // The session stays paused, waiting for a client to reconnect. + // The server's connection loop will accept a new client and + // call HandleClientConnected, which re-sends the stopped event. + } + + internal void SetDapServer(IDapServer server) + { + _server = server; + _replExecutor = new DapReplExecutor(HostContext, server); + Trace.Info("DAP server reference set"); + } + + internal async Task WaitForHandshakeAsync(CancellationToken cancellationToken) + { + Trace.Info("Waiting for DAP handshake (configurationDone)..."); + + using (cancellationToken.Register(() => _handshakeTcs.TrySetCanceled())) + { + await _handshakeTcs.Task; + } + + Trace.Info("DAP handshake complete, session is ready"); + } + + internal async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken) + { + bool pauseOnNextStep; + lock (_stateLock) + { + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return; + } + + _currentStep = step; + _jobContext = jobContext; + _currentStepIndex = _completedSteps.Count; + pauseOnNextStep = _pauseOnNextStep; + } + + // Reset variable references so stale nested refs from the + // previous step are not served to the client. + _variableProvider?.Reset(); + + // Determine if we should pause + bool shouldPause = isFirstStep || pauseOnNextStep; + + if (!shouldPause) + { + Trace.Info("Step starting without debugger pause"); + return; + } + + var reason = isFirstStep ? "entry" : "step"; + var description = isFirstStep + ? $"Stopped at job entry: {step.DisplayName}" + : $"Stopped before step: {step.DisplayName}"; + + Trace.Info("Step starting with debugger pause"); + + // Send stopped event to debugger (only if client is connected) + SendStoppedEvent(reason, description); + + // Wait for debugger command + await WaitForCommandAsync(cancellationToken); + } + + internal void OnJobCompleted() + { + Trace.Info("Job completed, sending terminated event"); + + int exitCode; + lock (_stateLock) + { + if (_state == DapSessionState.Terminated) + { + Trace.Info("Session already terminated, skipping OnJobCompleted events"); + return; + } + _state = DapSessionState.Terminated; + exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1; + } + + _server?.SendEvent(new Event + { + EventType = "terminated", + Body = new TerminatedEventBody() + }); + + _server?.SendEvent(new Event + { + EventType = "exited", + Body = new ExitedEventBody + { + ExitCode = exitCode + } + }); + } + + private static TaskCompletionSource CreateHandshakeCompletionSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private void ResetSessionState() + { + lock (_stateLock) + { + _state = DapSessionState.WaitingForConnection; + _commandTcs = null; + _handshakeTcs = CreateHandshakeCompletionSource(); + _pauseOnNextStep = true; + _isFirstStep = true; + _currentStep = null; + _jobContext = null; + _currentStepIndex = 0; + _completedSteps.Clear(); + _nextCompletedFrameId = CompletedFrameIdBase; + _isClientConnected = false; + } + } + + private Response HandleInitialize(Request request) + { + if (request.Arguments != null) + { + try + { + request.Arguments.ToObject(); + Trace.Info("Initialize arguments received"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to parse initialize arguments ({ex.GetType().Name})"); + } + } + + lock (_stateLock) + { + _state = DapSessionState.Initializing; + } + + // Build capabilities — MVP only supports configurationDone + var capabilities = new Capabilities + { + SupportsConfigurationDoneRequest = true, + SupportsEvaluateForHovers = true, + + // All other capabilities are false for MVP + SupportsFunctionBreakpoints = false, + SupportsConditionalBreakpoints = false, + SupportsStepBack = false, + SupportsSetVariable = false, + SupportsRestartFrame = false, + SupportsGotoTargetsRequest = false, + SupportsStepInTargetsRequest = false, + SupportsCompletionsRequest = true, + SupportsModulesRequest = false, + SupportsTerminateRequest = false, + SupportTerminateDebuggee = false, + SupportsDelayedStackTraceLoading = false, + SupportsLoadedSourcesRequest = false, + SupportsProgressReporting = false, + SupportsRunInTerminalRequest = false, + SupportsCancelRequest = false, + SupportsExceptionOptions = false, + SupportsValueFormattingOptions = false, + SupportsExceptionInfoRequest = false, + }; + + // Send initialized event after a brief delay to ensure the + // response is delivered first (DAP spec requirement) + _ = Task.Run(async () => + { + await Task.Delay(50); + _server?.SendEvent(new Event + { + EventType = "initialized" + }); + Trace.Info("Sent initialized event"); + }); + + Trace.Info("Initialize request handled, capabilities sent"); + return CreateResponse(request, true, body: capabilities); + } + + private Response HandleAttach(Request request) + { + Trace.Info("Attach request handled"); + return CreateResponse(request, true, body: null); + } + + private Response HandleConfigurationDone(Request request) + { + lock (_stateLock) + { + _state = DapSessionState.Ready; + } + + _handshakeTcs.TrySetResult(true); + + Trace.Info("Configuration done, debug session is ready"); + return CreateResponse(request, true, body: null); + } + + private Response HandleDisconnect(Request request) + { + Trace.Info("Disconnect request received"); + + lock (_stateLock) + { + _state = DapSessionState.Terminated; + + // Release any blocked step execution + _commandTcs?.TrySetResult(DapCommand.Disconnect); + } + + return CreateResponse(request, true, body: null); + } + + private Response HandleThreads(Request request) + { + IExecutionContext jobContext; + lock (_stateLock) + { + jobContext = _jobContext; + } + + var threadName = jobContext != null + ? MaskUserVisibleText($"Job: {jobContext.GetGitHubContext("job") ?? "workflow job"}") + : "Job Thread"; + + var body = new ThreadsResponseBody + { + Threads = new List + { + new Thread + { + Id = JobThreadId, + Name = threadName + } + } + }; + + return CreateResponse(request, true, body: body); + } + + private Response HandleStackTrace(Request request) + { + IStep currentStep; + int currentStepIndex; + CompletedStepInfo[] completedSteps; + lock (_stateLock) + { + currentStep = _currentStep; + currentStepIndex = _currentStepIndex; + completedSteps = _completedSteps.ToArray(); + } + + var frames = new List(); + + // Add current step as the top frame + if (currentStep != null) + { + var resultIndicator = currentStep.ExecutionContext?.Result != null + ? $" [{currentStep.ExecutionContext.Result}]" + : " [running]"; + + frames.Add(new StackFrame + { + Id = CurrentFrameId, + Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), + Line = currentStepIndex + 1, + Column = 1, + PresentationHint = "normal" + }); + } + else + { + frames.Add(new StackFrame + { + Id = CurrentFrameId, + Name = "(no step executing)", + Line = 0, + Column = 1, + PresentationHint = "subtle" + }); + } + + // Add completed steps as additional frames (most recent first) + for (int i = completedSteps.Length - 1; i >= 0; i--) + { + var completedStep = completedSteps[i]; + var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : ""; + frames.Add(new StackFrame + { + Id = completedStep.FrameId, + Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"), + Line = 1, + Column = 1, + PresentationHint = "subtle" + }); + } + + var body = new StackTraceResponseBody + { + StackFrames = frames, + TotalFrames = frames.Count + }; + + return CreateResponse(request, true, body: body); + } + + private Response HandleScopes(Request request) + { + var args = request.Arguments?.ToObject(); + var frameId = args?.FrameId ?? CurrentFrameId; + + var context = GetExecutionContextForFrame(frameId); + if (context == null) + { + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = new List() + }); + } + + var scopes = _variableProvider.GetScopes(context); + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = scopes + }); + } + + private Response HandleVariables(Request request) + { + var args = request.Arguments?.ToObject(); + var variablesRef = args?.VariablesReference ?? 0; + + var context = GetCurrentExecutionContext(); + if (context == null) + { + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = new List() + }); + } + + var variables = _variableProvider.GetVariables(context, variablesRef); + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = variables + }); + } + + private async Task HandleEvaluateAsync(Request request, CancellationToken cancellationToken) + { + var args = request.Arguments?.ToObject(); + var expression = args?.Expression ?? string.Empty; + var frameId = args?.FrameId ?? CurrentFrameId; + var evalContext = args?.Context ?? "hover"; + + Trace.Info("Evaluate request received"); + + // REPL context -> route through the DSL dispatcher + if (string.Equals(evalContext, "repl", StringComparison.OrdinalIgnoreCase)) + { + var result = await HandleReplInputAsync(expression, frameId, cancellationToken); + return CreateResponse(request, true, body: result); + } + + // Watch/hover/variables/clipboard -> expression evaluation only + var context = GetExecutionContextForFrame(frameId); + var evalResult = _variableProvider.EvaluateExpression(expression, context); + return CreateResponse(request, true, body: evalResult); + } + + /// + /// Routes REPL input through the DSL parser. If the input matches a + /// known command it is dispatched; otherwise it falls through to + /// expression evaluation. + /// + private async Task HandleReplInputAsync( + string input, + int frameId, + CancellationToken cancellationToken) + { + // Try to parse as a DSL command + var command = DapReplParser.TryParse(input, out var parseError); + + if (parseError != null) + { + return new EvaluateResponseBody + { + Result = parseError, + Type = "error", + VariablesReference = 0 + }; + } + + if (command != null) + { + return await DispatchReplCommandAsync(command, frameId, cancellationToken); + } + + // Not a DSL command -> evaluate as a GitHub Actions expression + // (this lets the REPL console also work for ad-hoc expression queries) + var context = GetExecutionContextForFrame(frameId); + return _variableProvider.EvaluateExpression(input, context); + } + + private async Task DispatchReplCommandAsync( + DapReplCommand command, + int frameId, + CancellationToken cancellationToken) + { + switch (command) + { + case HelpCommand help: + var helpText = string.IsNullOrEmpty(help.Topic) + ? DapReplParser.GetGeneralHelp() + : help.Topic.Equals("run", StringComparison.OrdinalIgnoreCase) + ? DapReplParser.GetRunHelp() + : $"Unknown help topic: {help.Topic}. Try: help or help(\"run\")"; + return new EvaluateResponseBody + { + Result = helpText, + Type = "string", + VariablesReference = 0 + }; + + case RunCommand run: + var context = GetExecutionContextForFrame(frameId); + return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken); + + default: + return new EvaluateResponseBody + { + Result = $"Unknown command type: {command.GetType().Name}", + Type = "error", + VariablesReference = 0 + }; + } + } + + private Response HandleCompletions(Request request) + { + var args = request.Arguments?.ToObject(); + var text = args?.Text ?? string.Empty; + + var items = new List(); + + // Offer DSL commands when the user is starting to type + if (string.IsNullOrEmpty(text) || "help".StartsWith(text, StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "help", + Text = "help", + Detail = "Show available debug console commands", + Type = "function" + }); + } + if (string.IsNullOrEmpty(text) || "help(\"run\")".StartsWith(text, StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "help(\"run\")", + Text = "help(\"run\")", + Detail = "Show help for the run command", + Type = "function" + }); + } + if (string.IsNullOrEmpty(text) || "run(".StartsWith(text, StringComparison.OrdinalIgnoreCase) + || text.StartsWith("run(", StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "run(\"...\")", + Text = "run(\"", + Detail = "Execute a script (like a workflow run step)", + Type = "function" + }); + } + + return CreateResponse(request, true, body: new CompletionsResponseBody + { + Targets = items + }); + } + + private Response HandleContinue(Request request) + { + Trace.Info("Continue command received"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + _pauseOnNextStep = false; + _commandTcs?.TrySetResult(DapCommand.Continue); + } + } + + return CreateResponse(request, true, body: new ContinueResponseBody + { + AllThreadsContinued = true + }); + } + + private Response HandleNext(Request request) + { + Trace.Info("Next (step over) command received"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + _pauseOnNextStep = true; + _commandTcs?.TrySetResult(DapCommand.Next); + } + } + + return CreateResponse(request, true, body: null); + } + + private Response HandleSetBreakpoints(Request request) + { + // MVP: acknowledge but don't process breakpoints + // All steps pause automatically via _pauseOnNextStep + return CreateResponse(request, true, body: new { breakpoints = Array.Empty() }); + } + + private Response HandleSetExceptionBreakpoints(Request request) + { + // MVP: acknowledge but don't process exception breakpoints + return CreateResponse(request, true, body: null); + } + + /// + /// Blocks the step execution thread until a debugger command is received + /// or the job is cancelled. + /// + private async Task WaitForCommandAsync(CancellationToken cancellationToken) + { + lock (_stateLock) + { + if (_state == DapSessionState.Terminated) + { + return; + } + _state = DapSessionState.Paused; + _commandTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + Trace.Info("Waiting for debugger command..."); + + using (cancellationToken.Register(() => + { + Trace.Info("Job cancellation detected, releasing debugger wait"); + _commandTcs?.TrySetResult(DapCommand.Disconnect); + })) + { + var command = await _commandTcs.Task; + + Trace.Info("Received debugger command"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + } + } + + // Send continued event for normal flow commands + if (!cancellationToken.IsCancellationRequested && + (command == DapCommand.Continue || command == DapCommand.Next)) + { + _server?.SendEvent(new Event + { + EventType = "continued", + Body = new ContinuedEventBody + { + ThreadId = JobThreadId, + AllThreadsContinued = true + } + }); + } + } + } + + /// + /// Resolves the execution context for a given stack frame ID. + /// Frame 1 = current step; frames 1000+ = completed steps (no + /// context available - those steps have already finished). + /// Falls back to the job-level context when no step is active. + /// + private IExecutionContext GetExecutionContextForFrame(int frameId) + { + if (frameId == CurrentFrameId) + { + return GetCurrentExecutionContext(); + } + + // Completed-step frames don't carry a live execution context. + return null; + } + + private IExecutionContext GetCurrentExecutionContext() + { + lock (_stateLock) + { + return _currentStep?.ExecutionContext ?? _jobContext; + } + } + + /// + /// Sends a stopped event to the connected client. + /// Silently no-ops if no client is connected. + /// + private void SendStoppedEvent(string reason, string description) + { + if (!_isClientConnected) + { + Trace.Info("No client connected, deferring stopped event"); + return; + } + + _server?.SendEvent(new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = reason, + Description = MaskUserVisibleText(description), + ThreadId = JobThreadId, + AllThreadsStopped = true + } + }); + } + + private string MaskUserVisibleText(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value ?? string.Empty; + } + + return HostContext?.SecretMasker?.MaskSecrets(value) ?? value; + } + + /// + /// Creates a DAP response with common fields pre-populated. + /// + private Response CreateResponse(Request request, bool success, string message = null, object body = null) + { + return new Response + { + Type = "response", + RequestSeq = request.Seq, + Command = request.Command, + Success = success, + Message = success ? null : message, + Body = body + }; + } + private int ResolvePort() { var portEnv = Environment.GetEnvironmentVariable(PortEnvironmentVariable); diff --git a/src/Runner.Worker/Dap/DapServer.cs b/src/Runner.Worker/Dap/DapServer.cs index c10094501c8..13c921c3fe6 100644 --- a/src/Runner.Worker/Dap/DapServer.cs +++ b/src/Runner.Worker/Dap/DapServer.cs @@ -24,7 +24,7 @@ public sealed class DapServer : RunnerService, IDapServer private TcpListener _listener; private TcpClient _client; private NetworkStream _stream; - private IDapDebugSession _session; + private IDapDebuggerCallbacks _debugger; private CancellationTokenSource _cts; private TaskCompletionSource _connectionTcs; private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); @@ -38,10 +38,15 @@ public override void Initialize(IHostContext hostContext) Trace.Info("DapServer initialized"); } - public void SetSession(IDapDebugSession session) + void IDapServer.SetDebugger(IDapDebuggerCallbacks debugger) { - _session = session; - Trace.Info("Debug session set"); + SetDebugger(debugger); + } + + internal void SetDebugger(IDapDebuggerCallbacks debugger) + { + _debugger = debugger; + Trace.Info("Debugger callbacks set"); } public Task StartAsync(int port, CancellationToken cancellationToken) @@ -95,15 +100,15 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken) // Signal first connection (no-op on subsequent connections) _connectionTcs.TrySetResult(true); - // Notify session of new client - _session?.HandleClientConnected(); + // Notify debugger of new client + _debugger?.HandleClientConnected(); // Process messages until client disconnects await ProcessMessagesAsync(cancellationToken); - // Client disconnected — notify session and clean up + // Client disconnected — notify debugger and clean up Trace.Info("Client disconnected, waiting for reconnection..."); - _session?.HandleClientDisconnected(); + _debugger?.HandleClientDisconnected(); CleanupConnection(); } catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) @@ -243,16 +248,16 @@ private async Task ProcessSingleMessageAsync(string json, CancellationToken canc Trace.Info("Received DAP request"); - if (_session == null) + if (_debugger == null) { - Trace.Error("No debug session configured"); - SendErrorResponse(request, "No debug session configured"); + Trace.Error("No debugger configured"); + SendErrorResponse(request, "No debugger configured"); return; } - // Pass raw JSON to session — session handles deserialization, dispatch, + // Pass raw JSON to the debugger — it handles deserialization, dispatch, // and calls back to SendResponse when done. - await _session.HandleMessageAsync(json, cancellationToken); + await _debugger.HandleMessageAsync(json, cancellationToken); } catch (JsonException ex) { @@ -397,7 +402,7 @@ private async Task ReadLineAsync(CancellationToken cancellationToken) /// Instead, each DAP producer masks user-visible text at the point of /// construction via or the /// runner's SecretMasker directly. See DapVariableProvider, DapReplExecutor, - /// and DapDebugSession for the call sites. + /// and DapDebugger for the call sites. /// private void SendMessageInternal(ProtocolMessage message) { diff --git a/src/Runner.Worker/Dap/IDapDebugSession.cs b/src/Runner.Worker/Dap/IDapDebugSession.cs deleted file mode 100644 index 5a45d49dac2..00000000000 --- a/src/Runner.Worker/Dap/IDapDebugSession.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using GitHub.Runner.Common; - -namespace GitHub.Runner.Worker.Dap -{ - public enum DapSessionState - { - WaitingForConnection, - Initializing, - Ready, - Paused, - Running, - Terminated - } - - [ServiceLocator(Default = typeof(DapDebugSession))] - public interface IDapDebugSession : IRunnerService - { - bool IsActive { get; } - DapSessionState State { get; } - void SetDapServer(IDapServer server); - Task WaitForHandshakeAsync(CancellationToken cancellationToken); - Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken); - void OnStepCompleted(IStep step); - void OnJobCompleted(); - void CancelSession(); - void HandleClientConnected(); - void HandleClientDisconnected(); - Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken); - } -} diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs index 45a85e8fc52..52de741c281 100644 --- a/src/Runner.Worker/Dap/IDapDebugger.cs +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -4,6 +4,16 @@ namespace GitHub.Runner.Worker.Dap { + public enum DapSessionState + { + WaitingForConnection, + Initializing, + Ready, + Paused, + Running, + Terminated + } + [ServiceLocator(Default = typeof(DapDebugger))] public interface IDapDebugger : IRunnerService { diff --git a/src/Runner.Worker/Dap/IDapServer.cs b/src/Runner.Worker/Dap/IDapServer.cs index a5b879360aa..53faeaf42d4 100644 --- a/src/Runner.Worker/Dap/IDapServer.cs +++ b/src/Runner.Worker/Dap/IDapServer.cs @@ -1,13 +1,20 @@ -using System.Threading; +using System.Threading; using System.Threading.Tasks; using GitHub.Runner.Common; namespace GitHub.Runner.Worker.Dap { + internal interface IDapDebuggerCallbacks + { + Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken); + void HandleClientConnected(); + void HandleClientDisconnected(); + } + [ServiceLocator(Default = typeof(DapServer))] - public interface IDapServer : IRunnerService + internal interface IDapServer : IRunnerService { - void SetSession(IDapDebugSession session); + void SetDebugger(IDapDebuggerCallbacks debugger); Task StartAsync(int port, CancellationToken cancellationToken); Task WaitForConnectionAsync(CancellationToken cancellationToken); Task StopAsync(); diff --git a/src/Runner.Worker/InternalsVisibleTo.cs b/src/Runner.Worker/InternalsVisibleTo.cs index a825116a601..de556bce35f 100644 --- a/src/Runner.Worker/InternalsVisibleTo.cs +++ b/src/Runner.Worker/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Test")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index d5ecdeffd81..828ba67a0c6 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -305,7 +305,10 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat runnerShutdownRegistration = null; } - await dapDebugger?.OnJobCompletedAsync(); + if (dapDebugger != null) + { + await dapDebugger.OnJobCompletedAsync(); + } await ShutdownQueue(throwOnFailure: false); } diff --git a/src/Test/L0/ServiceInterfacesL0.cs b/src/Test/L0/ServiceInterfacesL0.cs index 59b890285be..0746e0433e2 100644 --- a/src/Test/L0/ServiceInterfacesL0.cs +++ b/src/Test/L0/ServiceInterfacesL0.cs @@ -2,6 +2,7 @@ using GitHub.Runner.Listener.Check; using GitHub.Runner.Listener.Configuration; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; using GitHub.Runner.Worker.Container.ContainerHooks; using GitHub.Runner.Worker.Handlers; using System; @@ -71,6 +72,7 @@ public void WorkerInterfacesSpecifyDefaultImplementation() typeof(IDiagnosticLogManager), typeof(IEnvironmentContextData), typeof(IHookArgs), + typeof(IDapDebuggerCallbacks), }; Validate( assembly: typeof(IStepsRunner).GetTypeInfo().Assembly, diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs deleted file mode 100644 index c11a5f834f0..00000000000 --- a/src/Test/L0/Worker/DapDebugSessionL0.cs +++ /dev/null @@ -1,1294 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using GitHub.DistributedTask.ObjectTemplating.Tokens; -using GitHub.DistributedTask.Pipelines.ContextData; -using GitHub.DistributedTask.WebApi; -using GitHub.Runner.Worker; -using GitHub.Runner.Worker.Dap; -using Moq; -using Newtonsoft.Json; -using Xunit; - -namespace GitHub.Runner.Common.Tests.Worker -{ - public sealed class DapDebugSessionL0 - { - private static readonly TimeSpan DefaultAsyncTimeout = TimeSpan.FromSeconds(5); - - private DapDebugSession _session; - private Mock _mockServer; - private List _sentEvents; - private List _sentResponses; - private readonly object _eventWaitersLock = new object(); - private List<(Predicate Predicate, TaskCompletionSource Completion)> _eventWaiters; - - private TestHostContext CreateTestContext([CallerMemberName] string testName = "") - { - var hc = new TestHostContext(this, testName); - - _session = new DapDebugSession(); - _session.Initialize(hc); - - _sentEvents = new List(); - _sentResponses = new List(); - _eventWaiters = new List<(Predicate, TaskCompletionSource)>(); - - _mockServer = new Mock(); - _mockServer.Setup(x => x.SendEvent(It.IsAny())) - .Callback(e => - { - List> matchedWaiters = null; - lock (_eventWaitersLock) - { - _sentEvents.Add(e); - for (int i = _eventWaiters.Count - 1; i >= 0; i--) - { - var waiter = _eventWaiters[i]; - if (!waiter.Predicate(e)) - { - continue; - } - - matchedWaiters ??= new List>(); - matchedWaiters.Add(waiter.Completion); - _eventWaiters.RemoveAt(i); - } - } - - if (matchedWaiters == null) - { - return; - } - - foreach (var waiter in matchedWaiters) - { - waiter.TrySetResult(e); - } - }); - _mockServer.Setup(x => x.SendResponse(It.IsAny())) - .Callback(r => _sentResponses.Add(r)); - - _session.SetDapServer(_mockServer.Object); - - return hc; - } - - private Mock CreateMockStep(string displayName, TaskResult? result = null) - { - var mockEc = new Mock(); - mockEc.SetupAllProperties(); - mockEc.Object.Result = result; - - var mockStep = new Mock(); - mockStep.Setup(x => x.DisplayName).Returns(displayName); - mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); - - return mockStep; - } - - private Mock CreateMockJobContext(string jobName = "test-job") - { - var mockJobContext = new Mock(); - mockJobContext.Setup(x => x.GetGitHubContext("job")).Returns(jobName); - return mockJobContext; - } - - private async Task InitializeSessionAsync() - { - var initializedEventTask = WaitForEventAsync(e => e.EventType == "initialized"); - - var initJson = JsonConvert.SerializeObject(new Request - { - Seq = 1, - Type = "request", - Command = "initialize" - }); - await _session.HandleMessageAsync(initJson, CancellationToken.None); - - var attachJson = JsonConvert.SerializeObject(new Request - { - Seq = 2, - Type = "request", - Command = "attach" - }); - await _session.HandleMessageAsync(attachJson, CancellationToken.None); - - var configJson = JsonConvert.SerializeObject(new Request - { - Seq = 3, - Type = "request", - Command = "configurationDone" - }); - await _session.HandleMessageAsync(configJson, CancellationToken.None); - await WaitForTaskAsync(initializedEventTask); - } - - private Task WaitForEventAsync(Predicate predicate) - { - lock (_eventWaitersLock) - { - foreach (var sentEvent in _sentEvents) - { - if (predicate(sentEvent)) - { - return Task.FromResult(sentEvent); - } - } - - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _eventWaiters.Add((predicate, completion)); - return completion.Task; - } - } - - private Task WaitForEventAsync(string eventType) - { - return WaitForEventAsync(e => string.Equals(e.EventType, eventType, StringComparison.Ordinal)); - } - - private static async Task WaitForTaskAsync(Task task) - { - await task.WaitAsync(DefaultAsyncTimeout); - } - - private static async Task WaitForTaskAsync(Task task) - { - return await task.WaitAsync(DefaultAsyncTimeout); - } - - private async Task WaitForStepPauseAsync(Task stepTask) - { - var stoppedEvent = await WaitForTaskAsync(WaitForEventAsync("stopped")); - Assert.False(stepTask.IsCompleted); - Assert.Equal(DapSessionState.Paused, _session.State); - return stoppedEvent; - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void InitialStateIsWaitingForConnection() - { - using (CreateTestContext()) - { - Assert.Equal(DapSessionState.WaitingForConnection, _session.State); - Assert.False(_session.IsActive); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task InitializeHandlerSetsInitializingState() - { - using (CreateTestContext()) - { - var json = JsonConvert.SerializeObject(new Request - { - Seq = 1, - Type = "request", - Command = "initialize" - }); - - await _session.HandleMessageAsync(json, CancellationToken.None); - - Assert.Equal(DapSessionState.Initializing, _session.State); - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ConfigurationDoneSetsReadyState() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - Assert.Equal(DapSessionState.Ready, _session.State); - Assert.True(_session.IsActive); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepStartingPausesAndSendsStoppedEvent() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Checkout code"); - var jobContext = CreateMockJobContext(); - - var cts = new CancellationTokenSource(); - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); - - await WaitForStepPauseAsync(stepTask); - - var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); - Assert.Single(stoppedEvents); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task NextCommandPausesOnFollowingStep() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step1 = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(step1Task); - - var nextJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "next" - }); - await _session.HandleMessageAsync(nextJson, CancellationToken.None); - await WaitForTaskAsync(step1Task); - - _session.OnStepCompleted(step1.Object); - _sentEvents.Clear(); - - var step2 = CreateMockStep("Step 2"); - var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); - - await WaitForStepPauseAsync(step2Task); - - var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); - Assert.Single(stoppedEvents); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 11, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(step2Task); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ContinueCommandSkipsNextPause() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step1 = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(step1Task); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(step1Task); - - _session.OnStepCompleted(step1.Object); - _sentEvents.Clear(); - - var step2 = CreateMockStep("Step 2"); - var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); - - await WaitForTaskAsync(step2Task); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task CancellationUnblocksPausedStep() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var cts = new CancellationTokenSource(); - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); - - await WaitForStepPauseAsync(stepTask); - - cts.Cancel(); - - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task CancelSessionSendsTerminatedAndExitedEvents() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _sentEvents.Clear(); - - _session.CancelSession(); - - Assert.Equal(DapSessionState.Terminated, _session.State); - Assert.False(_session.IsActive); - - var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); - var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); - Assert.Single(terminatedEvents); - Assert.Single(exitedEvents); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task CancelSessionReleasesBlockedStep() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Blocked Step"); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - _session.CancelSession(); - - await WaitForTaskAsync(stepTask); - Assert.Equal(DapSessionState.Terminated, _session.State); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ReconnectionResendStoppedEvent() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); - Assert.Single(stoppedEvents); - - _session.HandleClientDisconnected(); - Assert.Equal(DapSessionState.Paused, _session.State); - - _sentEvents.Clear(); - _session.HandleClientConnected(); - - Assert.Single(_sentEvents); - Assert.Equal("stopped", _sentEvents[0].EventType); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task DisconnectCommandTerminatesSession() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var disconnectJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "disconnect" - }); - await _session.HandleMessageAsync(disconnectJson, CancellationToken.None); - - Assert.Equal(DapSessionState.Terminated, _session.State); - Assert.False(_session.IsActive); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepCompletedTracksCompletedSteps() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var step1 = CreateMockStep("Step 1"); - step1.Object.ExecutionContext.Result = TaskResult.Succeeded; - var jobContext = CreateMockJobContext(); - - var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(step1Task); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(step1Task); - - _session.OnStepCompleted(step1.Object); - - var stackTraceJson = JsonConvert.SerializeObject(new Request - { - Seq = 11, - Type = "request", - Command = "stackTrace" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(stackTraceJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task StoppedEventAndStackTraceMaskSecretStepDisplayName() - { - using (var hc = CreateTestContext()) - { - hc.SecretMasker.AddValue("ghs_step_secret"); - - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - - var step = CreateMockStep("Deploy ghs_step_secret"); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - var stoppedEvent = await WaitForStepPauseAsync(stepTask); - - var stoppedBody = Assert.IsType(stoppedEvent.Body); - Assert.Contains(DapVariableProvider.RedactedValue, stoppedBody.Description); - Assert.DoesNotContain("ghs_step_secret", stoppedBody.Description); - - var stackTraceJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "stackTrace" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(stackTraceJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - var stackTraceBody = Assert.IsType(_sentResponses[0].Body); - Assert.Single(stackTraceBody.StackFrames); - Assert.Contains(DapVariableProvider.RedactedValue, stackTraceBody.StackFrames[0].Name); - Assert.DoesNotContain("ghs_step_secret", stackTraceBody.StackFrames[0].Name); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 11, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnJobCompletedSendsTerminatedAndExitedEvents() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _sentEvents.Clear(); - - _session.OnJobCompleted(); - - Assert.Equal(DapSessionState.Terminated, _session.State); - - var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); - var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); - Assert.Single(terminatedEvents); - Assert.Single(exitedEvents); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepStartingNoOpWhenNotActive() - { - using (CreateTestContext()) - { - var step = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext(); - - var task = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - - await WaitForTaskAsync(task); - - _mockServer.Verify(x => x.SendEvent(It.IsAny()), Times.Never); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ThreadsCommandReturnsJobThread() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var threadsJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "threads" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(threadsJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ThreadsCommandMasksSecretJobName() - { - using (var hc = CreateTestContext()) - { - hc.SecretMasker.AddValue("very-secret-job"); - - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var step = CreateMockStep("Step 1"); - var jobContext = CreateMockJobContext("very-secret-job"); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var threadsJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "threads" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(threadsJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - var threadsBody = Assert.IsType(_sentResponses[0].Body); - Assert.Single(threadsBody.Threads); - Assert.Contains(DapVariableProvider.RedactedValue, threadsBody.Threads[0].Name); - Assert.DoesNotContain("very-secret-job", threadsBody.Threads[0].Name); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 11, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task UnsupportedCommandReturnsErrorResponse() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var json = JsonConvert.SerializeObject(new Request - { - Seq = 99, - Type = "request", - Command = "stepIn" - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(json, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.False(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task FullFlowInitAttachConfigStepContinueComplete() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - _sentEvents.Clear(); - _sentResponses.Clear(); - - Assert.Equal(DapSessionState.Ready, _session.State); - - var step = CreateMockStep("Run tests"); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); - Assert.Single(stoppedEvents); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - - var continuedEvents = _sentEvents.FindAll(e => e.EventType == "continued"); - Assert.Single(continuedEvents); - - step.Object.ExecutionContext.Result = TaskResult.Succeeded; - _session.OnStepCompleted(step.Object); - - _sentEvents.Clear(); - _session.OnJobCompleted(); - - Assert.Equal(DapSessionState.Terminated, _session.State); - var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); - var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); - Assert.Single(terminatedEvents); - Assert.Single(exitedEvents); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task DoubleCancelSessionIsIdempotent() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _sentEvents.Clear(); - - _session.CancelSession(); - _session.CancelSession(); - - Assert.Equal(DapSessionState.Terminated, _session.State); - - var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); - Assert.Single(terminatedEvents); - } - } - - #region Scope inspection integration tests - - private Mock CreateMockStepWithContext( - string displayName, - DictionaryContextData expressionValues, - TaskResult? result = null) - { - var mockEc = new Mock(); - mockEc.SetupAllProperties(); - mockEc.Object.Result = result; - mockEc.Setup(x => x.ExpressionValues).Returns(expressionValues); - - var mockStep = new Mock(); - mockStep.Setup(x => x.DisplayName).Returns(displayName); - mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); - - return mockStep; - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ScopesRequestReturnsScopesFromExecutionContext() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "repository", new StringContextData("owner/repo") } - }; - exprValues["env"] = new DictionaryContextData - { - { "CI", new StringContextData("true") } - }; - - var step = CreateMockStepWithContext("Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var scopesJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "scopes", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new ScopesArguments { FrameId = 1 }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(scopesJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task VariablesRequestReturnsVariablesFromExecutionContext() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["env"] = new DictionaryContextData - { - { "CI", new StringContextData("true") }, - { "HOME", new StringContextData("/home/runner") } - }; - - var step = CreateMockStepWithContext("Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - // "env" is at ScopeNames index 1 → variablesReference = 2 - var variablesJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "variables", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new VariablesArguments { VariablesReference = 2 }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(variablesJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ScopesRequestReturnsEmptyWhenNoStepActive() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var scopesJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "scopes", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new ScopesArguments { FrameId = 1 }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(scopesJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task SecretsValuesAreRedactedThroughSession() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["secrets"] = new DictionaryContextData - { - { "MY_TOKEN", new StringContextData("ghp_verysecret") } - }; - - var step = CreateMockStepWithContext("Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - // "secrets" is at ScopeNames index 5 → variablesReference = 6 - var variablesJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "variables", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new VariablesArguments { VariablesReference = 6 }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(variablesJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Verify the response body actually contains redacted values - var body = _sentResponses[0].Body; - Assert.NotNull(body); - var varsBody = Assert.IsType(body); - Assert.NotEmpty(varsBody.Variables); - foreach (var variable in varsBody.Variables) - { - Assert.Equal(DapVariableProvider.RedactedValue, variable.Value); - Assert.DoesNotContain("ghp_verysecret", variable.Value); - } - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - #endregion - - #region Evaluate request integration tests - - private Mock CreateMockStepWithEvaluatableContext( - TestHostContext hc, - string displayName, - DictionaryContextData expressionValues, - TaskResult? result = null) - { - var mockEc = new Mock(); - mockEc.SetupAllProperties(); - mockEc.Object.Result = result; - mockEc.Setup(x => x.ExpressionValues).Returns(expressionValues); - mockEc.Setup(x => x.ExpressionFunctions) - .Returns(new List()); - mockEc.Setup(x => x.Global).Returns(new GlobalContext - { - FileTable = new List(), - Variables = new Variables(hc, new Dictionary()), - }); - mockEc.Setup(x => x.Write(It.IsAny(), It.IsAny())); - - var mockStep = new Mock(); - mockStep.Setup(x => x.DisplayName).Returns(displayName); - mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); - - return mockStep; - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task EvaluateRequestReturnsResult() - { - using (var hc = CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "repository", new StringContextData("owner/repo") } - }; - - var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "github.repository", - FrameId = 1, - Context = "watch" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task EvaluateRequestReturnsGracefulErrorWhenNoContext() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - // No step is active — evaluate should still succeed with - // a descriptive "no context" message, not an error response. - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "github.repository", - FrameId = 1, - Context = "hover" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task EvaluateRequestWithWrapperSyntax() - { - using (var hc = CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "event_name", new StringContextData("push") } - }; - - var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "${{ github.event_name }}", - FrameId = 1, - Context = "watch" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - // Resume to unblock - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - #endregion - - #region REPL routing tests - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ReplHelpReturnsHelpText() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "help", - Context = "repl" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ReplExpressionFallsThroughToEvaluation() - { - using (var hc = CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "repository", new StringContextData("owner/repo") } - }; - - var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - // In REPL context, a non-DSL expression should still evaluate - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "github.repository", - Context = "repl" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ReplParseErrorReturnsErrorResult() - { - using (CreateTestContext()) - { - await InitializeSessionAsync(); - - // Malformed run() command - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 10, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "run()", - Context = "repl" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - // The response is successful at the DAP level (not an error - // response), but the result body conveys the parse error - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task WatchContextStillEvaluatesExpressions() - { - using (var hc = CreateTestContext()) - { - await InitializeSessionAsync(); - _session.HandleClientConnected(); - - var exprValues = new DictionaryContextData(); - exprValues["github"] = new DictionaryContextData - { - { "repository", new StringContextData("owner/repo") } - }; - - var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); - var jobContext = CreateMockJobContext(); - - var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); - await WaitForStepPauseAsync(stepTask); - - // watch context should NOT route through REPL even if input - // looks like a DSL command — it should evaluate as expression - var evaluateJson = JsonConvert.SerializeObject(new Request - { - Seq = 20, - Type = "request", - Command = "evaluate", - Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments - { - Expression = "github.repository", - Context = "watch" - }) - }); - _sentResponses.Clear(); - await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); - - Assert.Single(_sentResponses); - Assert.True(_sentResponses[0].Success); - - var continueJson = JsonConvert.SerializeObject(new Request - { - Seq = 21, - Type = "request", - Command = "continue" - }); - await _session.HandleMessageAsync(continueJson, CancellationToken.None); - await WaitForTaskAsync(stepTask); - } - } - - #endregion - } -} diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index ddf6f0153e1..f680294d791 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -5,6 +5,7 @@ using GitHub.Runner.Worker; using GitHub.Runner.Worker.Dap; using Moq; +using Newtonsoft.Json; using Xunit; namespace GitHub.Runner.Common.Tests.Worker @@ -21,6 +22,31 @@ private TestHostContext CreateTestContext([CallerMemberName] string testName = " return hc; } + private static Mock CreateServerMock() + { + var mockServer = new Mock(); + mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + mockServer.Setup(x => x.SendEvent(It.IsAny())); + mockServer.Setup(x => x.SendResponse(It.IsAny())); + return mockServer; + } + + private Task CompleteHandshakeAsync() + { + var configJson = JsonConvert.SerializeObject(new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + return _debugger.HandleMessageAsync(configJson, CancellationToken.None); + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -40,22 +66,13 @@ public async Task StartAndStopLifecycle() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); - mockServer.Verify(x => x.SetSession(mockSession.Object), Times.Once); - mockSession.Verify(x => x.SetDapServer(mockServer.Object), Times.Once); + mockServer.Verify(x => x.SetDebugger(It.IsAny()), Times.Once); mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once); await _debugger.StopAsync(); @@ -70,16 +87,8 @@ public async Task StartUsesCustomPortFromEnvironment() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "9999"); try @@ -105,16 +114,8 @@ public async Task StartIgnoresInvalidPortFromEnvironment() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "not-a-number"); try @@ -122,7 +123,6 @@ public async Task StartIgnoresInvalidPortFromEnvironment() var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); - // Falls back to default port mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once); await _debugger.StopAsync(); @@ -141,16 +141,8 @@ public async Task StartIgnoresOutOfRangePortFromEnvironment() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "99999"); try @@ -158,7 +150,6 @@ public async Task StartIgnoresOutOfRangePortFromEnvironment() var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); - // Falls back to default port mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once); await _debugger.StopAsync(); @@ -173,31 +164,20 @@ public async Task StartIgnoresOutOfRangePortFromEnvironment() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyCallsServerAndSession() + public async Task WaitUntilReadyCallsServerAndCompletesHandshake() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .Returns(Task.CompletedTask); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); + await CompleteHandshakeAsync(); await _debugger.WaitUntilReadyAsync(cts.Token); mockServer.Verify(x => x.WaitForConnectionAsync(It.IsAny()), Times.Once); - mockSession.Verify(x => x.WaitForHandshakeAsync(It.IsAny()), Times.Once); + Assert.Equal(DapSessionState.Ready, _debugger.State); await _debugger.StopAsync(); } @@ -210,29 +190,17 @@ public async Task WaitUntilReadyRegistersCancellation() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .Returns(Task.CompletedTask); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); + await CompleteHandshakeAsync(); await _debugger.WaitUntilReadyAsync(cts.Token); - // Trigger cancellation — should call CancelSession on the session cts.Cancel(); - mockSession.Verify(x => x.CancelSession(), Times.Once); + Assert.Equal(DapSessionState.Terminated, _debugger.State); await _debugger.StopAsync(); } } @@ -251,181 +219,24 @@ public async Task StopWithoutStartDoesNotThrow() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task OnStepStartingDelegatesWhenActive() - { - using (var hc = CreateTestContext()) - { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.IsActive).Returns(true); - - hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); - - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - - var mockStep = new Mock(); - var mockJobContext = new Mock(); - - await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None); - - mockSession.Verify(x => x.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, true, CancellationToken.None), Times.Once); - - await _debugger.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepStartingSkipsWhenNotActive() - { - using (var hc = CreateTestContext()) - { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.IsActive).Returns(false); - - hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); - - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - - var mockStep = new Mock(); - var mockJobContext = new Mock(); - - await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None); - - mockSession.Verify(x => x.OnStepStartingAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - - await _debugger.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepCompletedDelegatesWhenActive() - { - using (var hc = CreateTestContext()) - { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.IsActive).Returns(true); - - hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); - - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - - var mockStep = new Mock(); - _debugger.OnStepCompleted(mockStep.Object); - - mockSession.Verify(x => x.OnStepCompleted(mockStep.Object), Times.Once); - - await _debugger.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnJobCompletedDelegatesWhenActive() + public async Task OnJobCompletedStopsServer() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.IsActive).Returns(true); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); + await CompleteHandshakeAsync(); + await _debugger.WaitUntilReadyAsync(cts.Token); await _debugger.OnJobCompletedAsync(); - mockSession.Verify(x => x.OnJobCompleted(), Times.Once); mockServer.Verify(x => x.StopAsync(), Times.Once); } } - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task OnStepStartingSwallowsSessionException() - { - using (var hc = CreateTestContext()) - { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.IsActive).Returns(true); - mockSession.Setup(x => x.OnStepStartingAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException("test error")); - - hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); - - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - - var mockStep = new Mock(); - var mockJobContext = new Mock(); - - // Should not throw - await _debugger.OnStepStartingAsync(mockStep.Object, mockJobContext.Object, CancellationToken.None); - - await _debugger.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void CancelSessionDelegatesToSession() - { - using (var hc = CreateTestContext()) - { - var mockServer = new Mock(); - var mockSession = new Mock(); - - hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); - - // CancelSession before start should not throw - _debugger.CancelSession(); - } - } - [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -433,7 +244,6 @@ public async Task WaitUntilReadyBeforeStartIsNoOp() { using (CreateTestContext()) { - // Should not throw or block await _debugger.WaitUntilReadyAsync(CancellationToken.None); } } @@ -455,20 +265,15 @@ public async Task WaitUntilReadyPassesLinkedTokenNotOriginal() .Returns(Task.CompletedTask); mockServer.Setup(x => x.StopAsync()) .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .Returns(Task.CompletedTask); + mockServer.Setup(x => x.SendResponse(It.IsAny())); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); + await CompleteHandshakeAsync(); await _debugger.WaitUntilReadyAsync(cts.Token); - // The token passed to WaitForConnectionAsync should be a linked token - // (combines job cancellation + internal timeout), not the raw job token Assert.NotEqual(cts.Token, capturedToken); await _debugger.StopAsync(); @@ -482,8 +287,6 @@ public async Task WaitUntilReadyTimeoutSurfacesAsTimeoutException() { using (var hc = CreateTestContext()) { - // Mock WaitForConnectionAsync to block until its cancellation token fires, - // then throw OperationCanceledException — simulating "no client connected" var mockServer = new Mock(); mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); @@ -497,22 +300,15 @@ public async Task WaitUntilReadyTimeoutSurfacesAsTimeoutException() mockServer.Setup(x => x.StopAsync()) .Returns(Task.CompletedTask); - var mockSession = new Mock(); - hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); var jobCts = new CancellationTokenSource(); await _debugger.StartAsync(jobCts.Token); - // Start wait in background var waitTask = _debugger.WaitUntilReadyAsync(jobCts.Token); await Task.Delay(50); Assert.False(waitTask.IsCompleted); - // The linked token includes the internal timeout CTS. - // We can't easily make it fire fast (it uses minutes), but we can - // verify the contract: cancelling the job token produces OCE, not TimeoutException. jobCts.Cancel(); var ex = await Assert.ThrowsAnyAsync(() => waitTask); Assert.IsNotType(ex); @@ -528,33 +324,16 @@ public async Task WaitUntilReadyUsesCustomTimeoutFromEnvironment() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .Returns(Task.CompletedTask); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "30"); try { var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); - - // The timeout is applied internally — we can verify it worked - // by checking the trace output contains the custom value + await CompleteHandshakeAsync(); await _debugger.WaitUntilReadyAsync(cts.Token); - - // If we got here without exception, the custom timeout was accepted - // (it didn't default to something that would fail) await _debugger.StopAsync(); } finally @@ -571,29 +350,16 @@ public async Task WaitUntilReadyIgnoresInvalidTimeoutFromEnvironment() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .Returns(Task.CompletedTask); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "not-a-number"); try { var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); + await CompleteHandshakeAsync(); await _debugger.WaitUntilReadyAsync(cts.Token); - - // Should succeed with default timeout (no crash from bad env var) await _debugger.StopAsync(); } finally @@ -610,29 +376,16 @@ public async Task WaitUntilReadyIgnoresZeroTimeoutFromEnvironment() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - var mockSession = new Mock(); - mockSession.Setup(x => x.WaitForHandshakeAsync(It.IsAny())) - .Returns(Task.CompletedTask); - + var mockServer = CreateServerMock(); hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "0"); try { var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); + await CompleteHandshakeAsync(); await _debugger.WaitUntilReadyAsync(cts.Token); - - // Zero is not > 0, so falls back to default (should succeed, not throw) await _debugger.StopAsync(); } finally @@ -662,10 +415,7 @@ public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledExc mockServer.Setup(x => x.StopAsync()) .Returns(Task.CompletedTask); - var mockSession = new Mock(); - hc.SetSingleton(mockServer.Object); - hc.SetSingleton(mockSession.Object); var cts = new CancellationTokenSource(); await _debugger.StartAsync(cts.Token); @@ -673,7 +423,6 @@ public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledExc var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); await Task.Delay(50); - // Cancel the job token — should surface as OperationCanceledException, NOT TimeoutException cts.Cancel(); var ex = await Assert.ThrowsAnyAsync(() => waitTask); Assert.IsNotType(ex); diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs index f91e5fa997a..1f71d853731 100644 --- a/src/Test/L0/Worker/DapServerL0.cs +++ b/src/Test/L0/Worker/DapServerL0.cs @@ -44,8 +44,8 @@ public void SetSessionAcceptsMock() { using (CreateTestContext()) { - var mockSession = new Mock(); - _server.SetSession(mockSession.Object); + var mockSession = new Mock(); + _server.SetDebugger(mockSession.Object); } } @@ -182,11 +182,11 @@ public async Task MessageFraming_ValidMessage_ProcessedSuccessfully() using (var hc = CreateTestContext()) { var messageReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var mockSession = new Mock(); + var mockSession = new Mock(); mockSession.Setup(x => x.HandleMessageAsync(It.IsAny(), It.IsAny())) .Callback((json, ct) => messageReceived.TrySetResult(json)) .Returns(Task.CompletedTask); - _server.SetSession(mockSession.Object); + _server.SetDebugger(mockSession.Object); var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await _server.StartAsync(0, cts.Token); From fa298ebe45fc85297926fea7f88ba59211d6eb8c Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Tue, 17 Mar 2026 14:13:18 +0000 Subject: [PATCH 36/42] Add connection telemetry --- src/Runner.Worker/JobRunner.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 828ba67a0c6..bfe64cb334d 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -29,6 +29,7 @@ public interface IJobRunner : IRunnerService public sealed class JobRunner : RunnerService, IJobRunner { + private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult"; private IJobServerQueue _jobServerQueue; private RunnerSettings _runnerSettings; private ITempDirectoryManager _tempDirectoryManager; @@ -193,6 +194,7 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat catch (Exception ex) { Trace.Error($"Failed to start DAP debugger: {ex.Message}"); + AddDebuggerConnectionTelemetry(jobContext, "Failed"); jobContext.Error("Failed to start debugger."); return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); } @@ -247,16 +249,19 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat try { await dapDebugger.WaitUntilReadyAsync(jobRequestCancellationToken); + AddDebuggerConnectionTelemetry(jobContext, "Connected"); } catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) { Trace.Info("Job was cancelled before debugger client connected."); + AddDebuggerConnectionTelemetry(jobContext, "Canceled"); jobContext.Error("Job was cancelled before debugger client connected."); return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled); } catch (Exception ex) { Trace.Error($"DAP debugger failed to become ready: {ex.Message}"); + AddDebuggerConnectionTelemetry(jobContext, "Failed"); // If debugging was requested but the debugger is not available, fail the job var errorMessage = "The debugger failed to start or no debugger client connected in time."; @@ -491,6 +496,15 @@ private async Task CompleteJobAsync(IJobServer jobServer, IExecution throw new AggregateException(exceptions); } + private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result) + { + jobContext.Global.JobTelemetry.Add(new JobTelemetry + { + Type = JobTelemetryType.General, + Message = $"{DebuggerConnectionTelemetryPrefix}: {result}" + }); + } + private void MaskTelemetrySecrets(List jobTelemetry) { foreach (var telemetryItem in jobTelemetry) From d15ab3d8fa304afe28fcfd9853e40b3da8082490 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Tue, 17 Mar 2026 15:30:19 +0000 Subject: [PATCH 37/42] Apply suggestions from code review Co-authored-by: Tingluo Huang --- src/Runner.Worker/JobRunner.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index bfe64cb334d..bb1c74d8b4b 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -194,7 +194,7 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat catch (Exception ex) { Trace.Error($"Failed to start DAP debugger: {ex.Message}"); - AddDebuggerConnectionTelemetry(jobContext, "Failed"); + AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); jobContext.Error("Failed to start debugger."); return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); } @@ -261,11 +261,10 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat catch (Exception ex) { Trace.Error($"DAP debugger failed to become ready: {ex.Message}"); - AddDebuggerConnectionTelemetry(jobContext, "Failed"); + AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); // If debugging was requested but the debugger is not available, fail the job - var errorMessage = "The debugger failed to start or no debugger client connected in time."; - jobContext.Error(errorMessage); + jobContext.Error("The debugger failed to start or no debugger client connected in time."); return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); } } From a86ed1ec8f940806d789d4d45d4a2f4e245e2d01 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 18 Mar 2026 09:36:00 +0000 Subject: [PATCH 38/42] PR Feedback --- src/Runner.Worker/Dap/IDapDebugger.cs | 3 --- src/Test/L0/Worker/JobExtensionL0.cs | 1 - src/Test/L0/Worker/StepsRunnerL0.cs | 1 - 3 files changed, 5 deletions(-) diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs index 52de741c281..d895850dac3 100644 --- a/src/Runner.Worker/Dap/IDapDebugger.cs +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -17,11 +17,8 @@ public enum DapSessionState [ServiceLocator(Default = typeof(DapDebugger))] public interface IDapDebugger : IRunnerService { - bool IsActive { get; } Task StartAsync(CancellationToken cancellationToken); Task WaitUntilReadyAsync(CancellationToken cancellationToken); - Task StopAsync(); - void CancelSession(); Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken); void OnStepCompleted(IStep step); Task OnJobCompletedAsync(); diff --git a/src/Test/L0/Worker/JobExtensionL0.cs b/src/Test/L0/Worker/JobExtensionL0.cs index 34413552c5f..40c495a8183 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -550,7 +550,6 @@ private async Task EnsureSnapshotPostJobStepForToken(TemplateToken snapshotToken _stepsRunner.Initialize(hc); var mockDapDebugger = new Mock(); - mockDapDebugger.Setup(x => x.IsActive).Returns(false); hc.SetSingleton(mockDapDebugger.Object); await _stepsRunner.RunAsync(_jobEc); diff --git a/src/Test/L0/Worker/StepsRunnerL0.cs b/src/Test/L0/Worker/StepsRunnerL0.cs index e9e99d82ce5..2ab9f57fda9 100644 --- a/src/Test/L0/Worker/StepsRunnerL0.cs +++ b/src/Test/L0/Worker/StepsRunnerL0.cs @@ -64,7 +64,6 @@ private TestHostContext CreateTestContext([CallerMemberName] String testName = " _stepsRunner.Initialize(hc); var mockDapDebugger = new Mock(); - mockDapDebugger.Setup(x => x.IsActive).Returns(false); hc.SetSingleton(mockDapDebugger.Object); return hc; From ba771350ac13fe55759d7f7623a8161703b189cc Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 18 Mar 2026 15:20:56 +0000 Subject: [PATCH 39/42] merge debugger and server --- src/Runner.Worker/Dap/DapDebugger.cs | 438 +++++++++++++++++-- src/Runner.Worker/Dap/DapReplExecutor.cs | 16 +- src/Runner.Worker/Dap/DapServer.cs | 504 ---------------------- src/Runner.Worker/Dap/IDapServer.cs | 25 -- src/Test/L0/ServiceInterfacesL0.cs | 1 - src/Test/L0/Worker/DapDebuggerL0.cs | 520 ++++++++++++----------- src/Test/L0/Worker/DapReplExecutorL0.cs | 17 +- src/Test/L0/Worker/DapServerL0.cs | 403 ------------------ 8 files changed, 678 insertions(+), 1246 deletions(-) delete mode 100644 src/Runner.Worker/Dap/DapServer.cs delete mode 100644 src/Runner.Worker/Dap/IDapServer.cs delete mode 100644 src/Test/L0/Worker/DapServerL0.cs diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index a10bda19a86..751e7bad8ca 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -1,5 +1,9 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; using System.Threading; using System.Threading.Tasks; using GitHub.DistributedTask.WebApi; @@ -20,15 +24,18 @@ internal sealed class CompletedStepInfo /// /// Single public facade for the Debug Adapter Protocol subsystem. - /// Owns the DapServer internally and handles handshake, step-level - /// pauses, variable inspection, reconnection, and cancellation. + /// Owns the full transport, handshake, step-level pauses, variable + /// inspection, reconnection, and cancellation flow. /// - public sealed class DapDebugger : RunnerService, IDapDebugger, IDapDebuggerCallbacks + 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 + private const int MaxHeaderLineLength = 8192; // 8 KB // Thread ID for the single job execution thread private const int JobThreadId = 1; @@ -39,7 +46,15 @@ public sealed class DapDebugger : RunnerService, IDapDebugger, IDapDebuggerCallb // Frame IDs for completed steps start at 1000 private const int CompletedFrameIdBase = 1000; - private IDapServer _server; + private TcpListener _listener; + private TcpClient _client; + private NetworkStream _stream; + private CancellationTokenSource _cts; + private TaskCompletionSource _connectionTcs; + private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); + private int _nextSeq = 1; + private Task _connectionLoopTask; + private volatile bool _acceptConnections = true; private volatile DapSessionState _state = DapSessionState.WaitingForConnection; private CancellationTokenRegistration? _cancellationRegistration; private volatile bool _started; @@ -84,26 +99,34 @@ public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); _variableProvider = new DapVariableProvider(hostContext); + _replExecutor = new DapReplExecutor(hostContext, SendOutput); Trace.Info("DapDebugger initialized"); } - public async Task StartAsync(CancellationToken cancellationToken) + public Task StartAsync(CancellationToken cancellationToken) { ResetSessionState(); var port = ResolvePort(); - SetDapServer(HostContext.GetService()); - _server.SetDebugger(this); + Trace.Info($"Starting DAP debugger on port {port}"); - await _server.StartAsync(port, cancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _connectionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _listener = new TcpListener(IPAddress.Loopback, port); + _listener.Start(); + Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}"); + + _connectionLoopTask = ConnectionLoopAsync(_cts.Token); _started = true; Trace.Info($"DAP debugger started on port {port}"); + return Task.CompletedTask; } public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) { - if (!_started || _server == null) + if (!_started || _listener == null) { return; } @@ -115,7 +138,7 @@ public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) try { Trace.Info($"Waiting for debugger client connection (timeout: {timeoutMinutes} minutes)..."); - await _server.WaitForConnectionAsync(linkedCts.Token); + await WaitForConnectionAsync(linkedCts.Token); Trace.Info("Debugger client connected."); await WaitForHandshakeAsync(linkedCts.Token); @@ -158,12 +181,27 @@ public async Task StopAsync() _cancellationRegistration = null; } - if (_server != null && _started) + if (_started) { try { Trace.Info("Stopping DAP debugger"); - await _server.StopAsync(); + _acceptConnections = false; + _cts?.Cancel(); + + CleanupConnection(); + + try { _listener?.Stop(); } + catch { /* best effort */ } + + if (_connectionLoopTask != null) + { + try + { + await Task.WhenAny(_connectionLoopTask, Task.Delay(5000)); + } + catch { /* best effort */ } + } } catch (Exception ex) { @@ -181,8 +219,12 @@ public async Task StopAsync() } _isClientConnected = false; - _server = null; - _replExecutor = null; + _listener = null; + _client = null; + _stream = null; + _cts = null; + _connectionTcs = null; + _connectionLoopTask = null; _started = false; } @@ -201,14 +243,14 @@ public void CancelSession() } // Send terminated event to debugger so it updates its UI - _server?.SendEvent(new Event + SendEvent(new Event { EventType = "terminated", Body = new TerminatedEventBody() }); // Send exited event with cancellation exit code (130 = SIGINT convention) - _server?.SendEvent(new Event + SendEvent(new Event { EventType = "exited", Body = new ExitedEventBody { ExitCode = 130 } @@ -278,21 +320,6 @@ public void OnStepCompleted(IStep step) } } - Task IDapDebuggerCallbacks.HandleMessageAsync(string messageJson, CancellationToken cancellationToken) - { - return HandleMessageAsync(messageJson, cancellationToken); - } - - void IDapDebuggerCallbacks.HandleClientConnected() - { - HandleClientConnected(); - } - - void IDapDebuggerCallbacks.HandleClientDisconnected() - { - HandleClientDisconnected(); - } - internal async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken) { Request request = null; @@ -305,6 +332,12 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can return; } + if (!string.Equals(request.Type, "request", StringComparison.OrdinalIgnoreCase)) + { + Trace.Warning("Received DAP message that was not a request"); + return; + } + Trace.Info("Handling DAP request"); Response response; @@ -341,7 +374,7 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can response.RequestSeq = request.Seq; response.Command = request.Command; - _server?.SendResponse(response); + SendResponse(response); } catch (Exception ex) { @@ -352,7 +385,7 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can var errorResponse = CreateResponse(request, false, maskedMessage, body: null); errorResponse.RequestSeq = request.Seq; errorResponse.Command = request.Command; - _server?.SendResponse(errorResponse); + SendResponse(errorResponse); } } } @@ -387,15 +420,328 @@ internal void HandleClientDisconnected() // Intentionally do NOT release the command TCS here. // The session stays paused, waiting for a client to reconnect. - // The server's connection loop will accept a new client and + // The debugger's connection loop will accept a new client and // call HandleClientConnected, which re-sends the stopped event. } - internal void SetDapServer(IDapServer server) + private async Task ConnectionLoopAsync(CancellationToken cancellationToken) + { + while (_acceptConnections && !cancellationToken.IsCancellationRequested) + { + try + { + Trace.Info("Waiting for debug client connection..."); + + using (cancellationToken.Register(() => + { + try { _listener?.Stop(); } + catch { /* listener already stopped */ } + })) + { + _client = await _listener.AcceptTcpClientAsync(); + } + + if (cancellationToken.IsCancellationRequested) + { + break; + } + + _stream = _client.GetStream(); + var remoteEndPoint = _client.Client.RemoteEndPoint; + Trace.Info($"Debug client connected from {remoteEndPoint}"); + + _connectionTcs.TrySetResult(true); + HandleClientConnected(); + + await ProcessMessagesAsync(cancellationToken); + + Trace.Info("Client disconnected, waiting for reconnection..."); + HandleClientDisconnected(); + CleanupConnection(); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (SocketException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Trace.Warning($"Connection error ({ex.GetType().Name})"); + CleanupConnection(); + + if (!_acceptConnections || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + await Task.Delay(100, cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + _connectionTcs?.TrySetCanceled(); + Trace.Info("Connection loop ended"); + } + + private void CleanupConnection() + { + _sendLock.Wait(); + try + { + try { _stream?.Close(); } catch { /* best effort */ } + try { _client?.Close(); } catch { /* best effort */ } + _stream = null; + _client = null; + } + finally + { + _sendLock.Release(); + } + } + + private async Task WaitForConnectionAsync(CancellationToken cancellationToken) + { + Trace.Info("Waiting for debug client to connect..."); + + using (cancellationToken.Register(() => _connectionTcs?.TrySetCanceled())) + { + await _connectionTcs.Task; + } + + Trace.Info("Debug client connected"); + } + + private async Task ProcessMessagesAsync(CancellationToken cancellationToken) + { + Trace.Info("Starting DAP message processing loop"); + + try + { + while (!cancellationToken.IsCancellationRequested && _client?.Connected == true) + { + var json = await ReadMessageAsync(cancellationToken); + if (json == null) + { + Trace.Info("Client disconnected (end of stream)"); + break; + } + + await HandleMessageAsync(json, cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Trace.Info("Message processing cancelled"); + } + catch (IOException ex) + { + Trace.Info($"Connection closed ({ex.GetType().Name})"); + } + catch (Exception ex) + { + Trace.Error($"Error in message loop ({ex.GetType().Name})"); + } + + Trace.Info("DAP message processing loop ended"); + } + + private async Task ReadMessageAsync(CancellationToken cancellationToken) { - _server = server; - _replExecutor = new DapReplExecutor(HostContext, server); - Trace.Info("DAP server reference set"); + int contentLength = -1; + + while (true) + { + var line = await ReadLineAsync(cancellationToken); + if (line == null) + { + return null; + } + + if (line.Length == 0) + { + break; + } + + if (line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase)) + { + var lengthStr = line.Substring(ContentLengthHeader.Length).Trim(); + if (!int.TryParse(lengthStr, out contentLength)) + { + throw new InvalidDataException($"Invalid Content-Length: {lengthStr}"); + } + } + } + + if (contentLength < 0) + { + throw new InvalidDataException("Missing Content-Length header"); + } + + if (contentLength > MaxMessageSize) + { + throw new InvalidDataException($"Message size {contentLength} exceeds maximum allowed size of {MaxMessageSize}"); + } + + var buffer = new byte[contentLength]; + var totalRead = 0; + while (totalRead < contentLength) + { + var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading message body"); + } + totalRead += bytesRead; + } + + var json = Encoding.UTF8.GetString(buffer); + Trace.Verbose("Received DAP message body"); + return json; + } + + private async Task ReadLineAsync(CancellationToken cancellationToken) + { + var lineBuilder = new StringBuilder(); + var buffer = new byte[1]; + var previousWasCr = false; + + while (true) + { + var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken); + if (bytesRead == 0) + { + return lineBuilder.Length > 0 ? lineBuilder.ToString() : null; + } + + var c = (char)buffer[0]; + + if (c == '\n' && previousWasCr) + { + if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r') + { + lineBuilder.Length--; + } + return lineBuilder.ToString(); + } + + previousWasCr = c == '\r'; + lineBuilder.Append(c); + + if (lineBuilder.Length > MaxHeaderLineLength) + { + throw new InvalidDataException($"Header line exceeds maximum length of {MaxHeaderLineLength}"); + } + } + } + + /// + /// Serializes and writes a DAP message with Content-Length framing. + /// Must be called within the _sendLock. + /// + /// Secret masking is intentionally NOT applied here at the serialization + /// layer. Masking the raw JSON would corrupt protocol envelope fields + /// (type, event, command, seq) if a secret collides with those strings. + /// Instead, each DAP producer masks user-visible text at the point of + /// construction via or the + /// runner's SecretMasker directly. See DapVariableProvider, DapReplExecutor, + /// and DapDebugger for the call sites. + /// + private void SendMessageInternal(ProtocolMessage message) + { + var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }); + + var bodyBytes = Encoding.UTF8.GetBytes(json); + var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + + _stream.Write(headerBytes, 0, headerBytes.Length); + _stream.Write(bodyBytes, 0, bodyBytes.Length); + _stream.Flush(); + + Trace.Verbose("Sent DAP message"); + } + + private void SendEvent(Event evt) + { + try + { + _sendLock.Wait(); + try + { + if (_stream == null) + { + Trace.Warning("Cannot send event: no client connected"); + return; + } + + evt.Seq = _nextSeq++; + SendMessageInternal(evt); + } + finally + { + _sendLock.Release(); + } + + Trace.Info("Sent event"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to send event ({ex.GetType().Name})"); + } + } + + private void SendResponse(Response response) + { + try + { + _sendLock.Wait(); + try + { + if (_stream == null) + { + Trace.Warning("Cannot send response: no client connected"); + return; + } + + response.Seq = _nextSeq++; + SendMessageInternal(response); + } + finally + { + _sendLock.Release(); + } + + Trace.Info("Sent response"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to send response ({ex.GetType().Name})"); + } + } + + private void SendOutput(string category, string text) + { + SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = category, + Output = text + } + }); } internal async Task WaitForHandshakeAsync(CancellationToken cancellationToken) @@ -471,13 +817,13 @@ internal void OnJobCompleted() exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1; } - _server?.SendEvent(new Event + SendEvent(new Event { EventType = "terminated", Body = new TerminatedEventBody() }); - _server?.SendEvent(new Event + SendEvent(new Event { EventType = "exited", Body = new ExitedEventBody @@ -507,6 +853,8 @@ private void ResetSessionState() _completedSteps.Clear(); _nextCompletedFrameId = CompletedFrameIdBase; _isClientConnected = false; + _acceptConnections = true; + _nextSeq = 1; } } @@ -563,7 +911,7 @@ private Response HandleInitialize(Request request) _ = Task.Run(async () => { await Task.Delay(50); - _server?.SendEvent(new Event + SendEvent(new Event { EventType = "initialized" }); @@ -969,7 +1317,7 @@ private async Task WaitForCommandAsync(CancellationToken cancellationToken) if (!cancellationToken.IsCancellationRequested && (command == DapCommand.Continue || command == DapCommand.Next)) { - _server?.SendEvent(new Event + SendEvent(new Event { EventType = "continued", Body = new ContinuedEventBody @@ -1019,7 +1367,7 @@ private void SendStoppedEvent(string reason, string description) return; } - _server?.SendEvent(new Event + SendEvent(new Event { EventType = "stopped", Body = new StoppedEventBody @@ -1058,7 +1406,7 @@ private Response CreateResponse(Request request, bool success, string message = }; } - private int ResolvePort() + internal int ResolvePort() { var portEnv = Environment.GetEnvironmentVariable(PortEnvironmentVariable); if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 0 && customPort <= 65535) @@ -1070,7 +1418,7 @@ private int ResolvePort() return DefaultPort; } - private int ResolveTimeout() + internal int ResolveTimeout() { var timeoutEnv = Environment.GetEnvironmentVariable(TimeoutEnvironmentVariable); if (!string.IsNullOrEmpty(timeoutEnv) && int.TryParse(timeoutEnv, out var customTimeout) && customTimeout > 0) diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index e0a746f7ea0..751f92c514c 100644 --- a/src/Runner.Worker/Dap/DapReplExecutor.cs +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -26,13 +26,13 @@ namespace GitHub.Runner.Worker.Dap internal sealed class DapReplExecutor { private readonly IHostContext _hostContext; - private readonly IDapServer _server; + private readonly Action _sendOutput; private readonly Tracing _trace; - public DapReplExecutor(IHostContext hostContext, IDapServer server) + public DapReplExecutor(IHostContext hostContext, Action sendOutput) { _hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext)); - _server = server; + _sendOutput = sendOutput ?? throw new ArgumentNullException(nameof(sendOutput)); _trace = hostContext.GetTrace(nameof(DapReplExecutor)); } @@ -353,15 +353,7 @@ internal Dictionary BuildEnvironment( private void SendOutput(string category, string text) { - _server?.SendEvent(new Event - { - EventType = "output", - Body = new OutputEventBody - { - Category = category, - Output = text - } - }); + _sendOutput(category, text); } private static EvaluateResponseBody ErrorResult(string message) diff --git a/src/Runner.Worker/Dap/DapServer.cs b/src/Runner.Worker/Dap/DapServer.cs deleted file mode 100644 index 13c921c3fe6..00000000000 --- a/src/Runner.Worker/Dap/DapServer.cs +++ /dev/null @@ -1,504 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using GitHub.Runner.Common; -using Newtonsoft.Json; - -namespace GitHub.Runner.Worker.Dap -{ - /// - /// TCP server for the Debug Adapter Protocol. - /// Handles Content-Length message framing, JSON serialization, - /// client reconnection, and graceful shutdown. - /// - public sealed class DapServer : RunnerService, IDapServer - { - private const string ContentLengthHeader = "Content-Length: "; - private const int MaxMessageSize = 10 * 1024 * 1024; // 10 MB - private const int MaxHeaderLineLength = 8192; // 8 KB - - private TcpListener _listener; - private TcpClient _client; - private NetworkStream _stream; - private IDapDebuggerCallbacks _debugger; - private CancellationTokenSource _cts; - private TaskCompletionSource _connectionTcs; - private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); - private int _nextSeq = 1; - private Task _connectionLoopTask; - private volatile bool _acceptConnections = true; - - public override void Initialize(IHostContext hostContext) - { - base.Initialize(hostContext); - Trace.Info("DapServer initialized"); - } - - void IDapServer.SetDebugger(IDapDebuggerCallbacks debugger) - { - SetDebugger(debugger); - } - - internal void SetDebugger(IDapDebuggerCallbacks debugger) - { - _debugger = debugger; - Trace.Info("Debugger callbacks set"); - } - - public Task StartAsync(int port, CancellationToken cancellationToken) - { - Trace.Info($"Starting DAP server on port {port}"); - - _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _connectionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _listener = new TcpListener(IPAddress.Loopback, port); - _listener.Start(); - Trace.Info($"DAP server listening on 127.0.0.1:{port}"); - - // Start the connection loop in the background - _connectionLoopTask = ConnectionLoopAsync(_cts.Token); - - return Task.CompletedTask; - } - - /// - /// Accepts client connections in a loop, supporting reconnection. - /// When a client disconnects, the server waits for a new connection - /// without blocking step execution. - /// - private async Task ConnectionLoopAsync(CancellationToken cancellationToken) - { - while (_acceptConnections && !cancellationToken.IsCancellationRequested) - { - try - { - Trace.Info("Waiting for debug client connection..."); - - using (cancellationToken.Register(() => - { - try { _listener?.Stop(); } - catch { /* listener already stopped */ } - })) - { - _client = await _listener.AcceptTcpClientAsync(); - } - - if (cancellationToken.IsCancellationRequested) - { - break; - } - - _stream = _client.GetStream(); - var remoteEndPoint = _client.Client.RemoteEndPoint; - Trace.Info($"Debug client connected from {remoteEndPoint}"); - - // Signal first connection (no-op on subsequent connections) - _connectionTcs.TrySetResult(true); - - // Notify debugger of new client - _debugger?.HandleClientConnected(); - - // Process messages until client disconnects - await ProcessMessagesAsync(cancellationToken); - - // Client disconnected — notify debugger and clean up - Trace.Info("Client disconnected, waiting for reconnection..."); - _debugger?.HandleClientDisconnected(); - CleanupConnection(); - } - catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) - { - break; - } - catch (SocketException) when (cancellationToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - Trace.Warning($"Connection error ({ex.GetType().Name})"); - CleanupConnection(); - - if (!_acceptConnections || cancellationToken.IsCancellationRequested) - { - break; - } - - // Brief delay before accepting next connection - try - { - await Task.Delay(100, cancellationToken); - } - catch (OperationCanceledException) - { - break; - } - } - } - - _connectionTcs.TrySetCanceled(); - Trace.Info("Connection loop ended"); - } - - /// - /// Cleans up the current client connection without stopping the listener. - /// - private void CleanupConnection() - { - _sendLock.Wait(); - try - { - try { _stream?.Close(); } catch { /* best effort */ } - try { _client?.Close(); } catch { /* best effort */ } - _stream = null; - _client = null; - } - finally - { - _sendLock.Release(); - } - } - - public async Task WaitForConnectionAsync(CancellationToken cancellationToken) - { - Trace.Info("Waiting for debug client to connect..."); - - using (cancellationToken.Register(() => _connectionTcs.TrySetCanceled())) - { - await _connectionTcs.Task; - } - - Trace.Info("Debug client connected"); - } - - public async Task StopAsync() - { - Trace.Info("Stopping DAP server"); - - _acceptConnections = false; - _cts?.Cancel(); - - CleanupConnection(); - - try { _listener?.Stop(); } - catch { /* best effort */ } - - if (_connectionLoopTask != null) - { - try - { - await Task.WhenAny(_connectionLoopTask, Task.Delay(5000)); - } - catch { /* best effort */ } - } - - Trace.Info("DAP server stopped"); - } - - private async Task ProcessMessagesAsync(CancellationToken cancellationToken) - { - Trace.Info("Starting DAP message processing loop"); - - try - { - while (!cancellationToken.IsCancellationRequested && _client?.Connected == true) - { - var json = await ReadMessageAsync(cancellationToken); - if (json == null) - { - Trace.Info("Client disconnected (end of stream)"); - break; - } - - await ProcessSingleMessageAsync(json, cancellationToken); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - Trace.Info("Message processing cancelled"); - } - catch (IOException ex) - { - Trace.Info($"Connection closed ({ex.GetType().Name})"); - } - catch (Exception ex) - { - Trace.Error($"Error in message loop ({ex.GetType().Name})"); - } - - Trace.Info("DAP message processing loop ended"); - } - - private async Task ProcessSingleMessageAsync(string json, CancellationToken cancellationToken) - { - Request request = null; - try - { - request = JsonConvert.DeserializeObject(json); - if (request == null || request.Type != "request") - { - Trace.Warning("Received DAP message that was not a request"); - return; - } - - Trace.Info("Received DAP request"); - - if (_debugger == null) - { - Trace.Error("No debugger configured"); - SendErrorResponse(request, "No debugger configured"); - return; - } - - // Pass raw JSON to the debugger — it handles deserialization, dispatch, - // and calls back to SendResponse when done. - await _debugger.HandleMessageAsync(json, cancellationToken); - } - catch (JsonException ex) - { - Trace.Error($"Failed to parse request ({ex.GetType().Name})"); - } - catch (Exception ex) - { - Trace.Error($"Error processing request ({ex.GetType().Name})"); - if (request != null) - { - SendErrorResponse(request, ex.Message); - } - } - } - - private void SendErrorResponse(Request request, string message) - { - var response = new Response - { - Type = "response", - RequestSeq = request.Seq, - Command = request.Command, - Success = false, - Message = message, - Body = new ErrorResponseBody - { - Error = new Message - { - Id = 1, - Format = message, - ShowUser = true - } - } - }; - - SendResponse(response); - } - - /// - /// Reads a DAP message using Content-Length framing. - /// Format: Content-Length: N\r\n\r\n{json} - /// - private async Task ReadMessageAsync(CancellationToken cancellationToken) - { - int contentLength = -1; - - while (true) - { - var line = await ReadLineAsync(cancellationToken); - if (line == null) - { - return null; - } - - if (line.Length == 0) - { - break; - } - - if (line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase)) - { - var lengthStr = line.Substring(ContentLengthHeader.Length).Trim(); - if (!int.TryParse(lengthStr, out contentLength)) - { - throw new InvalidDataException($"Invalid Content-Length: {lengthStr}"); - } - } - } - - if (contentLength < 0) - { - throw new InvalidDataException("Missing Content-Length header"); - } - - if (contentLength > MaxMessageSize) - { - throw new InvalidDataException($"Message size {contentLength} exceeds maximum allowed size of {MaxMessageSize}"); - } - - var buffer = new byte[contentLength]; - var totalRead = 0; - while (totalRead < contentLength) - { - var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken); - if (bytesRead == 0) - { - throw new EndOfStreamException("Connection closed while reading message body"); - } - totalRead += bytesRead; - } - - var json = Encoding.UTF8.GetString(buffer); - Trace.Verbose("Received DAP message body"); - return json; - } - - /// - /// Reads a line terminated by \r\n from the network stream. - /// - private async Task ReadLineAsync(CancellationToken cancellationToken) - { - var lineBuilder = new StringBuilder(); - var buffer = new byte[1]; - var previousWasCr = false; - - while (true) - { - var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken); - if (bytesRead == 0) - { - return lineBuilder.Length > 0 ? lineBuilder.ToString() : null; - } - - var c = (char)buffer[0]; - - if (c == '\n' && previousWasCr) - { - if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r') - { - lineBuilder.Length--; - } - return lineBuilder.ToString(); - } - - previousWasCr = (c == '\r'); - lineBuilder.Append(c); - - if (lineBuilder.Length > MaxHeaderLineLength) - { - throw new InvalidDataException($"Header line exceeds maximum length of {MaxHeaderLineLength}"); - } - } - } - - /// - /// Serializes and writes a DAP message with Content-Length framing. - /// Must be called within the _sendLock. - /// - /// Secret masking is intentionally NOT applied here at the serialization - /// layer. Masking the raw JSON would corrupt protocol envelope fields - /// (type, event, command, seq) if a secret collides with those strings. - /// Instead, each DAP producer masks user-visible text at the point of - /// construction via or the - /// runner's SecretMasker directly. See DapVariableProvider, DapReplExecutor, - /// and DapDebugger for the call sites. - /// - private void SendMessageInternal(ProtocolMessage message) - { - var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore - }); - - var bodyBytes = Encoding.UTF8.GetBytes(json); - var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n"; - var headerBytes = Encoding.ASCII.GetBytes(header); - - _stream.Write(headerBytes, 0, headerBytes.Length); - _stream.Write(bodyBytes, 0, bodyBytes.Length); - _stream.Flush(); - - Trace.Verbose("Sent DAP message"); - } - - public void SendMessage(ProtocolMessage message) - { - try - { - _sendLock.Wait(); - try - { - if (_stream == null) - { - return; - } - message.Seq = _nextSeq++; - SendMessageInternal(message); - } - finally - { - _sendLock.Release(); - } - } - catch (Exception ex) - { - Trace.Warning($"Failed to send message ({ex.GetType().Name})"); - } - } - - public void SendEvent(Event evt) - { - try - { - _sendLock.Wait(); - try - { - if (_stream == null) - { - Trace.Warning("Cannot send event: no client connected"); - return; - } - evt.Seq = _nextSeq++; - SendMessageInternal(evt); - } - finally - { - _sendLock.Release(); - } - Trace.Info("Sent event"); - } - catch (Exception ex) - { - Trace.Warning($"Failed to send event ({ex.GetType().Name})"); - } - } - - public void SendResponse(Response response) - { - try - { - _sendLock.Wait(); - try - { - if (_stream == null) - { - Trace.Warning("Cannot send response: no client connected"); - return; - } - response.Seq = _nextSeq++; - SendMessageInternal(response); - } - finally - { - _sendLock.Release(); - } - Trace.Info("Sent response"); - } - catch (Exception ex) - { - Trace.Warning($"Failed to send response ({ex.GetType().Name})"); - } - } - } -} diff --git a/src/Runner.Worker/Dap/IDapServer.cs b/src/Runner.Worker/Dap/IDapServer.cs deleted file mode 100644 index 53faeaf42d4..00000000000 --- a/src/Runner.Worker/Dap/IDapServer.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using GitHub.Runner.Common; - -namespace GitHub.Runner.Worker.Dap -{ - internal interface IDapDebuggerCallbacks - { - Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken); - void HandleClientConnected(); - void HandleClientDisconnected(); - } - - [ServiceLocator(Default = typeof(DapServer))] - internal interface IDapServer : IRunnerService - { - void SetDebugger(IDapDebuggerCallbacks debugger); - Task StartAsync(int port, CancellationToken cancellationToken); - Task WaitForConnectionAsync(CancellationToken cancellationToken); - Task StopAsync(); - void SendMessage(ProtocolMessage message); - void SendEvent(Event evt); - void SendResponse(Response response); - } -} diff --git a/src/Test/L0/ServiceInterfacesL0.cs b/src/Test/L0/ServiceInterfacesL0.cs index 0746e0433e2..ec8a270f5e7 100644 --- a/src/Test/L0/ServiceInterfacesL0.cs +++ b/src/Test/L0/ServiceInterfacesL0.cs @@ -72,7 +72,6 @@ public void WorkerInterfacesSpecifyDefaultImplementation() typeof(IDiagnosticLogManager), typeof(IEnvironmentContextData), typeof(IHookArgs), - typeof(IDapDebuggerCallbacks), }; Validate( assembly: typeof(IStepsRunner).GetTypeInfo().Assembly, diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index f680294d791..1a9b1866364 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -1,10 +1,13 @@ using System; +using System.IO; +using System.Net; +using System.Net.Sockets; using System.Runtime.CompilerServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using GitHub.Runner.Worker; using GitHub.Runner.Worker.Dap; -using Moq; using Newtonsoft.Json; using Xunit; @@ -12,6 +15,8 @@ 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; private TestHostContext CreateTestContext([CallerMemberName] string testName = "") @@ -22,29 +27,116 @@ private TestHostContext CreateTestContext([CallerMemberName] string testName = " return hc; } - private static Mock CreateServerMock() + private static async Task WithEnvironmentVariableAsync(string name, string value, Func action) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.SendEvent(It.IsAny())); - mockServer.Setup(x => x.SendResponse(It.IsAny())); - return mockServer; + var originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + try + { + await action(); + } + finally + { + Environment.SetEnvironmentVariable(name, originalValue); + } + } + + private static void WithEnvironmentVariable(string name, string value, Action action) + { + var originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + try + { + action(); + } + finally + { + Environment.SetEnvironmentVariable(name, originalValue); + } } - private Task CompleteHandshakeAsync() + private static int GetFreePort() { - var configJson = JsonConvert.SerializeObject(new Request + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task ConnectClientAsync(int port) + { + var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + return client; + } + + private static async Task SendRequestAsync(NetworkStream stream, Request request) + { + var json = JsonConvert.SerializeObject(request); + var body = Encoding.UTF8.GetBytes(json); + var header = $"Content-Length: {body.Length}\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + + await stream.WriteAsync(headerBytes, 0, headerBytes.Length); + await stream.WriteAsync(body, 0, body.Length); + await stream.FlushAsync(); + } + + /// + /// Reads a single DAP-framed message from a stream with a timeout. + /// Parses the Content-Length header, reads exactly that many bytes, + /// and returns the JSON body. Fails with a clear error on timeout. + /// + private static async Task ReadDapMessageAsync(NetworkStream stream, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + var token = cts.Token; + + var headerBuilder = new StringBuilder(); + var buffer = new byte[1]; + var contentLength = -1; + + while (true) { - Seq = 1, - Type = "request", - Command = "configurationDone" - }); - return _debugger.HandleMessageAsync(configJson, CancellationToken.None); + var readTask = stream.ReadAsync(buffer, 0, 1, token); + var bytesRead = await readTask; + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading DAP headers"); + } + + headerBuilder.Append((char)buffer[0]); + var headers = headerBuilder.ToString(); + if (headers.EndsWith("\r\n\r\n", StringComparison.Ordinal)) + { + foreach (var line in headers.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("Content-Length: ", StringComparison.OrdinalIgnoreCase)) + { + contentLength = int.Parse(line.Substring("Content-Length: ".Length).Trim()); + } + } + break; + } + } + + if (contentLength < 0) + { + throw new InvalidOperationException("No Content-Length header found in DAP message"); + } + + var body = new byte[contentLength]; + var totalRead = 0; + while (totalRead < contentLength) + { + var bytesRead = await stream.ReadAsync(body, totalRead, contentLength - totalRead, token); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading DAP body"); + } + totalRead += bytesRead; + } + + return Encoding.UTF8.GetString(body); } [Fact] @@ -62,257 +154,192 @@ public void InitializeSucceeds() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task StartAndStopLifecycle() + public void ResolvePortUsesCustomPortFromEnvironment() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - - mockServer.Verify(x => x.SetDebugger(It.IsAny()), Times.Once); - mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once); - - await _debugger.StopAsync(); - mockServer.Verify(x => x.StopAsync(), Times.Once); + WithEnvironmentVariable(PortEnvironmentVariable, "9999", () => + { + Assert.Equal(9999, _debugger.ResolvePort()); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task StartUsesCustomPortFromEnvironment() + public void ResolvePortIgnoresInvalidPortFromEnvironment() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "9999"); - try - { - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - - mockServer.Verify(x => x.StartAsync(9999, cts.Token), Times.Once); - - await _debugger.StopAsync(); - } - finally + WithEnvironmentVariable(PortEnvironmentVariable, "not-a-number", () => { - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", null); - } + Assert.Equal(4711, _debugger.ResolvePort()); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task StartIgnoresInvalidPortFromEnvironment() + public void ResolvePortIgnoresOutOfRangePortFromEnvironment() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "not-a-number"); - try + WithEnvironmentVariable(PortEnvironmentVariable, "99999", () => { - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - - mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once); - - await _debugger.StopAsync(); - } - finally - { - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", null); - } + Assert.Equal(4711, _debugger.ResolvePort()); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task StartIgnoresOutOfRangePortFromEnvironment() + public void ResolveTimeoutUsesCustomTimeoutFromEnvironment() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", "99999"); - try + WithEnvironmentVariable(TimeoutEnvironmentVariable, "30", () => { - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - - mockServer.Verify(x => x.StartAsync(4711, cts.Token), Times.Once); - - await _debugger.StopAsync(); - } - finally - { - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_PORT", null); - } + Assert.Equal(30, _debugger.ResolveTimeout()); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyCallsServerAndCompletesHandshake() + public void ResolveTimeoutIgnoresInvalidTimeoutFromEnvironment() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - await CompleteHandshakeAsync(); - await _debugger.WaitUntilReadyAsync(cts.Token); - - mockServer.Verify(x => x.WaitForConnectionAsync(It.IsAny()), Times.Once); - Assert.Equal(DapSessionState.Ready, _debugger.State); - - await _debugger.StopAsync(); + WithEnvironmentVariable(TimeoutEnvironmentVariable, "not-a-number", () => + { + Assert.Equal(15, _debugger.ResolveTimeout()); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyRegistersCancellation() + public void ResolveTimeoutIgnoresZeroTimeoutFromEnvironment() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - await CompleteHandshakeAsync(); - await _debugger.WaitUntilReadyAsync(cts.Token); - - cts.Cancel(); - - Assert.Equal(DapSessionState.Terminated, _debugger.State); - await _debugger.StopAsync(); + WithEnvironmentVariable(TimeoutEnvironmentVariable, "0", () => + { + Assert.Equal(15, _debugger.ResolveTimeout()); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task StopWithoutStartDoesNotThrow() + public async Task StartAndStopLifecycle() { using (CreateTestContext()) { - await _debugger.StopAsync(); + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _debugger.StartAsync(cts.Token); + using var client = await ConnectClientAsync(port); + Assert.True(client.Connected); + await _debugger.StopAsync(); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task OnJobCompletedStopsServer() + public async Task StartAndStopMultipleTimesDoesNotThrow() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - await CompleteHandshakeAsync(); - await _debugger.WaitUntilReadyAsync(cts.Token); - - await _debugger.OnJobCompletedAsync(); - - mockServer.Verify(x => x.StopAsync(), Times.Once); + foreach (var port in new[] { GetFreePort(), GetFreePort() }) + { + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _debugger.StartAsync(cts.Token); + await _debugger.StopAsync(); + }); + } } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyBeforeStartIsNoOp() + public async Task WaitUntilReadyCompletesAfterClientConnectionAndConfigurationDone() { using (CreateTestContext()) { - await _debugger.WaitUntilReadyAsync(CancellationToken.None); + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _debugger.StartAsync(cts.Token); + + var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); + 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(); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyPassesLinkedTokenNotOriginal() + public async Task WaitUntilReadyRegistersCancellation() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - CancellationToken capturedToken = default; - - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Callback(ct => capturedToken = ct) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.SendResponse(It.IsAny())); - - hc.SetSingleton(mockServer.Object); + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _debugger.StartAsync(cts.Token); - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - await CompleteHandshakeAsync(); - await _debugger.WaitUntilReadyAsync(cts.Token); + var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); - Assert.NotEqual(cts.Token, capturedToken); + await waitTask; + cts.Cancel(); - await _debugger.StopAsync(); + Assert.True(SpinWait.SpinUntil(() => _debugger.State == DapSessionState.Terminated, TimeSpan.FromSeconds(5))); + await _debugger.StopAsync(); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyTimeoutSurfacesAsTimeoutException() + public async Task StopWithoutStartDoesNotThrow() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(async ct => - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - ct.Register(() => tcs.TrySetCanceled(ct)); - await tcs.Task; - }); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); - - hc.SetSingleton(mockServer.Object); - - var jobCts = new CancellationTokenSource(); - await _debugger.StartAsync(jobCts.Token); - - var waitTask = _debugger.WaitUntilReadyAsync(jobCts.Token); - await Task.Delay(50); - Assert.False(waitTask.IsCompleted); - - jobCts.Cancel(); - var ex = await Assert.ThrowsAnyAsync(() => waitTask); - Assert.IsNotType(ex); - await _debugger.StopAsync(); } } @@ -320,114 +347,105 @@ public async Task WaitUntilReadyTimeoutSurfacesAsTimeoutException() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyUsesCustomTimeoutFromEnvironment() + public async Task OnJobCompletedTerminatesSession() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "30"); - try + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => { - var cts = new CancellationTokenSource(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await _debugger.StartAsync(cts.Token); - await CompleteHandshakeAsync(); - await _debugger.WaitUntilReadyAsync(cts.Token); - await _debugger.StopAsync(); - } - finally - { - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", null); - } + + var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); + 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); + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyIgnoresInvalidTimeoutFromEnvironment() + public async Task WaitUntilReadyBeforeStartIsNoOp() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "not-a-number"); - try - { - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); - await CompleteHandshakeAsync(); - await _debugger.WaitUntilReadyAsync(cts.Token); - await _debugger.StopAsync(); - } - finally - { - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", null); - } + await _debugger.WaitUntilReadyAsync(CancellationToken.None); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyIgnoresZeroTimeoutFromEnvironment() + public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledException() { - using (var hc = CreateTestContext()) + using (CreateTestContext()) { - var mockServer = CreateServerMock(); - hc.SetSingleton(mockServer.Object); - - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", "0"); - try + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => { - var cts = new CancellationTokenSource(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await _debugger.StartAsync(cts.Token); - await CompleteHandshakeAsync(); - await _debugger.WaitUntilReadyAsync(cts.Token); + + var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); + await Task.Delay(50); + cts.Cancel(); + + var ex = await Assert.ThrowsAnyAsync(() => waitTask); + Assert.IsNotType(ex); await _debugger.StopAsync(); - } - finally - { - Environment.SetEnvironmentVariable("ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT", null); - } + }); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledException() + public async Task InitializeRequestOverSocketPreservesProtocolMetadataWhenSecretsCollide() { using (var hc = CreateTestContext()) { - var mockServer = new Mock(); - mockServer.Setup(x => x.StartAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - mockServer.Setup(x => x.WaitForConnectionAsync(It.IsAny())) - .Returns(ct => - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - ct.Register(() => tcs.TrySetCanceled(ct)); - return tcs.Task; - }); - mockServer.Setup(x => x.StopAsync()) - .Returns(Task.CompletedTask); + hc.SecretMasker.AddValue("response"); + hc.SecretMasker.AddValue("initialize"); + hc.SecretMasker.AddValue("event"); + hc.SecretMasker.AddValue("initialized"); - hc.SetSingleton(mockServer.Object); + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _debugger.StartAsync(cts.Token); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); - var cts = new CancellationTokenSource(); - await _debugger.StartAsync(cts.Token); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "initialize" + }); - var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); - await Task.Delay(50); + var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"type\":\"response\"", response); + Assert.Contains("\"command\":\"initialize\"", response); + Assert.Contains("\"success\":true", response); - cts.Cancel(); - var ex = await Assert.ThrowsAnyAsync(() => waitTask); - Assert.IsNotType(ex); + var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"type\":\"event\"", initializedEvent); + Assert.Contains("\"event\":\"initialized\"", initializedEvent); - await _debugger.StopAsync(); + await _debugger.StopAsync(); + }); } } } diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs index bd8950e92e7..687d2093a02 100644 --- a/src/Test/L0/Worker/DapReplExecutorL0.cs +++ b/src/Test/L0/Worker/DapReplExecutorL0.cs @@ -17,17 +17,24 @@ public sealed class DapReplExecutorL0 { private TestHostContext _hc; private DapReplExecutor _executor; - private Mock _mockServer; private List _sentEvents; private TestHostContext CreateTestContext([CallerMemberName] string testName = "") { _hc = new TestHostContext(this, testName); _sentEvents = new List(); - _mockServer = new Mock(); - _mockServer.Setup(x => x.SendEvent(It.IsAny())) - .Callback(e => _sentEvents.Add(e)); - _executor = new DapReplExecutor(_hc, _mockServer.Object); + _executor = new DapReplExecutor(_hc, (category, text) => + { + _sentEvents.Add(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = category, + Output = text + } + }); + }); return _hc; } diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs deleted file mode 100644 index 1f71d853731..00000000000 --- a/src/Test/L0/Worker/DapServerL0.cs +++ /dev/null @@ -1,403 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using GitHub.Runner.Worker.Dap; -using Moq; -using Xunit; - -namespace GitHub.Runner.Common.Tests.Worker -{ - public sealed class DapServerL0 - { - private DapServer _server; - - private TestHostContext CreateTestContext([CallerMemberName] string testName = "") - { - var hc = new TestHostContext(this, testName); - _server = new DapServer(); - _server.Initialize(hc); - return hc; - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void InitializeSucceeds() - { - using (CreateTestContext()) - { - Assert.NotNull(_server); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void SetSessionAcceptsMock() - { - using (CreateTestContext()) - { - var mockSession = new Mock(); - _server.SetDebugger(mockSession.Object); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void SendEventNoClientDoesNotThrow() - { - using (CreateTestContext()) - { - var evt = new Event - { - EventType = "stopped", - Body = new StoppedEventBody - { - Reason = "entry", - ThreadId = 1, - AllThreadsStopped = true - } - }; - - _server.SendEvent(evt); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void SendResponseNoClientDoesNotThrow() - { - using (CreateTestContext()) - { - var response = new Response - { - Type = "response", - RequestSeq = 1, - Command = "initialize", - Success = true - }; - - _server.SendResponse(response); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void SendMessageNoClientDoesNotThrow() - { - using (CreateTestContext()) - { - var msg = new ProtocolMessage - { - Type = "response", - Seq = 1 - }; - - _server.SendMessage(msg); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task StopWithoutStartDoesNotThrow() - { - using (CreateTestContext()) - { - await _server.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task StartAndStopOnAvailablePort() - { - using (CreateTestContext()) - { - var cts = new CancellationTokenSource(); - await _server.StartAsync(0, cts.Token); - await _server.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task WaitForConnectionCancelledByCancellationToken() - { - using (CreateTestContext()) - { - var cts = new CancellationTokenSource(); - await _server.StartAsync(0, cts.Token); - - var waitTask = _server.WaitForConnectionAsync(cts.Token); - - cts.Cancel(); - - await Assert.ThrowsAnyAsync(async () => - { - await waitTask; - }); - - await _server.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task StartAndStopMultipleTimesDoesNotThrow() - { - using (CreateTestContext()) - { - var cts1 = new CancellationTokenSource(); - await _server.StartAsync(0, cts1.Token); - await _server.StopAsync(); - } - - using (CreateTestContext($"{nameof(StartAndStopMultipleTimesDoesNotThrow)}_SecondStart")) - { - var cts2 = new CancellationTokenSource(); - await _server.StartAsync(0, cts2.Token); - await _server.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task MessageFraming_ValidMessage_ProcessedSuccessfully() - { - using (var hc = CreateTestContext()) - { - var messageReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var mockSession = new Mock(); - mockSession.Setup(x => x.HandleMessageAsync(It.IsAny(), It.IsAny())) - .Callback((json, ct) => messageReceived.TrySetResult(json)) - .Returns(Task.CompletedTask); - _server.SetDebugger(mockSession.Object); - - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _server.StartAsync(0, cts.Token); - - var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); - var listener = (TcpListener)listenerField.GetValue(_server); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - - var connectionTask = _server.WaitForConnectionAsync(cts.Token); - using var client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, port); - await connectionTask; - var stream = client.GetStream(); - - // Send a valid DAP request with Content-Length framing - var requestJson = "{\"seq\":1,\"type\":\"request\",\"command\":\"initialize\"}"; - var body = Encoding.UTF8.GetBytes(requestJson); - var header = $"Content-Length: {body.Length}\r\n\r\n"; - var headerBytes = Encoding.ASCII.GetBytes(header); - - await stream.WriteAsync(headerBytes, 0, headerBytes.Length); - await stream.WriteAsync(body, 0, body.Length); - await stream.FlushAsync(); - - // Wait for session to receive the message (deterministic, bounded) - var completed = await Task.WhenAny(messageReceived.Task, Task.Delay(5000)); - Assert.Equal(messageReceived.Task, completed); - Assert.Contains("initialize", await messageReceived.Task); - - cts.Cancel(); - await _server.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ProtocolMetadata_PreservedWhenSecretCollidesWithKeywords() - { - using (var hc = CreateTestContext()) - { - // Register secrets that match DAP protocol keywords - hc.SecretMasker.AddValue("response"); - hc.SecretMasker.AddValue("output"); - hc.SecretMasker.AddValue("evaluate"); - - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _server.StartAsync(0, cts.Token); - - var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); - var listener = (TcpListener)listenerField.GetValue(_server); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - - var connectionTask = _server.WaitForConnectionAsync(cts.Token); - using var client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, port); - await connectionTask; - var stream = client.GetStream(); - - // Send a response whose protocol fields collide with secrets - var response = new Response - { - Type = "response", - RequestSeq = 1, - Command = "evaluate", - Success = true, - Body = new EvaluateResponseBody - { - Result = "some result", - Type = "string", - VariablesReference = 0 - } - }; - - _server.SendResponse(response); - - // Read a full framed DAP message with timeout - var received = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - - // Protocol metadata MUST be preserved even when secrets collide - Assert.Contains("\"type\":\"response\"", received); - Assert.Contains("\"command\":\"evaluate\"", received); - Assert.Contains("\"success\":true", received); - - cts.Cancel(); - await _server.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task ProtocolMetadata_EventFieldsPreservedWhenSecretCollidesWithKeywords() - { - using (var hc = CreateTestContext()) - { - hc.SecretMasker.AddValue("output"); - hc.SecretMasker.AddValue("stdout"); - - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _server.StartAsync(0, cts.Token); - - var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); - var listener = (TcpListener)listenerField.GetValue(_server); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - - var connectionTask = _server.WaitForConnectionAsync(cts.Token); - using var client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, port); - await connectionTask; - var stream = client.GetStream(); - - _server.SendEvent(new Event - { - EventType = "output", - Body = new OutputEventBody - { - Category = "stdout", - Output = "hello world" - } - }); - - // Read a full framed DAP message with timeout - var received = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); - - // Protocol fields MUST be preserved - Assert.Contains("\"type\":\"event\"", received); - Assert.Contains("\"event\":\"output\"", received); - Assert.Contains("\"category\":\"stdout\"", received); - - cts.Cancel(); - await _server.StopAsync(); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public async Task StopAsync_AwaitsConnectionLoopShutdown() - { - using (CreateTestContext()) - { - var cts = new CancellationTokenSource(); - await _server.StartAsync(0, cts.Token); - - // Stop should complete within a reasonable time - var stopTask = _server.StopAsync(); - var completed = await Task.WhenAny(stopTask, Task.Delay(10000)); - Assert.Equal(stopTask, completed); - } - } - - /// - /// Reads a single DAP-framed message from a stream with a timeout. - /// Parses the Content-Length header, reads exactly that many bytes, - /// and returns the JSON body. Fails with a clear error on timeout. - /// - private static async Task ReadDapMessageAsync(NetworkStream stream, TimeSpan timeout) - { - using var cts = new CancellationTokenSource(timeout); - var token = cts.Token; - - // Read headers byte-by-byte until we see \r\n\r\n - var headerBuilder = new StringBuilder(); - var buffer = new byte[1]; - var contentLength = -1; - - while (true) - { - var readTask = stream.ReadAsync(buffer, 0, 1, token); - var bytesRead = await readTask; - if (bytesRead == 0) - { - throw new EndOfStreamException("Connection closed while reading DAP headers"); - } - - headerBuilder.Append((char)buffer[0]); - var headers = headerBuilder.ToString(); - if (headers.EndsWith("\r\n\r\n")) - { - // Parse Content-Length - foreach (var line in headers.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries)) - { - if (line.StartsWith("Content-Length: ", StringComparison.OrdinalIgnoreCase)) - { - contentLength = int.Parse(line.Substring("Content-Length: ".Length).Trim()); - } - } - break; - } - } - - if (contentLength < 0) - { - throw new InvalidOperationException("No Content-Length header found in DAP message"); - } - - // Read exactly contentLength bytes - var body = new byte[contentLength]; - var totalRead = 0; - while (totalRead < contentLength) - { - var bytesRead = await stream.ReadAsync(body, totalRead, contentLength - totalRead, token); - if (bytesRead == 0) - { - throw new EndOfStreamException("Connection closed while reading DAP body"); - } - totalRead += bytesRead; - } - - return Encoding.UTF8.GetString(body); - } - } -} From 4dc0a8d1fdef94d9d76ced38c67ed646ace1677e Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 18 Mar 2026 17:00:44 +0000 Subject: [PATCH 40/42] PR Feedback --- src/Runner.Worker/Dap/DapDebugger.cs | 39 ++++++---------------------- 1 file changed, 8 insertions(+), 31 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 751e7bad8ca..9416b16e7c2 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -105,7 +105,6 @@ public override void Initialize(IHostContext hostContext) public Task StartAsync(CancellationToken cancellationToken) { - ResetSessionState(); var port = ResolvePort(); Trace.Info($"Starting DAP debugger on port {port}"); @@ -426,20 +425,18 @@ internal void HandleClientDisconnected() private async Task ConnectionLoopAsync(CancellationToken cancellationToken) { + using var listenerCancellationRegistration = cancellationToken.Register(() => + { + try { _listener?.Stop(); } + catch { /* listener already stopped */ } + }); + while (_acceptConnections && !cancellationToken.IsCancellationRequested) { try { Trace.Info("Waiting for debug client connection..."); - - using (cancellationToken.Register(() => - { - try { _listener?.Stop(); } - catch { /* listener already stopped */ } - })) - { - _client = await _listener.AcceptTcpClientAsync(); - } + _client = await _listener.AcceptTcpClientAsync(); if (cancellationToken.IsCancellationRequested) { @@ -838,26 +835,6 @@ private static TaskCompletionSource CreateHandshakeCompletionSource() return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } - private void ResetSessionState() - { - lock (_stateLock) - { - _state = DapSessionState.WaitingForConnection; - _commandTcs = null; - _handshakeTcs = CreateHandshakeCompletionSource(); - _pauseOnNextStep = true; - _isFirstStep = true; - _currentStep = null; - _jobContext = null; - _currentStepIndex = 0; - _completedSteps.Clear(); - _nextCompletedFrameId = CompletedFrameIdBase; - _isClientConnected = false; - _acceptConnections = true; - _nextSeq = 1; - } - } - private Response HandleInitialize(Request request) { if (request.Arguments != null) @@ -1409,7 +1386,7 @@ 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 > 0 && customPort <= 65535) + 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; From c9418a620c036ebeda09086cf2ef2150b834987f Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 19 Mar 2026 15:30:24 +0000 Subject: [PATCH 41/42] PR Feedback --- src/Runner.Worker/Dap/DapDebugger.cs | 70 +++++++++++--------------- src/Runner.Worker/Dap/IDapDebugger.cs | 9 ++-- src/Runner.Worker/JobRunner.cs | 4 +- src/Runner.Worker/StepsRunner.cs | 2 +- src/Test/L0/Worker/DapDebuggerL0.cs | 72 ++++++++++++++++++++++----- 5 files changed, 97 insertions(+), 60 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 9416b16e7c2..359c6a4d5d8 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; +using GitHub.Runner.Sdk; using Newtonsoft.Json; namespace GitHub.Runner.Worker.Dap @@ -36,6 +37,7 @@ public sealed class DapDebugger : RunnerService, IDapDebugger private const string ContentLengthHeader = "Content-Length: "; private const int MaxMessageSize = 10 * 1024 * 1024; // 10 MB private const int MaxHeaderLineLength = 8192; // 8 KB + private const int ConnectionRetryDelayMilliseconds = 500; // Thread ID for the single job execution thread private const int JobThreadId = 1; @@ -54,7 +56,6 @@ public sealed class DapDebugger : RunnerService, IDapDebugger private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); private int _nextSeq = 1; private Task _connectionLoopTask; - private volatile bool _acceptConnections = true; private volatile DapSessionState _state = DapSessionState.WaitingForConnection; private CancellationTokenRegistration? _cancellationRegistration; private volatile bool _started; @@ -65,7 +66,7 @@ public sealed class DapDebugger : RunnerService, IDapDebugger private readonly object _stateLock = new object(); // Handshake completion — signaled when configurationDone is received - private TaskCompletionSource _handshakeTcs = CreateHandshakeCompletionSource(); + private TaskCompletionSource _handshakeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Whether to pause before the next step (set by 'next' command) private bool _pauseOnNextStep = true; @@ -103,13 +104,15 @@ public override void Initialize(IHostContext hostContext) Trace.Info("DapDebugger initialized"); } - public Task StartAsync(CancellationToken cancellationToken) + public Task StartAsync(IExecutionContext jobContext) { + ArgUtil.NotNull(jobContext, nameof(jobContext)); var port = ResolvePort(); Trace.Info($"Starting DAP debugger on port {port}"); - _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _jobContext = jobContext; + _cts = CancellationTokenSource.CreateLinkedTokenSource(jobContext.CancellationToken); _connectionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _listener = new TcpListener(IPAddress.Loopback, port); @@ -123,13 +126,14 @@ public Task StartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) + public async Task WaitUntilReadyAsync() { - if (!_started || _listener == null) + if (!_started || _listener == null || _jobContext == null) { return; } + var cancellationToken = _jobContext.CancellationToken; var timeoutMinutes = ResolveTimeout(); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(timeoutMinutes)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); @@ -185,7 +189,6 @@ public async Task StopAsync() try { Trace.Info("Stopping DAP debugger"); - _acceptConnections = false; _cts?.Cancel(); CleanupConnection(); @@ -264,7 +267,7 @@ public void CancelSession() Trace.Info("Debug session cancelled"); } - public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken) + public async Task OnStepStartingAsync(IStep step) { if (!IsActive) { @@ -275,7 +278,7 @@ public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, { bool isFirst = _isFirstStep; _isFirstStep = false; - await OnStepStartingAsync(step, jobContext, isFirst, cancellationToken); + await OnStepStartingAsync(step, isFirst); } catch (Exception ex) { @@ -374,6 +377,15 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can response.Command = request.Command; SendResponse(response); + + if (request.Command == "initialize") + { + SendEvent(new Event + { + EventType = "initialized" + }); + Trace.Info("Sent initialized event"); + } } catch (Exception ex) { @@ -431,7 +443,7 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken) catch { /* listener already stopped */ } }); - while (_acceptConnections && !cancellationToken.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) { try { @@ -456,27 +468,21 @@ private async Task ConnectionLoopAsync(CancellationToken cancellationToken) HandleClientDisconnected(); CleanupConnection(); } - catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) - { - break; - } - catch (SocketException) when (cancellationToken.IsCancellationRequested) - { - break; - } catch (Exception ex) { - Trace.Warning($"Connection error ({ex.GetType().Name})"); CleanupConnection(); - if (!_acceptConnections || cancellationToken.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested) { break; } + Trace.Error("Debugger connection error"); + Trace.Error(ex); + try { - await Task.Delay(100, cancellationToken); + await Task.Delay(ConnectionRetryDelayMilliseconds, cancellationToken); } catch (OperationCanceledException) { @@ -753,9 +759,10 @@ internal async Task WaitForHandshakeAsync(CancellationToken cancellationToken) Trace.Info("DAP handshake complete, session is ready"); } - internal async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken) + internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) { bool pauseOnNextStep; + CancellationToken cancellationToken; lock (_stateLock) { if (_state != DapSessionState.Ready && @@ -766,9 +773,9 @@ internal async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext } _currentStep = step; - _jobContext = jobContext; _currentStepIndex = _completedSteps.Count; pauseOnNextStep = _pauseOnNextStep; + cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None; } // Reset variable references so stale nested refs from the @@ -830,11 +837,6 @@ internal void OnJobCompleted() }); } - private static TaskCompletionSource CreateHandshakeCompletionSource() - { - return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - private Response HandleInitialize(Request request) { if (request.Arguments != null) @@ -883,18 +885,6 @@ private Response HandleInitialize(Request request) SupportsExceptionInfoRequest = false, }; - // Send initialized event after a brief delay to ensure the - // response is delivered first (DAP spec requirement) - _ = Task.Run(async () => - { - await Task.Delay(50); - SendEvent(new Event - { - EventType = "initialized" - }); - Trace.Info("Sent initialized event"); - }); - Trace.Info("Initialize request handled, capabilities sent"); return CreateResponse(request, true, body: capabilities); } diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs index d895850dac3..72bff01759f 100644 --- a/src/Runner.Worker/Dap/IDapDebugger.cs +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -1,5 +1,4 @@ -using System.Threading; -using System.Threading.Tasks; +using System.Threading.Tasks; using GitHub.Runner.Common; namespace GitHub.Runner.Worker.Dap @@ -17,9 +16,9 @@ public enum DapSessionState [ServiceLocator(Default = typeof(DapDebugger))] public interface IDapDebugger : IRunnerService { - Task StartAsync(CancellationToken cancellationToken); - Task WaitUntilReadyAsync(CancellationToken cancellationToken); - Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken); + Task StartAsync(IExecutionContext jobContext); + Task WaitUntilReadyAsync(); + Task OnStepStartingAsync(IStep step); void OnStepCompleted(IStep step); Task OnJobCompletedAsync(); } diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index bb1c74d8b4b..10623bbef10 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -189,7 +189,7 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat try { dapDebugger = HostContext.GetService(); - await dapDebugger.StartAsync(jobRequestCancellationToken); + await dapDebugger.StartAsync(jobContext); } catch (Exception ex) { @@ -248,7 +248,7 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat { try { - await dapDebugger.WaitUntilReadyAsync(jobRequestCancellationToken); + await dapDebugger.WaitUntilReadyAsync(); AddDebuggerConnectionTelemetry(jobContext, "Connected"); } catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) diff --git a/src/Runner.Worker/StepsRunner.cs b/src/Runner.Worker/StepsRunner.cs index a109012002e..21bdfa6f779 100644 --- a/src/Runner.Worker/StepsRunner.cs +++ b/src/Runner.Worker/StepsRunner.cs @@ -229,7 +229,7 @@ public async Task RunAsync(IExecutionContext jobContext) else { // Pause for DAP debugger before step execution - await dapDebugger?.OnStepStartingAsync(step, jobContext, jobContext.CancellationToken); + await dapDebugger?.OnStepStartingAsync(step); // Run the step await RunStepAsync(step, jobContext.CancellationToken); diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index 1a9b1866364..df6a49e74e7 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Moq; using GitHub.Runner.Worker; using GitHub.Runner.Worker.Dap; using Newtonsoft.Json; @@ -139,6 +140,16 @@ 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.GetGitHubContext(It.IsAny())) + .Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null); + return jobContext; + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -246,7 +257,8 @@ public async Task StartAndStopLifecycle() await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _debugger.StartAsync(cts.Token); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); using var client = await ConnectClientAsync(port); Assert.True(client.Connected); await _debugger.StopAsync(); @@ -266,7 +278,8 @@ public async Task StartAndStopMultipleTimesDoesNotThrow() await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _debugger.StartAsync(cts.Token); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); await _debugger.StopAsync(); }); } @@ -284,9 +297,10 @@ public async Task WaitUntilReadyCompletesAfterClientConnectionAndConfigurationDo await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _debugger.StartAsync(cts.Token); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); - var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); + var waitTask = _debugger.WaitUntilReadyAsync(); using var client = await ConnectClientAsync(port); await SendRequestAsync(client.GetStream(), new Request { @@ -302,6 +316,36 @@ await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), asy } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + 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 = 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(); + }); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -313,9 +357,10 @@ public async Task WaitUntilReadyRegistersCancellation() await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _debugger.StartAsync(cts.Token); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); - var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); + var waitTask = _debugger.WaitUntilReadyAsync(); using var client = await ConnectClientAsync(port); await SendRequestAsync(client.GetStream(), new Request { @@ -355,9 +400,10 @@ public async Task OnJobCompletedTerminatesSession() await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _debugger.StartAsync(cts.Token); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); - var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); + var waitTask = _debugger.WaitUntilReadyAsync(); using var client = await ConnectClientAsync(port); await SendRequestAsync(client.GetStream(), new Request { @@ -380,7 +426,7 @@ public async Task WaitUntilReadyBeforeStartIsNoOp() { using (CreateTestContext()) { - await _debugger.WaitUntilReadyAsync(CancellationToken.None); + await _debugger.WaitUntilReadyAsync(); } } @@ -395,9 +441,10 @@ public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledExc await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _debugger.StartAsync(cts.Token); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); - var waitTask = _debugger.WaitUntilReadyAsync(cts.Token); + var waitTask = _debugger.WaitUntilReadyAsync(); await Task.Delay(50); cts.Cancel(); @@ -424,7 +471,8 @@ public async Task InitializeRequestOverSocketPreservesProtocolMetadataWhenSecret await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await _debugger.StartAsync(cts.Token); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); using var client = await ConnectClientAsync(port); var stream = client.GetStream(); From 6e96b66d0c7b6e00bb01dabb9e827c95d0f90ea8 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 19 Mar 2026 18:27:39 +0000 Subject: [PATCH 42/42] PR Feedback --- src/Runner.Worker/Dap/DapDebugger.cs | 7 ++-- src/Runner.Worker/Dap/DapVariableProvider.cs | 35 ++++++-------------- src/Test/L0/Worker/DapVariableProviderL0.cs | 18 +--------- 3 files changed, 14 insertions(+), 46 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 359c6a4d5d8..171001a6135 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -99,7 +99,7 @@ public sealed class DapDebugger : RunnerService, IDapDebugger public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); - _variableProvider = new DapVariableProvider(hostContext); + _variableProvider = new DapVariableProvider(hostContext.SecretMasker); _replExecutor = new DapReplExecutor(hostContext, SendOutput); Trace.Info("DapDebugger initialized"); } @@ -654,9 +654,8 @@ private async Task ReadLineAsync(CancellationToken cancellationToken) /// layer. Masking the raw JSON would corrupt protocol envelope fields /// (type, event, command, seq) if a secret collides with those strings. /// Instead, each DAP producer masks user-visible text at the point of - /// construction via or the - /// runner's SecretMasker directly. See DapVariableProvider, DapReplExecutor, - /// and DapDebugger for the call sites. + /// construction via the runner's SecretMasker. See DapVariableProvider, + /// DapReplExecutor, and DapDebugger for the call sites. /// private void SendMessageInternal(ProtocolMessage message) { diff --git a/src/Runner.Worker/Dap/DapVariableProvider.cs b/src/Runner.Worker/Dap/DapVariableProvider.cs index ced6c9d5fff..b8e2a4499fe 100644 --- a/src/Runner.Worker/Dap/DapVariableProvider.cs +++ b/src/Runner.Worker/Dap/DapVariableProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using GitHub.DistributedTask.Logging; using GitHub.DistributedTask.ObjectTemplating.Tokens; using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.Runner.Common; @@ -39,16 +40,16 @@ internal sealed class DapVariableProvider internal const string RedactedValue = "***"; - private readonly IHostContext _hostContext; + private readonly ISecretMasker _secretMasker; // Maps dynamic variable reference IDs to the backing data and its // dot-separated path (e.g. "github.event.pull_request"). private readonly Dictionary _variableReferences = new(); private int _nextVariableReference = DynamicReferenceBase; - public DapVariableProvider(IHostContext hostContext) + public DapVariableProvider(ISecretMasker secretMasker) { - _hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext)); + _secretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker)); } /// @@ -155,22 +156,6 @@ public List GetVariables(IExecutionContext context, int variablesRefer return variables; } - /// - /// Applies the runner's secret masker to the given value. - /// This is the single masking entry-point for all DAP-visible strings - /// and is intentionally public so future DAP features (evaluate, REPL) - /// can reuse it without duplicating policy. - /// - public string MaskSecrets(string value) - { - if (string.IsNullOrEmpty(value)) - { - return value ?? string.Empty; - } - - return _hostContext.SecretMasker.MaskSecrets(value); - } - /// /// Evaluates a GitHub Actions expression (e.g. "github.repository", /// "${{ github.event_name }}") in the context of the current step and @@ -219,7 +204,7 @@ public EvaluateResponseBody EvaluateExpression(string expression, IExecutionCont context.ExpressionValues, context.ExpressionFunctions); - result = MaskSecrets(result ?? "null"); + result = _secretMasker.MaskSecrets(result ?? "null"); return new EvaluateResponseBody { @@ -230,7 +215,7 @@ public EvaluateResponseBody EvaluateExpression(string expression, IExecutionCont } catch (Exception ex) { - var errorMessage = MaskSecrets($"Evaluation error: {ex.Message}"); + var errorMessage = _secretMasker.MaskSecrets($"Evaluation error: {ex.Message}"); return new EvaluateResponseBody { Result = errorMessage, @@ -326,19 +311,19 @@ private Variable CreateVariable( switch (value) { case StringContextData str: - variable.Value = MaskSecrets(str.Value); + variable.Value = _secretMasker.MaskSecrets(str.Value); variable.Type = "string"; variable.VariablesReference = 0; break; case NumberContextData num: - variable.Value = MaskSecrets(num.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture)); + variable.Value = _secretMasker.MaskSecrets(num.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture)); variable.Type = "number"; variable.VariablesReference = 0; break; case BooleanContextData boolVal: - variable.Value = MaskSecrets(boolVal.Value ? "true" : "false"); + variable.Value = _secretMasker.MaskSecrets(boolVal.Value ? "true" : "false"); variable.Type = "boolean"; variable.VariablesReference = 0; break; @@ -366,7 +351,7 @@ private Variable CreateVariable( default: var rawValue = value.ToJToken()?.ToString() ?? "unknown"; - variable.Value = MaskSecrets(rawValue); + variable.Value = _secretMasker.MaskSecrets(rawValue); variable.Type = value.GetType().Name; variable.VariablesReference = 0; break; diff --git a/src/Test/L0/Worker/DapVariableProviderL0.cs b/src/Test/L0/Worker/DapVariableProviderL0.cs index 2f84b9f34f1..197c6484f31 100644 --- a/src/Test/L0/Worker/DapVariableProviderL0.cs +++ b/src/Test/L0/Worker/DapVariableProviderL0.cs @@ -19,7 +19,7 @@ public sealed class DapVariableProviderL0 private TestHostContext CreateTestContext([CallerMemberName] string testName = "") { _hc = new TestHostContext(this, testName); - _provider = new DapVariableProvider(_hc); + _provider = new DapVariableProvider(_hc.SecretMasker); return _hc; } @@ -423,22 +423,6 @@ public void GetVariables_NonSecretScopeValuesMaskedBySecretMasker() } } - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Worker")] - public void MaskSecrets_DelegatesToHostContextSecretMasker() - { - using (var hc = CreateTestContext()) - { - hc.SecretMasker.AddValue("my-secret"); - - Assert.Equal("before-***-after", _provider.MaskSecrets("before-my-secret-after")); - Assert.Equal("no secrets here", _provider.MaskSecrets("no secrets here")); - Assert.Equal(string.Empty, _provider.MaskSecrets(null)); - Assert.Equal(string.Empty, _provider.MaskSecrets(string.Empty)); - } - } - #endregion #region Reset