From 3739a74f530e372bea9619d2f0cc0604f40847eb Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Wed, 29 Jul 2026 20:01:12 +0200 Subject: [PATCH 1/5] Add "Open from running process" dialog With a modern .NET app the executable in the process list is a native apphost that carries no IL, so the path a user can see is precisely the one a decompiler cannot open - ILSpy itself is an example. Ask the runtime instead: since .NET Core 3.0 every CoreCLR process serves a diagnostics endpoint that names its managed entry assembly and, via an EventPipe rundown, every assembly it has loaded, including ones behind a single-file bundle or with no file at all. The endpoint answers the same way on Windows, Linux and macOS and needs no privileges beyond same-user, which also makes it the only workable route on macOS, where native process introspection is gated by SIP. The protocol and the nettrace container it returns are implemented here rather than taken from Microsoft.Diagnostics.NETCore.Client, so the feature costs no new package reference; the reader is scoped to loader rundown events and steps over everything else by size. The rundown asks for the loader keyword alone: the runtime's default set also collects the JIT and IL-to-native-map rundown, which in a long-running process buries the module list and overruns the session buffer, costing the very events the dialog needs. Windows additionally lists .NET Framework processes, which predate the endpoint and are read from their OS module list instead. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Processes/DiagnosticsIpcClientTests.cs | 111 +++++ ILSpy.Tests/Processes/FakeProcessExplorer.cs | 79 ++++ ILSpy.Tests/Processes/NativeRuntimeHost.cs | 51 ++ .../Processes/NettraceRundownReaderTests.cs | 135 ++++++ .../OpenFromProcessDialogViewModelTests.cs | 278 +++++++++++ ILSpy.Tests/Processes/ProcessExplorerTests.cs | 147 ++++++ ILSpy.Tests/Views/MainMenuTests.cs | 27 ++ .../OpenFromProcessDialogStructureTests.cs | 143 ++++++ ILSpy/Commands/FileCommands.cs | 25 + ILSpy/Processes/DiagnosticsIpcClient.cs | 256 +++++++++++ ILSpy/Processes/DiagnosticsIpcMessage.cs | 115 +++++ .../Processes/DiagnosticsPortScanner.Unix.cs | 94 ++++ .../DiagnosticsPortScanner.Windows.cs | 43 ++ ILSpy/Processes/DiagnosticsPortScanner.cs | 57 +++ ILSpy/Processes/IProcessExplorer.cs | 43 ++ .../NetFrameworkProcesses.Windows.cs | 123 +++++ ILSpy/Processes/NettraceRundownReader.cs | 435 ++++++++++++++++++ ILSpy/Processes/ProcessExplorer.cs | 141 ++++++ ILSpy/Processes/RunningDotNetProcess.cs | 144 ++++++ ILSpy/Properties/Resources.Designer.cs | 135 ++++++ ILSpy/Properties/Resources.resx | 45 ++ .../OpenFromProcessDialogViewModel.cs | 218 +++++++++ ILSpy/ViewModels/ProcessModuleRowViewModel.cs | 47 ++ ILSpy/ViewModels/ProcessRowViewModel.cs | 79 ++++ ILSpy/Views/OpenFromProcessDialog.axaml | 86 ++++ ILSpy/Views/OpenFromProcessDialog.axaml.cs | 105 +++++ 26 files changed, 3162 insertions(+) create mode 100644 ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs create mode 100644 ILSpy.Tests/Processes/FakeProcessExplorer.cs create mode 100644 ILSpy.Tests/Processes/NativeRuntimeHost.cs create mode 100644 ILSpy.Tests/Processes/NettraceRundownReaderTests.cs create mode 100644 ILSpy.Tests/Processes/OpenFromProcessDialogViewModelTests.cs create mode 100644 ILSpy.Tests/Processes/ProcessExplorerTests.cs create mode 100644 ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs create mode 100644 ILSpy/Processes/DiagnosticsIpcClient.cs create mode 100644 ILSpy/Processes/DiagnosticsIpcMessage.cs create mode 100644 ILSpy/Processes/DiagnosticsPortScanner.Unix.cs create mode 100644 ILSpy/Processes/DiagnosticsPortScanner.Windows.cs create mode 100644 ILSpy/Processes/DiagnosticsPortScanner.cs create mode 100644 ILSpy/Processes/IProcessExplorer.cs create mode 100644 ILSpy/Processes/NetFrameworkProcesses.Windows.cs create mode 100644 ILSpy/Processes/NettraceRundownReader.cs create mode 100644 ILSpy/Processes/ProcessExplorer.cs create mode 100644 ILSpy/Processes/RunningDotNetProcess.cs create mode 100644 ILSpy/ViewModels/OpenFromProcessDialogViewModel.cs create mode 100644 ILSpy/ViewModels/ProcessModuleRowViewModel.cs create mode 100644 ILSpy/ViewModels/ProcessRowViewModel.cs create mode 100644 ILSpy/Views/OpenFromProcessDialog.axaml create mode 100644 ILSpy/Views/OpenFromProcessDialog.axaml.cs diff --git a/ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs b/ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs new file mode 100644 index 0000000000..9252769931 --- /dev/null +++ b/ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Christoph Wille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Processes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// Exercises the hand-rolled diagnostics IPC implementation on two levels: golden-byte +/// checks of the wire format (header and string encoding, per dotnet/diagnostics +/// ipc-protocol.md), and live end-to-end checks against the one .NET process guaranteed +/// to exist on every OS this suite runs on - the test host itself, which exposes a +/// diagnostics port like any other CoreCLR process. +/// +[TestFixture] +public class DiagnosticsIpcClientTests +{ + [Test] + public void EncodeRequest_Produces_The_Documented_Header_Layout() + { + byte[] payload = { 1, 2, 3 }; + + byte[] message = DiagnosticsIpcMessage.EncodeRequest(0x04, 0x04, payload); + + message.Should().HaveCount(23, "the header is 20 bytes, followed by the payload"); + Encoding.ASCII.GetString(message, 0, 13).Should().Be("DOTNET_IPC_V1"); + message[13].Should().Be(0, "the magic is null-terminated to 14 bytes"); + BitConverter.ToUInt16(message, 14).Should().Be(23, "the size field covers header plus payload"); + message[16].Should().Be(0x04, "command set"); + message[17].Should().Be(0x04, "command id"); + BitConverter.ToUInt16(message, 18).Should().Be(0, "the reserved field is zero"); + message.Skip(20).Should().Equal(payload); + } + + [Test] + public void Strings_Round_Trip_In_The_Documented_Wire_Format() + { + using var buffer = new MemoryStream(); + using (var writer = new BinaryWriter(buffer, Encoding.Unicode, leaveOpen: true)) + { + DiagnosticsIpcMessage.WriteString(writer, "abc"); + } + + buffer.ToArray().Should().Equal(new byte[] { + 4, 0, 0, 0, // u32 char count, including the null terminator + 0x61, 0, 0x62, 0, 0x63, 0, // "abc" as UTF-16LE + 0, 0, // null terminator + }); + + buffer.Position = 0; + using var reader = new BinaryReader(buffer, Encoding.Unicode, leaveOpen: true); + DiagnosticsIpcMessage.ReadString(reader).Should().Be("abc"); + } + + [Test] + public void A_Zero_Length_String_Reads_As_Null() + { + using var buffer = new MemoryStream(new byte[] { 0, 0, 0, 0 }); + using var reader = new BinaryReader(buffer, Encoding.Unicode); + + DiagnosticsIpcMessage.ReadString(reader).Should().BeNull(); + } + + [Test] + public void The_Port_Scan_Finds_The_Current_Process() + { + DiagnosticsPortScanner.GetProcessIds().Should().Contain(Environment.ProcessId, + "the test host is a CoreCLR process and must expose a diagnostics port"); + } + + [Test] + public async Task ProcessInfo_Of_The_Current_Process_Reports_Its_Entry_Assembly() + { + var info = await DiagnosticsIpcClient.GetProcessInfoAsync( + Environment.ProcessId, CancellationToken.None); + + info.Pid.Should().Be(Environment.ProcessId); + info.RuntimeCookie.Should().NotBe(Guid.Empty); + info.CommandLine.Should().NotBeNullOrWhiteSpace(); + info.EntryAssemblyName.Should().Be(Assembly.GetEntryAssembly()!.GetName().Name, + "ProcessInfo2 reports the managed entry-point assembly, not the native host"); + info.ClrVersion.Should().NotBeNullOrWhiteSpace(); + } +} diff --git a/ILSpy.Tests/Processes/FakeProcessExplorer.cs b/ILSpy.Tests/Processes/FakeProcessExplorer.cs new file mode 100644 index 0000000000..3f6d27081e --- /dev/null +++ b/ILSpy.Tests/Processes/FakeProcessExplorer.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Christoph Wille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using ICSharpCode.ILSpy.Processes; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// Stands in for the live process explorer so the dialog's view model can be driven without +/// running processes or IPC. Records every call, and can be made to fail or to park a +/// module query on a gate so cancellation and superseded-selection behavior is observable. +/// +sealed class FakeProcessExplorer : IProcessExplorer +{ + public List ProcessesToReturn { get; set; } = new(); + public Dictionary> ModulesByPid { get; } = new(); + + public List ModuleCalls { get; } = new(); + public int ProcessCalls { get; private set; } + + public Exception? ProcessesException { get; set; } + public Exception? ModulesException { get; set; } + + /// When set, module queries wait for it before returning. + public TaskCompletionSource? ModulesGate { get; set; } + + public CancellationToken LastModulesToken { get; private set; } + + public static RunningDotNetProcess Process(int pid, string name, string? entryAssembly = null, + RuntimeKind kind = RuntimeKind.CoreClr, string? commandLine = null) + => new(pid, name, kind, RuntimeVersion: "10.0.0", Architecture: "x64", + CommandLine: commandLine, EntryAssemblyName: entryAssembly); + + public async Task> GetProcessesAsync(CancellationToken cancellationToken) + { + ProcessCalls++; + if (ProcessesException != null) + throw ProcessesException; + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + return ProcessesToReturn; + } + + public async Task> GetModulesAsync( + RunningDotNetProcess process, CancellationToken cancellationToken) + { + ModuleCalls.Add(process.Pid); + LastModulesToken = cancellationToken; + if (ModulesException != null) + throw ModulesException; + if (ModulesGate != null) + await ModulesGate.Task.WaitAsync(cancellationToken); + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + return ModulesByPid.TryGetValue(process.Pid, out var modules) + ? modules + : Array.Empty(); + } +} diff --git a/ILSpy.Tests/Processes/NativeRuntimeHost.cs b/ILSpy.Tests/Processes/NativeRuntimeHost.cs new file mode 100644 index 0000000000..9327938d35 --- /dev/null +++ b/ILSpy.Tests/Processes/NativeRuntimeHost.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Christoph Wille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// The native CoreCLR host that backs the running test process. It serves as the fixture for +/// two things the process explorer must get right: a real file that carries no IL, and a +/// module the managed rundown must never report. +/// +/// +/// It is located through the runtime directory rather than the process' own module list, +/// because is not implemented on macOS - +/// there it reports the main module and nothing else. +/// +static class NativeRuntimeHost +{ + /// + /// File name of the runtime host on the current OS. + /// + public static string FileName { get; } = + OperatingSystem.IsWindows() ? "coreclr.dll" + : OperatingSystem.IsMacOS() ? "libcoreclr.dylib" + : "libcoreclr.so"; + + /// + /// Full path of the runtime host loaded by the current process. It sits next to + /// System.Private.CoreLib, whether the app is framework-dependent or self-contained. + /// + public static string FullPath { get; } = + Path.Combine(RuntimeEnvironment.GetRuntimeDirectory(), FileName); +} diff --git a/ILSpy.Tests/Processes/NettraceRundownReaderTests.cs b/ILSpy.Tests/Processes/NettraceRundownReaderTests.cs new file mode 100644 index 0000000000..a4af9397c0 --- /dev/null +++ b/ILSpy.Tests/Processes/NettraceRundownReaderTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Christoph Wille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Processes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// Covers the loaded-assembly half of the process explorer: an EventPipe rundown session +/// against the test host itself, and the scoped nettrace reader that turns the resulting +/// stream into module entries. The test host is the one process guaranteed to be running +/// a known set of assemblies on every OS, so it doubles as the fixture. +/// +[TestFixture] +public class NettraceRundownReaderTests +{ + static MemoryStream? rundown; + + /// + /// Collecting a rundown takes a moment, so every test in this fixture shares one. + /// + [OneTimeSetUp] + public async Task CollectRundownOfTheTestHost() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + rundown = await DiagnosticsIpcClient.CollectModuleRundownAsync(Environment.ProcessId, cts.Token); + } + + [OneTimeTearDown] + public void DisposeRundown() => rundown?.Dispose(); + + static MemoryStream Rundown() + { + rundown.Should().NotBeNull("the rundown collected in OneTimeSetUp is the fixture"); + return new MemoryStream(rundown!.ToArray(), writable: false); + } + + [Test] + public void The_Collected_Stream_Is_A_Nettrace_Stream() + { + var buffer = new byte[8]; + Rundown().ReadExactly(buffer); + + Encoding.ASCII.GetString(buffer).Should().Be("Nettrace", + "CollectTracing2 with format=NetTrace must produce a nettrace container"); + } + + [Test] + public void Rundown_Lists_CoreLib_With_A_Real_Path() + { + var modules = NettraceRundownReader.ReadModules(Rundown()); + + var coreLib = modules.Should().ContainSingle( + m => string.Equals(m.Name, "System.Private.CoreLib.dll", StringComparison.OrdinalIgnoreCase), + "every CoreCLR process has exactly one CoreLib loaded").Subject; + coreLib.IsInMemory.Should().BeFalse(); + File.Exists(coreLib.Path).Should().BeTrue("the rundown reports the module's real IL path"); + } + + [Test] + public void Rundown_Lists_The_Entry_Assembly_Of_The_Process() + { + var modules = NettraceRundownReader.ReadModules(Rundown()); + + string entryAssembly = Assembly.GetEntryAssembly()!.GetName().Name!; + modules.Should().Contain( + m => string.Equals(Path.GetFileNameWithoutExtension(m.Name), entryAssembly, StringComparison.OrdinalIgnoreCase), + "the entry assembly - the dll behind the apphost - is the whole point of the feature"); + } + + [Test] + public void Rundown_Lists_Managed_Assemblies_Only() + { + var modules = NettraceRundownReader.ReadModules(Rundown()); + + // The native side of the same process is dominated by libraries the decompiler has no + // use for - the runtime host itself among them, loaded by every CoreCLR process. + // Asking the runtime yields exactly the managed set, which is why the feature reads + // the rundown rather than enumerating native modules. + File.Exists(NativeRuntimeHost.FullPath).Should().BeTrue( + "the runtime host of the current process is the native module to look for"); + + modules.Select(m => m.Name).Should().NotContain( + name => name.StartsWith("coreclr", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("libcoreclr", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("hostfxr", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("kernel32", StringComparison.OrdinalIgnoreCase)); + } + + [Test] + public void Modules_Are_Reported_Once_Each() + { + var modules = NettraceRundownReader.ReadModules(Rundown()); + + modules.Where(m => !m.IsInMemory).Select(m => m.Path) + .Should().OnlyHaveUniqueItems("a module loaded once must not be listed twice"); + } + + [Test] + public void A_Stream_That_Is_Not_Nettrace_Is_Rejected_Clearly() + { + using var garbage = new MemoryStream(Encoding.ASCII.GetBytes("this is not a trace")); + + var read = () => NettraceRundownReader.ReadModules(garbage); + + read.Should().Throw().WithMessage("*Nettrace*"); + } +} diff --git a/ILSpy.Tests/Processes/OpenFromProcessDialogViewModelTests.cs b/ILSpy.Tests/Processes/OpenFromProcessDialogViewModelTests.cs new file mode 100644 index 0000000000..1cc2a63500 --- /dev/null +++ b/ILSpy.Tests/Processes/OpenFromProcessDialogViewModelTests.cs @@ -0,0 +1,278 @@ +// Copyright (c) 2026 Christoph Wille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Processes; +using ICSharpCode.ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// Behavior of the "Open from Running Process" dialog's view model against a fake explorer: +/// which processes are listed and filtered, when a process's assemblies are fetched, and +/// what the dialog hands back to be opened. Assemblies that exist only in a process's +/// memory are listed but cannot be opened, which several of these tests pin down. +/// +[TestFixture] +public class OpenFromProcessDialogViewModelTests +{ + static string TestFile(string name) => Path.Combine(TestContext.CurrentContext.TestDirectory, name); + + static ProcessModuleInfo OnDisk(string name) => new(name, TestFile(name), IsInMemory: false); + + static ProcessModuleInfo InMemory(string name) => new(name, Path: null, IsInMemory: true); + + static (OpenFromProcessDialogViewModel vm, FakeProcessExplorer explorer) CreateViewModel() + { + var explorer = new FakeProcessExplorer(); + explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(100, "ILSpy", "ILSpy")); + explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(200, "dotnet", "MyTool")); + explorer.ModulesByPid[100] = new[] { OnDisk("ILSpy.dll"), OnDisk("ICSharpCode.Decompiler.dll") }; + explorer.ModulesByPid[200] = new[] { OnDisk("ILSpy.Tests.dll"), InMemory("Dynamic.Proxies") }; + return (new OpenFromProcessDialogViewModel(explorer), explorer); + } + + [AvaloniaTest] + public async Task Refresh_Lists_The_Running_Processes() + { + var (vm, explorer) = CreateViewModel(); + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + vm.Processes.Select(p => p.Pid).Should().Equal(new[] { 100, 200 }); + vm.Processes[0].ProcessName.Should().Be("ILSpy"); + vm.IsLoadingProcesses.Should().BeFalse(); + explorer.ProcessCalls.Should().Be(1); + } + + [AvaloniaTest] + public async Task The_Filter_Matches_Process_Name_Pid_And_Entry_Assembly() + { + var (vm, _) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + vm.FilterText = "ilspy"; + vm.Processes.Select(p => p.Pid).Should().Equal(new[] { 100 }, "the name matches case-insensitively"); + + vm.FilterText = "200"; + vm.Processes.Select(p => p.Pid).Should().Equal(new[] { 200 }, "a pid is a natural thing to search for"); + + vm.FilterText = "MyTool"; + vm.Processes.Select(p => p.Pid).Should().Equal(new[] { 200 }, "the entry assembly is the interesting name"); + + vm.FilterText = ""; + vm.Processes.Should().HaveCount(2); + } + + [AvaloniaTest] + public async Task Selecting_A_Process_Loads_Its_Assemblies() + { + var (vm, explorer) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + + explorer.ModuleCalls.Should().Equal(new[] { 100 }); + vm.Modules.Select(m => m.Name).Should().Equal(new[] { "ILSpy.dll", "ICSharpCode.Decompiler.dll" }); + vm.IsLoadingModules.Should().BeFalse(); + } + + [AvaloniaTest] + public async Task Clearing_The_Selection_Empties_The_Assembly_List() + { + var (vm, _) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + + vm.SelectedProcess = null; + await Waiters.WaitForAsync(() => vm.Modules.Count == 0); + } + + [AvaloniaTest] + public async Task Add_Selected_Closes_The_Dialog_With_The_Assembly_Paths() + { + var (vm, _) = CreateViewModel(); + string[]? closedWith = null; + vm.CloseRequested += paths => closedWith = paths; + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + + vm.SelectedModules.Add(vm.Modules[0]); + vm.SelectedModules.Add(vm.Modules[1]); + vm.AddSelectedModulesCommand.Execute(null); + + closedWith.Should().Equal(new[] { TestFile("ILSpy.dll"), TestFile("ICSharpCode.Decompiler.dll") }); + } + + [AvaloniaTest] + public async Task Assemblies_That_Exist_Only_In_Memory_Cannot_Be_Added() + { + var (vm, _) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + vm.SelectedProcess = vm.Processes[1]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + + var dynamicModule = vm.Modules.Single(m => m.IsInMemory); + vm.SelectedModules.Add(dynamicModule); + + vm.AddSelectedModulesCommand.CanExecute(null).Should().BeFalse( + "an assembly with no file on disk cannot be opened"); + + vm.SelectedModules.Add(vm.Modules.Single(m => !m.IsInMemory)); + vm.AddSelectedModulesCommand.CanExecute(null).Should().BeTrue(); + + string[]? closedWith = null; + vm.CloseRequested += paths => closedWith = paths; + vm.AddSelectedModulesCommand.Execute(null); + + closedWith.Should().Equal(new[] { TestFile("ILSpy.Tests.dll") }, + "the in-memory row is skipped rather than blocking the rest"); + } + + [AvaloniaTest] + public async Task Add_Entry_Assembly_Closes_With_The_Assembly_Behind_The_Host() + { + var (vm, explorer) = CreateViewModel(); + explorer.ProcessesToReturn.Clear(); + explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(300, "MyApp", "ILSpy.Tests")); + explorer.ModulesByPid[300] = new[] { OnDisk("ILSpy.Tests.dll"), OnDisk("ILSpy.dll") }; + string[]? closedWith = null; + vm.CloseRequested += paths => closedWith = paths; + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 1); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.AddEntryAssemblyCommand.CanExecute(null)); + + vm.AddEntryAssemblyCommand.Execute(null); + + closedWith.Should().Equal(new[] { TestFile("ILSpy.Tests.dll") }); + } + + [AvaloniaTest] + public async Task A_Process_Whose_Entry_Assembly_Is_Unknown_Offers_Nothing_To_Add() + { + var (vm, explorer) = CreateViewModel(); + explorer.ProcessesToReturn.Clear(); + explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(400, "ghost", entryAssembly: null)); + explorer.ModulesByPid[400] = Array.Empty(); + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 1); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => !vm.IsLoadingModules); + + vm.AddEntryAssemblyCommand.CanExecute(null).Should().BeFalse(); + vm.AddSelectedModulesCommand.CanExecute(null).Should().BeFalse(); + } + + [AvaloniaTest] + public async Task A_Superseded_Assembly_Load_Does_Not_Overwrite_The_Current_One() + { + var (vm, explorer) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + var gate = new TaskCompletionSource(); + explorer.ModulesGate = gate; + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => explorer.ModuleCalls.Count == 1); + + // The user moves on before the first process answers. + explorer.ModulesGate = null; + vm.SelectedProcess = vm.Processes[1]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + gate.SetResult(); + await Task.Delay(50); + + vm.Modules.Select(m => m.Name).Should().Equal(new[] { "ILSpy.Tests.dll", "Dynamic.Proxies" }, + "the abandoned query must not paint its result over the current selection"); + } + + [AvaloniaTest] + public async Task A_Failing_Process_Scan_Surfaces_The_Error_And_Clears_On_Retry() + { + var (vm, explorer) = CreateViewModel(); + explorer.ProcessesException = new IOException("the diagnostics port is unreachable"); + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.ErrorMessage != null); + + vm.ErrorMessage.Should().Contain("the diagnostics port is unreachable"); + vm.IsLoadingProcesses.Should().BeFalse(); + + explorer.ProcessesException = null; + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + vm.ErrorMessage.Should().BeNull("a successful retry must dismiss the stale error"); + } + + [AvaloniaTest] + public async Task A_Failing_Assembly_Query_Surfaces_The_Error_Without_Closing() + { + var (vm, explorer) = CreateViewModel(); + bool closed = false; + vm.CloseRequested += _ => closed = true; + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + explorer.ModulesException = new UnauthorizedAccessException("access denied"); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.ErrorMessage != null); + + vm.ErrorMessage.Should().Contain("access denied"); + vm.Modules.Should().BeEmpty(); + vm.IsLoadingModules.Should().BeFalse(); + closed.Should().BeFalse(); + } + + [AvaloniaTest] + public async Task Closing_The_Dialog_Cancels_Work_Still_In_Flight() + { + var (vm, explorer) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + explorer.ModulesGate = new TaskCompletionSource(); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => explorer.ModuleCalls.Count == 1); + + vm.CancelAllOperations(); + + explorer.LastModulesToken.IsCancellationRequested.Should().BeTrue( + "a dialog that is gone must not keep a rundown session open"); + } +} diff --git a/ILSpy.Tests/Processes/ProcessExplorerTests.cs b/ILSpy.Tests/Processes/ProcessExplorerTests.cs new file mode 100644 index 0000000000..d1838b6a88 --- /dev/null +++ b/ILSpy.Tests/Processes/ProcessExplorerTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Christoph Wille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Processes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// The facade the dialog talks to, verified against the running test host, plus the pure +/// helpers it relies on: telling a managed file from a native one, and working out which +/// assembly is the entry point behind a native apphost. +/// +[TestFixture] +public class ProcessExplorerTests +{ + static readonly ProcessExplorer Explorer = new(); + + [Test] + public async Task The_Current_Process_Is_Listed_As_A_CoreClr_Process() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + var processes = await Explorer.GetProcessesAsync(cts.Token); + + var self = processes.Should().ContainSingle(p => p.Pid == Environment.ProcessId).Subject; + self.Kind.Should().Be(RuntimeKind.CoreClr); + self.ProcessName.Should().NotBeNullOrWhiteSpace(); + self.EntryAssemblyName.Should().Be(Assembly.GetEntryAssembly()!.GetName().Name); + self.RuntimeVersion.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public async Task Modules_Of_The_Current_Process_Include_Its_Own_Assemblies() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var processes = await Explorer.GetProcessesAsync(cts.Token); + var self = processes.Single(p => p.Pid == Environment.ProcessId); + + var modules = await Explorer.GetModulesAsync(self, cts.Token); + + modules.Should().Contain(m => m.Name.Equals("ILSpy.dll", StringComparison.OrdinalIgnoreCase), + "the assembly under test is loaded in the test host"); + } + + [Test] + public async Task The_Entry_Assembly_Of_The_Current_Process_Resolves_To_A_Real_File() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var processes = await Explorer.GetProcessesAsync(cts.Token); + var self = processes.Single(p => p.Pid == Environment.ProcessId); + var modules = await Explorer.GetModulesAsync(self, cts.Token); + + string? entryPath = self.ResolveEntryAssemblyPath(modules); + + File.Exists(entryPath).Should().BeTrue(); + Path.GetFileNameWithoutExtension(entryPath).Should().Be(Assembly.GetEntryAssembly()!.GetName().Name); + } + + [Test] + public void The_Entry_Assembly_Falls_Back_To_The_Dll_Beside_The_Apphost() + { + // A modern app's process shows an .exe that holds no IL at all; the assembly to + // decompile is the dll of the same name next to it. This is the path taken when the + // module list is unavailable (an old runtime, or a rundown that failed). + string apphost = Path.Combine(TestContext.CurrentContext.TestDirectory, "ILSpy.Tests.exe"); + string expected = Path.Combine(TestContext.CurrentContext.TestDirectory, "ILSpy.Tests.dll"); + var process = new RunningDotNetProcess(1, "ILSpy.Tests", RuntimeKind.CoreClr, + RuntimeVersion: null, Architecture: null, CommandLine: $"\"{apphost}\" --a b", + EntryAssemblyName: null); + + process.ResolveEntryAssemblyPath(Array.Empty()) + .Should().Be(expected); + } + + [Test] + public void The_Entry_Assembly_Is_Taken_From_A_Dotnet_Command_Line() + { + string dll = Path.Combine(TestContext.CurrentContext.TestDirectory, "ILSpy.Tests.dll"); + var process = new RunningDotNetProcess(1, "dotnet", RuntimeKind.CoreClr, + RuntimeVersion: null, Architecture: null, CommandLine: $"/usr/bin/dotnet {dll}", + EntryAssemblyName: null); + + process.ResolveEntryAssemblyPath(Array.Empty()).Should().Be(dll); + } + + [Test] + public void An_Unresolvable_Entry_Assembly_Is_Reported_As_Missing() + { + var process = new RunningDotNetProcess(1, "ghost", RuntimeKind.CoreClr, + RuntimeVersion: null, Architecture: null, CommandLine: "/no/such/path/ghost", + EntryAssemblyName: "ghost"); + + process.ResolveEntryAssemblyPath(Array.Empty()).Should().BeNull(); + } + + [Test] + public void An_In_Memory_Module_Never_Resolves_To_A_Path() + { + var process = new RunningDotNetProcess(1, "host", RuntimeKind.CoreClr, + RuntimeVersion: null, Architecture: null, CommandLine: null, EntryAssemblyName: "Dynamic"); + var modules = new[] { new ProcessModuleInfo("Dynamic", Path: null, IsInMemory: true) }; + + process.ResolveEntryAssemblyPath(modules).Should().BeNull( + "an assembly with no file cannot be opened from a path"); + } + + [Test] + public void Managed_Files_Are_Told_Apart_From_Native_Ones() + { + string managed = typeof(ProcessExplorer).Assembly.Location; + string native = NativeRuntimeHost.FullPath; + File.Exists(native).Should().BeTrue( + "a native file that is merely missing would pass the assertion below for the wrong reason"); + + ProcessExplorer.IsManagedAssembly(managed).Should().BeTrue(); + ProcessExplorer.IsManagedAssembly(native).Should().BeFalse("the runtime host carries no IL"); + ProcessExplorer.IsManagedAssembly(Path.Combine(Path.GetTempPath(), "does-not-exist.dll")) + .Should().BeFalse(); + } +} diff --git a/ILSpy.Tests/Views/MainMenuTests.cs b/ILSpy.Tests/Views/MainMenuTests.cs index c99f1538f4..52987b6d12 100644 --- a/ILSpy.Tests/Views/MainMenuTests.cs +++ b/ILSpy.Tests/Views/MainMenuTests.cs @@ -78,6 +78,33 @@ public void OpenFromNuGetFeed_item_sits_between_GAC_and_Reload_and_works_on_ever nugetIndex.Should().BeLessThan(reloadIndex, "Reload ends the open group"); } + [AvaloniaTest] + public void OpenFromRunningProcess_item_sits_between_NuGet_feed_and_Reload_and_works_on_every_OS() + { + var window = new Window(); + MainMenu.Attach(window); + + var menu = NativeMenu.GetMenu(window); + menu.Should().NotBeNull(); + + var process = Find(menu!, i => i.Header?.Contains("running", StringComparison.OrdinalIgnoreCase) == true); + process.Should().NotBeNull("the File menu must contain an 'Open from running process' item"); + process!.IsEnabled.Should().BeTrue( + "the runtime's diagnostics endpoint answers on Windows, Linux and macOS alike"); + + var fileSubmenu = menu!.Items.OfType() + .Select(i => i.Menu) + .First(m => m != null && m.Items.OfType() + .Any(i => i.Header?.Contains("GAC", StringComparison.OrdinalIgnoreCase) == true)); + var items = fileSubmenu!.Items.OfType().ToList(); + int nugetIndex = items.FindIndex(i => i.Header?.Contains("NuGet feed", StringComparison.OrdinalIgnoreCase) == true); + int processIndex = items.FindIndex(i => i.Header?.Contains("running", StringComparison.OrdinalIgnoreCase) == true); + int reloadIndex = items.FindIndex(i => i.Header?.Contains("Reload", StringComparison.OrdinalIgnoreCase) == true); + + processIndex.Should().BeGreaterThan(nugetIndex, "it joins the group of open-from sources at its end"); + processIndex.Should().BeLessThan(reloadIndex, "Reload ends the open group"); + } + [AvaloniaTest] public void OpenFromGac_item_enabled_state_follows_the_command() { diff --git a/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs b/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs new file mode 100644 index 0000000000..456068aa1c --- /dev/null +++ b/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Christoph Wille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.Tests.Processes; +using ICSharpCode.ILSpy.ViewModels; +using ICSharpCode.ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Views; + +/// +/// Pins the process-explorer dialog's shape: a filter box, a process grid above an +/// assembly grid, and the buttons that drive it. Everything is reachable by button - the +/// dialog carries no context menu - and the assembly grid allows multi-select so several +/// assemblies can be added in one go. +/// +[TestFixture] +public class OpenFromProcessDialogStructureTests +{ + static OpenFromProcessDialog CreateDialog(FakeProcessExplorer? explorer = null) + { + explorer ??= new FakeProcessExplorer(); + return new OpenFromProcessDialog(explorer); + } + + [AvaloniaTest] + public void Dialog_Title_And_Captions_Come_From_Localised_Resources() + { + var dialog = CreateDialog(); + + dialog.Title.Should().Be(Resources.OpenFromProcess_Title); + dialog.FindControl