diff --git a/ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs b/ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs
new file mode 100644
index 000000000..925276993
--- /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 000000000..3f6d27081
--- /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 000000000..9327938d3
--- /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 000000000..a4af9397c
--- /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 000000000..1cc2a6350
--- /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 000000000..d1838b6a8
--- /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 c99f1538f..52987b6d1 100644
--- a/ILSpy.Tests/Views/MainMenuTests.cs
+++ b/ILSpy.Tests/Views/MainMenuTests.cs
@@ -78,6 +78,33 @@ public class MainMenuTests
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 000000000..456068aa1
--- /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