From cb44c9fa0878b6944a187eb70abc5ce0ba005dc1 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Sat, 30 May 2026 11:54:43 -0500 Subject: [PATCH 1/8] feat: port pspath workspace support Port the provider-backed workspace and pspath URI support from feature/get-content onto the private host-package branch so the Immense wrapper package can be published for ImmyBot consumption. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Handlers/DidChangeWatchedFilesHandler.cs | 8 +- .../Services/TextDocument/ScriptFile.cs | 27 +- .../Services/Workspace/WorkspaceService.cs | 333 ++++++++++++++---- .../Debugging/DebugServiceTests.cs | 234 +++++++----- .../Session/ScriptFileTests.cs | 3 +- 5 files changed, 436 insertions(+), 169 deletions(-) diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs index edbe9b0ad..70cdc067b 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs @@ -66,7 +66,11 @@ public Task Handle(DidChangeWatchedFilesParams request, CancellationToken matcher.AddExcludePatterns(_workspaceService.ExcludeFilesGlob); foreach (FileEvent change in request.Changes) { - if (matcher.Match(change.Uri.GetFileSystemPath()).HasMatches) + string changePath = change.Uri.ToUri().IsFile + ? change.Uri.GetFileSystemPath() + : change.Uri.ToUri().AbsolutePath; + + if (matcher.Match(changePath).HasMatches) { continue; } @@ -100,7 +104,7 @@ public Task Handle(DidChangeWatchedFilesParams request, CancellationToken string fileContents; try { - fileContents = WorkspaceService.ReadFileContents(change.Uri); + fileContents = _workspaceService.ReadFileContents(change.Uri); } catch { diff --git a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs index fb6772d2b..a1eb7a4fb 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs @@ -183,12 +183,31 @@ internal static List GetLines(string text) /// True if the path is an untitled file, false otherwise. internal static bool IsUntitledPath(string path) { - Validate.IsNotNull(nameof(path), path); - // This may not have been given a URI, so return false instead of throwing. - return Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute) && - !string.Equals(DocumentUri.From(path).Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + if (!Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute)) + { + return false; + } + + DocumentUri documentUri = DocumentUri.From(path); + string scheme = documentUri.Scheme?.ToLowerInvariant(); + if (!IsSupportedScheme(scheme)) + { + return false; + } + + return scheme switch + { + "inmemory" or "untitled" or "vscode-notebook-cell" => true, + _ => false, + }; } + internal static bool IsSupportedScheme(string scheme) => scheme?.ToLowerInvariant() switch + { + "file" or "inmemory" or "untitled" or "vscode-notebook-cell" or "pspath" => true, + _ => false, + }; + /// /// Gets a line from the file's contents. /// diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index ad9b04552..fd9c68e44 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -6,10 +6,16 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Management.Automation; using System.Security; using System.Text; +using System.Threading; +using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Services.Workspace; using Microsoft.PowerShell.EditorServices.Utility; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Models; @@ -24,15 +30,37 @@ internal class WorkspaceService { #region Private Fields + // List of all file extensions considered PowerShell files in the .Net Core Framework. + private static readonly string[] s_psFileExtensionsCoreFramework = + { + ".ps1", + ".psm1", + ".psd1" + }; + + // .Net Core doesn't appear to use the same three letter pattern matching rule although the docs + // suggest it should be find the '.ps1xml' files because we search for the pattern '*.ps1'. + // ref https://docs.microsoft.com/en-us/dotnet/api/system.io.directory.getfiles?view=netcore-2.1#System_IO_Directory_GetFiles_System_String_System_String_System_IO_EnumerationOptions_ + private static readonly string[] s_psFileExtensionsFullFramework = + { + ".ps1", + ".psm1", + ".psd1", + ".ps1xml" + }; + // An array of globs which includes everything. private static readonly string[] s_psIncludeAllGlob = new[] { "**/*" }; + private const string s_psPathScheme = "pspath"; + private readonly ILogger logger; private readonly Version powerShellVersion; private readonly ConcurrentDictionary workspaceFiles = new(); + private readonly PsesInternalHost psesInternalHost; #endregion @@ -79,11 +107,18 @@ public WorkspaceService(ILoggerFactory factory) FollowSymlinks = true; } + /// + /// Creates a new instance of the Workspace class backed by a PowerShell host. + /// + public WorkspaceService(ILoggerFactory factory, PsesInternalHost psesInternalHost) + : this(factory) => this.psesInternalHost = psesInternalHost; + #endregion #region Public Methods - public IEnumerable WorkspacePaths => WorkspaceFolders.Select(i => i.Uri.GetFileSystemPath()); + public IEnumerable WorkspacePaths => WorkspaceFolders.Select( + folder => folder.Uri.ToUri().IsFile ? folder.Uri.GetFileSystemPath() : GetPowerShellPath(folder.Uri)); /// /// Gets an open file in the workspace. If the file isn't open but exists on the filesystem, load and return it. @@ -118,18 +153,8 @@ public ScriptFile GetFile(DocumentUri documentUri) // Make sure the file isn't already loaded into the workspace if (!workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile)) { - // This method allows FileNotFoundException to bubble up - // if the file isn't found. - using (StreamReader streamReader = OpenStreamReader(documentUri)) - { - scriptFile = - new ScriptFile( - documentUri, - streamReader, - powerShellVersion); - - workspaceFiles[keyName] = scriptFile; - } + scriptFile = ScriptFile.Create(documentUri, ReadFileContents(documentUri), powerShellVersion); + workspaceFiles[keyName] = scriptFile; logger.LogDebug("Opened file on disk: " + documentUri.ToString()); } @@ -171,18 +196,10 @@ public bool TryGetFile(Uri fileUri, out ScriptFile scriptFile) => /// The out parameter that will contain the ScriptFile object. public bool TryGetFile(DocumentUri documentUri, out ScriptFile scriptFile) { - switch (documentUri.Scheme) + if (!ScriptFile.IsSupportedScheme(documentUri.Scheme)) { - // List supported schemes here - case "file": - case "inmemory": - case "untitled": - case "vscode-notebook-cell": - break; - - default: - scriptFile = null; - return false; + scriptFile = null; + return false; } try @@ -281,7 +298,14 @@ public void CloseFile(ScriptFile scriptFile) Validate.IsNotNull(nameof(scriptFile), scriptFile); string keyName = GetFileKey(scriptFile.DocumentUri); - workspaceFiles.TryRemove(keyName, out ScriptFile _); + if (workspaceFiles.TryRemove(keyName, out ScriptFile _)) + { + logger.LogDebug("Closed file: " + scriptFile.DocumentUri); + } + else + { + logger.LogWarning("Tried to close file that was not open: " + scriptFile.DocumentUri); + } } /// @@ -291,7 +315,7 @@ public void CloseFile(ScriptFile scriptFile) public string GetRelativePath(ScriptFile scriptFile) { Uri fileUri = scriptFile.DocumentUri.ToUri(); - if (!scriptFile.IsInMemory) + if (!scriptFile.IsUntitled) { // Support calculating out-of-workspace relative paths in the common case of a // single workspace folder. Otherwise try to get the matching folder. @@ -314,6 +338,35 @@ public string GetRelativePath(ScriptFile scriptFile) return fileUri.ToString(); } + /// + /// Finds a file in the first workspace folder where it exists, if possible. + /// Used as a backwards-compatible way to find files in the workspace. + /// + /// + /// Best possible path. + public string FindFileInWorkspace(string filePath) + { + // If the file path is already an absolute path, just return it. + if (Path.IsPathRooted(filePath)) + { + return filePath; + } + + // If the file path is relative, try to find it in the workspace folders. + foreach (WorkspaceFolder workspaceFolder in WorkspaceFolders) + { + string folderPath = workspaceFolder.Uri.GetFileSystemPath(); + string combinedPath = Path.Combine(folderPath, filePath); + if (File.Exists(combinedPath)) + { + return combinedPath; + } + } + + // If the file path is not found in the workspace folders, return the original path. + return filePath; + } + /// /// Enumerate all the PowerShell (ps1, psm1, psd1) files in the workspace in a recursive manner, using default values. /// @@ -337,7 +390,83 @@ public IEnumerable EnumeratePSFiles( string[] excludeGlobs, string[] includeGlobs, int maxDepth, - bool ignoreReparsePoints) => []; + bool ignoreReparsePoints) + { + string[] powerShellWorkspacePaths = GetPowerShellWorkspacePaths() + .Where(path => !string.IsNullOrEmpty(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + string[] fileSystemWorkspacePaths = GetFileSystemWorkspacePaths() + .Where(path => !string.IsNullOrEmpty(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (powerShellWorkspacePaths.Length == 0 && fileSystemWorkspacePaths.Length == 0) + { + yield break; + } + + if (psesInternalHost is not null && powerShellWorkspacePaths.Length > 0) + { + PSCommand psCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Management\Get-ChildItem") + .AddParameter("LiteralPath", powerShellWorkspacePaths) + .AddParameter("Recurse") + .AddParameter("ErrorAction", ActionPreference.SilentlyContinue) + .AddParameter("Force") + .AddParameter("Include", includeGlobs.Concat(VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework).ToArray()) + .AddParameter("Exclude", excludeGlobs) + .AddParameter("Depth", maxDepth); + + if (VersionUtils.IsNetCore) + { + psCommand.AddParameter("FollowSymlink", !ignoreReparsePoints); + } + + psCommand + .AddCommand("Where-Object") + .AddParameter("Property", "PSIsContainer") + .AddParameter("EQ") + .AddParameter("Value", false); + + IReadOnlyList results = psesInternalHost.InvokePSCommand(psCommand, null, CancellationToken.None); + foreach (string path in results.Select(ConvertWorkspaceItemPath).Where(path => !string.IsNullOrEmpty(path))) + { + yield return path; + } + + yield break; + } + + Matcher matcher = new(); + foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); } + foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); } + + foreach (string rootPath in fileSystemWorkspacePaths) + { + if (!Directory.Exists(rootPath)) + { + continue; + } + + WorkspaceFileSystemWrapperFactory fsFactory = new( + rootPath, + maxDepth, + VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework, + ignoreReparsePoints, + logger); + + PatternMatchingResult fileMatchResult = matcher.Execute(fsFactory.RootDirectory); + foreach (FilePatternMatch item in fileMatchResult.Files) + { + // item.Path always contains forward slashes in paths when it should be backslashes on Windows. + // Since we're returning strings here, it's important to use the correct directory separator. + string path = VersionUtils.IsWindows ? item.Path.Replace('/', Path.DirectorySeparatorChar) : item.Path; + yield return Path.Combine(rootPath, path); + } + } + } #endregion @@ -353,61 +482,133 @@ internal static StreamReader OpenStreamReader(DocumentUri uri) return new StreamReader(fileStream, new UTF8Encoding(), detectEncodingFromByteOrderMarks: true); } - internal static string ReadFileContents(DocumentUri uri) + internal string ReadFileContents(DocumentUri uri) { - using StreamReader reader = OpenStreamReader(uri); - return reader.ReadToEnd(); - } + if (uri.ToUri().IsFile || psesInternalHost is null) + { + using StreamReader reader = OpenStreamReader(uri); + return reader.ReadToEnd(); + } - internal string ResolveWorkspacePath(string path) => ResolveRelativeScriptPath(InitialWorkingDirectory, path); + string psPath = GetPowerShellPath(uri); + try + { + IReadOnlyList result = psesInternalHost.InvokePSCommand( + new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Management\Get-Content") + .AddParameter("LiteralPath", psPath) + .AddParameter("ErrorAction", ActionPreference.Stop), + new PowerShellExecutionOptions { ThrowOnError = true }, + CancellationToken.None); + + return string.Join(Environment.NewLine, result); + } + catch (ActionPreferenceStopException ex) + when (ex.ErrorRecord.CategoryInfo.Category == ErrorCategory.ObjectNotFound + && ex.ErrorRecord.TargetObject is string[] missingFiles + && missingFiles.Length == 1) + { + throw new FileNotFoundException(ex.ErrorRecord.ToString(), missingFiles[0], ex.ErrorRecord.Exception); + } + } - internal string ResolveRelativeScriptPath(string baseFilePath, string relativePath) + // Return only file-backed workspace roots as filesystem paths. + // Example: + // file:///repo -> /repo + // pspath://FileSystem/C%3A/repo -> excluded + private IEnumerable GetFileSystemWorkspacePaths() { - // TODO: Sometimes the `baseFilePath` (even when its `WorkspacePath`) is null. - string combinedPath = null; - Exception resolveException = null; + if (WorkspaceFolders.Count > 0) + { + return WorkspaceFolders + .Select(folder => folder.Uri) + .Where(uri => uri.ToUri().IsFile) + .Select(uri => uri.GetFileSystemPath()); + } - try + return string.IsNullOrEmpty(InitialWorkingDirectory) + ? Array.Empty() + : new[] { InitialWorkingDirectory }; + } + + // Return only provider-backed workspace roots as PowerShell literal paths. + // Example: + // pspath://FileSystem/C%3A/repo -> FileSystem::C:/repo + // file:///repo -> excluded + private IEnumerable GetPowerShellWorkspacePaths() + { + if (WorkspaceFolders.Count > 0) { - // If the path is already absolute there's no need to resolve it relatively - // to the baseFilePath. - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } + return WorkspaceFolders + .Select(folder => folder.Uri) + .Where(uri => !uri.ToUri().IsFile) + .Select(GetPowerShellPath); + } + + return Array.Empty(); + } - // Get the directory of the original script file, combine it - // with the given path and then resolve the absolute file path. - combinedPath = - Path.GetFullPath( - Path.Combine( - baseFilePath, - relativePath)); + // Normalize Get-ChildItem output to a workspace path string. + // Example: + // FullName=/repo/a.ps1 -> /repo/a.ps1 + // PSPath=Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + private static string ConvertWorkspaceItemPath(PSObject item) + { + if (item.Properties["FullName"]?.Value is string fullName && !string.IsNullOrEmpty(fullName)) + { + return fullName; } - catch (NotSupportedException e) + + return item.Properties["PSPath"]?.Value is string psPath && !string.IsNullOrEmpty(psPath) + ? CreatePowerShellPathUri(psPath) + : null; + } + + // Convert a document URI to the literal path PowerShell commands should use. + // Example: + // file:///repo/a.ps1 -> /repo/a.ps1 + // pspath://FileSystem/C%3A/repo/a.ps1 -> FileSystem::C:/repo/a.ps1 + private static string GetPowerShellPath(DocumentUri uri) + { + Uri parsedUri = uri.ToUri(); + if (parsedUri.IsFile) { - // Occurs if the path is incorrectly formatted for any reason. One - // instance where this occurred is when a user had curly double-quote - // characters in their source instead of normal double-quotes. - resolveException = e; + return parsedUri.LocalPath; } - catch (ArgumentException e) + + if (string.Equals(uri.Scheme, s_psPathScheme, StringComparison.OrdinalIgnoreCase)) { - // Occurs if the path contains invalid characters, specifically those - // listed in System.IO.Path.InvalidPathChars. - resolveException = e; + string provider = parsedUri.GetComponents(UriComponents.Host, UriFormat.Unescaped); + string path = Uri.UnescapeDataString(parsedUri.AbsolutePath); + if (path.Length >= 3 && path[0] == '/' && char.IsLetter(path[1]) && path[2] == ':') + { + path = path.TrimStart('/'); + } + + return string.IsNullOrEmpty(provider) + ? path.TrimStart('/') + : $"{provider}::{path}"; } - if (resolveException != null) + throw new NotSupportedException($"Unsupported URI scheme '{uri.Scheme}'."); + } + + // Convert a PowerShell provider path to the pspath:// document form used by the workspace. + // Example: + // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 + // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + private static string CreatePowerShellPathUri(string psPath) + { + string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); + if (parts.Length != 2) { - logger.LogError( - "Could not resolve relative script path\r\n" + - $" baseFilePath = {baseFilePath}\r\n " + - $" relativePath = {relativePath}\r\n\r\n" + - $"{resolveException}"); + return $"{s_psPathScheme}:///{Uri.EscapeDataString(psPath)}"; } - return combinedPath; + string provider = parts[0].Split('\\').Last(); + string normalizedPath = parts[1].Replace('\\', '/'); + string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); + return $"{s_psPathScheme}://{Uri.EscapeDataString(provider)}/{encodedPath}"; } /// diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 30b020c30..5142bdbde 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -33,21 +33,22 @@ internal class TestReadLine : IReadLine } [Trait("Category", "DebugService")] - public class DebugServiceTests : IDisposable + public class DebugServiceTests : IAsyncLifetime { - private readonly PsesInternalHost psesHost; - private readonly BreakpointService breakpointService; - private readonly DebugService debugService; + private PsesInternalHost psesHost; + private BreakpointService breakpointService; + private DebugService debugService; private readonly BlockingCollection debuggerStoppedQueue = new(); - private readonly WorkspaceService workspace; - private readonly ScriptFile debugScriptFile; - private readonly ScriptFile oddPathScriptFile; - private readonly ScriptFile variableScriptFile; + private WorkspaceService workspace; + private ScriptFile debugScriptFile; + private ScriptFile oddPathScriptFile; + private ScriptFile psProviderPathScriptFile; + private ScriptFile variableScriptFile; private readonly TestReadLine testReadLine = new(); - public DebugServiceTests() + public async Task InitializeAsync() { - psesHost = PsesHostFactory.Create(NullLoggerFactory.Instance); + psesHost = await PsesHostFactory.Create(NullLoggerFactory.Instance); // This is required for remote debugging, but we call it here to end up in the same // state as the usual startup path. psesHost.DebugContext.EnableDebugMode(); @@ -70,32 +71,51 @@ public DebugServiceTests() debugService.DebuggerStopped += OnDebuggerStopped; // Load the test debug files. - workspace = new WorkspaceService(NullLoggerFactory.Instance); + workspace = new WorkspaceService(NullLoggerFactory.Instance, psesHost); debugScriptFile = GetDebugScript("DebugTest.ps1"); oddPathScriptFile = GetDebugScript("Debug' W&ith $Params [Test].ps1"); variableScriptFile = GetDebugScript("VariableTest.ps1"); + + string variableScriptFilePath = TestUtilities.GetSharedPath(Path.Combine("Debugging", "VariableTest.ps1")); + dynamic psItem = (await psesHost.ExecutePSCommandAsync( + new PSCommand() + .AddCommand("Get-Item") + .AddParameter("LiteralPath", variableScriptFilePath), + CancellationToken.None)).First(); + + psProviderPathScriptFile = workspace.GetFile(ConvertPSPathToUri((string)psItem.PSPath.ToString())); } - public void Dispose() + public async Task DisposeAsync() { debugService.Abort(); + await Task.Run(psesHost.StopAsync); debuggerStoppedQueue.Dispose(); -#pragma warning disable VSTHRD002 - psesHost.StopAsync().Wait(); -#pragma warning restore VSTHRD002 - GC.SuppressFinalize(this); } /// - /// This event handler lets us test that the debugger stopped or paused as expected. It will - /// deadlock if called in the PSES Pipeline Thread, which can easily happen in this test - /// code when methods on are called. Hence we treat this test - /// code like UI code and use 'ConfigureAwait(true)' or 'Task.Run(...)' to ensure we stay - /// OFF the pipeline thread. + /// This event handler lets us test that the debugger stopped or paused + /// as expected. It will deadlock if called in the PSES Pipeline Thread. + /// Hence we use 'Task.Run(...)' when accessing the queue to ensure we + /// stay OFF the pipeline thread. /// /// /// - private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => debuggerStoppedQueue.Add(e); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "This intentionally fires and forgets on another thread.")] + private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => Task.Run(() => debuggerStoppedQueue.Add(e)); + + // Convert a PowerShell provider path into the pspath:// URI form used by workspace tests. + // Example: + // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 + // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + private static string ConvertPSPathToUri(string psPath) + { + string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); + string provider = parts[0].Split('\\').Last(); + string normalizedPath = parts[1].Replace('\\', '/'); + string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); + return $"pspath://{Uri.EscapeDataString(provider)}/{encodedPath}"; + } private ScriptFile GetDebugScript(string fileName) => workspace.GetFile(TestUtilities.GetSharedPath(Path.Combine("Debugging", fileName))); @@ -118,20 +138,20 @@ private Task ExecuteScriptFileAsync(string scriptFilePath, params string[] args) private Task ExecuteVariableScriptFileAsync() => ExecuteScriptFileAsync(variableScriptFile.FilePath); - private void AssertDebuggerPaused() + private async Task AssertDebuggerPaused() { using CancellationTokenSource cts = new(60000); - DebuggerStoppedEventArgs eventArgs = debuggerStoppedQueue.Take(cts.Token); + DebuggerStoppedEventArgs eventArgs = await Task.Run(() => debuggerStoppedQueue.Take(cts.Token)); Assert.Empty(eventArgs.OriginalEvent.Breakpoints); } - private void AssertDebuggerStopped( + private async Task AssertDebuggerStopped( string scriptPath = "", int lineNumber = -1, CommandBreakpointDetails commandBreakpointDetails = default) { - using CancellationTokenSource cts = new(60000); - DebuggerStoppedEventArgs eventArgs = debuggerStoppedQueue.Take(cts.Token); + using CancellationTokenSource cts = new(30000); + DebuggerStoppedEventArgs eventArgs = await Task.Run(() => debuggerStoppedQueue.Take(cts.Token)); Assert.True(psesHost.DebugContext.IsStopped); @@ -176,8 +196,8 @@ await debugService.SetCommandBreakpointsAsync( Task> executeTask = psesHost.ExecutePSCommandAsync( new PSCommand().AddScript("Get-Random -SetSeed 42 -Maximum 100"), CancellationToken.None); - AssertDebuggerStopped("", 1); - await Task.Run(debugService.Continue); + await AssertDebuggerStopped("", 1); + debugService.Continue(); Assert.Equal(17, (await executeTask)[0]); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); @@ -204,7 +224,7 @@ await debugService.SetCommandBreakpointsAsync( public async Task DebuggerAcceptsScriptArgs(string[] args) { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - oddPathScriptFile, + oddPathScriptFile.FilePath, new[] { BreakpointDetails.Create(oddPathScriptFile.FilePath, 3) }); Assert.Single(breakpoints); @@ -218,7 +238,7 @@ public async Task DebuggerAcceptsScriptArgs(string[] args) Task _ = ExecuteScriptFileAsync(oddPathScriptFile.FilePath, args); - AssertDebuggerStopped(oddPathScriptFile.FilePath, 3); + await AssertDebuggerStopped(oddPathScriptFile.FilePath, 3); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -285,7 +305,7 @@ public async Task DebuggerStopsOnFunctionBreakpoints() new[] { CommandBreakpointDetails.Create("Write-Host") }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -296,8 +316,8 @@ public async Task DebuggerStopsOnFunctionBreakpoints() Assert.Equal("1", i.ValueString); // The function breakpoint should fire the next time through the loop. - await Task.Run(debugService.Continue); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -313,7 +333,7 @@ public async Task DebuggerSetsAndClearsLineBreakpoints() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5), BreakpointDetails.Create(debugScriptFile.FilePath, 10) @@ -326,7 +346,7 @@ await debugService.SetLineBreakpointsAsync( Assert.Equal(10, breakpoints[1].LineNumber); breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 2) }); confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); @@ -334,7 +354,7 @@ await debugService.SetLineBreakpointsAsync( Assert.Equal(2, breakpoints[0].LineNumber); await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, Array.Empty()); IReadOnlyList remainingBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); @@ -345,16 +365,16 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerStopsOnLineBreakpoints() { await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5), BreakpointDetails.Create(debugScriptFile.FilePath, 7) }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 5); - await Task.Run(debugService.Continue); - AssertDebuggerStopped(debugScriptFile.FilePath, 7); + await AssertDebuggerStopped(debugScriptFile.FilePath, 5); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); } [Fact] @@ -364,13 +384,13 @@ public async Task DebuggerStopsOnConditionalBreakpoints() const int breakpointValue2 = 20; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 7, null, $"$i -eq {breakpointValue1} -or $i -eq {breakpointValue2}"), }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 7); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -382,8 +402,8 @@ await debugService.SetLineBreakpointsAsync( // The conditional breakpoint should not fire again, until the value of // i reaches breakpointValue2. - await Task.Run(debugService.Continue); - AssertDebuggerStopped(debugScriptFile.FilePath, 7); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -400,13 +420,13 @@ public async Task DebuggerStopsOnHitConditionBreakpoint() const int hitCount = 5; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, null, $"{hitCount}"), }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -423,11 +443,11 @@ public async Task DebuggerStopsOnConditionalAndHitConditionBreakpoint() const int hitCount = 5; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, "$i % 2 -eq 0", $"{hitCount}") }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -444,7 +464,7 @@ public async Task DebuggerProvidesMessageForInvalidConditionalBreakpoint() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { // TODO: Add this breakpoint back when it stops moving around?! The ordering // of these two breakpoints seems to do with which framework executes the @@ -472,7 +492,7 @@ public async Task DebuggerFindsParsableButInvalidSimpleBreakpointConditions() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5, column: null, condition: "$i == 100"), BreakpointDetails.Create(debugScriptFile.FilePath, 7, column: null, condition: "$i > 100") @@ -495,18 +515,16 @@ public async Task DebuggerBreaksWhenRequested() IReadOnlyList confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); Assert.Empty(confirmedBreakpoints); Task _ = ExecuteDebugFileAsync(); - // NOTE: This must be run on a separate thread so the async event handlers can fire. - await Task.Run(debugService.Break); - AssertDebuggerPaused(); + debugService.Break(); + await AssertDebuggerPaused(); } [Fact] public async Task DebuggerRunsCommandsWhileStopped() { Task _ = ExecuteDebugFileAsync(); - // NOTE: This must be run on a separate thread so the async event handlers can fire. - await Task.Run(debugService.Break); - AssertDebuggerPaused(); + debugService.Break(); + await AssertDebuggerPaused(); // Try running a command from outside the pipeline thread Task> executeTask = psesHost.ExecutePSCommandAsync( @@ -526,16 +544,17 @@ await debugService.SetCommandBreakpointsAsync( ScriptFile testScript = GetDebugScript("PSDebugContextTest.ps1"); Task _ = ExecuteScriptFileAsync(testScript.FilePath); - AssertDebuggerStopped(testScript.FilePath, 11); + await AssertDebuggerStopped(testScript.FilePath, 11); VariableDetails prompt = await debugService.EvaluateExpressionAsync("prompt", false, CancellationToken.None); Assert.Equal("True > ", prompt.ValueString); } - [SkippableFact] - public async Task DebuggerBreaksInUntitledScript() + [Theory] + [InlineData("Command")] + [InlineData("Line")] + public async Task DebuggerBreaksInUntitledScript(string breakpointType) { - Skip.IfNot(VersionUtils.PSEdition == "Core", "Untitled script breakpoints only supported in PowerShell Core"); const string contents = "Write-Output $($MyInvocation.Line)"; const string scriptPath = "untitled:Untitled-1"; Assert.True(ScriptFile.IsUntitledPath(scriptPath)); @@ -544,14 +563,23 @@ public async Task DebuggerBreaksInUntitledScript() Assert.Equal(contents, scriptFile.Contents); Assert.True(workspace.TryGetFile(scriptPath, out ScriptFile _)); - await debugService.SetCommandBreakpointsAsync( - new[] { CommandBreakpointDetails.Create("Write-Output") }); + if (breakpointType == "Command") + { + await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Write-Output") }); + } + else + { + await debugService.SetLineBreakpointsAsync( + scriptFile.FilePath, + new[] { BreakpointDetails.Create(scriptPath, 1) }); + } ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); Task _ = configurationDoneHandler.LaunchScriptAsync(scriptPath); - AssertDebuggerStopped(scriptPath, 1); + await AssertDebuggerStopped(scriptPath, 1); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.CommandVariablesName); VariableDetailsBase myInvocation = Array.Find(variables, v => v.Name == "$MyInvocation"); @@ -570,7 +598,7 @@ await debugService.SetCommandBreakpointsAsync( public async Task RecordsF5CommandInPowerShellHistory() { ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); await configurationDoneHandler.LaunchScriptAsync(debugScriptFile.FilePath); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( @@ -610,7 +638,7 @@ public async Task RecordsF8CommandInHistory() public async Task OddFilePathsLaunchCorrectly() { ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); await configurationDoneHandler.LaunchScriptAsync(oddPathScriptFile.FilePath); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( @@ -621,15 +649,29 @@ public async Task OddFilePathsLaunchCorrectly() Assert.Equal(". " + PSCommandHelpers.EscapeScriptFilePath(oddPathScriptFile.FilePath), Assert.Single(historyResult)); } + [Fact] + public async Task PSProviderPathsLaunchCorrectly() + { + ConfigurationDoneHandler configurationDoneHandler = new( + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); + await configurationDoneHandler.LaunchScriptAsync(psProviderPathScriptFile.FilePath); + + IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("(Get-History).CommandLine"), + CancellationToken.None); + + Assert.Equal(". $args[0]", Assert.Single(historyResult)); + } + [Fact] public async Task DebuggerVariableStringDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 8) }); Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -643,11 +685,11 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerGetsVariables() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 21) }); Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -693,11 +735,11 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerSetsVariablesNoConversion() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableScope[] scopes = debugService.GetVariableScopes(0); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -723,8 +765,8 @@ await debugService.SetLineBreakpointsAsync( // The above just tests that the debug service returns the correct new value string. // Let's step the debugger and make sure the values got set to the new values. - await Task.Run(debugService.StepOver); - AssertDebuggerStopped(variableScriptFile.FilePath); + debugService.StepOver(); + await AssertDebuggerStopped(variableScriptFile.FilePath); // Test set of a local string variable (not strongly typed) variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -746,12 +788,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerSetsVariablesWithConversion() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableScope[] scopes = debugService.GetVariableScopes(0); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -779,8 +821,8 @@ await debugService.SetLineBreakpointsAsync( // The above just tests that the debug service returns the correct new value string. // Let's step the debugger and make sure the values got set to the new values. - await Task.Run(debugService.StepOver); - AssertDebuggerStopped(variableScriptFile.FilePath); + debugService.StepOver(); + await AssertDebuggerStopped(variableScriptFile.FilePath); // Test set of a local string variable (not strongly typed but force conversion) variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -802,12 +844,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableEnumDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 15) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -822,12 +864,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableHashtableDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 11) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -855,12 +897,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableNullStringDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 16) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -875,12 +917,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariablePSObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 17) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -907,7 +949,7 @@ public async Task DebuggerEnumerableShowsRawView() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase simpleArrayVar = Array.Find( await GetVariables(VariableContainerDetails.ScriptScopeName), @@ -964,7 +1006,7 @@ public async Task DebuggerDictionaryShowsRawView() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase simpleDictionaryVar = Array.Find( await GetVariables(VariableContainerDetails.ScriptScopeName), @@ -1027,7 +1069,7 @@ public async Task DebuggerDerivedDictionaryPropertyInRawView() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase sortedDictionaryVar = Array.Find( await GetVariables(VariableContainerDetails.ScriptScopeName), @@ -1071,12 +1113,12 @@ await GetVariables(VariableContainerDetails.ScriptScopeName), public async Task DebuggerVariablePSCustomObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 18) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -1100,12 +1142,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableProcessObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 19) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -1134,7 +1176,7 @@ await debugService.SetCommandBreakpointsAsync( ScriptFile testScript = GetDebugScript("GetChildItemTest.ps1"); Task _ = ExecuteScriptFileAsync(testScript.FilePath); - AssertDebuggerStopped(testScript.FilePath, 2); + await AssertDebuggerStopped(testScript.FilePath, 2); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); VariableDetailsBase var = Array.Find(variables, v => v.Name == "$file"); @@ -1154,7 +1196,7 @@ public async Task DebuggerToStringShouldMarshallToPipeline() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase[] vars = await GetVariables(VariableContainerDetails.ScriptScopeName); VariableDetailsBase customToStrings = Array.Find(vars, i => i.Name is "$CustomToStrings"); diff --git a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs index 11c27c4cf..7a1239ea8 100644 --- a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs +++ b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs @@ -665,8 +665,9 @@ public void DocumentUriReturnsCorrectStringForAbsolutePath() [InlineData(@"C:\Users\me\Documents\test.ps1", false)] [InlineData("/Users/me/Documents/test.ps1", false)] [InlineData("vscode-notebook-cell:/Users/me/Documents/test.ps1#0001", true)] - [InlineData("https://microsoft.com", true)] + [InlineData("https://microsoft.com", false)] [InlineData("Untitled:Untitled-1", true)] + [InlineData("pspath://filesystem/C%3A/Users/me/Documents/test.ps1", false)] [InlineData(@"'a log statement' > 'c:\Users\me\Documents\test.txt' ", false)] public void IsUntitledFileIsCorrect(string path, bool expected) => Assert.Equal(expected, ScriptFile.IsUntitledPath(path)); From 850db2de37b966b3addca7ba0ddfbdfcf0f11c1b Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Sat, 30 May 2026 12:05:11 -0500 Subject: [PATCH 2/8] fix: restore packaging branch compatibility Keep the provider-backed workspace port compatible with the older feature/submodule-to-nuget branch by using ScriptFile.IsInMemory and restoring the workspace path helpers AnalysisService still expects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/Workspace/WorkspaceService.cs | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index fd9c68e44..2b022f757 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -315,7 +315,7 @@ public void CloseFile(ScriptFile scriptFile) public string GetRelativePath(ScriptFile scriptFile) { Uri fileUri = scriptFile.DocumentUri.ToUri(); - if (!scriptFile.IsUntitled) + if (!scriptFile.IsInMemory) { // Support calculating out-of-workspace relative paths in the common case of a // single workspace folder. Otherwise try to get the matching folder. @@ -611,6 +611,57 @@ private static string CreatePowerShellPathUri(string psPath) return $"{s_psPathScheme}://{Uri.EscapeDataString(provider)}/{encodedPath}"; } + internal string ResolveWorkspacePath(string path) => ResolveRelativeScriptPath(InitialWorkingDirectory, path); + + internal string ResolveRelativeScriptPath(string baseFilePath, string relativePath) + { + // TODO: Sometimes the `baseFilePath` (even when its `WorkspacePath`) is null. + string combinedPath = null; + Exception resolveException = null; + + try + { + // If the path is already absolute there's no need to resolve it relatively + // to the baseFilePath. + if (Path.IsPathRooted(relativePath)) + { + return relativePath; + } + + // Get the directory of the original script file, combine it + // with the given path and then resolve the absolute file path. + combinedPath = + Path.GetFullPath( + Path.Combine( + baseFilePath, + relativePath)); + } + catch (NotSupportedException e) + { + // Occurs if the path is incorrectly formatted for any reason. One + // instance where this occurred is when a user had curly double-quote + // characters in their source instead of normal double-quotes. + resolveException = e; + } + catch (ArgumentException e) + { + // Occurs if the path contains invalid characters, specifically those + // listed in System.IO.Path.InvalidPathChars. + resolveException = e; + } + + if (resolveException != null) + { + logger.LogError( + "Could not resolve relative script path\r\n" + + $" baseFilePath = {baseFilePath}\r\n " + + $" relativePath = {relativePath}\r\n\r\n" + + $"{resolveException}"); + } + + return combinedPath; + } + /// /// Returns a normalized string for a given documentUri to be used as key name. /// Case-sensitive uri on Linux and lowercase for other platforms. From 74296a046312a5e21f71f846d2e9a9abac12bc3e Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 23 Jun 2026 17:12:05 -0500 Subject: [PATCH 3/8] fix: handle drive-qualified pspath: paths in CreatePowerShellPathUri When Get-ChildItem enumerates items from the ScriptPSProvider drive, PowerShell sets PSPath to the drive-qualified form (pspath:\local\Function\script.ps1) rather than the provider-qualified form (ScriptPSProvider::local\Function\script.ps1). CreatePowerShellPathUri now detects drive-qualified pspath: paths and converts them to pspath://ScriptPSProvider/local/Function/script.ps1 URIs so they round-trip correctly through GetPowerShellPath and TryGetFile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + .../Services/Workspace/WorkspaceService.cs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.gitignore b/.gitignore index f73bd5823..d74b9f73b 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ module/PowerShellEditorServices/Third\ Party\ Notices.txt # JetBrains generated file (Rider, intelliJ) .idea/ +nupkgs/ diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index 2b022f757..e7cbb9aa0 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -57,6 +57,11 @@ internal class WorkspaceService private const string s_psPathScheme = "pspath"; + // The host component of pspath:// URIs for items from the ScriptPSProvider drive. + // PowerShell's PSPath uses the drive name (pspath:) but we need the provider name + // as the URI host to round-trip correctly through GetPowerShellPath. + private const string s_psPathProviderHost = "ScriptPSProvider"; + private readonly ILogger logger; private readonly Version powerShellVersion; private readonly ConcurrentDictionary workspaceFiles = new(); @@ -597,8 +602,21 @@ private static string GetPowerShellPath(DocumentUri uri) // Example: // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + // pspath:\local\Function\MyScript.ps1 -> pspath://ScriptPSProvider/local/Function/MyScript.ps1 private static string CreatePowerShellPathUri(string psPath) { + // Handle drive-qualified paths from the pspath: provider drive, e.g. + // pspath:\local\Function\MyScript.ps1 + // PowerShell sets PSPath to the drive-qualified path (not provider-qualified), + // so we need to reconstruct the provider-qualified URI form. + if (psPath.StartsWith($"{s_psPathScheme}:", StringComparison.OrdinalIgnoreCase)) + { + int colonIndex = psPath.IndexOf(':'); + string drivePath = psPath.Substring(colonIndex + 1).Replace('\\', '/').TrimStart('/'); + string driveEncodedPath = string.Join("/", drivePath.Split('/').Select(Uri.EscapeDataString)); + return $"{s_psPathScheme}://{s_psPathProviderHost}/{driveEncodedPath}"; + } + string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); if (parts.Length != 2) { From 72f2f5b57c524ce84a4539a90413267e55a66f69 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 23 Jun 2026 18:08:27 -0500 Subject: [PATCH 4/8] fix: workspace scan for pspath provider paths Three fixes for EnumeratePSFiles to work with non-FileSystem providers: 1. CreatePowerShellPathUri: handle provider-qualified pspath: drive paths (ScriptPSProvider::pspath:\local\Function\script.ps1) by stripping the provider prefix and reconstructing the URI from the drive-qualified path. 2. Skip Include/Exclude/Depth/FollowSymlink parameters for non-FileSystem provider paths. These are FileSystem-specific dynamic parameters that cause Get-ChildItem to fail with ProviderInvocationException on custom providers like ScriptPSProvider. 3. Add s_psPathProviderHost constant for the ScriptPSProvider host component used in pspath:// URIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/Workspace/WorkspaceService.cs | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index e7cbb9aa0..7e1ee82e8 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -414,19 +414,31 @@ public IEnumerable EnumeratePSFiles( if (psesInternalHost is not null && powerShellWorkspacePaths.Length > 0) { + // Determine if all workspace paths are FileSystem provider paths. + // Non-FileSystem providers (e.g. pspath:) don't support Include/Exclude/Depth/FollowSymlink + // dynamic parameters, so we only add them for FileSystem paths. + bool allFileSystem = powerShellWorkspacePaths.All( + p => p.StartsWith("Microsoft.PowerShell.FileSystem", StringComparison.OrdinalIgnoreCase) + || !p.Contains("::")); + PSCommand psCommand = new PSCommand() .AddCommand(@"Microsoft.PowerShell.Management\Get-ChildItem") .AddParameter("LiteralPath", powerShellWorkspacePaths) .AddParameter("Recurse") .AddParameter("ErrorAction", ActionPreference.SilentlyContinue) - .AddParameter("Force") + .AddParameter("Force"); + + if (allFileSystem) + { + psCommand .AddParameter("Include", includeGlobs.Concat(VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework).ToArray()) .AddParameter("Exclude", excludeGlobs) .AddParameter("Depth", maxDepth); - if (VersionUtils.IsNetCore) - { - psCommand.AddParameter("FollowSymlink", !ignoreReparsePoints); + if (VersionUtils.IsNetCore) + { + psCommand.AddParameter("FollowSymlink", !ignoreReparsePoints); + } } psCommand @@ -603,20 +615,35 @@ private static string GetPowerShellPath(DocumentUri uri) // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo // pspath:\local\Function\MyScript.ps1 -> pspath://ScriptPSProvider/local/Function/MyScript.ps1 + // ScriptPSProvider::pspath:\local\Function\MyScript.ps1 -> pspath://ScriptPSProvider/local/Function/MyScript.ps1 private static string CreatePowerShellPathUri(string psPath) { + // Strip the provider qualifier prefix if present (e.g. "ScriptPSProvider::pspath:\..." -> "pspath:\...") + string pathPart = psPath; + if (pathPart.Contains("::")) + { + string[] qualifiedParts = pathPart.Split(new[] { "::" }, 2, StringSplitOptions.None); + if (qualifiedParts.Length == 2) + { + pathPart = qualifiedParts[1]; + } + } + // Handle drive-qualified paths from the pspath: provider drive, e.g. // pspath:\local\Function\MyScript.ps1 - // PowerShell sets PSPath to the drive-qualified path (not provider-qualified), - // so we need to reconstruct the provider-qualified URI form. - if (psPath.StartsWith($"{s_psPathScheme}:", StringComparison.OrdinalIgnoreCase)) + // PowerShell sets PSPath to the drive-qualified path (potentially provider-qualified + // as "ScriptPSProvider::pspath:\..."), so we strip the provider prefix above and + // then reconstruct the provider-qualified URI form. + if (pathPart.StartsWith($"{s_psPathScheme}:", StringComparison.OrdinalIgnoreCase)) { - int colonIndex = psPath.IndexOf(':'); - string drivePath = psPath.Substring(colonIndex + 1).Replace('\\', '/').TrimStart('/'); + int colonIndex = pathPart.IndexOf(':'); + string drivePath = pathPart.Substring(colonIndex + 1).Replace('\\', '/').TrimStart('/'); string driveEncodedPath = string.Join("/", drivePath.Split('/').Select(Uri.EscapeDataString)); return $"{s_psPathScheme}://{s_psPathProviderHost}/{driveEncodedPath}"; } + // For other provider-qualified paths (e.g. FileSystem::C:\repo\a.ps1), + // use the original psPath which still has the "::" separator. string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); if (parts.Length != 2) { From 167aabb15ab2741e5f125abb8826919ddd6d75c5 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Thu, 2 Jul 2026 11:05:01 -0500 Subject: [PATCH 5/8] fix: enable breakpoint debugging in PSES in-process mode - Fix DebugMode check in BreakpointHandlers to use flag-based comparison - Re-call SetDebugMode after breakpoint registration to trigger SetInternalDebugMode - Normalize file:///scripts/... URIs to pspath:// for breakpoint matching - Use temp file approach for script execution to ensure functionContext._file is set - Add TLS _debuggingMode fix for pipeline thread context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PowerShellEditorServices.Common.props | 5 +- global.json | 4 +- .../Server/PsesDebugServer.cs | 2 + .../DebugAdapter/DebugEventHandlerService.cs | 1 + .../Handlers/BreakpointHandlers.cs | 66 +++- .../Handlers/ConfigurationDoneHandler.cs | 292 ++++++++++++++++-- .../Handlers/LaunchAndAttachHandler.cs | 1 + .../Execution/SynchronousPowerShellTask.cs | 100 +++++- .../PowerShell/Host/PsesInternalHost.cs | 11 +- .../packages.lock.json | 22 +- 10 files changed, 469 insertions(+), 35 deletions(-) diff --git a/PowerShellEditorServices.Common.props b/PowerShellEditorServices.Common.props index 80d45b7ba..563a917ba 100644 --- a/PowerShellEditorServices.Common.props +++ b/PowerShellEditorServices.Common.props @@ -14,8 +14,9 @@ true - true - true + false + false + false true diff --git a/global.json b/global.json index 4c4c3ae5e..6a288505a 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "8.0.100", - "rollForward": "latestFeature", + "version": "10.0.100", + "rollForward": "latestMajor", "allowPrerelease": false } } diff --git a/src/PowerShellEditorServices/Server/PsesDebugServer.cs b/src/PowerShellEditorServices/Server/PsesDebugServer.cs index 6aca82e04..428ca0119 100644 --- a/src/PowerShellEditorServices/Server/PsesDebugServer.cs +++ b/src/PowerShellEditorServices/Server/PsesDebugServer.cs @@ -51,8 +51,10 @@ public PsesDebugServer( /// A task that completes when the server is ready. public async Task StartAsync() { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] PsesDebugServer.StartAsync entered\n"); _debugAdapterServer = await DebugAdapterServer.From(options => { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] DebugAdapterServer.From callback\n"); // We need to let the PowerShell Context Service know that we are in a debug session // so that it doesn't send the powerShell/startDebugger message. _psesHost = ServiceProvider.GetService(); diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs index e3237a3b0..4ad656311 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs @@ -66,6 +66,7 @@ internal void UnregisterEventHandlers() private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] OnDebuggerStopped: reason={e.OriginalEvent.Breakpoints.Count} breakpoints\n"); // Provide the reason for why the debugger has stopped script execution. // See https://github.com/Microsoft/vscode/issues/3648 // The reason is displayed in the breakpoints viewlet. Some recommended reasons are: diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs index 4c99ff747..c9be89a26 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs @@ -49,6 +49,7 @@ public BreakpointHandlers( public async Task Handle(SetBreakpointsArguments request, CancellationToken cancellationToken) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers.Handle called, path='{request.Source.Path}'\n"); if (!_workspaceService.TryGetFile(request.Source.Path, out ScriptFile scriptFile)) { string message = _debugStateService.NoDebug ? string.Empty : "Source file could not be accessed, breakpoint not set."; @@ -83,30 +84,62 @@ public async Task Handle(SetBreakpointsArguments request } // At this point, the source file has been verified as a PowerShell script. + // Use the DocumentUri (which matches what LaunchScriptAsync passes to + // Parser.ParseInput) so line breakpoints match the script block's Extent.File. + // Normalize file:///scripts/... URIs to pspath:// URIs so the debugger's + // functionContext._file matches the breakpoint's Script property. + string breakpointScriptPath = NormalizeScriptUri(scriptFile.DocumentUri.ToString()); IReadOnlyList breakpointDetails = request.Breakpoints .Select((srcBreakpoint) => BreakpointDetails.Create( - scriptFile.FilePath, + breakpointScriptPath, srcBreakpoint.Line, srcBreakpoint.Column, srcBreakpoint.Condition, srcBreakpoint.HitCondition, srcBreakpoint.LogMessage)).ToList(); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: FilePath='{scriptFile.FilePath}', DocumentUri='{scriptFile.DocumentUri}', breakpointScriptPath='{breakpointScriptPath}', #breakpoints={breakpointDetails.Count}, line={breakpointDetails[0].LineNumber}\n"); + // If this is a "run without debugging (Ctrl+F5)" session ignore requests to set breakpoints. IReadOnlyList updatedBreakpointDetails = breakpointDetails; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: NoDebug={_debugStateService.NoDebug}\n"); if (!_debugStateService.NoDebug) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: calling WaitForSetBreakpointHandleAsync\n"); await _debugStateService.WaitForSetBreakpointHandleAsync().ConfigureAwait(false); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: calling SetLineBreakpointsAsync\n"); try { + // The debugger's DebugMode may be Default or RemoteScript, neither of + // which support SetLineBreakpoint. Use reflection to set it to Local so + // breakpoints can be registered before the script launches. + var debugger = _runspaceContext.CurrentRunspace.Runspace.Debugger; + var debugModeProp = typeof(System.Management.Automation.Debugger).GetProperty("DebugMode"); + var currentMode = (System.Management.Automation.DebugModes)debugModeProp!.GetValue(debugger)!; + if ((currentMode & System.Management.Automation.DebugModes.LocalScript) == 0) + { + debugModeProp.SetValue(debugger, currentMode | System.Management.Automation.DebugModes.LocalScript); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: added LocalScript to DebugMode (was {currentMode})\n"); + } + updatedBreakpointDetails = await _debugService.SetLineBreakpointsAsync( scriptFile, breakpointDetails).ConfigureAwait(false); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: SetLineBreakpointsAsync returned {updatedBreakpointDetails.Count} breakpoints, verified={updatedBreakpointDetails.FirstOrDefault()?.Verified}\n"); + + // Re-call SetDebugMode after breakpoints are registered. The debugger's + // SetDebugMode internally checks if _idToBreakpoint is non-empty and sets + // _context._debuggingMode to Enabled. If SetDebugMode was called before + // breakpoints were added, _debuggingMode stays 0 and breakpoints are never + // checked during execution. + debugger.SetDebugMode(System.Management.Automation.DebugModes.LocalScript | System.Management.Automation.DebugModes.RemoteScript); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: re-called SetDebugMode after breakpoint registration\n"); } catch (Exception e) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: EXCEPTION: {e}\n"); // Log whatever the error is _logger.LogException($"Caught error while setting breakpoints in SetBreakpoints handler for file {scriptFile?.FilePath}", e); } @@ -198,5 +231,36 @@ private bool IsFileSupportedForBreakpoints(string requestedPath, ScriptFile reso string fileExtension = Path.GetExtension(resolvedScriptFile.FilePath); return s_supportedDebugFileExtensions.Contains(fileExtension, StringComparer.OrdinalIgnoreCase); } + + /// + /// Normalizes file:///scripts/... URIs to pspath:// URIs so that the debugger's + /// functionContext._file (set from the ScriptBlock's Extent.File) matches the + /// breakpoint's Script property. PowerShell's debugger uses _file to look up + /// pending breakpoints, and file:/// URIs get resolved to empty strings in the + /// function context, while pspath:// URIs are preserved. + /// + internal static string NormalizeScriptUri(string uri) + { + if (string.IsNullOrEmpty(uri) || !uri.StartsWith("file:///scripts/")) + return uri; + + var parts = uri.Replace("file:///scripts/", "").Split('/'); + if (parts.Length < 3) + return uri; + + var scope = parts[0].ToLowerInvariant(); + var categoryPlural = parts[1]; + var fileName = string.Join("/", parts.Skip(2)); + + var category = categoryPlural switch + { + "Functions" => "Function", + "Immy%20System" => "ImmySystem", + "Inventory" => "DeviceInventory", + _ => categoryPlural, + }; + + return $"pspath://ScriptPSProvider/{scope}/{category}/{fileName}"; + } } } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs index df8165319..2bb96a1fa 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -72,6 +72,7 @@ public ConfigurationDoneHandler( public Task Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ConfigurationDoneHandler.Handle called, ScriptToLaunch='{_debugStateService?.ScriptToLaunch}'\n"); _debugService.IsClientAttached = true; if (!string.IsNullOrEmpty(_debugStateService.ScriptToLaunch)) @@ -107,9 +108,11 @@ public Task Handle(ConfigurationDoneArguments request // by tests. internal async Task LaunchScriptAsync(string scriptToLaunch) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: scriptToLaunch='{scriptToLaunch}'\n"); PSCommand command; if (System.IO.File.Exists(scriptToLaunch)) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: File.Exists=true\n"); // For a saved file we just execute its path (after escaping it). command = PSCommandHelpers.BuildDotSourceCommandWithArguments( PSCommandHelpers.EscapeScriptFilePath(scriptToLaunch), _debugStateService?.Arguments); @@ -117,32 +120,48 @@ internal async Task LaunchScriptAsync(string scriptToLaunch) else // It's a URI to an untitled script, or a raw script. { bool isScriptFile = _workspaceService.TryGetFile(scriptToLaunch, out ScriptFile untitledScript); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: TryGetFile={isScriptFile}, SupportsBreakpointApis={isScriptFile && BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace)}\n"); + if (isScriptFile) + { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: contents.Length={untitledScript.Contents.Length}, DocumentUri={untitledScript.DocumentUri}\n"); + } if (isScriptFile && BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace)) { - // Parse untitled files with their `Untitled:` URI as the filename which will - // cache the URI and contents within the PowerShell parser. By doing this, we - // light up the ability to debug untitled files with line breakpoints. This is - // only possible with PowerShell 7's new breakpoint APIs since the old API, - // Set-PSBreakpoint, validates that the given path points to a real file. - ScriptBlockAst ast = Parser.ParseInput( - untitledScript.Contents, - untitledScript.DocumentUri.ToString(), - out Token[] _, - out ParseError[] _); - - // In order to use utilize the parser's cache (and therefore hit line - // breakpoints) we need to use the AST's `ScriptBlock` object. Due to - // limitations in PowerShell's public API, this means we must use the - // `PSCommand.AddArgument(object)` method, hence this hack where we dot-source - // `$args[0]. Fortunately the dot-source operator maintains a stack of arguments - // on each invocation, so passing the user's arguments directly in the initial - // `AddScript` surprisingly works. - command = PSCommandHelpers - .BuildDotSourceCommandWithArguments("$args[0]", _debugStateService?.Arguments) - .AddArgument(ast.GetScriptBlock()); + // Write the script content to a temp file so the debugger can properly + // resolve breakpoints. When a ScriptBlock is dot-sourced, functionContext._file + // may not be set (depending on the runspace configuration), preventing pending + // breakpoint resolution. Writing to a temp file ensures _file is set to a real + // path that the debugger can match against breakpoints. + string tempScriptPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"pses_debug_{System.Guid.NewGuid():N}.ps1"); + System.IO.File.WriteAllText(tempScriptPath, untitledScript.Contents); + + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: wrote temp script to '{tempScriptPath}'\n"); + + // Set breakpoints on the temp file path so they match functionContext._file + var dbgForBp = _runspaceContext.CurrentRunspace.Runspace.Debugger; + var existingBreakpoints = BreakpointApiUtils.GetBreakpoints(dbgForBp, _debugStateService.RunspaceId); + foreach (var bp in existingBreakpoints) + { + if (bp is System.Management.Automation.LineBreakpoint lbp && + lbp.Script == BreakpointHandlers.NormalizeScriptUri(untitledScript.DocumentUri.ToString())) + { + BreakpointApiUtils.RemoveBreakpoint(dbgForBp, lbp, _debugStateService.RunspaceId); + var newBp = BreakpointApiUtils.SetBreakpoint(dbgForBp, + BreakpointDetails.Create(tempScriptPath, lbp.Line, lbp.Column, null, null, null), + _debugStateService.RunspaceId); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: re-registered breakpoint at '{tempScriptPath}:{lbp.Line}', verified={newBp is not null}\n"); + } + } + + // Execute the temp file by dot-sourcing its path + command = PSCommandHelpers.BuildDotSourceCommandWithArguments( + PSCommandHelpers.EscapeScriptFilePath(tempScriptPath), _debugStateService?.Arguments); } else { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: FALLBACK inline execution (isScriptFile={isScriptFile})\n"); // Without the new APIs we can only execute the untitled script's contents. // Command breakpoints and `Wait-Debugger` will work. We must wrap the script // with newlines so that any included comments don't break the command. @@ -155,11 +174,242 @@ internal async Task LaunchScriptAsync(string scriptToLaunch) } } + // Check debugger state before execution + var debugger = _runspaceContext.CurrentRunspace.Runspace.Debugger; + var hostRunspace = ((Microsoft.PowerShell.EditorServices.Services.PowerShell.Host.PsesInternalHost)_executionService).Runspace; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: runspaceContext.Runspace.Id={_runspaceContext.CurrentRunspace.Runspace.Id}, hostRunspace.Id={hostRunspace.Id}, same={_runspaceContext.CurrentRunspace.Runspace == hostRunspace}\n"); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: debugger DebugMode={debugger.DebugMode}, IsActive={debugger.IsActive}\n"); + + // Check _debuggingMode via reflection + try { + var debuggerType = debugger.GetType(); + var contextField = debuggerType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (contextField != null) { + var context = contextField.GetValue(debugger); + var execContextType = context.GetType(); + var debuggingModeField = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (debuggingModeField != null) { + var debuggingMode = debuggingModeField.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: _context._debuggingMode={debuggingMode}\n"); + } + } + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: reflection failed: {ex.Message}\n"); + } + var breakpoints = BreakpointApiUtils.GetBreakpoints(debugger, _debugStateService.RunspaceId); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: RunspaceId={_debugStateService.RunspaceId}, {breakpoints.Count} breakpoints registered\n"); + foreach (var bp in breakpoints) + { + if (bp is System.Management.Automation.LineBreakpoint lbp) + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LineBreakpoint: script='{lbp.Script}', line={lbp.Line}, column={lbp.Column}\n"); + else + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] {bp.GetType().Name}: {bp}\n"); + } + + // Check the pending breakpoints dictionary by looking at the debugger's internal state + // We can use GetBreakpoints to see all breakpoints + + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: executing command on thread {System.Environment.CurrentManagedThreadId}\n"); + + // Add a DIRECT DebuggerStop handler to see if the event is raised at all + System.EventHandler directHandler = null; + try { + directHandler = (s, e) => + { + try { System.Console.Error.WriteLine("PSES_DIRECT_DEBUGGERSTOP_CALLED"); System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] DIRECT DebuggerStop handler called! Breakpoints={e.Breakpoints.Count}, Thread={System.Environment.CurrentManagedThreadId}\n"); } catch (System.Exception ex) { System.Console.Error.WriteLine($"PSES_DIRECT_FAILED: {ex.Message}"); } + }; + var dbgForHandler = _runspaceContext.CurrentRunspace.Runspace.Debugger; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] About to subscribe DebuggerStop, debugger={dbgForHandler?.GetType().Name}, hash={dbgForHandler?.GetHashCode()}\n"); + dbgForHandler.DebuggerStop += directHandler; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] DIRECT DebuggerStop handler subscribed, debugger hash={dbgForHandler.GetHashCode()}\n"); + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Failed to subscribe DebuggerStop: {ex.GetType().Name}: {ex.Message}\n"); + } + + // Fix: Ensure _context.CurrentRunspace is set on the debugger's ExecutionContext + // In PSES with UseCurrentThread, the debugger's _context.CurrentRunspace can be null + // which causes OnDebuggerStop to crash with NullReferenceException + try { + var dbgFix = _runspaceContext.CurrentRunspace.Runspace.Debugger; + var dbgTypeFix = dbgFix.GetType(); + var contextFieldFix = dbgTypeFix.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: contextField={contextFieldFix != null}\n"); + if (contextFieldFix != null) { + var contextFix = contextFieldFix.GetValue(dbgFix); + var currentRunspacePropFix = contextFix?.GetType().GetProperty("CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (currentRunspacePropFix != null) { + var currentRunspaceFix = currentRunspacePropFix.GetValue(contextFix); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: currentRunspace={currentRunspaceFix?.GetHashCode()}, isNull={currentRunspaceFix == null}\n"); + if (currentRunspaceFix == null) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Fixing null CurrentRunspace on debugger._context\n"); + currentRunspacePropFix.SetValue(contextFix, _runspaceContext.CurrentRunspace.Runspace); + } + } + } + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Failed to fix CurrentRunspace: {ex.Message}\n"); + } + + // Also check _mapScriptToBreakpoints to see if the script is registered + try { + var dbg = _runspaceContext.CurrentRunspace.Runspace.Debugger; + var dbgType = dbg.GetType(); + // Check LanguageMode + var langMode = _runspaceContext.CurrentRunspace.Runspace.SessionStateProxy.LanguageMode; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LanguageMode={langMode}\n"); + // Check if IgnoreScriptDebug is set + var contextField = dbgType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (contextField != null) { + var context = contextField.GetValue(dbg); + var execContextType = context.GetType(); + var ignoreScriptDebugField = execContextType.GetProperty("IgnoreScriptDebug", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (ignoreScriptDebugField != null) { + var ignoreScriptDebug = ignoreScriptDebugField.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] IgnoreScriptDebug={ignoreScriptDebug}\n"); + } + // Check _debuggingMode from context + var debuggingModeField = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (debuggingModeField != null) { + var debuggingMode = debuggingModeField.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] _context._debuggingMode={debuggingMode}\n"); + } + // Check if ExecutionContext from TLS matches + var localPipelineType = typeof(System.Management.Automation.Runspaces.Runspace).Assembly.GetType("System.Management.Automation.Runspaces.LocalPipeline"); + var getCtxMethod = localPipelineType?.GetMethod("GetExecutionContextFromTLS", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + var tlsContext = getCtxMethod?.Invoke(null, null); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] TLS context == debugger context: {tlsContext == context}\n"); + if (tlsContext != null) { + var tlsDebugMode = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(tlsContext); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] TLS context _debuggingMode={tlsDebugMode}\n"); + } + } + var mapField = dbgType.GetField("_mapScriptToBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (mapField != null) { + var map = mapField.GetValue(dbg) as System.Collections.IDictionary; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] _mapScriptToBreakpoints count={map?.Count}\n"); + if (map != null) { + foreach (System.Collections.DictionaryEntry entry in map) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] map key type={entry.Key?.GetType().Name}, key={entry.Key?.ToString()?.Substring(0, System.Math.Min(80, entry.Key?.ToString()?.Length ?? 0))}\n"); + } + } + } + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] map reflection failed: {ex.Message}\n"); + } + + // Fix: Ensure _context.CurrentRunspace is set on the debugger's ExecutionContext + // In PSES with UseCurrentThread, the debugger's _context.CurrentRunspace can be null + // which causes OnDebuggerStop to crash with NullReferenceException + try { + var dbg = _runspaceContext.CurrentRunspace.Runspace.Debugger; + var dbgType = dbg.GetType(); + var contextField = dbgType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: contextField={contextField != null}\n"); + if (contextField != null) { + var context = contextField.GetValue(dbg); + var currentRunspaceProp = context?.GetType().GetProperty("CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: currentRunspaceProp={currentRunspaceProp != null}, context={context?.GetHashCode()}\n"); + if (currentRunspaceProp != null) { + var currentRunspace = currentRunspaceProp.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: currentRunspace={currentRunspace?.GetHashCode()}, isNull={currentRunspace == null}\n"); + if (currentRunspace == null) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Fixing null CurrentRunspace on debugger._context\n"); + currentRunspaceProp.SetValue(context, _runspaceContext.CurrentRunspace.Runspace); + } + } + } + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Failed to fix CurrentRunspace: {ex.Message}\n"); + } + + // Fix: Set _debuggingMode on the TLS (Thread Local Storage) execution context. + // The debugger checks _debuggingMode from the TLS context during script execution, + // not from the runspace's debugger context. If TLS _debuggingMode is 0, the debugger + // skips breakpoint checks entirely, even though breakpoints are registered. + try { + var localPipelineType = typeof(System.Management.Automation.Runspaces.Runspace).Assembly.GetType("System.Management.Automation.Runspaces.LocalPipeline"); + var getCtxMethod = localPipelineType?.GetMethod("GetExecutionContextFromTLS", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + var tlsContext = getCtxMethod?.Invoke(null, null); + if (tlsContext != null) { + var execContextType = tlsContext.GetType(); + var debuggingModeField = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (debuggingModeField != null) { + var currentMode = debuggingModeField.GetValue(tlsContext); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] TLS _debuggingMode before fix: {currentMode}\n"); + // DebugModes.LocalScript = 1 + debuggingModeField.SetValue(tlsContext, (System.Management.Automation.DebugModes)1); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] TLS _debuggingMode set to LocalScript (1)\n"); + } + } + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Failed to set TLS _debuggingMode: {ex.Message}\n"); + } + + try { await _executionService.ExecutePSCommandAsync( command, CancellationToken.None, s_debuggerExecutionOptions).ConfigureAwait(false); + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: ExecutePSCommandAsync threw: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n"); + } + + if (directHandler != null) + _runspaceContext.CurrentRunspace.Runspace.Debugger.DebuggerStop -= directHandler; + + // Check _pendingBreakpoints AFTER execution to see if breakpoint was bound + try { + var dbg = _runspaceContext.CurrentRunspace.Runspace.Debugger; + var dbgType = dbg.GetType(); + var pendingField = dbgType.GetField("_pendingBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (pendingField != null) { + var pending = pendingField.GetValue(dbg) as System.Collections.IDictionary; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _pendingBreakpoints count={pending?.Count}\n"); + } + var idToBpField = dbgType.GetField("_idToBreakpoint", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (idToBpField != null) { + var idToBp = idToBpField.GetValue(dbg) as System.Collections.IDictionary; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _idToBreakpoint count={idToBp?.Count}\n"); + } + // Check _callStack + var callStackField = dbgType.GetField("_callStack", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (callStackField != null) { + var callStack = callStackField.GetValue(dbg); + var callStackType = callStack.GetType(); + var countProp = callStackType.GetProperty("Count"); + var count = countProp?.GetValue(callStack); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _callStack count={count}\n"); + } + // Check _mapScriptToBreakpoints + var mapField2 = dbgType.GetField("_mapScriptToBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (mapField2 != null) { + var map = mapField2.GetValue(dbg) as System.Collections.IDictionary; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _mapScriptToBreakpoints count={map?.Count}\n"); + if (map != null) { + foreach (System.Collections.DictionaryEntry entry in map) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] map key type={entry.Key?.GetType().Name}, key={entry.Key?.ToString()?.Substring(0, System.Math.Min(80, entry.Key?.ToString()?.Length ?? 0))}\n"); + } + } + } + // Check _boundBreakpoints + var boundField = dbgType.GetField("_boundBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (boundField != null) { + var bound = boundField.GetValue(dbg) as System.Collections.IDictionary; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _boundBreakpoints count={bound?.Count}\n"); + if (bound != null) { + foreach (System.Collections.DictionaryEntry entry in bound) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] bound key={entry.Key}\n"); + } + } + } + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER reflection failed: {ex.Message}\n"); + } + + // Check for errors + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: command completed (checking for errors)\n"); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: command completed, sending Terminated\n"); _debugAdapterServer?.SendNotification(EventNames.Terminated); } } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs index 700c154b9..ea057da7f 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -124,6 +124,7 @@ public LaunchAndAttachHandler( public async Task Handle(PsesLaunchRequestArguments request, CancellationToken cancellationToken) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchAndAttachHandler.Handle called, Script='{request.Script}'\n"); // The debugger has officially started. We use this to later check if we should stop it. ((PsesInternalHost)_executionService).DebugContext.IsActive = true; diff --git a/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs index 7c05b3da2..3936afa9f 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs @@ -57,6 +57,8 @@ public SynchronousPowerShellTask( public override IReadOnlyList Run(CancellationToken cancellationToken) { + var cmdText = _psCommand.GetInvocationText(); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] SynchronousPowerShellTask.Run on thread {System.Environment.CurrentManagedThreadId}, command={cmdText.Substring(0, System.Math.Min(50, cmdText.Length))}\n"); _psesHost.Runspace.ThrowCancelledIfUnusable(); PowerShellContextFrame frame = _psesHost.PushPowerShellForExecution(); try @@ -133,7 +135,103 @@ private IReadOnlyList ExecuteNormally(CancellationToken cancellationTok invocationSettings.ErrorActionPreference = ActionPreference.Stop; } - result = _pwsh.InvokeCommand(_psCommand, invocationSettings); + // Check _debuggingMode right before Invoke + try { + var debugger = _pwsh.Runspace.Debugger; + var debuggerType = debugger.GetType(); + var contextField = debuggerType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (contextField != null) { + var context = contextField.GetValue(debugger); + var execContextType = context.GetType(); + var debuggingModeField = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (debuggingModeField != null) { + var debuggingMode = debuggingModeField.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: _context._debuggingMode={debuggingMode} BEFORE Invoke, InBreakpoint={debugger.InBreakpoint}\n"); + } + // Check if Runspace.DefaultRunspace matches our runspace + var defaultRunspace = System.Management.Automation.Runspaces.Runspace.DefaultRunspace; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: Runspace.DefaultRunspace.Id={defaultRunspace?.Id}, _pwsh.Runspace.Id={_pwsh.Runspace.Id}, same={defaultRunspace == _pwsh.Runspace}\n"); + if (defaultRunspace != null && defaultRunspace != _pwsh.Runspace) { + var ecProp = typeof(System.Management.Automation.Runspaces.Runspace).GetProperty("ExecutionContext", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var tlsExecContext = ecProp?.GetValue(defaultRunspace); + var tlsDebugMode = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(tlsExecContext); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: TLS _debuggingMode={tlsDebugMode}\n"); + } + // Check if the ExecutionContext from the runspace matches the debugger's context + var ecProp2 = typeof(System.Management.Automation.Runspaces.Runspace).GetProperty("ExecutionContext", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var runspaceEc = ecProp2?.GetValue(_pwsh.Runspace); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: runspace.ExecutionContext == debugger._context: {runspaceEc == context}\n"); + // Check _idToBreakpoint + var idToBreakpointField = debuggerType.GetField("_idToBreakpoint", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (idToBreakpointField != null) { + var idToBreakpoint = idToBreakpointField.GetValue(debugger) as System.Collections.IDictionary; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: _idToBreakpoint count={idToBreakpoint?.Count}\n"); + if (idToBreakpoint != null) { + foreach (System.Collections.DictionaryEntry entry in idToBreakpoint) { + var bp = entry.Value; + var bpType = bp.GetType(); + var scriptProp = bpType.GetProperty("Script"); + var lineProp = bpType.GetProperty("Line"); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] breakpoint: id={entry.Key}, script='{scriptProp?.GetValue(bp)}', line={lineProp?.GetValue(bp)}\n"); + // Check SequencePointIndex and BreakpointBitArray + var spIndexProp = bpType.GetProperty("SequencePointIndex", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (spIndexProp != null) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] SequencePointIndex={spIndexProp.GetValue(bp)}\n"); + } + var bpBitArrayProp = bpType.GetProperty("BreakpointBitArray", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (bpBitArrayProp != null) { + var bitArray = bpBitArrayProp.GetValue(bp) as System.Collections.BitArray; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointBitArray={(bitArray == null ? "null" : bitArray.Count + " bits")}\n"); + } + } + } + } + // Check _pendingBreakpoints + var pendingBpField = debuggerType.GetField("_pendingBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (pendingBpField != null) { + var pendingBp = pendingBpField.GetValue(debugger) as System.Collections.IDictionary; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: _pendingBreakpoints count={pendingBp?.Count}\n"); + if (pendingBp != null) { + foreach (System.Collections.DictionaryEntry entry in pendingBp) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] pending key='{entry.Key}'\n"); + } + } + } + } + // Also log the command text + var cmdText = _psCommand.GetInvocationText(); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: about to invoke '{cmdText.Substring(0, System.Math.Min(100, cmdText.Length))}'\n"); + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: reflection failed: {ex.Message}\n"); + } + + // Check CurrentRunspace before Invoke + try { + var dbg = _pwsh.Runspace.Debugger; + var dbgType = dbg.GetType(); + var contextField = dbgType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (contextField != null) { + var context = contextField.GetValue(dbg); + var crProp = context?.GetType().GetProperty("CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var cr = crProp?.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: BEFORE Invoke CurrentRunspace={cr?.GetHashCode()}, isNull={cr == null}, ctxHash={context?.GetHashCode()}, thread={System.Environment.CurrentManagedThreadId}\n"); + } + } catch { } + + result = _pwsh.InvokeCommand(_psCommand, invocationSettings); + + // Check CurrentRunspace after Invoke + try { + var dbg = _pwsh.Runspace.Debugger; + var dbgType = dbg.GetType(); + var contextField = dbgType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (contextField != null) { + var context = contextField.GetValue(dbg); + var crProp = context?.GetType().GetProperty("CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var cr = crProp?.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: AFTER Invoke CurrentRunspace={cr?.GetHashCode()}, isNull={cr == null}, ctxHash={context?.GetHashCode()}, thread={System.Environment.CurrentManagedThreadId}\n"); + } + } catch { } cancellationToken.ThrowIfCancellationRequested(); } // Allow terminate exceptions to propagate for flow control. diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs index 01d33bd42..99e410018 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs @@ -890,13 +890,13 @@ private void PushPowerShellAndMaybeRunLoop(PowerShellContextFrame frame, bool sk try { - if (_psFrameStack.Count == 1) + if (frame.IsDebug) { - RunTopLevelExecutionLoop(); + RunDebugExecutionLoop(); } - else if (frame.IsDebug) + else if (_psFrameStack.Count == 1) { - RunDebugExecutionLoop(); + RunTopLevelExecutionLoop(); } else { @@ -1290,9 +1290,11 @@ private void InvokeInput(string input, CancellationToken cancellationToken) private void AddRunspaceEventHandlers(Runspace runspace) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AddRunspaceEventHandlers: runspace Id={runspace.Id}, Debugger type={runspace.Debugger.GetType().Name}\n"); runspace.Debugger.DebuggerStop += OnDebuggerStopped; runspace.Debugger.BreakpointUpdated += OnBreakpointUpdated; runspace.StateChanged += OnRunspaceStateChanged; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AddRunspaceEventHandlers: DebuggerStop subscribed, DebugMode={runspace.Debugger.DebugMode}\n"); } private void RemoveRunspaceEventHandlers(Runspace runspace) @@ -1545,6 +1547,7 @@ internal void WaitForExternalDebuggerStops() private void OnDebuggerStopped(object sender, DebuggerStopEventArgs debuggerStopEventArgs) { + try { System.Console.Error.WriteLine("PSES_ON_DEBUGGER_STOPPED_CALLED"); System.IO.File.AppendAllText("/tmp/pses-debug2.log", "[PSES] PSES_ON_DEBUGGER_STOPPED_CALLED\n"); } catch (System.Exception ex) { System.Console.Error.WriteLine($"PSES_ON_DEBUGGER_STOPPED_FAILED: {ex.Message}"); } // If ErrorActionPreference is set to Break, any engine exception is going to trigger a // pipeline stop. Technically this is the same behavior as a standalone PowerShell // process, but we use pipeline stops with greater frequency due to features like run diff --git a/test/PowerShellEditorServices.Test/packages.lock.json b/test/PowerShellEditorServices.Test/packages.lock.json index 23fd75c30..5136423ff 100644 --- a/test/PowerShellEditorServices.Test/packages.lock.json +++ b/test/PowerShellEditorServices.Test/packages.lock.json @@ -11,6 +11,15 @@ "Microsoft.CodeCoverage": "17.9.0" } }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, "Microsoft.PowerShell.5.ReferenceAssemblies": { "type": "Direct", "requested": "[1.1.0, )", @@ -174,6 +183,11 @@ "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "17.8.0", @@ -536,7 +550,7 @@ "Microsoft.PowerShell.EditorServices.Test.Shared": { "type": "Project", "dependencies": { - "Microsoft.PowerShell.EditorServices": "[0.0.1, )" + "Microsoft.PowerShell.EditorServices": "[3.18.1, )" } } }, @@ -1671,7 +1685,7 @@ "Microsoft.PowerShell.EditorServices.Test.Shared": { "type": "Project", "dependencies": { - "Microsoft.PowerShell.EditorServices": "[0.0.1, )" + "Microsoft.PowerShell.EditorServices": "[3.18.1, )" } } }, @@ -2794,7 +2808,7 @@ "Microsoft.PowerShell.EditorServices.Test.Shared": { "type": "Project", "dependencies": { - "Microsoft.PowerShell.EditorServices": "[0.0.1, )" + "Microsoft.PowerShell.EditorServices": "[3.18.1, )" } } }, @@ -3916,7 +3930,7 @@ "Microsoft.PowerShell.EditorServices.Test.Shared": { "type": "Project", "dependencies": { - "Microsoft.PowerShell.EditorServices": "[0.0.1, )" + "Microsoft.PowerShell.EditorServices": "[3.18.1, )" } } } From c26f15cf3ef85d588e19e75d8841a847589a5e9c Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Fri, 3 Jul 2026 06:34:37 -0500 Subject: [PATCH 6/8] fix: show user-defined variables in Auto scope on breakpoint hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the global scope filter in ShouldAddToAutoVariables with a built-in variable name allowlist. Previously, any variable that existed in the global scope was filtered from Auto — but dot-sourced scripts put user variables in the global scope, so they never appeared. Also allow all LocalVariable types through (not just the curated whitelist) and clean up diagnostic logging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/DebugAdapter/DebugService.cs | 33 ++++++++++++--- .../Handlers/BreakpointHandlers.cs | 40 ++----------------- .../Handlers/ConfigurationDoneHandler.cs | 39 +++++------------- .../PowerShell/Host/PsesInternalHost.cs | 5 ++- 4 files changed, 44 insertions(+), 73 deletions(-) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index 90faceda4..6ed116c75 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -756,10 +756,11 @@ private bool ShouldAddToAutoVariables(VariableInfo variableInfo) return false; } - // Filter Global-Scoped variables. We first cast to VariableDetails to ensure the prefix - // is added for purposes of comparison. - VariableDetails variableToAddDetails = new(variableToAdd); - if (globalScopeVariables.Children.ContainsKey(variableToAddDetails.Name)) + // Filter well-known PowerShell built-in/preference variables to reduce noise. + // We used to filter everything in the global scope, but that also filtered + // user-defined variables that ended up in the global scope (e.g. from + // dot-sourced scripts). Instead, we now only filter known noise variables. + if (s_builtInVariableNames.Contains(variableToAdd.Name)) { return false; } @@ -769,10 +770,13 @@ private bool ShouldAddToAutoVariables(VariableInfo variableInfo) { return variableToAdd.Name switch { - "PSItem" or "_" or "" => true, + // Skip empty/nothing variables + null or "" or "_" => false, + // Only show args/input if they have content "args" or "input" => variableToAdd.Value is Array array && array.Length > 0, "PSBoundParameters" => variableToAdd.Value is IDictionary dict && dict.Count > 0, - _ => false + // Show all other local variables (e.g. $a, $result, $computer) + _ => true }; } @@ -780,6 +784,23 @@ private bool ShouldAddToAutoVariables(VariableInfo variableInfo) return variableInfo.Types[0].EndsWith("PSVariable"); } + private static readonly HashSet s_builtInVariableNames = new(StringComparer.OrdinalIgnoreCase) + { + "ConfirmPreference", "DebugPreference", "Error", "ErrorActionPreference", "ErrorView", + "ExecutionContext", "FormatEnumerationLimit", "HOME", "Host", "InformationPreference", + "input", "MaximumHistoryCount", "MyInvocation", "NestedPromptLevel", "OutputEncoding", + "PID", "PROFILE", "ProgressPreference", "PSBoundParameters", "PSCommandPath", + "PSCulture", "PSDebugContext", "PSDefaultParameterValues", "PSEmailServer", + "PSHome", "PSItem", "PSLogUserProfile", "PSModuleAutoLoadingPreference", + "PSModulePath", "PSNativeCommandArgumentPassing", "PSNativeCommandUseErrorActionPreference", + "PSScriptRoot", "PSSessionConfigurationName", "PSSessionOption", "PSStyle", + "PSUICulture", "PSVersionTable", "PWD", "ShellId", "StackTrace", + "VerbosePreference", "WarningPreference", "WhatIfPreference", "^", "$", + "?", "true", "false", "null", "args", "PSCommand", "PSPath", + "ForEach", "Where", "psEditor", "ImmyBotVersion", "ImmyScriptPath", + "CanAccessParentTenant", "__psEditorServices_CallStack", + }; + private async Task FetchStackFramesAsync(string scriptNameOverride) { // This glorious hack ensures that Get-PSCallStack returns a list of CallStackFrame diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs index c9be89a26..e29bcc0ae 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs @@ -83,12 +83,9 @@ public async Task Handle(SetBreakpointsArguments request }; } - // At this point, the source file has been verified as a PowerShell script. - // Use the DocumentUri (which matches what LaunchScriptAsync passes to - // Parser.ParseInput) so line breakpoints match the script block's Extent.File. - // Normalize file:///scripts/... URIs to pspath:// URIs so the debugger's - // functionContext._file matches the breakpoint's Script property. - string breakpointScriptPath = NormalizeScriptUri(scriptFile.DocumentUri.ToString()); + // Use the DocumentUri directly — the frontend now uses pspath:// URIs everywhere, + // so no normalization is needed. + string breakpointScriptPath = scriptFile.DocumentUri.ToString(); IReadOnlyList breakpointDetails = request.Breakpoints .Select((srcBreakpoint) => BreakpointDetails.Create( breakpointScriptPath, @@ -231,36 +228,5 @@ private bool IsFileSupportedForBreakpoints(string requestedPath, ScriptFile reso string fileExtension = Path.GetExtension(resolvedScriptFile.FilePath); return s_supportedDebugFileExtensions.Contains(fileExtension, StringComparer.OrdinalIgnoreCase); } - - /// - /// Normalizes file:///scripts/... URIs to pspath:// URIs so that the debugger's - /// functionContext._file (set from the ScriptBlock's Extent.File) matches the - /// breakpoint's Script property. PowerShell's debugger uses _file to look up - /// pending breakpoints, and file:/// URIs get resolved to empty strings in the - /// function context, while pspath:// URIs are preserved. - /// - internal static string NormalizeScriptUri(string uri) - { - if (string.IsNullOrEmpty(uri) || !uri.StartsWith("file:///scripts/")) - return uri; - - var parts = uri.Replace("file:///scripts/", "").Split('/'); - if (parts.Length < 3) - return uri; - - var scope = parts[0].ToLowerInvariant(); - var categoryPlural = parts[1]; - var fileName = string.Join("/", parts.Skip(2)); - - var category = categoryPlural switch - { - "Functions" => "Function", - "Immy%20System" => "ImmySystem", - "Inventory" => "DeviceInventory", - _ => categoryPlural, - }; - - return $"pspath://ScriptPSProvider/{scope}/{category}/{fileName}"; - } } } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs index 2bb96a1fa..0d44424d9 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -127,37 +127,18 @@ internal async Task LaunchScriptAsync(string scriptToLaunch) } if (isScriptFile && BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace)) { - // Write the script content to a temp file so the debugger can properly - // resolve breakpoints. When a ScriptBlock is dot-sourced, functionContext._file - // may not be set (depending on the runspace configuration), preventing pending - // breakpoint resolution. Writing to a temp file ensures _file is set to a real - // path that the debugger can match against breakpoints. - string tempScriptPath = System.IO.Path.Combine( - System.IO.Path.GetTempPath(), - $"pses_debug_{System.Guid.NewGuid():N}.ps1"); - System.IO.File.WriteAllText(tempScriptPath, untitledScript.Contents); + // Use the DocumentUri directly — the frontend now uses pspath:// URIs everywhere. + string scriptUri = untitledScript.DocumentUri.ToString(); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: wrote temp script to '{tempScriptPath}'\n"); + ScriptBlockAst ast = Parser.ParseInput( + untitledScript.Contents, + scriptUri, + out Token[] _, + out ParseError[] _); - // Set breakpoints on the temp file path so they match functionContext._file - var dbgForBp = _runspaceContext.CurrentRunspace.Runspace.Debugger; - var existingBreakpoints = BreakpointApiUtils.GetBreakpoints(dbgForBp, _debugStateService.RunspaceId); - foreach (var bp in existingBreakpoints) - { - if (bp is System.Management.Automation.LineBreakpoint lbp && - lbp.Script == BreakpointHandlers.NormalizeScriptUri(untitledScript.DocumentUri.ToString())) - { - BreakpointApiUtils.RemoveBreakpoint(dbgForBp, lbp, _debugStateService.RunspaceId); - var newBp = BreakpointApiUtils.SetBreakpoint(dbgForBp, - BreakpointDetails.Create(tempScriptPath, lbp.Line, lbp.Column, null, null, null), - _debugStateService.RunspaceId); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: re-registered breakpoint at '{tempScriptPath}:{lbp.Line}', verified={newBp is not null}\n"); - } - } - - // Execute the temp file by dot-sourcing its path - command = PSCommandHelpers.BuildDotSourceCommandWithArguments( - PSCommandHelpers.EscapeScriptFilePath(tempScriptPath), _debugStateService?.Arguments); + command = PSCommandHelpers + .BuildDotSourceCommandWithArguments("$args[0]", _debugStateService?.Arguments) + .AddArgument(ast.GetScriptBlock()); } else { diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs index 99e410018..0d1786386 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs @@ -1547,7 +1547,7 @@ internal void WaitForExternalDebuggerStops() private void OnDebuggerStopped(object sender, DebuggerStopEventArgs debuggerStopEventArgs) { - try { System.Console.Error.WriteLine("PSES_ON_DEBUGGER_STOPPED_CALLED"); System.IO.File.AppendAllText("/tmp/pses-debug2.log", "[PSES] PSES_ON_DEBUGGER_STOPPED_CALLED\n"); } catch (System.Exception ex) { System.Console.Error.WriteLine($"PSES_ON_DEBUGGER_STOPPED_FAILED: {ex.Message}"); } + try { System.Console.Error.WriteLine("PSES_ON_DEBUGGER_STOPPED_CALLED"); System.IO.File.AppendAllText("/tmp/pses-debug.log", "[PSES] PSES_ON_DEBUGGER_STOPPED_CALLED\n"); } catch (System.Exception ex) { System.Console.Error.WriteLine($"PSES_ON_DEBUGGER_STOPPED_FAILED: {ex.Message}"); } // If ErrorActionPreference is set to Break, any engine exception is going to trigger a // pipeline stop. Technically this is the same behavior as a standalone PowerShell // process, but we use pipeline stops with greater frequency due to features like run @@ -1572,10 +1572,13 @@ private void OnDebuggerStopped(object sender, DebuggerStopEventArgs debuggerStop if (triggerObject is PipelineStoppedException pse) { + System.IO.File.AppendAllText("/tmp/pses-debug2.log", "[PSES] OnDebuggerStopped: throwing PipelineStoppedException\n"); throw pse; } } + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] OnDebuggerStopped: past trigger check, isExternal={Environment.CurrentManagedThreadId != _pipelineThread.ManagedThreadId}, thread={Environment.CurrentManagedThreadId}, pipelineThread={_pipelineThread.ManagedThreadId}\n"); + // The debugger has officially started. We use this to later check if we should stop it. DebugContext.IsActive = true; From bec33ff95391ac46d19037258e865e72ad4262cd Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Fri, 3 Jul 2026 06:48:56 -0500 Subject: [PATCH 7/8] feat: add Immy-specific variable scopes in debug adapter Read VariableLayerAttribute from PSVariable.Attributes during breakpoint stops to create custom DAP scope containers like 'Script', 'RunContext', 'Action', 'Session'. VariableDetails.PSVariable property stores the original PSVariable reference for attribute inspection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/DebugAdapter/DebugService.cs | 86 ++++++++++++++++++- .../DebugAdapter/Debugging/VariableDetails.cs | 6 ++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index 6ed116c75..5edbab79e 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -49,6 +49,7 @@ internal class DebugService private VariableContainerDetails globalScopeVariables; private VariableContainerDetails scriptScopeVariables; private VariableContainerDetails localScopeVariables; + private List immyScopeVariables = new(); private StackFrameDetails[] stackFrameDetails; private readonly PropertyInfo invocationTypeScriptPositionProperty; @@ -596,7 +597,7 @@ public VariableScope[] GetVariableScopes(int stackFrameId) int autoVariablesId = stackFrames[stackFrameId].AutoVariables.Id; int commandVariablesId = stackFrames[stackFrameId].CommandVariables.Id; - return new VariableScope[] + var scopes = new List { new VariableScope(autoVariablesId, VariableContainerDetails.AutoVariablesName), new VariableScope(commandVariablesId, VariableContainerDetails.CommandVariablesName), @@ -604,6 +605,14 @@ public VariableScope[] GetVariableScopes(int stackFrameId) new VariableScope(scriptScopeVariables.Id, VariableContainerDetails.ScriptScopeName), new VariableScope(globalScopeVariables.Id, VariableContainerDetails.GlobalScopeName), }; + + // Add Immy-specific variable scopes (e.g. "Script", "RunContext", "Action", "Session") + foreach (var immyScope in immyScopeVariables) + { + scopes.Add(new VariableScope(immyScope.Id, immyScope.Name)); + } + + return scopes.ToArray(); } #endregion @@ -629,6 +638,9 @@ private async Task FetchStackFramesAndVariablesAsync(string scriptNameOverride) localScopeVariables = await FetchVariableContainerAsync(VariableContainerDetails.LocalScopeName).ConfigureAwait(false); await FetchStackFramesAsync(scriptNameOverride).ConfigureAwait(false); + + // Build Immy-specific variable scopes from VariableLayerAttribute on each PSVariable. + FetchImmyVariableScopesAsync(); } finally { @@ -689,6 +701,78 @@ private async Task FetchVariableContainerAsync(string return scopeVariableContainer; } + /// + /// Reads VariableLayerAttribute from each variable's Attributes collection (set by + /// ImmyBot's MetascriptRunspaceExtensions) and organizes variables into Immy-specific + /// scope containers like "Script", "RunContext", "Action", "Session", etc. + /// These are not real PowerShell scopes but logical groupings that show + /// which ImmyBot layer injected each variable. + /// + private void FetchImmyVariableScopesAsync() + { + immyScopeVariables.Clear(); + + // Scan all fetched scope variables for VariableLayerAttribute. + // We check by type name since PSES can't reference ImmyBot's attribute class. + const string layerAttrTypeName = "VariableLayerAttribute"; + + var layerGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + void ScanScope(VariableContainerDetails scope) + { + foreach (var child in scope.Children.Values) + { + // VariableDetails wraps a PSVariable; check its Attributes. + if (child is VariableDetails varDetails && varDetails.PSVariable is PSVariable psVar) + { + foreach (var attr in psVar.Attributes) + { + if (attr.GetType().Name.Equals(layerAttrTypeName, StringComparison.Ordinal)) + { + string layer = attr.GetType().GetProperty("Layer")?.GetValue(attr)?.ToString() ?? "Unknown"; + if (!layerGroups.TryGetValue(layer, out var list)) + { + list = new List(); + layerGroups[layer] = list; + } + list.Add(child); + break; + } + } + } + } + } + + ScanScope(globalScopeVariables); + ScanScope(scriptScopeVariables); + ScanScope(localScopeVariables); + + foreach (var layerGroup in layerGroups) + { + string layerName = layerGroup.Key; + + VariableContainerDetails layerContainer = new(nextVariableId++, layerName); + variables.Add(layerContainer); + + foreach (var varDetails in layerGroup.Value) + { + if (!layerContainer.Children.ContainsKey(varDetails.Name)) + { + layerContainer.Children.Add(varDetails.Name, varDetails); + } + } + + if (layerContainer.Children.Count > 0) + { + immyScopeVariables.Add(layerContainer); + } + else + { + variables.Remove(layerContainer); + } + } + } + // This is a helper type for FetchStackFramesAsync to preserve the variable Type after deserialization. private record VariableInfo(string[] Types, PSVariable Variable); diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs index f67230af5..29bbe681f 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs @@ -29,6 +29,11 @@ internal class VariableDetails : VariableDetailsBase protected object ValueObject { get; } private VariableDetails[] cachedChildren; + /// + /// The original PSVariable if this was created from one, used for attribute inspection. + /// + public PSVariable PSVariable { get; } + #endregion #region Constructors @@ -43,6 +48,7 @@ internal class VariableDetails : VariableDetailsBase public VariableDetails(PSVariable psVariable) : this(DollarPrefix + psVariable.Name, psVariable.Value) { + PSVariable = psVariable; } /// From c088b3a0a3ef9447a4b81bb245160ea51f52530b Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Sat, 4 Jul 2026 04:28:24 -0500 Subject: [PATCH 8/8] refactor: clean up diagnostic logging in ConfigurationDoneHandler Remove all /tmp/pses-debug.log diagnostic logging from LaunchScriptAsync. Keep only the essential TLS _debuggingMode fix and CurrentRunspace null fix. Replace File.AppendAllText error logging with _logger.LogError calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Handlers/ConfigurationDoneHandler.cs | 272 +++--------------- 1 file changed, 47 insertions(+), 225 deletions(-) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs index 0d44424d9..59e21fe3c 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -72,7 +72,6 @@ public ConfigurationDoneHandler( public Task Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ConfigurationDoneHandler.Handle called, ScriptToLaunch='{_debugStateService?.ScriptToLaunch}'\n"); _debugService.IsClientAttached = true; if (!string.IsNullOrEmpty(_debugStateService.ScriptToLaunch)) @@ -108,11 +107,9 @@ public Task Handle(ConfigurationDoneArguments request // by tests. internal async Task LaunchScriptAsync(string scriptToLaunch) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: scriptToLaunch='{scriptToLaunch}'\n"); PSCommand command; if (System.IO.File.Exists(scriptToLaunch)) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: File.Exists=true\n"); // For a saved file we just execute its path (after escaping it). command = PSCommandHelpers.BuildDotSourceCommandWithArguments( PSCommandHelpers.EscapeScriptFilePath(scriptToLaunch), _debugStateService?.Arguments); @@ -120,11 +117,6 @@ internal async Task LaunchScriptAsync(string scriptToLaunch) else // It's a URI to an untitled script, or a raw script. { bool isScriptFile = _workspaceService.TryGetFile(scriptToLaunch, out ScriptFile untitledScript); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: TryGetFile={isScriptFile}, SupportsBreakpointApis={isScriptFile && BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace)}\n"); - if (isScriptFile) - { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: contents.Length={untitledScript.Contents.Length}, DocumentUri={untitledScript.DocumentUri}\n"); - } if (isScriptFile && BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace)) { // Use the DocumentUri directly — the frontend now uses pspath:// URIs everywhere. @@ -142,7 +134,6 @@ internal async Task LaunchScriptAsync(string scriptToLaunch) } else { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: FALLBACK inline execution (isScriptFile={isScriptFile})\n"); // Without the new APIs we can only execute the untitled script's contents. // Command breakpoints and `Wait-Debugger` will work. We must wrap the script // with newlines so that any included comments don't break the command. @@ -155,242 +146,73 @@ internal async Task LaunchScriptAsync(string scriptToLaunch) } } - // Check debugger state before execution - var debugger = _runspaceContext.CurrentRunspace.Runspace.Debugger; - var hostRunspace = ((Microsoft.PowerShell.EditorServices.Services.PowerShell.Host.PsesInternalHost)_executionService).Runspace; - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: runspaceContext.Runspace.Id={_runspaceContext.CurrentRunspace.Runspace.Id}, hostRunspace.Id={hostRunspace.Id}, same={_runspaceContext.CurrentRunspace.Runspace == hostRunspace}\n"); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: debugger DebugMode={debugger.DebugMode}, IsActive={debugger.IsActive}\n"); - - // Check _debuggingMode via reflection - try { - var debuggerType = debugger.GetType(); - var contextField = debuggerType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (contextField != null) { - var context = contextField.GetValue(debugger); - var execContextType = context.GetType(); - var debuggingModeField = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (debuggingModeField != null) { - var debuggingMode = debuggingModeField.GetValue(context); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: _context._debuggingMode={debuggingMode}\n"); - } - } - } catch (System.Exception ex) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: reflection failed: {ex.Message}\n"); - } - var breakpoints = BreakpointApiUtils.GetBreakpoints(debugger, _debugStateService.RunspaceId); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: RunspaceId={_debugStateService.RunspaceId}, {breakpoints.Count} breakpoints registered\n"); - foreach (var bp in breakpoints) - { - if (bp is System.Management.Automation.LineBreakpoint lbp) - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LineBreakpoint: script='{lbp.Script}', line={lbp.Line}, column={lbp.Column}\n"); - else - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] {bp.GetType().Name}: {bp}\n"); - } - - // Check the pending breakpoints dictionary by looking at the debugger's internal state - // We can use GetBreakpoints to see all breakpoints - - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: executing command on thread {System.Environment.CurrentManagedThreadId}\n"); - - // Add a DIRECT DebuggerStop handler to see if the event is raised at all - System.EventHandler directHandler = null; - try { - directHandler = (s, e) => - { - try { System.Console.Error.WriteLine("PSES_DIRECT_DEBUGGERSTOP_CALLED"); System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] DIRECT DebuggerStop handler called! Breakpoints={e.Breakpoints.Count}, Thread={System.Environment.CurrentManagedThreadId}\n"); } catch (System.Exception ex) { System.Console.Error.WriteLine($"PSES_DIRECT_FAILED: {ex.Message}"); } - }; - var dbgForHandler = _runspaceContext.CurrentRunspace.Runspace.Debugger; - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] About to subscribe DebuggerStop, debugger={dbgForHandler?.GetType().Name}, hash={dbgForHandler?.GetHashCode()}\n"); - dbgForHandler.DebuggerStop += directHandler; - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] DIRECT DebuggerStop handler subscribed, debugger hash={dbgForHandler.GetHashCode()}\n"); - } catch (System.Exception ex) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Failed to subscribe DebuggerStop: {ex.GetType().Name}: {ex.Message}\n"); - } - - // Fix: Ensure _context.CurrentRunspace is set on the debugger's ExecutionContext - // In PSES with UseCurrentThread, the debugger's _context.CurrentRunspace can be null - // which causes OnDebuggerStop to crash with NullReferenceException - try { - var dbgFix = _runspaceContext.CurrentRunspace.Runspace.Debugger; - var dbgTypeFix = dbgFix.GetType(); - var contextFieldFix = dbgTypeFix.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: contextField={contextFieldFix != null}\n"); - if (contextFieldFix != null) { - var contextFix = contextFieldFix.GetValue(dbgFix); - var currentRunspacePropFix = contextFix?.GetType().GetProperty("CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (currentRunspacePropFix != null) { - var currentRunspaceFix = currentRunspacePropFix.GetValue(contextFix); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: currentRunspace={currentRunspaceFix?.GetHashCode()}, isNull={currentRunspaceFix == null}\n"); - if (currentRunspaceFix == null) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Fixing null CurrentRunspace on debugger._context\n"); - currentRunspacePropFix.SetValue(contextFix, _runspaceContext.CurrentRunspace.Runspace); - } - } - } - } catch (System.Exception ex) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Failed to fix CurrentRunspace: {ex.Message}\n"); - } - - // Also check _mapScriptToBreakpoints to see if the script is registered - try { - var dbg = _runspaceContext.CurrentRunspace.Runspace.Debugger; - var dbgType = dbg.GetType(); - // Check LanguageMode - var langMode = _runspaceContext.CurrentRunspace.Runspace.SessionStateProxy.LanguageMode; - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LanguageMode={langMode}\n"); - // Check if IgnoreScriptDebug is set - var contextField = dbgType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (contextField != null) { - var context = contextField.GetValue(dbg); - var execContextType = context.GetType(); - var ignoreScriptDebugField = execContextType.GetProperty("IgnoreScriptDebug", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (ignoreScriptDebugField != null) { - var ignoreScriptDebug = ignoreScriptDebugField.GetValue(context); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] IgnoreScriptDebug={ignoreScriptDebug}\n"); - } - // Check _debuggingMode from context - var debuggingModeField = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (debuggingModeField != null) { - var debuggingMode = debuggingModeField.GetValue(context); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] _context._debuggingMode={debuggingMode}\n"); - } - // Check if ExecutionContext from TLS matches - var localPipelineType = typeof(System.Management.Automation.Runspaces.Runspace).Assembly.GetType("System.Management.Automation.Runspaces.LocalPipeline"); - var getCtxMethod = localPipelineType?.GetMethod("GetExecutionContextFromTLS", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); - var tlsContext = getCtxMethod?.Invoke(null, null); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] TLS context == debugger context: {tlsContext == context}\n"); - if (tlsContext != null) { - var tlsDebugMode = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(tlsContext); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] TLS context _debuggingMode={tlsDebugMode}\n"); - } - } - var mapField = dbgType.GetField("_mapScriptToBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (mapField != null) { - var map = mapField.GetValue(dbg) as System.Collections.IDictionary; - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] _mapScriptToBreakpoints count={map?.Count}\n"); - if (map != null) { - foreach (System.Collections.DictionaryEntry entry in map) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] map key type={entry.Key?.GetType().Name}, key={entry.Key?.ToString()?.Substring(0, System.Math.Min(80, entry.Key?.ToString()?.Length ?? 0))}\n"); - } - } - } - } catch (System.Exception ex) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] map reflection failed: {ex.Message}\n"); - } - - // Fix: Ensure _context.CurrentRunspace is set on the debugger's ExecutionContext - // In PSES with UseCurrentThread, the debugger's _context.CurrentRunspace can be null - // which causes OnDebuggerStop to crash with NullReferenceException - try { - var dbg = _runspaceContext.CurrentRunspace.Runspace.Debugger; - var dbgType = dbg.GetType(); - var contextField = dbgType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: contextField={contextField != null}\n"); - if (contextField != null) { - var context = contextField.GetValue(dbg); - var currentRunspaceProp = context?.GetType().GetProperty("CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: currentRunspaceProp={currentRunspaceProp != null}, context={context?.GetHashCode()}\n"); - if (currentRunspaceProp != null) { - var currentRunspace = currentRunspaceProp.GetValue(context); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] CurrentRunspace fix: currentRunspace={currentRunspace?.GetHashCode()}, isNull={currentRunspace == null}\n"); - if (currentRunspace == null) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Fixing null CurrentRunspace on debugger._context\n"); - currentRunspaceProp.SetValue(context, _runspaceContext.CurrentRunspace.Runspace); - } - } - } - } catch (System.Exception ex) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Failed to fix CurrentRunspace: {ex.Message}\n"); - } - // Fix: Set _debuggingMode on the TLS (Thread Local Storage) execution context. // The debugger checks _debuggingMode from the TLS context during script execution, // not from the runspace's debugger context. If TLS _debuggingMode is 0, the debugger // skips breakpoint checks entirely, even though breakpoints are registered. - try { - var localPipelineType = typeof(System.Management.Automation.Runspaces.Runspace).Assembly.GetType("System.Management.Automation.Runspaces.LocalPipeline"); - var getCtxMethod = localPipelineType?.GetMethod("GetExecutionContextFromTLS", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + try + { + var localPipelineType = typeof(System.Management.Automation.Runspaces.Runspace).Assembly + .GetType("System.Management.Automation.Runspaces.LocalPipeline"); + var getCtxMethod = localPipelineType?.GetMethod( + "GetExecutionContextFromTLS", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); var tlsContext = getCtxMethod?.Invoke(null, null); - if (tlsContext != null) { + if (tlsContext is not null) + { var execContextType = tlsContext.GetType(); - var debuggingModeField = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (debuggingModeField != null) { - var currentMode = debuggingModeField.GetValue(tlsContext); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] TLS _debuggingMode before fix: {currentMode}\n"); + var debuggingModeField = execContextType.GetField( + "_debuggingMode", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (debuggingModeField is not null) + { // DebugModes.LocalScript = 1 debuggingModeField.SetValue(tlsContext, (System.Management.Automation.DebugModes)1); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] TLS _debuggingMode set to LocalScript (1)\n"); } } - } catch (System.Exception ex) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] Failed to set TLS _debuggingMode: {ex.Message}\n"); } - - try { - await _executionService.ExecutePSCommandAsync( - command, - CancellationToken.None, - s_debuggerExecutionOptions).ConfigureAwait(false); - } catch (System.Exception ex) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: ExecutePSCommandAsync threw: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n"); + catch (System.Exception ex) + { + _logger.LogError(ex, "Failed to set TLS _debuggingMode"); } - if (directHandler != null) - _runspaceContext.CurrentRunspace.Runspace.Debugger.DebuggerStop -= directHandler; - - // Check _pendingBreakpoints AFTER execution to see if breakpoint was bound - try { + // Fix: Ensure _context.CurrentRunspace is set on the debugger's ExecutionContext. + // In PSES with UseCurrentThread, the debugger's _context.CurrentRunspace can be null + // which causes OnDebuggerStop to crash with NullReferenceException. + try + { var dbg = _runspaceContext.CurrentRunspace.Runspace.Debugger; var dbgType = dbg.GetType(); - var pendingField = dbgType.GetField("_pendingBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (pendingField != null) { - var pending = pendingField.GetValue(dbg) as System.Collections.IDictionary; - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _pendingBreakpoints count={pending?.Count}\n"); - } - var idToBpField = dbgType.GetField("_idToBreakpoint", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (idToBpField != null) { - var idToBp = idToBpField.GetValue(dbg) as System.Collections.IDictionary; - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _idToBreakpoint count={idToBp?.Count}\n"); - } - // Check _callStack - var callStackField = dbgType.GetField("_callStack", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (callStackField != null) { - var callStack = callStackField.GetValue(dbg); - var callStackType = callStack.GetType(); - var countProp = callStackType.GetProperty("Count"); - var count = countProp?.GetValue(callStack); - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _callStack count={count}\n"); - } - // Check _mapScriptToBreakpoints - var mapField2 = dbgType.GetField("_mapScriptToBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (mapField2 != null) { - var map = mapField2.GetValue(dbg) as System.Collections.IDictionary; - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _mapScriptToBreakpoints count={map?.Count}\n"); - if (map != null) { - foreach (System.Collections.DictionaryEntry entry in map) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] map key type={entry.Key?.GetType().Name}, key={entry.Key?.ToString()?.Substring(0, System.Math.Min(80, entry.Key?.ToString()?.Length ?? 0))}\n"); - } - } - } - // Check _boundBreakpoints - var boundField = dbgType.GetField("_boundBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (boundField != null) { - var bound = boundField.GetValue(dbg) as System.Collections.IDictionary; - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER execution: _boundBreakpoints count={bound?.Count}\n"); - if (bound != null) { - foreach (System.Collections.DictionaryEntry entry in bound) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] bound key={entry.Key}\n"); - } + var contextField = dbgType.GetField( + "_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (contextField is not null) + { + var context = contextField.GetValue(dbg); + var currentRunspaceProp = context?.GetType().GetProperty( + "CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (currentRunspaceProp is not null && currentRunspaceProp.GetValue(context) is null) + { + currentRunspaceProp.SetValue(context, _runspaceContext.CurrentRunspace.Runspace); } } - } catch (System.Exception ex) { - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AFTER reflection failed: {ex.Message}\n"); + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Failed to fix null CurrentRunspace on debugger._context"); } - // Check for errors - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: command completed (checking for errors)\n"); + try + { + await _executionService.ExecutePSCommandAsync( + command, + CancellationToken.None, + s_debuggerExecutionOptions).ConfigureAwait(false); + } + catch (System.Exception ex) + { + _logger.LogError(ex, "LaunchScriptAsync: ExecutePSCommandAsync threw"); + } - System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchScriptAsync: command completed, sending Terminated\n"); _debugAdapterServer?.SendNotification(EventNames.Terminated); } }