diff --git a/src/PowerShellEditorServices/Index.cs b/src/PowerShellEditorServices/Index.cs new file mode 100644 index 000000000..078579aea --- /dev/null +++ b/src/PowerShellEditorServices/Index.cs @@ -0,0 +1,159 @@ +// +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// (with alterations) +#if NET5_0_OR_GREATER +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Index))] +#else + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace System +{ + /// Represent a type can be used to index a collection either from the start or the end. + /// + /// Index is used by the C# compiler to support the new index syntax + /// + /// int[] someArray = new int[5] { 1, 2, 3, 4, 5 } ; + /// int lastElement = someArray[^1]; // lastElement = 5 + /// + /// + internal readonly struct Index : IEquatable + { + private readonly int _value; + + /// Construct an Index using a value and indicating if the index is from the start or from the end. + /// The index value. it has to be zero or positive number. + /// Indicating if the index is from the start or from the end. + /// + /// If the Index constructed from the end, index value 1 means pointing at the last element and index value 0 means pointing at beyond last element. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Index(int value, bool fromEnd = false) + { + if (value < 0) + { + ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException(); + } + + if (fromEnd) + _value = ~value; + else + _value = value; + } + + // The following private constructors mainly created for perf reason to avoid the checks + private Index(int value) => _value = value; + + /// Create an Index pointing at first element. + public static Index Start => new Index(0); + + /// Create an Index pointing at beyond last element. + public static Index End => new Index(~0); + + /// Create an Index from the start at the position indicated by the value. + /// The index value from the start. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Index FromStart(int value) + { + if (value < 0) + { + ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException(); + } + + return new Index(value); + } + + /// Create an Index from the end at the position indicated by the value. + /// The index value from the end. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Index FromEnd(int value) + { + if (value < 0) + { + ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException(); + } + + return new Index(~value); + } + + /// Returns the index value. + public int Value + { + get + { + if (_value < 0) + return ~_value; + else + return _value; + } + } + + /// Indicates whether the index is from the start or the end. + public bool IsFromEnd => _value < 0; + + /// Calculate the offset from the start using the giving collection length. + /// The length of the collection that the Index will be used with. length has to be a positive value + /// + /// For performance reason, we don't validate the input length parameter and the returned offset value against negative values. + /// we don't validate either the returned offset is greater than the input length. + /// It is expected Index will be used with collections which always have non negative length/count. If the returned offset is negative and + /// then used to index a collection will get out of range exception which will be same affect as the validation. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetOffset(int length) + { + int offset = _value; + if (IsFromEnd) + { + // offset = length - (~value) + // offset = length + (~(~value) + 1) + // offset = length + value + 1 + + offset += length + 1; + } + return offset; + } + + /// Indicates whether the current Index object is equal to another object of the same type. + /// An object to compare with this object + public override bool Equals([NotNullWhen(true)] object? value) => value is Index && _value == ((Index)value)._value; + + /// Indicates whether the current Index object is equal to another Index object. + /// An object to compare with this object + public bool Equals(Index other) => _value == other._value; + + /// Returns the hash code for this instance. + public override int GetHashCode() => _value; + + /// Converts integer number to an Index. + public static implicit operator Index(int value) => FromStart(value); + + /// Converts the value of the current Index object to its equivalent string representation. + public override string ToString() + { + if (IsFromEnd) + return ToStringFromEnd(); + + return ((uint)Value).ToString(); + } + + private string ToStringFromEnd() + { + return '^' + Value.ToString(); + } + + internal static class ThrowHelper + { + [DoesNotReturn, MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowValueArgumentOutOfRange_NeedNonNegNumException() + { + throw new ArgumentOutOfRangeException( + "Non-negative number required. (Parameter 'value')", + "value"); + } + } + } +} +#endif diff --git a/src/PowerShellEditorServices/Nullable.cs b/src/PowerShellEditorServices/Nullable.cs new file mode 100644 index 000000000..1481b7ba7 --- /dev/null +++ b/src/PowerShellEditorServices/Nullable.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace System.Diagnostics.CodeAnalysis +{ + /// Specifies that null is allowed as an input even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class AllowNullAttribute : Attribute { } + + /// Specifies that null is disallowed as an input even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class DisallowNullAttribute : Attribute { } + + /// Specifies that an output may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class MaybeNullAttribute : Attribute { } + + /// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class NotNullAttribute : Attribute { } + + /// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class MaybeNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter may be null. + /// + public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class NotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that the output will be non-null if the named parameter is non-null. + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] + internal sealed class NotNullIfNotNullAttribute : Attribute + { + /// Initializes the attribute with the associated parameter name. + /// + /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. + /// + public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName; + + /// Gets the associated parameter name. + public string ParameterName { get; } + } + + /// Applied to a method that will never return under any circumstance. + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + internal sealed class DoesNotReturnAttribute : Attribute { } + + /// Specifies that the method will not return if the associated Boolean parameter is passed the specified value. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class DoesNotReturnIfAttribute : Attribute + { + /// Initializes the attribute with the specified parameter value. + /// + /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to + /// the associated parameter matches this value. + /// + public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue; + + /// Gets the condition parameter value. + public bool ParameterValue { get; } + } + + /// Specifies that the method or property will ensure that the listed field and property members have not-null values. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullAttribute : Attribute + { + /// Initializes the attribute with a field or property member. + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullAttribute(string member) => Members = new[] { member }; + + /// Initializes the attribute with the list of field and property members. + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullAttribute(params string[] members) => Members = members; + + /// Gets field or property member names. + public string[] Members { get; } + } + + /// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition and a field or property member. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, string member) + { + ReturnValue = returnValue; + Members = new[] { member }; + } + + /// Initializes the attribute with the specified return value condition and list of field and property members. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, params string[] members) + { + ReturnValue = returnValue; + Members = members; + } + + /// Gets the return value condition. + public bool ReturnValue { get; } + + /// Gets field or property member names. + public string[] Members { get; } + } +} diff --git a/src/PowerShellEditorServices/Range.cs b/src/PowerShellEditorServices/Range.cs new file mode 100644 index 000000000..ce0bcb2ac --- /dev/null +++ b/src/PowerShellEditorServices/Range.cs @@ -0,0 +1,123 @@ +// +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// (with alterations) +#if NET5_0_OR_GREATER +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Range))] +#else + +global using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; + +#nullable enable + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace System +{ + /// Represent a range has start and end indexes. + /// + /// Range is used by the C# compiler to support the range syntax. + /// + /// int[] someArray = new int[5] { 1, 2, 3, 4, 5 }; + /// int[] subArray1 = someArray[0..2]; // { 1, 2 } + /// int[] subArray2 = someArray[1..^0]; // { 2, 3, 4, 5 } + /// + /// + internal readonly struct Range : IEquatable + { + /// Represent the inclusive start index of the Range. + public Index Start { get; } + + /// Represent the exclusive end index of the Range. + public Index End { get; } + + /// Construct a Range object using the start and end indexes. + /// Represent the inclusive start index of the range. + /// Represent the exclusive end index of the range. + public Range(Index start, Index end) + { + Start = start; + End = end; + } + + /// Indicates whether the current Range object is equal to another object of the same type. + /// An object to compare with this object + public override bool Equals([NotNullWhen(true)] object? value) => + value is Range r && + r.Start.Equals(Start) && + r.End.Equals(End); + + /// Indicates whether the current Range object is equal to another Range object. + /// An object to compare with this object + public bool Equals(Range other) => other.Start.Equals(Start) && other.End.Equals(End); + + /// Returns the hash code for this instance. + public override int GetHashCode() + { + return (Start.GetHashCode(), End.GetHashCode()).GetHashCode(); + } + + /// Converts the value of the current Range object to its equivalent string representation. + public override string ToString() + { + return Start.ToString() + ".." + End.ToString(); + } + + /// Create a Range object starting from start index to the end of the collection. + public static Range StartAt(Index start) => new Range(start, Index.End); + + /// Create a Range object starting from first element in the collection to the end Index. + public static Range EndAt(Index end) => new Range(Index.Start, end); + + /// Create a Range object starting from first element to the end. + public static Range All => new Range(Index.Start, Index.End); + + /// Calculate the start offset and length of range object using a collection length. + /// The length of the collection that the range will be used with. length has to be a positive value. + /// + /// For performance reason, we don't validate the input length parameter against negative values. + /// It is expected Range will be used with collections which always have non negative length/count. + /// We validate the range is inside the length scope though. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public (int Offset, int Length) GetOffsetAndLength(int length) + { + int start; + Index startIndex = Start; + if (startIndex.IsFromEnd) + start = length - startIndex.Value; + else + start = startIndex.Value; + + int end; + Index endIndex = End; + if (endIndex.IsFromEnd) + end = length - endIndex.Value; + else + end = endIndex.Value; + + if ((uint)end > (uint)length || (uint)start > (uint)end) + { + ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.length); + } + + return (start, end - start); + } + + private static class ExceptionArgument + { + public const string length = nameof(length); + } + + private static class ThrowHelper + { + [DoesNotReturn, MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowArgumentOutOfRangeException(string parameterName) + { + throw new ArgumentOutOfRangeException(parameterName); + } + } + } +} +#endif diff --git a/src/PowerShellEditorServices/Server/PsesLanguageServer.cs b/src/PowerShellEditorServices/Server/PsesLanguageServer.cs index 58d52bb3a..accbe9918 100644 --- a/src/PowerShellEditorServices/Server/PsesLanguageServer.cs +++ b/src/PowerShellEditorServices/Server/PsesLanguageServer.cs @@ -86,6 +86,7 @@ public async Task StartAsync() .AddLanguageProtocolLogging() .SetMinimumLevel(_minimumLogLevel)) // TODO: Consider replacing all WithHandler with AddSingleton + .WithHandler() .WithHandler() .WithHandler() .WithHandler() @@ -149,11 +150,15 @@ public async Task StartAsync() InitialWorkingDirectory = initializationOptions?.GetValue("initialWorkingDirectory")?.Value() ?? workspaceService.WorkspaceFolders.FirstOrDefault()?.Uri.GetFileSystemPath() ?? Directory.GetCurrentDirectory(), + SupportsBreakpointSync = initializationOptions?.GetValue("supportsBreakpointSync")?.Value() + ?? false, // If a shell integration script path is provided, that implies the feature is enabled. ShellIntegrationScript = initializationOptions?.GetValue("shellIntegrationScript")?.Value() ?? "", }; + languageServer.Services.GetService().IsSupported = hostStartOptions.SupportsBreakpointSync; + workspaceService.InitialWorkingDirectory = hostStartOptions.InitialWorkingDirectory; _psesHost = languageServer.Services.GetService(); diff --git a/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs b/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs index 82081c341..06d465719 100644 --- a/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs +++ b/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs @@ -35,6 +35,9 @@ public static IServiceCollection AddPsesLanguageServices( (provider) => provider.GetService().DebugContext) .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton((provider) => { ExtensionService extensionService = new( @@ -59,18 +62,22 @@ public static IServiceCollection AddPsesDebugServices( PsesDebugServer psesDebugServer) { PsesInternalHost internalHost = languageServiceProvider.GetService(); + DebugStateService debugStateService = languageServiceProvider.GetService(); + BreakpointService breakpointService = languageServiceProvider.GetService(); + BreakpointSyncService breakpointSyncService = languageServiceProvider.GetService(); return collection .AddSingleton(internalHost) .AddSingleton(internalHost) .AddSingleton(internalHost.DebugContext) + .AddSingleton(breakpointSyncService) .AddSingleton(languageServiceProvider.GetService()) .AddSingleton(languageServiceProvider.GetService()) .AddSingleton(languageServiceProvider.GetService()) .AddSingleton(psesDebugServer) .AddSingleton() - .AddSingleton() - .AddSingleton() + .AddSingleton(breakpointService) + .AddSingleton(debugStateService) .AddSingleton(); } } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs index 748e21c6d..ab483dfef 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -22,6 +22,7 @@ internal class BreakpointService private readonly IInternalPowerShellExecutionService _executionService; private readonly PsesInternalHost _editorServicesHost; private readonly DebugStateService _debugStateService; + private readonly BreakpointSyncService _breakpointSyncService; // TODO: This needs to be managed per nested session internal readonly Dictionary> BreakpointsPerFile = new(); @@ -32,12 +33,14 @@ public BreakpointService( ILoggerFactory factory, IInternalPowerShellExecutionService executionService, PsesInternalHost editorServicesHost, - DebugStateService debugStateService) + DebugStateService debugStateService, + BreakpointSyncService breakpointSyncService) { _logger = factory.CreateLogger(); _executionService = executionService; _editorServicesHost = editorServicesHost; _debugStateService = debugStateService; + _breakpointSyncService = breakpointSyncService; } public async Task> GetBreakpointsAsync() @@ -59,6 +62,23 @@ public async Task> GetBreakpointsAsync() public async Task> SetBreakpointsAsync(string escapedScriptPath, IReadOnlyList breakpoints) { + if (_breakpointSyncService?.IsSupported is true) + { + // Since we're syncing breakpoints outside of debug configurations, if we can't find + // an existing breakpoint then mark it as unverified. + foreach (BreakpointDetails details in breakpoints) + { + if (_breakpointSyncService.TryGetBreakpointByServerId(details.Id, out SyncedBreakpoint syncedBreakpoint)) + { + continue; + } + + details.Verified = false; + } + + return breakpoints.ToArray(); + } + if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace)) { foreach (BreakpointDetails breakpointDetails in breakpoints) @@ -227,6 +247,13 @@ public async Task> SetCommandBreakpoints /// public async Task RemoveAllBreakpointsAsync(string scriptPath = null) { + // Only need to remove all breakpoints if we're not able to sync outside of debug + // sessions. + if (_breakpointSyncService?.IsSupported is true) + { + return; + } + try { if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace)) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointSyncService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointSyncService.cs new file mode 100644 index 000000000..67fc02834 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointSyncService.cs @@ -0,0 +1,915 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +using Debugger = System.Management.Automation.Debugger; + +namespace Microsoft.PowerShell.EditorServices; + +internal record SyncedBreakpoint( + ClientBreakpoint Client, + Breakpoint Server); + +internal enum SyncedBreakpointKind +{ + Line, + + Variable, + + Command, +} + +internal sealed class BreakpointSyncService +{ + private record BreakpointMap( + ConcurrentDictionary ByGuid, + ConcurrentDictionary ByPwshId) + { + public void Register(SyncedBreakpoint breakpoint) + { + ByGuid.AddOrUpdate( + breakpoint.Client.Id!, + breakpoint, + (_, _) => breakpoint); + + ByPwshId.AddOrUpdate( + breakpoint.Server.Id, + breakpoint, + (_, _) => breakpoint); + } + + public void Unregister(SyncedBreakpoint breakpoint) + { + ByGuid.TryRemove(breakpoint.Client.Id, out _); + ByPwshId.TryRemove(breakpoint.Server.Id, out _); + } + } + + private record BreakpointTranslationInfo( + SyncedBreakpointKind Kind, + ScriptBlock? Action = null, + string? Name = null, + VariableAccessMode VariableMode = default, + string? FilePath = null, + int Line = 0, + int Column = 0); + + internal record struct BreakpointHandle(SemaphoreSlim Target) : IDisposable + { + public void Dispose() => Target.Release(); + } + + private readonly BreakpointMap _map = new(new(), new()); + + private readonly ConditionalWeakTable _attachedRunspaceMap = new(); + + private readonly ConcurrentBag _pendingAdds = new(); + + private readonly ConcurrentBag _pendingRemovals = new(); + + private readonly SemaphoreSlim _breakpointMutateHandle = AsyncUtils.CreateSimpleLockingSemaphore(); + + private readonly IInternalPowerShellExecutionService _host; + + private readonly ILogger _logger; + + private readonly DebugStateService _debugStateService; + + private readonly ILanguageServerFacade _languageServer; + + private bool? _isSupported; + + public BreakpointSyncService( + IInternalPowerShellExecutionService host, + ILoggerFactory loggerFactory, + DebugStateService debugStateService, + ILanguageServerFacade languageServer) + { + _host = host; + _logger = loggerFactory.CreateLogger(); + _debugStateService = debugStateService; + _languageServer = languageServer; + } + + public bool IsSupported + { + get => _isSupported ?? false; + set + { + Debug.Assert(_isSupported is null, "BreakpointSyncService.IsSupported should only be set once by initialize."); + _isSupported = value; + } + } + + public bool IsMutatingBreakpoints + { + get + { + if (_breakpointMutateHandle.Wait(0)) + { + _breakpointMutateHandle.Release(); + return false; + } + + return true; + } + } + + private int? TargetRunspaceId => _debugStateService?.RunspaceId; + + public async Task UpdatedByServerAsync(BreakpointUpdatedEventArgs eventArgs) + { + if (_map.ByPwshId.TryGetValue(eventArgs.Breakpoint.Id, out SyncedBreakpoint existing)) + { + await ProcessExistingBreakpoint(eventArgs, existing).ConfigureAwait(false); + return; + } + + // If we haven't told the client about the breakpoint yet then we can just ignore. + if (eventArgs.UpdateType is BreakpointUpdateType.Removed) + { + return; + } + + SyncedBreakpoint newBreakpoint = CreateFromServerBreakpoint(eventArgs.Breakpoint); + string? id = await SendToClientAsync(newBreakpoint.Client, BreakpointUpdateType.Set) + .ConfigureAwait(false); + + if (id is null) + { + LogBreakpointError(newBreakpoint, "Did not receive a breakpoint ID from the client."); + } + + newBreakpoint.Client.Id = id; + RegisterBreakpoint(newBreakpoint); + + async Task ProcessExistingBreakpoint(BreakpointUpdatedEventArgs eventArgs, SyncedBreakpoint existing) + { + if (eventArgs.UpdateType is BreakpointUpdateType.Removed) + { + UnregisterBreakpoint(existing); + await SendToClientAsync(existing.Client, BreakpointUpdateType.Removed).ConfigureAwait(false); + return; + } + + if (eventArgs.UpdateType is BreakpointUpdateType.Enabled or BreakpointUpdateType.Disabled) + { + await SendToClientAsync(existing.Client, eventArgs.UpdateType).ConfigureAwait(false); + bool isActive = eventArgs.UpdateType is BreakpointUpdateType.Enabled; + SyncedBreakpoint newBreakpoint = existing with + { + Client = existing.Client with + { + Enabled = isActive, + }, + }; + + UnregisterBreakpoint(existing); + RegisterBreakpoint(newBreakpoint); + return; + } + + LogBreakpointError( + existing, + "Somehow we're syncing a new breakpoint that we've already sync'd. That's not supposed to happen."); + } + } + + public IReadOnlyList GetSyncedBreakpoints() => _map.ByGuid.Values.ToArray(); + + public bool TryGetBreakpointByClientId(string id, [MaybeNullWhen(false)] out SyncedBreakpoint? syncedBreakpoint) + => _map.ByGuid.TryGetValue(id, out syncedBreakpoint); + + public bool TryGetBreakpointByServerId(int id, [MaybeNullWhen(false)] out SyncedBreakpoint? syncedBreakpoint) + => _map.ByPwshId.TryGetValue(id, out syncedBreakpoint); + + public void SyncServerAfterAttach() + { + if (!BreakpointApiUtils.SupportsBreakpointApis(_host.CurrentRunspace)) + { + return; + } + + bool ownsHandle = false; + try + { + ownsHandle = _breakpointMutateHandle.Wait(0); + Runspace runspace = _host.CurrentRunspace.Runspace; + Debugger debugger = runspace.Debugger; + foreach (Breakpoint existingBp in BreakpointApiUtils.GetBreakpointsDelegate(debugger, TargetRunspaceId)) + { + BreakpointApiUtils.RemoveBreakpointDelegate(debugger, existingBp, TargetRunspaceId); + } + + SyncedBreakpoint[] currentBreakpoints = _map.ByGuid.Values.ToArray(); + BreakpointApiUtils.SetBreakpointsDelegate( + debugger, + currentBreakpoints.Select(sbp => sbp.Server), + TargetRunspaceId); + + BreakpointMap map = GetMapForRunspace(runspace); + foreach (SyncedBreakpoint sbp in currentBreakpoints) + { + map.Register(sbp); + } + } + finally + { + if (ownsHandle) + { + _breakpointMutateHandle.Release(); + } + } + } + + public void SyncServerAfterRunspacePop() + { + if (!BreakpointApiUtils.SupportsBreakpointApis(_host.CurrentRunspace)) + { + return; + } + + bool ownsHandle = false; + try + { + ownsHandle = _breakpointMutateHandle.Wait(0); + Debugger debugger = _host.CurrentRunspace.Runspace.Debugger; + while (_pendingRemovals.TryTake(out SyncedBreakpoint sbp)) + { + if (!_map.ByGuid.TryGetValue(sbp.Client.Id!, out SyncedBreakpoint existing)) + { + continue; + } + + BreakpointApiUtils.RemoveBreakpointDelegate(debugger, existing.Server, null); + _map.Unregister(existing); + } + + BreakpointApiUtils.SetBreakpointsDelegate( + debugger, + _pendingAdds.Select(sbp => sbp.Server), + null); + + while (_pendingAdds.TryTake(out SyncedBreakpoint sbp)) + { + _map.Register(sbp); + } + } + finally + { + if (ownsHandle) + { + _breakpointMutateHandle.Release(); + } + } + } + + public async Task UpdatedByClientAsync( + IReadOnlyList? clientBreakpoints, + CancellationToken cancellationToken = default) + { + if (clientBreakpoints is null or { Count: 0 }) + { + return; + } + + using BreakpointHandle _ = await StartMutatingBreakpointsAsync(cancellationToken) + .ConfigureAwait(false); + + List<(ClientBreakpoint Client, BreakpointTranslationInfo? Translation)>? toAdd = null; + List? toRemove = null; + List<(SyncedBreakpoint Breakpoint, bool Enabled)>? toSetActive = null; + + foreach (ClientBreakpoint clientBreakpoint in clientBreakpoints) + { + if (!_map.ByGuid.TryGetValue(clientBreakpoint.Id, out SyncedBreakpoint existing)) + { + (toAdd ??= new()).Add((clientBreakpoint, GetTranslationInfo(clientBreakpoint))); + continue; + } + + if (clientBreakpoint == existing.Client) + { + continue; + } + + // If the only thing that has changed is whether the breakpoint is enabled + // we can skip removing and re-adding. + if (clientBreakpoint.Enabled != existing.Client.Enabled + && clientBreakpoint with { Enabled = existing.Client.Enabled } != existing.Client) + { + (toSetActive ??= new()).Add((existing, clientBreakpoint.Enabled)); + continue; + } + + (toAdd ??= new()).Add((clientBreakpoint, GetTranslationInfo(clientBreakpoint))); + (toRemove ??= new()).Add(existing); + } + + await ExecuteDelegateAsync( + "SyncUpdatedClientBreakpoints", + executionOptions: null, + (cancellationToken) => + { + if (toRemove is not null and not { Count: 0 }) + { + foreach (SyncedBreakpoint bpToRemove in toRemove) + { + cancellationToken.ThrowIfCancellationRequested(); + UnsafeRemoveBreakpoint( + bpToRemove, + cancellationToken); + } + } + + if (toAdd is not null and not { Count: 0 }) + { + foreach ((ClientBreakpoint Client, BreakpointTranslationInfo? Translation) in toAdd) + { + cancellationToken.ThrowIfCancellationRequested(); + UnsafeCreateBreakpoint( + Client, + Translation, + cancellationToken); + } + } + + if (toSetActive is not null and not { Count: 0 }) + { + foreach ((SyncedBreakpoint Breakpoint, bool Enabled) bpToSetActive in toSetActive) + { + UnsafeSetBreakpointActive( + bpToSetActive.Enabled, + bpToSetActive.Breakpoint.Server, + cancellationToken); + + SyncedBreakpoint newBreakpoint = bpToSetActive.Breakpoint with + { + Client = bpToSetActive.Breakpoint.Client with + { + Enabled = bpToSetActive.Enabled, + }, + }; + + UnregisterBreakpoint(bpToSetActive.Breakpoint); + RegisterBreakpoint(newBreakpoint); + } + } + }, + cancellationToken) + .ConfigureAwait(false); + } + + public async Task RemovedFromClientAsync( + IReadOnlyList? clientBreakpoints, + CancellationToken cancellationToken = default) + { + if (clientBreakpoints is null or { Count: 0 }) + { + return; + } + + using BreakpointHandle _ = await StartMutatingBreakpointsAsync(cancellationToken) + .ConfigureAwait(false); + + List syncedBreakpoints = new(clientBreakpoints.Count); + foreach (ClientBreakpoint clientBreakpoint in clientBreakpoints) + { + // If we don't have a record of the breakpoint, we don't need to remove it. + if (_map.ByGuid.TryGetValue(clientBreakpoint.Id!, out SyncedBreakpoint? syncedBreakpoint)) + { + syncedBreakpoints.Add(syncedBreakpoint!); + } + } + + await ExecuteDelegateAsync( + "SyncRemovedClientBreakpoints", + executionOptions: null, + (cancellationToken) => + { + foreach (SyncedBreakpoint syncedBreakpoint in syncedBreakpoints) + { + cancellationToken.ThrowIfCancellationRequested(); + UnsafeRemoveBreakpoint( + syncedBreakpoint, + cancellationToken); + } + }, + cancellationToken) + .ConfigureAwait(false); + } + + public async Task FromClientAsync( + IReadOnlyList clientBreakpoints, + CancellationToken cancellationToken = default) + { + using BreakpointHandle _ = await StartMutatingBreakpointsAsync(cancellationToken) + .ConfigureAwait(false); + + List<(ClientBreakpoint Client, BreakpointTranslationInfo? Translation)> breakpointsToCreate = new(clientBreakpoints.Count); + foreach (ClientBreakpoint clientBreakpoint in clientBreakpoints) + { + breakpointsToCreate.Add((clientBreakpoint, GetTranslationInfo(clientBreakpoint))); + } + + await ExecuteDelegateAsync( + "SyncNewClientBreakpoints", + executionOptions: null, + (cancellationToken) => + { + foreach ((ClientBreakpoint Client, BreakpointTranslationInfo? Translation) in breakpointsToCreate) + { + UnsafeCreateBreakpoint( + Client, + Translation, + cancellationToken); + } + }, + cancellationToken) + .ConfigureAwait(false); + + return; + } + + private void LogBreakpointError(SyncedBreakpoint breakpoint, string message) + { + // Switch to the new C# 11 string literal syntax once it makes it to stable. + _logger.LogError( + "{Message}\n" + + " Server:\n" + + " Id: {ServerId}\n" + + " Enabled: {ServerEnabled}\n" + + " Action: {Action}\n" + + " Script: {Script}\n" + + " Command: {Command}\n" + + " Variable: {Variable}\n" + + " Client:\n" + + " Id: {ClientId}\n" + + " Enabled: {ClientEnabled}\n" + + " Condition: {Condition}\n" + + " HitCondition: {HitCondition}\n" + + " LogMessage: {LogMessage}\n" + + " Location: {Location}\n" + + " Function: {Function}\n", + message, + breakpoint.Server.Id, + breakpoint.Server.Enabled, + breakpoint.Server.Action, + breakpoint.Server.Script, + (breakpoint.Server as CommandBreakpoint)?.Command, + (breakpoint.Server as VariableBreakpoint)?.Variable, + breakpoint.Client.Id, + breakpoint.Client.Enabled, + breakpoint.Client.Condition, + breakpoint.Client.HitCondition, + breakpoint.Client.LogMessage, + breakpoint.Client.Location, + breakpoint.Client.FunctionName); + } + + private async Task ExecuteDelegateAsync( + string representation, + ExecutionOptions? executionOptions, + Action action, + CancellationToken cancellationToken) + { + if (BreakpointApiUtils.SupportsBreakpointApis(_host.CurrentRunspace)) + { + action(cancellationToken); + return; + } + + await _host.ExecuteDelegateAsync( + representation, + executionOptions, + action, + cancellationToken) + .ConfigureAwait(false); + } + + private static SyncedBreakpoint CreateFromServerBreakpoint(Breakpoint serverBreakpoint) + { + string? functionName = null; + ClientLocation? location = null; + if (serverBreakpoint is VariableBreakpoint vbp) + { + string mode = vbp.AccessMode switch + { + VariableAccessMode.Read => "R", + VariableAccessMode.Write => "W", + _ => "RW", + }; + + functionName = $"${vbp.Variable}!{mode}"; + } + else if (serverBreakpoint is CommandBreakpoint cbp) + { + functionName = cbp.Command; + } + else if (serverBreakpoint is LineBreakpoint lbp) + { + location = new ClientLocation( + lbp.Script, + new Range( + lbp.Line - 1, + Math.Max(lbp.Column - 1, 0), + lbp.Line - 1, + Math.Max(lbp.Column - 1, 0))); + } + + ClientBreakpoint clientBreakpoint = new( + serverBreakpoint.Enabled, + serverBreakpoint.Action?.ToString(), + null, + null, + location, + functionName); + + SyncedBreakpoint syncedBreakpoint = new(clientBreakpoint, serverBreakpoint); + return syncedBreakpoint; + } + + private async Task SendToClientAsync( + ClientBreakpoint clientBreakpoint, + BreakpointUpdateType updateType) + { + if (updateType is BreakpointUpdateType.Set) + { + if (_languageServer is null) + { + return null; + } + + // We need to send new breakpoints across as a separate request so we can get the client + // ID back. + return await _languageServer.SendRequest( + "powerShell/setBreakpoint", + clientBreakpoint) + .Returning(default) + .ConfigureAwait(false); + } + + bool isUpdate = updateType is BreakpointUpdateType.Enabled or BreakpointUpdateType.Disabled; + bool isRemove = updateType is BreakpointUpdateType.Removed; + _languageServer?.SendNotification( + "powerShell/breakpointsChanged", + new ClientBreakpointsChangedEvents( + Array.Empty(), + isRemove ? new[] { clientBreakpoint } : Array.Empty(), + isUpdate ? new[] { clientBreakpoint } : Array.Empty())); + + return null; + } + + private Breakpoint? UnsafeCreateBreakpoint( + BreakpointTranslationInfo translationInfo, + CancellationToken cancellationToken) + { + if (BreakpointApiUtils.SupportsBreakpointApis(_host.CurrentRunspace)) + { + return translationInfo.Kind switch + { + SyncedBreakpointKind.Command => BreakpointApiUtils.SetCommandBreakpointDelegate( + _host.CurrentRunspace.Runspace.Debugger, + translationInfo.Name, + translationInfo.Action, + string.Empty, + TargetRunspaceId), + SyncedBreakpointKind.Variable => BreakpointApiUtils.SetVariableBreakpointDelegate( + _host.CurrentRunspace.Runspace.Debugger, + translationInfo.Name, + translationInfo.VariableMode, + translationInfo.Action, + string.Empty, + TargetRunspaceId), + SyncedBreakpointKind.Line => BreakpointApiUtils.SetLineBreakpointDelegate( + _host.CurrentRunspace.Runspace.Debugger, + translationInfo.FilePath, + translationInfo.Line, + translationInfo.Column, + translationInfo.Action, + TargetRunspaceId), + _ => throw new ArgumentOutOfRangeException(nameof(translationInfo)), + }; + } + + PSCommand command = new PSCommand().AddCommand("Microsoft.PowerShell.Utility\\Set-PSBreakpoint"); + _ = translationInfo.Kind switch + { + SyncedBreakpointKind.Command => command.AddParameter("Command", translationInfo.Name), + SyncedBreakpointKind.Variable => command + .AddParameter("Variable", translationInfo.Name) + .AddParameter("Mode", translationInfo.VariableMode), + SyncedBreakpointKind.Line => command + .AddParameter("Script", translationInfo.FilePath) + .AddParameter("Line", translationInfo.Line), + _ => throw new ArgumentOutOfRangeException(nameof(translationInfo)), + }; + + if (translationInfo is { Kind: SyncedBreakpointKind.Line, Column: > 0 }) + { + command.AddParameter("Column", translationInfo.Column); + } + + return _host.UnsafeInvokePSCommand(command, null, cancellationToken) + is IReadOnlyList bp and { Count: 1 } + ? bp[0] + : null; + } + + private BreakpointTranslationInfo? GetTranslationInfo(ClientBreakpoint clientBreakpoint) + { + if (clientBreakpoint.Location?.Uri is DocumentUri uri + && !ScriptFile.IsUntitledPath(uri.ToString()) + && !PathUtils.HasPowerShellScriptExtension(uri.GetFileSystemPath())) + { + return null; + } + + ScriptBlock? actionScriptBlock = null; + + // Check if this is a "conditional" line breakpoint. + if (!string.IsNullOrWhiteSpace(clientBreakpoint.Condition) || + !string.IsNullOrWhiteSpace(clientBreakpoint.HitCondition) || + !string.IsNullOrWhiteSpace(clientBreakpoint.LogMessage)) + { + actionScriptBlock = BreakpointApiUtils.GetBreakpointActionScriptBlock( + clientBreakpoint.Condition, + clientBreakpoint.HitCondition, + clientBreakpoint.LogMessage, + out string errorMessage); + + if (errorMessage is not null and not "") + { + _logger.LogError( + "Unable to create breakpoint action ScriptBlock with error message: \"{Error}\"\n" + + " Condition: {Condition} \n" + + " HitCondition: {HitCondition}\n" + + " LogMessage: {LogMessage}\n", + errorMessage, + clientBreakpoint.Condition, + clientBreakpoint.HitCondition, + clientBreakpoint.LogMessage); + return null; + } + } + + string? functionName = null; + string? variableName = null; + string? script = null; + int line = 0, column = 0; + VariableAccessMode mode = default; + SyncedBreakpointKind kind = default; + + if (clientBreakpoint.FunctionName is not null) + { + ReadOnlySpan functionSpan = clientBreakpoint.FunctionName.AsSpan(); + if (functionSpan is { Length: > 1 } && functionSpan[0] is '$') + { + ReadOnlySpan nameWithSuffix = functionSpan[1..]; + mode = ParseSuffix(nameWithSuffix, out ReadOnlySpan name); + variableName = name.ToString(); + kind = SyncedBreakpointKind.Variable; + } + else + { + functionName = clientBreakpoint.FunctionName; + kind = SyncedBreakpointKind.Command; + } + } + + if (clientBreakpoint.Location is not null) + { + kind = SyncedBreakpointKind.Line; + script = clientBreakpoint.Location.Uri.Scheme is "untitled" + ? clientBreakpoint.Location.Uri.ToString() + : clientBreakpoint.Location.Uri.GetFileSystemPath(); + line = clientBreakpoint.Location.Range.Start.Line + 1; + column = clientBreakpoint.Location.Range.Start.Character is int c and not 0 ? c : 0; + } + + return new BreakpointTranslationInfo( + kind, + actionScriptBlock, + kind is SyncedBreakpointKind.Variable ? variableName : functionName, + mode, + script, + line, + column); + + static VariableAccessMode ParseSuffix(ReadOnlySpan nameWithSuffix, out ReadOnlySpan name) + { + // TODO: Simplify `if` logic when list patterns from C# 11 make it to stable. + // + // if (nameWithSuffix is [..ReadOnlySpan rest, '!', 'R' or 'r', 'W' or 'w']) + if (nameWithSuffix is { Length: > 3 } + && nameWithSuffix[^1] is 'w' or 'W' + && nameWithSuffix[^2] is 'r' or 'R' + && nameWithSuffix[^3] is '!') + { + name = nameWithSuffix[0..^3]; + return VariableAccessMode.ReadWrite; + } + + // if (nameWithSuffix is [..ReadOnlySpan rest1, '!', 'W' or 'w', 'R' or 'r']) + if (nameWithSuffix is { Length: > 3 } + && nameWithSuffix[^1] is 'r' or 'R' + && nameWithSuffix[^2] is 'w' or 'W' + && nameWithSuffix[^3] is '!') + { + name = nameWithSuffix[0..^3]; + return VariableAccessMode.ReadWrite; + } + + // if (nameWithSuffix is [..ReadOnlySpan rest2, '!', 'R' or 'r']) + if (nameWithSuffix is { Length: > 2 } + && nameWithSuffix[^1] is 'r' or 'R' + && nameWithSuffix[^2] is '!') + { + name = nameWithSuffix[0..^2]; + return VariableAccessMode.Read; + } + + // if (nameWithSuffix is [..ReadOnlySpan rest3, '!', 'W' or 'w']) + if (nameWithSuffix is { Length: > 2 } + && nameWithSuffix[^1] is 'w' or 'W' + && nameWithSuffix[^2] is '!') + { + name = nameWithSuffix[0..^2]; + return VariableAccessMode.Write; + } + + name = nameWithSuffix; + return VariableAccessMode.ReadWrite; + } + } + + internal BreakpointHandle StartMutatingBreakpoints(CancellationToken cancellationToken = default) + { + _breakpointMutateHandle.Wait(cancellationToken); + return new BreakpointHandle(_breakpointMutateHandle); + } + + private async Task StartMutatingBreakpointsAsync(CancellationToken cancellationToken = default) + { + await _breakpointMutateHandle.WaitAsync(cancellationToken).ConfigureAwait(false); + return new BreakpointHandle(_breakpointMutateHandle); + } + + private void UnsafeRemoveBreakpoint( + SyncedBreakpoint breakpoint, + CancellationToken cancellationToken) + { + UnsafeRemoveBreakpoint( + breakpoint.Server, + cancellationToken); + + UnregisterBreakpoint(breakpoint); + } + + private void UnsafeRemoveBreakpoint( + Breakpoint breakpoint, + CancellationToken cancellationToken) + { + if (BreakpointApiUtils.SupportsBreakpointApis(_host.CurrentRunspace)) + { + BreakpointApiUtils.RemoveBreakpointDelegate( + _host.CurrentRunspace.Runspace.Debugger, + breakpoint, + TargetRunspaceId); + return; + } + + _host.UnsafeInvokePSCommand( + new PSCommand() + .AddCommand("Microsoft.PowerShell.Utility\\Remove-PSBreakpoint") + .AddParameter("Id", breakpoint.Id), + executionOptions: null, + cancellationToken); + } + + private Breakpoint? UnsafeSetBreakpointActive( + bool enabled, + Breakpoint? breakpoint, + CancellationToken cancellationToken) + { + if (breakpoint is null) + { + return breakpoint; + } + + if (BreakpointApiUtils.SupportsBreakpointApis(_host.CurrentRunspace)) + { + if (enabled) + { + return BreakpointApiUtils.EnableBreakpointDelegate( + _host.CurrentRunspace.Runspace.Debugger, + breakpoint, + TargetRunspaceId); + } + + return BreakpointApiUtils.DisableBreakpointDelegate( + _host.CurrentRunspace.Runspace.Debugger, + breakpoint, + TargetRunspaceId); + } + + string commandToInvoke = enabled + ? "Microsoft.PowerShell.Utility\\Enable-PSBreakpoint" + : "Microsoft.PowerShell.Utility\\Disable-PSBreakpoint"; + + _host.UnsafeInvokePSCommand( + new PSCommand() + .AddCommand(commandToInvoke) + .AddParameter("Id", breakpoint.Id), + executionOptions: null, + cancellationToken); + + return breakpoint; + } + + private SyncedBreakpoint? UnsafeCreateBreakpoint( + ClientBreakpoint client, + BreakpointTranslationInfo? translation, + CancellationToken cancellationToken) + { + if (translation is null or { Kind: SyncedBreakpointKind.Command, Name: null or "" }) + { + return null; + } + + Breakpoint? pwshBreakpoint = UnsafeCreateBreakpoint( + translation, + cancellationToken); + + if (!client.Enabled) + { + pwshBreakpoint = UnsafeSetBreakpointActive( + false, + pwshBreakpoint, + cancellationToken); + } + + if (pwshBreakpoint is null) + { + return null; + } + + SyncedBreakpoint syncedBreakpoint = new(client, pwshBreakpoint); + RegisterBreakpoint(syncedBreakpoint); + return syncedBreakpoint; + } + + private void RegisterBreakpoint(SyncedBreakpoint breakpoint) + { + BreakpointMap map = _map; + if (_host.IsRunspacePushed) + { + map = GetMapForRunspace(_host.CurrentRunspace.Runspace); + _pendingAdds.Add(breakpoint); + } + + map.Register(breakpoint); + } + + private BreakpointMap GetMapForRunspace(Runspace runspace) + => _attachedRunspaceMap.GetValue( + runspace, + _ => new BreakpointMap( + new ConcurrentDictionary(), + new ConcurrentDictionary())); + + private void UnregisterBreakpoint(SyncedBreakpoint breakpoint) + { + BreakpointMap map = _map; + if (_host.IsRunspacePushed) + { + map = GetMapForRunspace(_host.CurrentRunspace.Runspace); + _pendingRemovals.Add(breakpoint); + } + + map.Unregister(breakpoint); + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs index 9736b3e85..706a9580b 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs @@ -42,5 +42,20 @@ internal class DebugStateService internal int ReleaseSetBreakpointHandle() => _setBreakpointInProgressHandle.Release(); internal Task WaitForSetBreakpointHandleAsync() => _setBreakpointInProgressHandle.WaitAsync(); + + internal void Reset() + { + NoDebug = false; + Arguments = null; + IsRemoteAttach = false; + RunspaceId = null; + IsAttachSession = false; + WaitingForAttach = false; + ScriptToLaunch = null; + ExecutionCompleted = false; + IsInteractiveDebugSession = false; + IsUsingTempIntegratedConsole = false; + ServerStarted = null; + } } } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs index 7884fbda5..d95a9ddf2 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs @@ -22,10 +22,18 @@ internal static class BreakpointApiUtils private static readonly Lazy> s_setCommandBreakpointLazy; + private static readonly Lazy> s_setVariableBreakpointLazy; + private static readonly Lazy>> s_getBreakpointsLazy; private static readonly Lazy> s_removeBreakpointLazy; + private static readonly Lazy> s_disableBreakpointLazy; + + private static readonly Lazy> s_enableBreakpointLazy; + + private static readonly Lazy, int?>> s_setBreakpointsLazy; + private static readonly Version s_minimumBreakpointApiPowerShellVersion = new(7, 0, 0, 0); private static int breakpointHitCounter; @@ -65,6 +73,17 @@ static BreakpointApiUtils() setCommandBreakpointMethod); }); + s_setVariableBreakpointLazy = new Lazy>(() => + { + Type[] setVariableBreakpointParameters = new[] { typeof(string), typeof(VariableAccessMode), typeof(ScriptBlock), typeof(string), typeof(int?) }; + MethodInfo setVariableBreakpointMethod = typeof(Debugger).GetMethod("SetVariableBreakpoint", setVariableBreakpointParameters); + + return (Func)Delegate.CreateDelegate( + typeof(Func), + firstArgument: null, + setVariableBreakpointMethod); + }); + s_getBreakpointsLazy = new Lazy>>(() => { Type[] getBreakpointsParameters = new[] { typeof(int?) }; @@ -86,19 +105,60 @@ static BreakpointApiUtils() firstArgument: null, removeBreakpointMethod); }); + + s_disableBreakpointLazy = new Lazy>(() => + { + Type[] disableBreakpointParameters = new[] { typeof(Breakpoint), typeof(int?) }; + MethodInfo disableBreakpointMethod = typeof(Debugger).GetMethod("DisableBreakpoint", disableBreakpointParameters); + + return (Func)Delegate.CreateDelegate( + typeof(Func), + firstArgument: null, + disableBreakpointMethod); + }); + + s_enableBreakpointLazy = new Lazy>(() => + { + Type[] enableBreakpointParameters = new[] { typeof(Breakpoint), typeof(int?) }; + MethodInfo enableBreakpointMethod = typeof(Debugger).GetMethod("EnableBreakpoint", enableBreakpointParameters); + + return (Func)Delegate.CreateDelegate( + typeof(Func), + firstArgument: null, + enableBreakpointMethod); + }); + + s_setBreakpointsLazy = new Lazy, int?>>(() => + { + Type[] setBreakpointsParameters = new[] { typeof(IEnumerable), typeof(int?) }; + MethodInfo setBreakpointsMethod = typeof(Debugger).GetMethod("SetBreakpoints", setBreakpointsParameters); + + return (Action, int?>)Delegate.CreateDelegate( + typeof(Action, int?>), + firstArgument: null, + setBreakpointsMethod); + }); } #endregion #region Private Static Properties - private static Func SetLineBreakpointDelegate => s_setLineBreakpointLazy.Value; + internal static Func SetLineBreakpointDelegate => s_setLineBreakpointLazy.Value; + + internal static Func SetCommandBreakpointDelegate => s_setCommandBreakpointLazy.Value; + + internal static Func SetVariableBreakpointDelegate => s_setVariableBreakpointLazy.Value; + + internal static Func> GetBreakpointsDelegate => s_getBreakpointsLazy.Value; + + internal static Func RemoveBreakpointDelegate => s_removeBreakpointLazy.Value; - private static Func SetCommandBreakpointDelegate => s_setCommandBreakpointLazy.Value; + internal static Func DisableBreakpointDelegate => s_disableBreakpointLazy.Value; - private static Func> GetBreakpointsDelegate => s_getBreakpointsLazy.Value; + internal static Func EnableBreakpointDelegate => s_enableBreakpointLazy.Value; - private static Func RemoveBreakpointDelegate => s_removeBreakpointLazy.Value; + internal static Action, int?> SetBreakpointsDelegate => s_setBreakpointsLazy.Value; #endregion diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs index 4c99ff747..454102e2e 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs @@ -32,23 +32,46 @@ internal class BreakpointHandlers : ISetFunctionBreakpointsHandler, ISetBreakpoi private readonly DebugStateService _debugStateService; private readonly WorkspaceService _workspaceService; private readonly IRunspaceContext _runspaceContext; + private readonly BreakpointSyncService _breakpointSyncService; public BreakpointHandlers( ILoggerFactory loggerFactory, DebugService debugService, DebugStateService debugStateService, WorkspaceService workspaceService, - IRunspaceContext runspaceContext) + IRunspaceContext runspaceContext, + BreakpointSyncService breakpointSyncService) { _logger = loggerFactory.CreateLogger(); _debugService = debugService; _debugStateService = debugStateService; _workspaceService = workspaceService; _runspaceContext = runspaceContext; + _breakpointSyncService = breakpointSyncService; } public async Task Handle(SetBreakpointsArguments request, CancellationToken cancellationToken) { + if (_breakpointSyncService.IsSupported) + { + // TODO: Find some way to actually verify these. Unfortunately the sync event comes + // through *after* the `SetBreakpoints` event so we can't just look at what synced + // breakpoints were successfully set. + return new SetBreakpointsResponse() + { + Breakpoints = new Container( + request.Breakpoints + .Select(sbp => new Breakpoint() + { + Line = sbp.Line, + Column = sbp.Column, + Verified = true, + Source = request.Source, + Id = 0, + })), + }; + } + if (!_workspaceService.TryGetFile(request.Source.Path, out ScriptFile scriptFile)) { string message = _debugStateService.NoDebug ? string.Empty : "Source file could not be accessed, breakpoint not set."; @@ -125,6 +148,21 @@ await _debugService.SetLineBreakpointsAsync( public async Task Handle(SetFunctionBreakpointsArguments request, CancellationToken cancellationToken) { + if (_breakpointSyncService.IsSupported) + { + return new SetFunctionBreakpointsResponse() + { + Breakpoints = new Container( + _breakpointSyncService.GetSyncedBreakpoints() + .Where(sbp => sbp.Client.FunctionName is not null and not "") + .Select(sbp => new Breakpoint() + { + Verified = true, + Message = sbp.Client.FunctionName, + })), + }; + } + IReadOnlyList breakpointDetails = request.Breakpoints .Select((funcBreakpoint) => CommandBreakpointDetails.Create( funcBreakpoint.Name, diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ClientBreakpointHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ClientBreakpointHandler.cs new file mode 100644 index 000000000..1d70db41b --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ClientBreakpointHandler.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Serialization; + +namespace Microsoft.PowerShell.EditorServices; + +internal record ClientLocation( + DocumentUri Uri, + Range Range); + +internal record ClientBreakpoint( + bool Enabled, + [property: Optional] string? Condition = null, + [property: Optional] string? HitCondition = null, + [property: Optional] string? LogMessage = null, + [property: Optional] ClientLocation? Location = null, + [property: Optional] string? FunctionName = null) +{ + // The ID needs to come from the client, so if the breakpoint is originating on the server + // then we need to create an ID-less breakpoint to send over to the client. + public string? Id { get; set; } +} + +internal record ClientBreakpointsChangedEvents( + Container Added, + Container Removed, + Container Changed) + : IRequest, IBaseRequest; + +[Serial, Method("powerShell/breakpointsChanged", Direction.Bidirectional)] +internal interface IClientBreakpointHandler : IJsonRpcNotificationHandler +{ +} + +internal sealed class ClientBreakpointHandler : IClientBreakpointHandler +{ + private readonly ILogger _logger; + + private readonly BreakpointSyncService _breakpoints; + + public ClientBreakpointHandler( + ILoggerFactory loggerFactory, + BreakpointSyncService breakpointSyncService) + { + _logger = loggerFactory.CreateLogger(); + _breakpoints = breakpointSyncService; + } + + public async Task Handle( + ClientBreakpointsChangedEvents request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Starting breakpoint sync initiated by client."); + await _breakpoints.FromClientAsync( + request.Added.ToArray(), + cancellationToken) + .ConfigureAwait(false); + + await _breakpoints.UpdatedByClientAsync( + request.Changed.ToArray(), + cancellationToken) + .ConfigureAwait(false); + + await _breakpoints.RemovedFromClientAsync( + request.Removed.ToArray(), + cancellationToken) + .ConfigureAwait(false); + + return new(); + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs index 97bd6cca7..29b9534f6 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -96,6 +96,7 @@ internal class LaunchAndAttachHandler : ILaunchHandler _logger; private readonly BreakpointService _breakpointService; + private readonly BreakpointSyncService _breakpointSyncService; private readonly DebugService _debugService; private readonly IRunspaceContext _runspaceContext; private readonly IInternalPowerShellExecutionService _executionService; @@ -108,6 +109,7 @@ public LaunchAndAttachHandler( ILoggerFactory factory, IDebugAdapterServerFacade debugAdapterServer, BreakpointService breakpointService, + BreakpointSyncService breakpointSyncService, DebugEventHandlerService debugEventHandlerService, DebugService debugService, IRunspaceContext runspaceContext, @@ -118,6 +120,7 @@ public LaunchAndAttachHandler( _logger = factory.CreateLogger(); _debugAdapterServer = debugAdapterServer; _breakpointService = breakpointService; + _breakpointSyncService = breakpointSyncService; _debugEventHandlerService = debugEventHandlerService; _debugService = debugService; _runspaceContext = runspaceContext; @@ -336,7 +339,15 @@ void RunspaceChangedHandler(object s, RunspaceChangedEventArgs _) debugRunspaceCmd.AddParameter("Id", request.RunspaceId); // Clear any existing breakpoints before proceeding - await _breakpointService.RemoveAllBreakpointsAsync().ConfigureAwait(continueOnCapturedContext: false); + if (_breakpointSyncService.IsSupported) + { + _breakpointSyncService.SyncServerAfterAttach(); + } + else + { + await _breakpointService.RemoveAllBreakpointsAsync() + .ConfigureAwait(continueOnCapturedContext: false); + } _debugStateService.WaitingForAttach = true; Task nonAwaitedTask = _executionService @@ -499,6 +510,7 @@ await _executionService.ExecutePSCommandAsync( _debugService.IsClientAttached = false; _debugAdapterServer.SendNotification(EventNames.Terminated); + _debugStateService.Reset(); } } } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/HostStartOptions.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/HostStartOptions.cs index 7de16fddf..8fbe06914 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Host/HostStartOptions.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/HostStartOptions.cs @@ -9,6 +9,8 @@ internal struct HostStartOptions public string InitialWorkingDirectory { get; set; } + public bool SupportsBreakpointSync { get; set; } + public string ShellIntegrationScript { get; set; } -} + } } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs index a2072b2d9..edcc85361 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs @@ -9,6 +9,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Hosting; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Console; @@ -112,6 +113,8 @@ internal class PsesInternalHost : PSHost, IHostSupportsInteractiveSession, IRuns private bool _resettingRunspace; + private BreakpointSyncService _breakpointSyncService; + static PsesInternalHost() { Type scriptDebuggerType = typeof(PSObject).Assembly @@ -234,6 +237,9 @@ public PsesInternalHost( private bool ShouldExitExecutionLoop => _shouldExit || _shuttingDown != 0; + private BreakpointSyncService BreakpointSync + => _breakpointSyncService ??= _languageServer?.GetService(); + public override void EnterNestedPrompt() => PushPowerShellAndRunLoop( CreateNestedPowerShell(CurrentRunspace), PowerShellFrameType.Nested | PowerShellFrameType.Repl); @@ -538,6 +544,18 @@ public IReadOnlyList InvokePSCommand(PSCommand psCommand, Powe return task.ExecuteAndGetResult(cancellationToken); } + void IInternalPowerShellExecutionService.UnsafeInvokePSCommand( + PSCommand psCommand, + PowerShellExecutionOptions executionOptions, + CancellationToken cancellationToken) + => InvokePSCommand(psCommand, executionOptions, cancellationToken); + + IReadOnlyList IInternalPowerShellExecutionService.UnsafeInvokePSCommand( + PSCommand psCommand, + PowerShellExecutionOptions executionOptions, + CancellationToken cancellationToken) + => InvokePSCommand(psCommand, executionOptions, cancellationToken); + public void InvokePSCommand(PSCommand psCommand, PowerShellExecutionOptions executionOptions, CancellationToken cancellationToken) => InvokePSCommand(psCommand, executionOptions, cancellationToken); public TResult InvokePSDelegate(string representation, ExecutionOptions executionOptions, Func func, CancellationToken cancellationToken) @@ -784,6 +802,11 @@ private void PopPowerShell(RunspaceChangeAction runspaceChangeAction = RunspaceC previousRunspaceFrame.RunspaceInfo, newRunspaceFrame.RunspaceInfo)); } + + if (BreakpointSync?.IsSupported is true) + { + BreakpointSync.SyncServerAfterRunspacePop(); + } } }); } @@ -1449,7 +1472,23 @@ void OnDebuggerStoppedImpl(object sender, DebuggerStopEventArgs debuggerStopEven } } - private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs breakpointUpdatedEventArgs) => DebugContext.HandleBreakpointUpdated(breakpointUpdatedEventArgs); + private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs breakpointUpdatedEventArgs) + { + if (BreakpointSync is not BreakpointSyncService breakpointSyncService) + { + DebugContext.HandleBreakpointUpdated(breakpointUpdatedEventArgs); + return; + } + + if (!breakpointSyncService.IsSupported || breakpointSyncService.IsMutatingBreakpoints) + { + DebugContext.HandleBreakpointUpdated(breakpointUpdatedEventArgs); + return; + } + + _ = Task.Run(() => breakpointSyncService.UpdatedByServerAsync(breakpointUpdatedEventArgs)); + DebugContext.HandleBreakpointUpdated(breakpointUpdatedEventArgs); + } private void OnRunspaceStateChanged(object sender, RunspaceStateEventArgs runspaceStateEventArgs) { diff --git a/src/PowerShellEditorServices/Services/PowerShell/IPowerShellExecutionService.cs b/src/PowerShellEditorServices/Services/PowerShell/IPowerShellExecutionService.cs index 31a75728f..9bd09d736 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/IPowerShellExecutionService.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/IPowerShellExecutionService.cs @@ -51,8 +51,22 @@ Task ExecutePSCommandAsync( void CancelCurrentTask(); } - internal interface IInternalPowerShellExecutionService : IPowerShellExecutionService + internal interface IInternalPowerShellExecutionService : IPowerShellExecutionService, IRunspaceContext { event Action RunspaceChanged; + + /// + /// Create and execute a without queuing + /// the work for the pipeline thread. This method must only be invoked when the caller + /// has ensured that they are already running on the pipeline thread. + /// + void UnsafeInvokePSCommand(PSCommand psCommand, PowerShellExecutionOptions executionOptions, CancellationToken cancellationToken); + + /// + /// Create and execute a without queuing + /// the work for the pipeline thread. This method must only be invoked when the caller + /// has ensured that they are already running on the pipeline thread. + /// + IReadOnlyList UnsafeInvokePSCommand(PSCommand psCommand, PowerShellExecutionOptions executionOptions, CancellationToken cancellationToken); } } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceContext.cs b/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceContext.cs index c9232d7d5..9c5bf88d0 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceContext.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceContext.cs @@ -6,5 +6,7 @@ namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace internal interface IRunspaceContext { IRunspaceInfo CurrentRunspace { get; } + + bool IsRunspacePushed { get; } } } diff --git a/src/PowerShellEditorServices/Utility/PathUtils.cs b/src/PowerShellEditorServices/Utility/PathUtils.cs index 112e8b465..f744f987a 100644 --- a/src/PowerShellEditorServices/Utility/PathUtils.cs +++ b/src/PowerShellEditorServices/Utility/PathUtils.cs @@ -62,5 +62,36 @@ internal static string WildcardEscapePath(string path, bool escapeSpaces = false } return wildcardEscapedPath; } + + internal static bool HasPowerShellScriptExtension(string fileNameOrPath) + { + if (fileNameOrPath is null or "") + { + return false; + } + + ReadOnlySpan pathSeparators = stackalloc char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + ReadOnlySpan asSpan = fileNameOrPath.AsSpan().TrimEnd(pathSeparators); + int separatorIndex = asSpan.LastIndexOfAny(pathSeparators); + if (separatorIndex is not -1) + { + asSpan = asSpan[(separatorIndex + 1)..]; + } + + int dotIndex = asSpan.LastIndexOf('.'); + if (dotIndex is -1) + { + return false; + } + + ReadOnlySpan extension = asSpan[(dotIndex + 1)..]; + if (extension.IsEmpty) + { + return false; + } + + return extension.Equals("psm1".AsSpan(), StringComparison.OrdinalIgnoreCase) + || extension.Equals("ps1".AsSpan(), StringComparison.OrdinalIgnoreCase); + } } } diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 05386d43a..d2c9abb09 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -53,11 +53,13 @@ public async Task InitializeAsync() psesHost.DebugContext.EnableDebugMode(); psesHost._readLineProvider.ReadLine = testReadLine; + DebugStateService debugStateService = new(); breakpointService = new BreakpointService( NullLoggerFactory.Instance, psesHost, psesHost, - new DebugStateService()); + debugStateService, + breakpointSyncService: null); debugService = new DebugService( psesHost,