mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3943/head
26 changed files with 3162 additions and 0 deletions
@ -0,0 +1,111 @@
@@ -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; |
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,79 @@
@@ -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; |
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
sealed class FakeProcessExplorer : IProcessExplorer |
||||
{ |
||||
public List<RunningDotNetProcess> ProcessesToReturn { get; set; } = new(); |
||||
public Dictionary<int, IReadOnlyList<ProcessModuleInfo>> ModulesByPid { get; } = new(); |
||||
|
||||
public List<int> ModuleCalls { get; } = new(); |
||||
public int ProcessCalls { get; private set; } |
||||
|
||||
public Exception? ProcessesException { get; set; } |
||||
public Exception? ModulesException { get; set; } |
||||
|
||||
/// <summary>When set, module queries wait for it before returning.</summary>
|
||||
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<IReadOnlyList<RunningDotNetProcess>> GetProcessesAsync(CancellationToken cancellationToken) |
||||
{ |
||||
ProcessCalls++; |
||||
if (ProcessesException != null) |
||||
throw ProcessesException; |
||||
await Task.Yield(); |
||||
cancellationToken.ThrowIfCancellationRequested(); |
||||
return ProcessesToReturn; |
||||
} |
||||
|
||||
public async Task<IReadOnlyList<ProcessModuleInfo>> 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<ProcessModuleInfo>(); |
||||
} |
||||
} |
||||
@ -0,0 +1,51 @@
@@ -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; |
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It is located through the runtime directory rather than the process' own module list,
|
||||
/// because <see cref="System.Diagnostics.Process.Modules"/> is not implemented on macOS -
|
||||
/// there it reports the main module and nothing else.
|
||||
/// </remarks>
|
||||
static class NativeRuntimeHost |
||||
{ |
||||
/// <summary>
|
||||
/// File name of the runtime host on the current OS.
|
||||
/// </summary>
|
||||
public static string FileName { get; } = |
||||
OperatingSystem.IsWindows() ? "coreclr.dll" |
||||
: OperatingSystem.IsMacOS() ? "libcoreclr.dylib" |
||||
: "libcoreclr.so"; |
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string FullPath { get; } = |
||||
Path.Combine(RuntimeEnvironment.GetRuntimeDirectory(), FileName); |
||||
} |
||||
@ -0,0 +1,135 @@
@@ -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; |
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[TestFixture] |
||||
public class NettraceRundownReaderTests |
||||
{ |
||||
static MemoryStream? rundown; |
||||
|
||||
/// <summary>
|
||||
/// Collecting a rundown takes a moment, so every test in this fixture shares one.
|
||||
/// </summary>
|
||||
[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<InvalidDataException>().WithMessage("*Nettrace*"); |
||||
} |
||||
} |
||||
@ -0,0 +1,278 @@
@@ -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; |
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<ProcessModuleInfo>(); |
||||
|
||||
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"); |
||||
} |
||||
} |
||||
@ -0,0 +1,147 @@
@@ -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; |
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<ProcessModuleInfo>()) |
||||
.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<ProcessModuleInfo>()).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<ProcessModuleInfo>()).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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,143 @@
@@ -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; |
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<Button>("RefreshButton")!.Content.Should().Be(Resources.OpenFromProcess_Refresh); |
||||
dialog.FindControl<Button>("AddSelectedButton")!.Content.Should().Be(Resources.OpenFromProcess_AddSelected); |
||||
dialog.FindControl<Button>("AddEntryAssemblyButton")!.Content.Should().Be(Resources.OpenFromProcess_AddEntryAssembly); |
||||
dialog.FindControl<Button>("CancelButton")!.Content.Should().Be(Resources.Cancel); |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public void Dialog_Contains_The_Process_Explorer_Controls() |
||||
{ |
||||
var dialog = CreateDialog(); |
||||
|
||||
dialog.FindControl<TextBox>("FilterBox").Should().NotBeNull("long process lists need filtering"); |
||||
dialog.FindControl<DataGrid>("ProcessesGrid").Should().NotBeNull(); |
||||
dialog.FindControl<DataGrid>("ModulesGrid").Should().NotBeNull("the selected process's assemblies are listed"); |
||||
dialog.FindControl<TextBlock>("VisibilityHint").Should().NotBeNull( |
||||
"the dialog states which processes it cannot show"); |
||||
|
||||
var errorBar = dialog.FindControl<Border>("ErrorBar"); |
||||
errorBar.Should().NotBeNull(); |
||||
errorBar!.IsVisible.Should().BeFalse("no error is showing before anything went wrong"); |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public void Several_Assemblies_Can_Be_Selected_At_Once() |
||||
{ |
||||
var grid = CreateDialog().FindControl<DataGrid>("ModulesGrid")!; |
||||
|
||||
grid.SelectionMode.Should().Be(DataGridSelectionMode.Extended, |
||||
"adding several assemblies of one process in one go is the common case"); |
||||
grid.IsReadOnly.Should().BeTrue("the grid lists assemblies, it does not edit them"); |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Opening_The_Dialog_Lists_The_Running_Processes() |
||||
{ |
||||
var explorer = new FakeProcessExplorer(); |
||||
explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(100, "ILSpy", "ILSpy")); |
||||
var dialog = CreateDialog(explorer); |
||||
|
||||
dialog.Show(); |
||||
|
||||
await Waiters.WaitForAsync(() => explorer.ProcessCalls > 0); |
||||
var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!; |
||||
await Waiters.WaitForAsync(() => vm.Processes.Count == 1); |
||||
dialog.FindControl<DataGrid>("ProcessesGrid")!.ItemsSource.Should().BeSameAs(vm.Processes); |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Selecting_Assemblies_In_The_Grid_Feeds_The_Add_Button() |
||||
{ |
||||
var explorer = new FakeProcessExplorer(); |
||||
explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(100, "ILSpy", "ILSpy")); |
||||
explorer.ModulesByPid[100] = new[] { |
||||
new ICSharpCode.ILSpy.Processes.ProcessModuleInfo("A.dll", @"C:\a\A.dll", IsInMemory: false), |
||||
new ICSharpCode.ILSpy.Processes.ProcessModuleInfo("B.dll", @"C:\b\B.dll", IsInMemory: false), |
||||
}; |
||||
var dialog = CreateDialog(explorer); |
||||
dialog.Show(); |
||||
var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!; |
||||
await Waiters.WaitForAsync(() => vm.Processes.Count == 1); |
||||
|
||||
vm.SelectedProcess = vm.Processes[0]; |
||||
await Waiters.WaitForAsync(() => vm.Modules.Count == 2); |
||||
|
||||
var grid = dialog.FindControl<DataGrid>("ModulesGrid")!; |
||||
grid.SelectedItems.Add(vm.Modules[0]); |
||||
await Waiters.WaitForAsync(() => vm.SelectedModules.Count == 1); |
||||
|
||||
vm.SelectedModules.Single().Name.Should().Be("A.dll", |
||||
"the grid's selection is what the Add button acts on"); |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Error_Bar_Shows_The_ViewModels_Error_Message() |
||||
{ |
||||
var dialog = CreateDialog(); |
||||
dialog.Show(); |
||||
var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!; |
||||
|
||||
vm.ErrorMessage = "the diagnostics port is unreachable"; |
||||
await Waiters.WaitForAsync(() => dialog.FindControl<Border>("ErrorBar")!.IsVisible); |
||||
|
||||
dialog.FindControl<Border>("ErrorBar")!.GetVisualDescendants().OfType<TextBlock>() |
||||
.Should().Contain(t => t.Text == "the diagnostics port is unreachable"); |
||||
} |
||||
} |
||||
@ -0,0 +1,256 @@
@@ -0,0 +1,256 @@
|
||||
// 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.Buffers.Binary; |
||||
using System.IO; |
||||
using System.IO.Pipes; |
||||
using System.Net.Sockets; |
||||
using System.Text; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
/// <summary>
|
||||
/// A CoreCLR runtime's answer to a diagnostics process-info query. Entry assembly and
|
||||
/// CLR version are only served by runtimes that understand ProcessInfo2 (.NET 6+); for
|
||||
/// older runtimes they are null and the rest comes from the ProcessInfo fallback.
|
||||
/// </summary>
|
||||
internal sealed record DotNetProcessInfo( |
||||
long Pid, |
||||
Guid RuntimeCookie, |
||||
string? CommandLine, |
||||
string? OperatingSystemName, |
||||
string? Architecture, |
||||
string? EntryAssemblyName, |
||||
string? ClrVersion); |
||||
|
||||
/// <summary>
|
||||
/// Speaks the diagnostics IPC protocol to a single CoreCLR process, over the transport
|
||||
/// found by <see cref="DiagnosticsPortScanner"/>. Every call opens a fresh connection
|
||||
/// (the protocol is one-command-per-connection) and is bounded by a timeout so a hung
|
||||
/// target cannot stall the caller.
|
||||
/// </summary>
|
||||
internal static class DiagnosticsIpcClient |
||||
{ |
||||
const byte ProcessCommandSet = 0x04; |
||||
const byte ProcessInfoCommandId = 0x00; |
||||
const byte ProcessInfo2CommandId = 0x04; |
||||
|
||||
const byte EventPipeCommandSet = 0x02; |
||||
const byte StopTracingCommandId = 0x01; |
||||
const byte CollectTracing2CommandId = 0x03; |
||||
const byte CollectTracing4CommandId = 0x05; |
||||
|
||||
// Which rundown events the runtime emits when the session stops. The runtime's own
|
||||
// default (0x80020139) additionally asks for the JIT, NGen and IL-to-native-map
|
||||
// rundown, which in a process that has been running for a while outweighs the module
|
||||
// list by orders of magnitude - enough to overrun the session buffer and cost the
|
||||
// loader events this feature exists to read. These bits ask for the loader rundown
|
||||
// and the stop-time ("end") emission of it, and nothing else.
|
||||
const ulong LoaderRundownKeyword = 0x80000108; |
||||
|
||||
const string RuntimeProviderName = "Microsoft-Windows-DotNETRuntime"; |
||||
const ulong LoaderKeyword = 0x8; |
||||
const uint InformationalLevel = 4; |
||||
const uint NetTraceFormat = 1; |
||||
// The session only ever carries loader events plus the rundown, so a small buffer is
|
||||
// ample; oversizing it would make the target runtime reserve memory for nothing.
|
||||
const uint CircularBufferMB = 16; |
||||
|
||||
static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(2); |
||||
// A rundown walks every loaded module and assembly, so it takes measurably longer
|
||||
// than a status query - and longer still on a large process.
|
||||
static readonly TimeSpan RundownTimeout = TimeSpan.FromSeconds(30); |
||||
|
||||
public static async Task<DotNetProcessInfo> GetProcessInfoAsync(int pid, CancellationToken cancellationToken) |
||||
{ |
||||
try |
||||
{ |
||||
return await QueryProcessInfoAsync(pid, ProcessInfo2CommandId, cancellationToken).ConfigureAwait(false); |
||||
} |
||||
catch (Exception ex) when (ex is IOException or EndOfStreamException) |
||||
{ |
||||
// ProcessInfo2 needs .NET 6+; a .NET Core 3.x/5 runtime answers with an
|
||||
// unknown-command error. The original ProcessInfo works from 3.0 on.
|
||||
return await QueryProcessInfoAsync(pid, ProcessInfoCommandId, cancellationToken).ConfigureAwait(false); |
||||
} |
||||
} |
||||
|
||||
static async Task<DotNetProcessInfo> QueryProcessInfoAsync(int pid, byte commandId, CancellationToken cancellationToken) |
||||
{ |
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); |
||||
timeout.CancelAfter(CommandTimeout); |
||||
|
||||
Stream stream = await ConnectAsync(pid, timeout.Token).ConfigureAwait(false); |
||||
await using (stream.ConfigureAwait(false)) |
||||
{ |
||||
byte[] request = DiagnosticsIpcMessage.EncodeRequest(ProcessCommandSet, commandId, ReadOnlySpan<byte>.Empty); |
||||
await stream.WriteAsync(request, timeout.Token).ConfigureAwait(false); |
||||
byte[] payload = await DiagnosticsIpcMessage.ReadResponseAsync(stream, timeout.Token).ConfigureAwait(false); |
||||
|
||||
using var reader = new BinaryReader(new MemoryStream(payload)); |
||||
long reportedPid = reader.ReadInt64(); |
||||
var runtimeCookie = new Guid(reader.ReadBytes(16)); |
||||
string? commandLine = DiagnosticsIpcMessage.ReadString(reader); |
||||
string? operatingSystem = DiagnosticsIpcMessage.ReadString(reader); |
||||
string? architecture = DiagnosticsIpcMessage.ReadString(reader); |
||||
string? entryAssembly = null; |
||||
string? clrVersion = null; |
||||
if (commandId == ProcessInfo2CommandId) |
||||
{ |
||||
entryAssembly = DiagnosticsIpcMessage.ReadString(reader); |
||||
clrVersion = DiagnosticsIpcMessage.ReadString(reader); |
||||
} |
||||
return new DotNetProcessInfo(reportedPid, runtimeCookie, commandLine, |
||||
operatingSystem, architecture, entryAssembly, clrVersion); |
||||
} |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Runs a minimal EventPipe session against <paramref name="pid"/> purely to obtain
|
||||
/// its rundown: the runtime emits one event per loaded module and assembly when the
|
||||
/// session stops. The returned stream is the raw nettrace container, positioned at 0.
|
||||
/// </summary>
|
||||
public static async Task<MemoryStream> CollectModuleRundownAsync(int pid, CancellationToken cancellationToken) |
||||
{ |
||||
try |
||||
{ |
||||
return await CollectAsync(pid, CollectTracing4CommandId, cancellationToken).ConfigureAwait(false); |
||||
} |
||||
catch (Exception ex) when (ex is IOException or EndOfStreamException) |
||||
{ |
||||
// Selecting the rundown events by keyword needs a runtime that knows
|
||||
// CollectTracing4; an older one answers with an unknown-command error and
|
||||
// only offers the all-or-nothing rundown.
|
||||
return await CollectAsync(pid, CollectTracing2CommandId, cancellationToken).ConfigureAwait(false); |
||||
} |
||||
} |
||||
|
||||
static async Task<MemoryStream> CollectAsync(int pid, byte commandId, CancellationToken cancellationToken) |
||||
{ |
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); |
||||
timeout.CancelAfter(RundownTimeout); |
||||
|
||||
Stream session = await ConnectAsync(pid, timeout.Token).ConfigureAwait(false); |
||||
await using (session.ConfigureAwait(false)) |
||||
{ |
||||
byte[] request = DiagnosticsIpcMessage.EncodeRequest( |
||||
EventPipeCommandSet, commandId, BuildCollectTracingPayload(commandId)); |
||||
await session.WriteAsync(request, timeout.Token).ConfigureAwait(false); |
||||
|
||||
byte[] response = await DiagnosticsIpcMessage.ReadResponseAsync(session, timeout.Token).ConfigureAwait(false); |
||||
if (response.Length < sizeof(ulong)) |
||||
throw new IOException("The runtime did not return an EventPipe session id."); |
||||
ulong sessionId = BinaryPrimitives.ReadUInt64LittleEndian(response); |
||||
|
||||
// The nettrace stream must be drained while the session is stopped: the
|
||||
// runtime writes the rundown into the same connection, and a full buffer
|
||||
// would block the stop from completing.
|
||||
var trace = new MemoryStream(); |
||||
Task drain = session.CopyToAsync(trace, timeout.Token); |
||||
try |
||||
{ |
||||
await StopTracingAsync(pid, sessionId, timeout.Token).ConfigureAwait(false); |
||||
await drain.ConfigureAwait(false); |
||||
} |
||||
catch |
||||
{ |
||||
await trace.DisposeAsync().ConfigureAwait(false); |
||||
throw; |
||||
} |
||||
trace.Position = 0; |
||||
return trace; |
||||
} |
||||
} |
||||
|
||||
static async Task StopTracingAsync(int pid, ulong sessionId, CancellationToken cancellationToken) |
||||
{ |
||||
Stream stream = await ConnectAsync(pid, cancellationToken).ConfigureAwait(false); |
||||
await using (stream.ConfigureAwait(false)) |
||||
{ |
||||
var payload = new byte[sizeof(ulong)]; |
||||
BinaryPrimitives.WriteUInt64LittleEndian(payload, sessionId); |
||||
byte[] request = DiagnosticsIpcMessage.EncodeRequest(EventPipeCommandSet, StopTracingCommandId, payload); |
||||
await stream.WriteAsync(request, cancellationToken).ConfigureAwait(false); |
||||
await DiagnosticsIpcMessage.ReadResponseAsync(stream, cancellationToken).ConfigureAwait(false); |
||||
} |
||||
} |
||||
|
||||
static byte[] BuildCollectTracingPayload(byte commandId) |
||||
{ |
||||
using var buffer = new MemoryStream(); |
||||
using (var writer = new BinaryWriter(buffer, Encoding.Unicode, leaveOpen: true)) |
||||
{ |
||||
writer.Write(CircularBufferMB); |
||||
writer.Write(NetTraceFormat); |
||||
// Rundown is what makes this a snapshot of everything already loaded rather
|
||||
// than a recording of what loads from now on.
|
||||
if (commandId == CollectTracing4CommandId) |
||||
{ |
||||
writer.Write(LoaderRundownKeyword); |
||||
writer.Write(false); // no stack walks: the call stacks are pure overhead here
|
||||
} |
||||
else |
||||
{ |
||||
writer.Write(true); |
||||
} |
||||
writer.Write(1u); // one provider follows
|
||||
writer.Write(LoaderKeyword); |
||||
writer.Write(InformationalLevel); |
||||
DiagnosticsIpcMessage.WriteString(writer, RuntimeProviderName); |
||||
DiagnosticsIpcMessage.WriteString(writer, null); // no filter data
|
||||
} |
||||
return buffer.ToArray(); |
||||
} |
||||
|
||||
internal static async Task<Stream> ConnectAsync(int pid, CancellationToken cancellationToken) |
||||
{ |
||||
if (OperatingSystem.IsWindows()) |
||||
{ |
||||
var pipe = new NamedPipeClientStream(".", "dotnet-diagnostic-" + pid, |
||||
PipeDirection.InOut, PipeOptions.Asynchronous); |
||||
try |
||||
{ |
||||
await pipe.ConnectAsync(cancellationToken).ConfigureAwait(false); |
||||
return pipe; |
||||
} |
||||
catch |
||||
{ |
||||
await pipe.DisposeAsync().ConfigureAwait(false); |
||||
throw; |
||||
} |
||||
} |
||||
|
||||
string socketPath = DiagnosticsPortScanner.GetUnixSocketPath(pid) |
||||
?? throw new IOException($"Process {pid} exposes no diagnostics socket."); |
||||
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); |
||||
try |
||||
{ |
||||
await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken).ConfigureAwait(false); |
||||
return new NetworkStream(socket, ownsSocket: true); |
||||
} |
||||
catch |
||||
{ |
||||
socket.Dispose(); |
||||
throw; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
|
||||
// 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.Buffers.Binary; |
||||
using System.IO; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
/// <summary>
|
||||
/// Wire format of the CoreCLR diagnostics IPC protocol (dotnet/diagnostics,
|
||||
/// documentation/design-docs/ipc-protocol.md): a 20-byte header - 14-byte null-terminated
|
||||
/// ASCII magic "DOTNET_IPC_V1", u16 total message size, u8 command set, u8 command id,
|
||||
/// u16 reserved - followed by a command-specific payload. Strings are UTF-16LE with a u32
|
||||
/// char-count prefix that includes the null terminator. All integers are little endian.
|
||||
/// </summary>
|
||||
internal static class DiagnosticsIpcMessage |
||||
{ |
||||
public const int HeaderSize = 20; |
||||
|
||||
const byte ServerCommandSet = 0xFF; |
||||
const byte ServerOkCommandId = 0x00; |
||||
|
||||
// A string longer than this is not a plausible command line or version string; treat
|
||||
// it as a corrupt response rather than allocating unbounded memory.
|
||||
const int MaxStringLength = 1024 * 1024; |
||||
|
||||
static ReadOnlySpan<byte> Magic => "DOTNET_IPC_V1\0"u8; |
||||
|
||||
public static byte[] EncodeRequest(byte commandSet, byte commandId, ReadOnlySpan<byte> payload) |
||||
{ |
||||
var message = new byte[HeaderSize + payload.Length]; |
||||
Magic.CopyTo(message); |
||||
BinaryPrimitives.WriteUInt16LittleEndian(message.AsSpan(14), checked((ushort)(HeaderSize + payload.Length))); |
||||
message[16] = commandSet; |
||||
message[17] = commandId; |
||||
// Bytes 18-19 are the reserved field and stay zero.
|
||||
payload.CopyTo(message.AsSpan(HeaderSize)); |
||||
return message; |
||||
} |
||||
|
||||
public static void WriteString(BinaryWriter writer, string? value) |
||||
{ |
||||
if (value == null) |
||||
{ |
||||
writer.Write(0u); |
||||
return; |
||||
} |
||||
writer.Write((uint)(value.Length + 1)); |
||||
foreach (char c in value) |
||||
writer.Write((ushort)c); |
||||
writer.Write((ushort)0); |
||||
} |
||||
|
||||
public static string? ReadString(BinaryReader reader) |
||||
{ |
||||
uint length = reader.ReadUInt32(); |
||||
if (length == 0) |
||||
return null; |
||||
if (length > MaxStringLength) |
||||
throw new IOException($"Diagnostics IPC string length {length} exceeds the sanity limit."); |
||||
var chars = new char[length]; |
||||
for (int i = 0; i < chars.Length; i++) |
||||
chars[i] = (char)reader.ReadUInt16(); |
||||
int end = chars.Length; |
||||
if (end > 0 && chars[end - 1] == '\0') |
||||
end--; |
||||
return new string(chars, 0, end); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Reads one response message and returns its payload. A success response's payload is
|
||||
/// command-specific; an error response carries an HRESULT and is surfaced as an
|
||||
/// <see cref="IOException"/>. For streaming commands (EventPipe), any data following
|
||||
/// the sized message remains in the stream for the caller.
|
||||
/// </summary>
|
||||
public static async Task<byte[]> ReadResponseAsync(Stream stream, CancellationToken cancellationToken) |
||||
{ |
||||
var header = new byte[HeaderSize]; |
||||
await stream.ReadExactlyAsync(header, cancellationToken).ConfigureAwait(false); |
||||
if (!header.AsSpan(0, Magic.Length).SequenceEqual(Magic)) |
||||
throw new IOException("Diagnostics IPC response does not start with the DOTNET_IPC_V1 magic."); |
||||
|
||||
ushort size = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(14)); |
||||
if (size < HeaderSize) |
||||
throw new IOException($"Diagnostics IPC response declares an impossible size of {size} bytes."); |
||||
var payload = new byte[size - HeaderSize]; |
||||
await stream.ReadExactlyAsync(payload, cancellationToken).ConfigureAwait(false); |
||||
|
||||
if (header[16] != ServerCommandSet || header[17] != ServerOkCommandId) |
||||
{ |
||||
int hresult = payload.Length >= 4 ? BinaryPrimitives.ReadInt32LittleEndian(payload) : 0; |
||||
throw new IOException($"The target runtime rejected the command (error 0x{hresult:X8})."); |
||||
} |
||||
return payload; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
|
||||
// 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; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
static partial class DiagnosticsPortScanner |
||||
{ |
||||
// The runtime places its socket in TMPDIR (else /tmp), which is exactly what
|
||||
// Path.GetTempPath() resolves on unix. Socket files of exited processes linger, so a
|
||||
// name match alone is not proof of a live process - callers see those filtered out.
|
||||
static void ScanUnixSockets(HashSet<int> pids) |
||||
{ |
||||
foreach (string path in Directory.EnumerateFiles(Path.GetTempPath(), TransportPrefix + "*")) |
||||
{ |
||||
if (TryParseSocketPid(Path.GetFileName(path)) is int pid && IsProcessAlive(pid)) |
||||
pids.Add(pid); |
||||
} |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Returns the socket file to connect to for <paramref name="pid"/>, or null if the
|
||||
/// process exposes none. If a stale file of a previous process with the same pid
|
||||
/// coexists with the live one, the newest file is the live runtime's.
|
||||
/// </summary>
|
||||
public static string? GetUnixSocketPath(int pid) |
||||
{ |
||||
try |
||||
{ |
||||
string? best = null; |
||||
DateTime bestTime = DateTime.MinValue; |
||||
foreach (string path in Directory.EnumerateFiles(Path.GetTempPath(), TransportPrefix + pid + "-*")) |
||||
{ |
||||
if (TryParseSocketPid(Path.GetFileName(path)) != pid) |
||||
continue; |
||||
DateTime writeTime = File.GetLastWriteTimeUtc(path); |
||||
if (best == null || writeTime > bestTime) |
||||
{ |
||||
best = path; |
||||
bestTime = writeTime; |
||||
} |
||||
} |
||||
return best; |
||||
} |
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) |
||||
{ |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
// Socket files are named "dotnet-diagnostic-{pid}-{disambiguation}-socket".
|
||||
static int? TryParseSocketPid(string fileName) |
||||
{ |
||||
if (!fileName.StartsWith(TransportPrefix, StringComparison.Ordinal) || !fileName.EndsWith("-socket", StringComparison.Ordinal)) |
||||
return null; |
||||
ReadOnlySpan<char> rest = fileName.AsSpan(TransportPrefix.Length); |
||||
int dash = rest.IndexOf('-'); |
||||
if (dash <= 0 || !int.TryParse(rest[..dash], out int pid)) |
||||
return null; |
||||
return pid; |
||||
} |
||||
|
||||
static bool IsProcessAlive(int pid) |
||||
{ |
||||
try |
||||
{ |
||||
using var process = System.Diagnostics.Process.GetProcessById(pid); |
||||
return !process.HasExited; |
||||
} |
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) |
||||
{ |
||||
return false; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
// 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; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
static partial class DiagnosticsPortScanner |
||||
{ |
||||
// The named-pipe filesystem can be listed like a directory; getting all names and
|
||||
// filtering beats a search pattern there (this mirrors the official diagnostics
|
||||
// client). The pipe name is exactly "dotnet-diagnostic-{pid}", no suffix.
|
||||
static void ScanWindowsPipes(HashSet<int> pids) |
||||
{ |
||||
foreach (string path in Directory.GetFiles(@"\\.\pipe\")) |
||||
{ |
||||
string name = Path.GetFileName(path); |
||||
if (name.StartsWith(TransportPrefix, StringComparison.Ordinal) |
||||
&& int.TryParse(name.AsSpan(TransportPrefix.Length), out int pid)) |
||||
{ |
||||
pids.Add(pid); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
|
||||
// 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; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
/// <summary>
|
||||
/// Finds the processes that expose a CoreCLR diagnostics IPC endpoint - the same
|
||||
/// discovery <c>dotnet-trace ps</c> performs. The endpoint's mere existence identifies a
|
||||
/// process as .NET (Core 3.0+): a named pipe <c>dotnet-diagnostic-{pid}</c> on Windows, a
|
||||
/// unix domain socket <c>dotnet-diagnostic-{pid}-*-socket</c> in the temp directory on
|
||||
/// Linux/macOS. Only same-user processes are visible, and processes started with
|
||||
/// <c>DOTNET_EnableDiagnostics=0</c> expose no endpoint at all - both limits are inherent
|
||||
/// to the mechanism. The per-OS scans live in the platform partials of this class.
|
||||
/// </summary>
|
||||
static partial class DiagnosticsPortScanner |
||||
{ |
||||
const string TransportPrefix = "dotnet-diagnostic-"; |
||||
|
||||
public static IReadOnlyList<int> GetProcessIds() |
||||
{ |
||||
var pids = new HashSet<int>(); |
||||
try |
||||
{ |
||||
if (OperatingSystem.IsWindows()) |
||||
ScanWindowsPipes(pids); |
||||
else |
||||
ScanUnixSockets(pids); |
||||
} |
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) |
||||
{ |
||||
// A transient error enumerating the transport directory yields an empty (or
|
||||
// partial) list rather than a failed refresh.
|
||||
} |
||||
return pids.OrderBy(pid => pid).ToList(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
// 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.Collections.Generic; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
/// <summary>
|
||||
/// Enumerates running .NET processes and the managed assemblies loaded in them.
|
||||
/// Abstracted so the "Open from Running Process" dialog's view model can be driven by a
|
||||
/// fake in headless tests, without live processes or IPC.
|
||||
/// </summary>
|
||||
public interface IProcessExplorer |
||||
{ |
||||
/// <summary>
|
||||
/// Lists the running .NET processes visible to the current user. Unreachable
|
||||
/// processes (exited mid-scan, access denied, hung) are silently skipped.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RunningDotNetProcess>> GetProcessesAsync(CancellationToken cancellationToken); |
||||
|
||||
/// <summary>
|
||||
/// Lists the managed assemblies currently loaded in <paramref name="process"/>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProcessModuleInfo>> GetModulesAsync(RunningDotNetProcess process, CancellationToken cancellationToken); |
||||
} |
||||
} |
||||
@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
|
||||
// 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.Diagnostics; |
||||
using System.IO; |
||||
using System.Linq; |
||||
using System.Runtime.Versioning; |
||||
using System.Threading; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
/// <summary>
|
||||
/// Finds and inspects .NET Framework processes, which exist on Windows only and predate
|
||||
/// the diagnostics endpoint every CoreCLR process exposes. Both answers come from the OS
|
||||
/// module list: the desktop CLR is present in it as clr.dll (or mscorwks.dll before .NET
|
||||
/// 4), and - unlike CoreCLR - the desktop loader registers the assemblies it loads from
|
||||
/// disk there too, so filtering that list to files carrying a CLI header yields the
|
||||
/// managed set. Assemblies loaded from a byte array have no file anywhere and are
|
||||
/// therefore invisible on this path.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")] |
||||
static class NetFrameworkProcesses |
||||
{ |
||||
static readonly string[] DesktopClrModules = { "clr.dll", "mscorwks.dll", "mscorsvr.dll" }; |
||||
|
||||
public static IEnumerable<RunningDotNetProcess> Enumerate(ISet<int> alreadyListed, CancellationToken cancellationToken) |
||||
{ |
||||
foreach (var process in Process.GetProcesses()) |
||||
{ |
||||
cancellationToken.ThrowIfCancellationRequested(); |
||||
using (process) |
||||
{ |
||||
if (alreadyListed.Contains(process.Id)) |
||||
continue; |
||||
var described = TryDescribe(process); |
||||
if (described != null) |
||||
yield return described; |
||||
} |
||||
} |
||||
} |
||||
|
||||
static RunningDotNetProcess? TryDescribe(Process process) |
||||
{ |
||||
try |
||||
{ |
||||
var clr = process.Modules.Cast<ProcessModule>() |
||||
.FirstOrDefault(m => DesktopClrModules.Contains(m.ModuleName, StringComparer.OrdinalIgnoreCase)); |
||||
if (clr == null) |
||||
return null; |
||||
|
||||
string? mainModule = TryGetMainModuleFileName(process); |
||||
return new RunningDotNetProcess(process.Id, process.ProcessName, RuntimeKind.NetFramework, |
||||
RuntimeVersion: clr.FileVersionInfo.FileVersion, |
||||
// The desktop CLR lives under Framework64 in a 64-bit process and under
|
||||
// Framework in a 32-bit one, which settles the architecture without
|
||||
// having to open the process for a bitness query.
|
||||
Architecture: clr.FileName.Contains(@"\Framework64\", StringComparison.OrdinalIgnoreCase) ? "x64" : "x86", |
||||
CommandLine: mainModule, |
||||
// A .NET Framework executable is itself managed - there is no separate
|
||||
// native host to see through.
|
||||
EntryAssemblyName: mainModule == null ? null : Path.GetFileNameWithoutExtension(mainModule)); |
||||
} |
||||
catch (Exception ex) when (IsProcessAccessFailure(ex)) |
||||
{ |
||||
// Processes of other users, elevated processes, and processes that exit
|
||||
// mid-scan are simply not listed.
|
||||
return null; |
||||
} |
||||
} |
||||
|
||||
public static IReadOnlyList<ProcessModuleInfo> GetModules(int pid) |
||||
{ |
||||
try |
||||
{ |
||||
using var process = Process.GetProcessById(pid); |
||||
return process.Modules.Cast<ProcessModule>() |
||||
.Select(m => m.FileName) |
||||
.Where(f => !string.IsNullOrEmpty(f) && ProcessExplorer.IsManagedAssembly(f)) |
||||
.Distinct(StringComparer.OrdinalIgnoreCase) |
||||
.Select(f => new ProcessModuleInfo(Path.GetFileName(f), f, IsInMemory: false)) |
||||
.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase) |
||||
.ToList(); |
||||
} |
||||
catch (Exception ex) when (IsProcessAccessFailure(ex)) |
||||
{ |
||||
return Array.Empty<ProcessModuleInfo>(); |
||||
} |
||||
} |
||||
|
||||
static string? TryGetMainModuleFileName(Process process) |
||||
{ |
||||
try |
||||
{ |
||||
return process.MainModule?.FileName; |
||||
} |
||||
catch (Exception ex) when (IsProcessAccessFailure(ex)) |
||||
{ |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
static bool IsProcessAccessFailure(Exception ex) |
||||
=> ex is System.ComponentModel.Win32Exception or InvalidOperationException |
||||
or NotSupportedException or ArgumentException; |
||||
} |
||||
} |
||||
@ -0,0 +1,435 @@
@@ -0,0 +1,435 @@
|
||||
// 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.Text; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
/// <summary>
|
||||
/// Extracts the loaded-module list from the nettrace stream of an EventPipe rundown
|
||||
/// session (container format: microsoft/perfview, src/TraceEvent/EventPipe).
|
||||
/// Deliberately not a general nettrace library: it walks the FastSerialization object
|
||||
/// stream far enough to find loader events, decodes those, and skips everything else -
|
||||
/// stacks, sequence points, and every other provider's payloads are stepped over by
|
||||
/// size without being interpreted.
|
||||
/// </summary>
|
||||
internal static class NettraceRundownReader |
||||
{ |
||||
// FastSerialization tags.
|
||||
const byte TagNullReference = 1; |
||||
const byte TagBeginObject = 4; |
||||
const byte TagBeginPrivateObject = 5; |
||||
const byte TagEndObject = 6; |
||||
|
||||
// The container version the runtime serves for CollectTracing2 with format=NetTrace.
|
||||
// V5 only adds tags a V4 reader may ignore; anything beyond that is a format the
|
||||
// event and block layouts below are not written for.
|
||||
const int MaxSupportedTraceVersion = 5; |
||||
|
||||
const int CompressedFlagMetadataId = 0x01; |
||||
const int CompressedFlagCaptureThreadAndSequence = 0x02; |
||||
const int CompressedFlagThreadId = 0x04; |
||||
const int CompressedFlagStackId = 0x08; |
||||
const int CompressedFlagActivityId = 0x10; |
||||
const int CompressedFlagRelatedActivityId = 0x20; |
||||
const int CompressedFlagDataLength = 0x80; |
||||
|
||||
// The CLR's own providers are manifest-based, so their events arrive with an empty
|
||||
// name in the stream and must be recognized by provider plus numeric id. The ids
|
||||
// overlap between the two providers and mean different things in each, so both parts
|
||||
// of the pair matter. Rundown emits the "DC" (data collection) variants at session
|
||||
// stop; the runtime provider reports what loads while the session is open.
|
||||
const string RundownProviderName = "Microsoft-Windows-DotNETRuntimeRundown"; |
||||
const string RuntimeProviderName = "Microsoft-Windows-DotNETRuntime"; |
||||
|
||||
const int RundownModuleDCStart = 153; |
||||
const int RundownModuleDCStop = 154; |
||||
const int RundownAssemblyDCStart = 155; |
||||
const int RundownAssemblyDCStop = 156; |
||||
const int RuntimeModuleLoad = 152; |
||||
const int RuntimeAssemblyLoad = 154; |
||||
|
||||
sealed record EventMetadata(string ProviderName, int EventId, int Version); |
||||
|
||||
/// <summary>
|
||||
/// A module as reported by the runtime, before assembly names are resolved.
|
||||
/// </summary>
|
||||
sealed record ModuleRecord(long AssemblyId, string? IlPath, string? NativePath); |
||||
|
||||
public static IReadOnlyList<ProcessModuleInfo> ReadModules(Stream stream) |
||||
{ |
||||
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); |
||||
ReadStreamHeader(reader); |
||||
|
||||
var metadata = new Dictionary<int, EventMetadata>(); |
||||
var modules = new List<ModuleRecord>(); |
||||
var assemblyNames = new Dictionary<long, string>(); |
||||
|
||||
while (true) |
||||
{ |
||||
byte tag = reader.ReadByte(); |
||||
if (tag == TagNullReference) |
||||
break; // End of the object stream.
|
||||
if (tag is not (TagBeginObject or TagBeginPrivateObject)) |
||||
throw new InvalidDataException($"Unexpected FastSerialization tag 0x{tag:X2} in the Nettrace stream."); |
||||
|
||||
string typeName = ReadTypeName(reader, out int version); |
||||
switch (typeName) |
||||
{ |
||||
case "Trace": |
||||
if (version > MaxSupportedTraceVersion) |
||||
throw new InvalidDataException( |
||||
$"Nettrace version {version} is newer than this reader supports."); |
||||
SkipTraceObject(reader); |
||||
break; |
||||
case "MetadataBlock": |
||||
ReadBlock(reader, (payload, id, _) => ReadMetadataEvent(payload, id, metadata)); |
||||
break; |
||||
case "EventBlock": |
||||
ReadBlock(reader, (payload, id, _) => ReadEvent(payload, id, metadata, modules, assemblyNames)); |
||||
break; |
||||
default: |
||||
// StackBlock, SPBlock and any block type added later: the block is
|
||||
// self-describing in length, so it can be stepped over wholesale.
|
||||
SkipBlock(reader); |
||||
break; |
||||
} |
||||
ExpectTag(reader, TagEndObject); |
||||
} |
||||
|
||||
return BuildModuleList(modules, assemblyNames); |
||||
} |
||||
|
||||
static void ReadStreamHeader(BinaryReader reader) |
||||
{ |
||||
byte[] magic; |
||||
try |
||||
{ |
||||
magic = reader.ReadBytes(8); |
||||
} |
||||
catch (EndOfStreamException) |
||||
{ |
||||
magic = Array.Empty<byte>(); |
||||
} |
||||
if (magic.Length < 8 || Encoding.ASCII.GetString(magic) != "Nettrace") |
||||
throw new InvalidDataException("The stream does not start with the Nettrace magic."); |
||||
|
||||
string serializer = ReadLengthPrefixedAsciiString(reader); |
||||
if (!serializer.StartsWith("!FastSerialization", StringComparison.Ordinal)) |
||||
throw new InvalidDataException($"Unexpected Nettrace serializer '{serializer}'."); |
||||
} |
||||
|
||||
static string ReadTypeName(BinaryReader reader, out int version) |
||||
{ |
||||
// The type of an object is itself an object: begin tag, a null type-of-type,
|
||||
// the version pair, the name, and the closing tag.
|
||||
ExpectTag(reader, TagBeginObject, TagBeginPrivateObject); |
||||
ExpectTag(reader, TagNullReference); |
||||
version = reader.ReadInt32(); |
||||
reader.ReadInt32(); // minimum reader version
|
||||
string name = ReadLengthPrefixedAsciiString(reader); |
||||
ExpectTag(reader, TagEndObject); |
||||
return name; |
||||
} |
||||
|
||||
static string ReadLengthPrefixedAsciiString(BinaryReader reader) |
||||
{ |
||||
int length = reader.ReadInt32(); |
||||
if (length is < 0 or > 1024) |
||||
throw new InvalidDataException($"Implausible Nettrace string length {length}."); |
||||
return Encoding.ASCII.GetString(reader.ReadBytes(length)); |
||||
} |
||||
|
||||
static void ExpectTag(BinaryReader reader, byte expected) |
||||
{ |
||||
byte tag = reader.ReadByte(); |
||||
if (tag != expected) |
||||
throw new InvalidDataException($"Expected FastSerialization tag 0x{expected:X2}, found 0x{tag:X2}."); |
||||
} |
||||
|
||||
static void ExpectTag(BinaryReader reader, byte expected, byte alternative) |
||||
{ |
||||
byte tag = reader.ReadByte(); |
||||
if (tag != expected && tag != alternative) |
||||
throw new InvalidDataException($"Expected FastSerialization tag 0x{expected:X2}, found 0x{tag:X2}."); |
||||
} |
||||
|
||||
static void SkipTraceObject(BinaryReader reader) |
||||
{ |
||||
// SyncTimeUtc (8 shorts), SyncTimeQpc, QpcFrequency, then four ints describing
|
||||
// the pointer size, process, cpu count and sampling rate.
|
||||
reader.BaseStream.Seek(8 * sizeof(short) + 2 * sizeof(long) + 4 * sizeof(int), SeekOrigin.Current); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Walks the event blobs of a metadata or event block, handing each one's payload to
|
||||
/// <paramref name="onEvent"/> together with its metadata id.
|
||||
/// </summary>
|
||||
static void ReadBlock(BinaryReader reader, Action<BinaryReader, int, int> onEvent) |
||||
{ |
||||
long blockEnd = BeginBlock(reader, out bool compressed); |
||||
|
||||
// The compressed layout omits fields that repeat, so the previous event's values
|
||||
// carry forward. The state is per block, since blocks decode independently.
|
||||
int metadataId = 0; |
||||
int payloadSize = 0; |
||||
|
||||
while (reader.BaseStream.Position < blockEnd) |
||||
{ |
||||
if (compressed) |
||||
ReadCompressedEventHeader(reader, ref metadataId, ref payloadSize); |
||||
else |
||||
ReadUncompressedEventHeader(reader, out metadataId, out payloadSize); |
||||
|
||||
long payloadEnd = reader.BaseStream.Position + payloadSize; |
||||
if (payloadEnd > blockEnd) |
||||
throw new InvalidDataException("A Nettrace event payload runs past the end of its block."); |
||||
onEvent(reader, metadataId, payloadSize); |
||||
reader.BaseStream.Seek(payloadEnd, SeekOrigin.Begin); |
||||
|
||||
if (!compressed) |
||||
AlignTo4(reader); |
||||
} |
||||
reader.BaseStream.Seek(blockEnd, SeekOrigin.Begin); |
||||
} |
||||
|
||||
static void SkipBlock(BinaryReader reader) |
||||
{ |
||||
long blockEnd = BeginBlock(reader, out _); |
||||
reader.BaseStream.Seek(blockEnd, SeekOrigin.Begin); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Reads a block's size and header and returns the stream position where the block
|
||||
/// ends. Blocks are 4-byte aligned relative to the start of the stream.
|
||||
/// </summary>
|
||||
static long BeginBlock(BinaryReader reader, out bool compressed) |
||||
{ |
||||
int blockSize = reader.ReadInt32(); |
||||
if (blockSize < 0) |
||||
throw new InvalidDataException($"Implausible Nettrace block size {blockSize}."); |
||||
AlignTo4(reader); |
||||
long blockEnd = reader.BaseStream.Position + blockSize; |
||||
|
||||
short headerSize = reader.ReadInt16(); |
||||
short flags = reader.ReadInt16(); |
||||
reader.ReadInt64(); // minimum timestamp
|
||||
reader.ReadInt64(); // maximum timestamp
|
||||
const int ReadHeaderBytes = sizeof(short) * 2 + sizeof(long) * 2; |
||||
if (headerSize > ReadHeaderBytes) |
||||
reader.BaseStream.Seek(headerSize - ReadHeaderBytes, SeekOrigin.Current); |
||||
|
||||
compressed = (flags & 1) != 0; |
||||
return blockEnd; |
||||
} |
||||
|
||||
static void AlignTo4(BinaryReader reader) |
||||
{ |
||||
long padding = (4 - (reader.BaseStream.Position % 4)) % 4; |
||||
if (padding != 0) |
||||
reader.BaseStream.Seek(padding, SeekOrigin.Current); |
||||
} |
||||
|
||||
static void ReadCompressedEventHeader(BinaryReader reader, ref int metadataId, ref int payloadSize) |
||||
{ |
||||
byte flags = reader.ReadByte(); |
||||
if ((flags & CompressedFlagMetadataId) != 0) |
||||
metadataId = (int)ReadVarUInt(reader); |
||||
if ((flags & CompressedFlagCaptureThreadAndSequence) != 0) |
||||
{ |
||||
ReadVarUInt(reader); // sequence number delta
|
||||
ReadVarUInt(reader); // capture thread id
|
||||
ReadVarUInt(reader); // capture processor number
|
||||
} |
||||
if ((flags & CompressedFlagThreadId) != 0) |
||||
ReadVarUInt(reader); |
||||
if ((flags & CompressedFlagStackId) != 0) |
||||
ReadVarUInt(reader); |
||||
ReadVarUInt(reader); // timestamp delta
|
||||
if ((flags & CompressedFlagActivityId) != 0) |
||||
reader.BaseStream.Seek(16, SeekOrigin.Current); |
||||
if ((flags & CompressedFlagRelatedActivityId) != 0) |
||||
reader.BaseStream.Seek(16, SeekOrigin.Current); |
||||
if ((flags & CompressedFlagDataLength) != 0) |
||||
payloadSize = (int)ReadVarUInt(reader); |
||||
} |
||||
|
||||
static void ReadUncompressedEventHeader(BinaryReader reader, out int metadataId, out int payloadSize) |
||||
{ |
||||
reader.ReadInt32(); // event size
|
||||
metadataId = reader.ReadInt32(); |
||||
reader.ReadInt32(); // sequence number
|
||||
reader.ReadInt64(); // thread id
|
||||
reader.ReadInt64(); // capture thread id
|
||||
reader.ReadInt32(); // processor number
|
||||
reader.ReadInt32(); // stack id
|
||||
reader.ReadInt64(); // timestamp
|
||||
reader.BaseStream.Seek(32, SeekOrigin.Current); // activity + related activity id
|
||||
payloadSize = reader.ReadInt32(); |
||||
} |
||||
|
||||
static ulong ReadVarUInt(BinaryReader reader) |
||||
{ |
||||
ulong value = 0; |
||||
int shift = 0; |
||||
while (true) |
||||
{ |
||||
byte b = reader.ReadByte(); |
||||
value |= (ulong)(b & 0x7F) << shift; |
||||
if ((b & 0x80) == 0) |
||||
return value; |
||||
shift += 7; |
||||
if (shift > 63) |
||||
throw new InvalidDataException("Malformed variable-length integer in the Nettrace stream."); |
||||
} |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// An event in a metadata block describes an event type: which provider and event
|
||||
/// name a metadata id stands for in the event blocks that follow.
|
||||
/// </summary>
|
||||
static void ReadMetadataEvent(BinaryReader reader, int metadataId, Dictionary<int, EventMetadata> metadata) |
||||
{ |
||||
if (metadataId != 0) |
||||
return; // Only the metadata records themselves are of interest here.
|
||||
int id = reader.ReadInt32(); |
||||
string providerName = ReadUtf16NullTerminated(reader); |
||||
int eventId = reader.ReadInt32(); |
||||
ReadUtf16NullTerminated(reader); // event name, empty for manifest-based providers
|
||||
reader.ReadInt64(); // keywords
|
||||
int version = reader.ReadInt32(); |
||||
metadata[id] = new EventMetadata(providerName, eventId, version); |
||||
} |
||||
|
||||
static void ReadEvent(BinaryReader reader, int metadataId, Dictionary<int, EventMetadata> metadata, |
||||
List<ModuleRecord> modules, Dictionary<long, string> assemblyNames) |
||||
{ |
||||
if (!metadata.TryGetValue(metadataId, out var meta)) |
||||
return; |
||||
|
||||
// ModuleLoad, ModuleDCStart and ModuleDCStop share one payload layout - unlike
|
||||
// the similarly named DomainModule* and ModuleRange* events, which carry
|
||||
// different fields and are deliberately not matched here.
|
||||
if (IsModuleEvent(meta)) |
||||
{ |
||||
reader.ReadInt64(); // module id
|
||||
long assemblyId = reader.ReadInt64(); |
||||
reader.ReadInt32(); // module flags
|
||||
reader.ReadInt32(); // reserved
|
||||
string ilPath = ReadUtf16NullTerminated(reader); |
||||
string nativePath = ReadUtf16NullTerminated(reader); |
||||
modules.Add(new ModuleRecord(assemblyId, ilPath, nativePath)); |
||||
} |
||||
else if (IsAssemblyEvent(meta)) |
||||
{ |
||||
long assemblyId = reader.ReadInt64(); |
||||
reader.ReadInt64(); // app domain id
|
||||
if (meta.Version >= 1) |
||||
reader.ReadInt64(); // binding id
|
||||
reader.ReadInt32(); // assembly flags
|
||||
string fullName = ReadUtf16NullTerminated(reader); |
||||
if (fullName.Length > 0) |
||||
{ |
||||
// "Foo, Version=1.0.0.0, Culture=..." - the simple name is enough to
|
||||
// label a module that has no file on disk.
|
||||
int comma = fullName.IndexOf(','); |
||||
assemblyNames[assemblyId] = comma > 0 ? fullName[..comma] : fullName; |
||||
} |
||||
} |
||||
} |
||||
|
||||
static bool IsModuleEvent(EventMetadata meta) => meta.ProviderName switch { |
||||
RundownProviderName => meta.EventId is RundownModuleDCStart or RundownModuleDCStop, |
||||
RuntimeProviderName => meta.EventId == RuntimeModuleLoad, |
||||
_ => false, |
||||
}; |
||||
|
||||
static bool IsAssemblyEvent(EventMetadata meta) => meta.ProviderName switch { |
||||
RundownProviderName => meta.EventId is RundownAssemblyDCStart or RundownAssemblyDCStop, |
||||
RuntimeProviderName => meta.EventId == RuntimeAssemblyLoad, |
||||
_ => false, |
||||
}; |
||||
|
||||
static string ReadUtf16NullTerminated(BinaryReader reader) |
||||
{ |
||||
var builder = new StringBuilder(); |
||||
while (true) |
||||
{ |
||||
ushort c = reader.ReadUInt16(); |
||||
if (c == 0) |
||||
return builder.ToString(); |
||||
builder.Append((char)c); |
||||
} |
||||
} |
||||
|
||||
static IReadOnlyList<ProcessModuleInfo> BuildModuleList( |
||||
List<ModuleRecord> modules, Dictionary<long, string> assemblyNames) |
||||
{ |
||||
var result = new List<ProcessModuleInfo>(); |
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
||||
|
||||
foreach (var module in modules) |
||||
{ |
||||
string? path = FirstExistingPath(module.IlPath, module.NativePath); |
||||
if (path != null) |
||||
{ |
||||
if (seen.Add(path)) |
||||
result.Add(new ProcessModuleInfo(Path.GetFileName(path), path, IsInMemory: false)); |
||||
continue; |
||||
} |
||||
|
||||
// No file behind it: a byte-array load, a dynamic assembly, or a module
|
||||
// whose file the current user cannot see. It can be listed, not opened.
|
||||
assemblyNames.TryGetValue(module.AssemblyId, out string? assemblyName); |
||||
string name = assemblyName |
||||
?? NonEmpty(module.IlPath) |
||||
?? NonEmpty(module.NativePath) |
||||
?? "(dynamic module)"; |
||||
if (seen.Add("\0" + name)) |
||||
result.Add(new ProcessModuleInfo(name, null, IsInMemory: true)); |
||||
} |
||||
|
||||
return result.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList(); |
||||
} |
||||
|
||||
static string? FirstExistingPath(params string?[] candidates) |
||||
{ |
||||
foreach (string? candidate in candidates) |
||||
{ |
||||
if (string.IsNullOrEmpty(candidate)) |
||||
continue; |
||||
try |
||||
{ |
||||
if (File.Exists(candidate)) |
||||
return Path.GetFullPath(candidate); |
||||
} |
||||
catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException) |
||||
{ |
||||
// A path the runtime reports but this process cannot probe.
|
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; |
||||
} |
||||
} |
||||
@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
|
||||
// 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.Diagnostics; |
||||
using System.IO; |
||||
using System.Linq; |
||||
using System.Reflection.PortableExecutable; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
/// <summary>
|
||||
/// Lists running .NET processes and their loaded assemblies. CoreCLR processes are found
|
||||
/// and inspected through the runtime's own diagnostics endpoint, which works identically
|
||||
/// on Windows, Linux and macOS and sees assemblies no OS-level module list can report.
|
||||
/// On Windows the list additionally covers .NET Framework processes, which predate that
|
||||
/// endpoint and are inspected through their native module list instead.
|
||||
/// </summary>
|
||||
public sealed class ProcessExplorer : IProcessExplorer |
||||
{ |
||||
public Task<IReadOnlyList<RunningDotNetProcess>> GetProcessesAsync(CancellationToken cancellationToken) |
||||
=> Task.Run(() => EnumerateProcesses(cancellationToken), cancellationToken); |
||||
|
||||
public Task<IReadOnlyList<ProcessModuleInfo>> GetModulesAsync( |
||||
RunningDotNetProcess process, CancellationToken cancellationToken) |
||||
=> Task.Run(() => EnumerateModules(process, cancellationToken), cancellationToken); |
||||
|
||||
static async Task<IReadOnlyList<RunningDotNetProcess>> EnumerateProcesses(CancellationToken cancellationToken) |
||||
{ |
||||
var pids = DiagnosticsPortScanner.GetProcessIds(); |
||||
// The runtimes are queried concurrently: one unresponsive process would
|
||||
// otherwise hold up the whole listing for its share of the timeout.
|
||||
var queries = pids.Select(pid => DescribeCoreClrProcessAsync(pid, cancellationToken)); |
||||
var processes = (await Task.WhenAll(queries).ConfigureAwait(false)) |
||||
.OfType<RunningDotNetProcess>() |
||||
.ToList(); |
||||
|
||||
if (OperatingSystem.IsWindows()) |
||||
{ |
||||
var known = processes.Select(p => p.Pid).ToHashSet(); |
||||
processes.AddRange(NetFrameworkProcesses.Enumerate(known, cancellationToken)); |
||||
} |
||||
|
||||
return processes |
||||
.OrderBy(p => p.ProcessName, StringComparer.CurrentCultureIgnoreCase) |
||||
.ThenBy(p => p.Pid) |
||||
.ToList(); |
||||
} |
||||
|
||||
static async Task<RunningDotNetProcess?> DescribeCoreClrProcessAsync(int pid, CancellationToken cancellationToken) |
||||
{ |
||||
string? processName = TryGetProcessName(pid); |
||||
if (processName == null) |
||||
return null; // Exited between the port scan and now.
|
||||
try |
||||
{ |
||||
var info = await DiagnosticsIpcClient.GetProcessInfoAsync(pid, cancellationToken).ConfigureAwait(false); |
||||
return new RunningDotNetProcess(pid, processName, RuntimeKind.CoreClr, |
||||
info.ClrVersion, info.Architecture, info.CommandLine, info.EntryAssemblyName); |
||||
} |
||||
catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) |
||||
{ |
||||
// The endpoint exists but did not answer - a suspended runtime, a stale
|
||||
// transport, or a process shutting down. Still worth listing.
|
||||
return new RunningDotNetProcess(pid, processName, RuntimeKind.CoreClr, |
||||
RuntimeVersion: null, Architecture: null, CommandLine: null, EntryAssemblyName: null); |
||||
} |
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) |
||||
{ |
||||
// The per-command timeout fired rather than the caller cancelling.
|
||||
return new RunningDotNetProcess(pid, processName, RuntimeKind.CoreClr, |
||||
RuntimeVersion: null, Architecture: null, CommandLine: null, EntryAssemblyName: null); |
||||
} |
||||
} |
||||
|
||||
static async Task<IReadOnlyList<ProcessModuleInfo>> EnumerateModules( |
||||
RunningDotNetProcess process, CancellationToken cancellationToken) |
||||
{ |
||||
if (process.Kind == RuntimeKind.NetFramework) |
||||
{ |
||||
if (!OperatingSystem.IsWindows()) |
||||
return Array.Empty<ProcessModuleInfo>(); |
||||
return NetFrameworkProcesses.GetModules(process.Pid); |
||||
} |
||||
|
||||
using var rundown = await DiagnosticsIpcClient |
||||
.CollectModuleRundownAsync(process.Pid, cancellationToken).ConfigureAwait(false); |
||||
return NettraceRundownReader.ReadModules(rundown); |
||||
} |
||||
|
||||
static string? TryGetProcessName(int pid) |
||||
{ |
||||
try |
||||
{ |
||||
using var process = Process.GetProcessById(pid); |
||||
return process.ProcessName; |
||||
} |
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) |
||||
{ |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Whether the file at <paramref name="path"/> is a managed assembly, i.e. a PE image
|
||||
/// carrying a CLI header. Used to keep native libraries out of the module list of a
|
||||
/// .NET Framework process, whose OS module list mixes both.
|
||||
/// </summary>
|
||||
internal static bool IsManagedAssembly(string path) |
||||
{ |
||||
try |
||||
{ |
||||
using var stream = File.OpenRead(path); |
||||
using var peReader = new PEReader(stream); |
||||
return peReader.HasMetadata; |
||||
} |
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or BadImageFormatException or ArgumentException) |
||||
{ |
||||
return false; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
|
||||
// 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.Text; |
||||
|
||||
namespace ICSharpCode.ILSpy.Processes |
||||
{ |
||||
/// <summary>
|
||||
/// The runtime flavor hosted by a running process. CoreCLR processes are discovered and
|
||||
/// inspected through the runtime's diagnostics IPC endpoint on every OS; .NET Framework
|
||||
/// processes exist on Windows only and are inspected through OS-level module scanning.
|
||||
/// </summary>
|
||||
public enum RuntimeKind |
||||
{ |
||||
CoreClr, |
||||
NetFramework, |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// A running process that hosts a .NET runtime. Deliberately free of
|
||||
/// <c>System.Diagnostics</c> types so view models and tests never touch live processes.
|
||||
/// All fields except pid, name and kind are best-effort: they come from the runtime's
|
||||
/// answer to a process-info query and may be missing on old runtimes.
|
||||
/// </summary>
|
||||
public sealed record RunningDotNetProcess( |
||||
int Pid, |
||||
string ProcessName, |
||||
RuntimeKind Kind, |
||||
string? RuntimeVersion, |
||||
string? Architecture, |
||||
string? CommandLine, |
||||
string? EntryAssemblyName) |
||||
{ |
||||
/// <summary>
|
||||
/// The file holding the assembly this process started from, or null if it cannot be
|
||||
/// reached from a path. With a modern app the executable in the process list is a
|
||||
/// native host that carries no IL, so the answer is normally a dll of the same name
|
||||
/// beside it - or, for a single-file app, the executable itself, which ILSpy opens
|
||||
/// as the bundle it is.
|
||||
/// </summary>
|
||||
public string? ResolveEntryAssemblyPath(IReadOnlyList<ProcessModuleInfo> modules) |
||||
{ |
||||
// The runtime's own module list is authoritative: it gives the real path even
|
||||
// when the assembly was loaded from somewhere unrelated to the command line.
|
||||
if (EntryAssemblyName != null) |
||||
{ |
||||
var match = modules.FirstOrDefault(m => !m.IsInMemory && m.Path != null |
||||
&& string.Equals(Path.GetFileNameWithoutExtension(m.Name), EntryAssemblyName, StringComparison.OrdinalIgnoreCase)); |
||||
if (match != null) |
||||
return match.Path; |
||||
} |
||||
return ResolveFromCommandLine(); |
||||
} |
||||
|
||||
string? ResolveFromCommandLine() |
||||
{ |
||||
string? executable = FirstCommandLineToken(); |
||||
if (executable == null) |
||||
return null; |
||||
|
||||
if (executable.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) |
||||
return File.Exists(executable) ? executable : null; |
||||
|
||||
// "dotnet app.dll" names the assembly in its second token.
|
||||
string? second = SecondCommandLineToken(); |
||||
if (second != null && second.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(second)) |
||||
return second; |
||||
|
||||
string sibling = Path.ChangeExtension(executable, ".dll"); |
||||
if (File.Exists(sibling)) |
||||
return sibling; |
||||
|
||||
// No managed sibling: a single-file app has its assemblies bundled into the
|
||||
// executable itself.
|
||||
return File.Exists(executable) ? executable : null; |
||||
} |
||||
|
||||
string? FirstCommandLineToken() => SplitCommandLine().FirstOrDefault(); |
||||
|
||||
string? SecondCommandLineToken() => SplitCommandLine().Skip(1).FirstOrDefault(); |
||||
|
||||
/// <summary>
|
||||
/// Splits the reported command line into tokens, honoring double quotes around paths
|
||||
/// that contain spaces.
|
||||
/// </summary>
|
||||
IEnumerable<string> SplitCommandLine() |
||||
{ |
||||
if (string.IsNullOrWhiteSpace(CommandLine)) |
||||
yield break; |
||||
var token = new StringBuilder(); |
||||
bool quoted = false; |
||||
foreach (char c in CommandLine) |
||||
{ |
||||
if (c == '"') |
||||
{ |
||||
quoted = !quoted; |
||||
} |
||||
else if (char.IsWhiteSpace(c) && !quoted) |
||||
{ |
||||
if (token.Length > 0) |
||||
{ |
||||
yield return token.ToString(); |
||||
token.Clear(); |
||||
} |
||||
} |
||||
else |
||||
{ |
||||
token.Append(c); |
||||
} |
||||
} |
||||
if (token.Length > 0) |
||||
yield return token.ToString(); |
||||
} |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// A managed assembly loaded in a running process. <see cref="Path"/> is null (and
|
||||
/// <see cref="IsInMemory"/> true) for assemblies without a file on disk - byte-array or
|
||||
/// dynamic loads - which can be listed but not opened from a path.
|
||||
/// </summary>
|
||||
public sealed record ProcessModuleInfo( |
||||
string Name, |
||||
string? Path, |
||||
bool IsInMemory); |
||||
} |
||||
@ -0,0 +1,218 @@
@@ -0,0 +1,218 @@
|
||||
// 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.Collections.ObjectModel; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
|
||||
using CommunityToolkit.Mvvm.ComponentModel; |
||||
using CommunityToolkit.Mvvm.Input; |
||||
|
||||
using ICSharpCode.ILSpy.Processes; |
||||
|
||||
namespace ICSharpCode.ILSpy.ViewModels |
||||
{ |
||||
/// <summary>
|
||||
/// Drives the "Open from Running Process" dialog: lists the running .NET processes,
|
||||
/// fetches the assemblies loaded in the selected one, and hands the chosen files back to
|
||||
/// be opened. All process inspection goes through <see cref="IProcessExplorer"/>; errors
|
||||
/// land in <see cref="ErrorMessage"/> rather than escaping. The dialog closes itself when
|
||||
/// <see cref="CloseRequested"/> fires with the paths to open.
|
||||
/// </summary>
|
||||
public sealed partial class OpenFromProcessDialogViewModel : ViewModelBase |
||||
{ |
||||
readonly IProcessExplorer explorer; |
||||
readonly List<ProcessRowViewModel> allProcesses = new(); |
||||
|
||||
CancellationTokenSource? refreshCts; |
||||
CancellationTokenSource? modulesCts; |
||||
// Distinguishes the current assembly query from ones the user has moved on from: a
|
||||
// superseded query must not publish its result over the current selection.
|
||||
int modulesGeneration; |
||||
|
||||
string? entryAssemblyPath; |
||||
|
||||
[ObservableProperty] |
||||
string filterText = string.Empty; |
||||
|
||||
[ObservableProperty] |
||||
[NotifyCanExecuteChangedFor(nameof(AddEntryAssemblyCommand))] |
||||
ProcessRowViewModel? selectedProcess; |
||||
|
||||
[ObservableProperty] |
||||
bool isLoadingProcesses; |
||||
|
||||
[ObservableProperty] |
||||
bool isLoadingModules; |
||||
|
||||
[ObservableProperty] |
||||
string? errorMessage; |
||||
|
||||
public ObservableCollection<ProcessRowViewModel> Processes { get; } = new(); |
||||
|
||||
public ObservableCollection<ProcessModuleRowViewModel> Modules { get; } = new(); |
||||
|
||||
/// <summary>
|
||||
/// The assembly rows the user has picked in the grid; the view keeps this in sync
|
||||
/// with the grid's selection.
|
||||
/// </summary>
|
||||
public ObservableCollection<ProcessModuleRowViewModel> SelectedModules { get; } = new(); |
||||
|
||||
/// <summary>
|
||||
/// Raised with the assembly paths to open, or null when the dialog is dismissed; the
|
||||
/// view responds by closing the dialog with that result.
|
||||
/// </summary>
|
||||
public event Action<string[]?>? CloseRequested; |
||||
|
||||
public OpenFromProcessDialogViewModel(IProcessExplorer explorer) |
||||
{ |
||||
this.explorer = explorer; |
||||
SelectedModules.CollectionChanged += (_, _) => AddSelectedModulesCommand.NotifyCanExecuteChanged(); |
||||
} |
||||
|
||||
partial void OnFilterTextChanged(string value) => ApplyFilter(); |
||||
|
||||
partial void OnSelectedProcessChanged(ProcessRowViewModel? value) => _ = LoadModulesAsync(value); |
||||
|
||||
[RelayCommand] |
||||
void Refresh() => _ = RefreshAsync(); |
||||
|
||||
bool CanAddSelectedModules => SelectedModules.Any(m => !m.IsInMemory); |
||||
|
||||
[RelayCommand(CanExecute = nameof(CanAddSelectedModules))] |
||||
void AddSelectedModules() |
||||
{ |
||||
var paths = SelectedModules |
||||
.Where(m => !m.IsInMemory && m.Path != null) |
||||
.Select(m => m.Path!) |
||||
.ToArray(); |
||||
if (paths.Length > 0) |
||||
CloseRequested?.Invoke(paths); |
||||
} |
||||
|
||||
bool CanAddEntryAssembly => entryAssemblyPath != null; |
||||
|
||||
[RelayCommand(CanExecute = nameof(CanAddEntryAssembly))] |
||||
void AddEntryAssembly() |
||||
{ |
||||
if (entryAssemblyPath != null) |
||||
CloseRequested?.Invoke(new[] { entryAssemblyPath }); |
||||
} |
||||
|
||||
/// <summary>Cancels every in-flight query; called when the dialog closes.</summary>
|
||||
public void CancelAllOperations() |
||||
{ |
||||
refreshCts?.Cancel(); |
||||
modulesCts?.Cancel(); |
||||
} |
||||
|
||||
async Task RefreshAsync() |
||||
{ |
||||
refreshCts?.Cancel(); |
||||
var cts = refreshCts = new CancellationTokenSource(); |
||||
IsLoadingProcesses = true; |
||||
try |
||||
{ |
||||
var processes = await explorer.GetProcessesAsync(cts.Token); |
||||
if (cts.IsCancellationRequested) |
||||
return; |
||||
|
||||
ErrorMessage = null; |
||||
SelectedProcess = null; |
||||
allProcesses.Clear(); |
||||
allProcesses.AddRange(processes.Select(p => new ProcessRowViewModel(p))); |
||||
ApplyFilter(); |
||||
} |
||||
catch (OperationCanceledException) |
||||
{ |
||||
// Superseded by another refresh, or the dialog closed.
|
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
ErrorMessage = ex.Message; |
||||
allProcesses.Clear(); |
||||
ApplyFilter(); |
||||
} |
||||
finally |
||||
{ |
||||
if (!cts.IsCancellationRequested) |
||||
IsLoadingProcesses = false; |
||||
} |
||||
} |
||||
|
||||
void ApplyFilter() |
||||
{ |
||||
Processes.Clear(); |
||||
foreach (var process in allProcesses.Where(p => p.Matches(FilterText))) |
||||
Processes.Add(process); |
||||
} |
||||
|
||||
async Task LoadModulesAsync(ProcessRowViewModel? process) |
||||
{ |
||||
modulesCts?.Cancel(); |
||||
var cts = modulesCts = new CancellationTokenSource(); |
||||
int generation = ++modulesGeneration; |
||||
|
||||
Modules.Clear(); |
||||
SelectedModules.Clear(); |
||||
SetEntryAssemblyPath(null); |
||||
if (process == null) |
||||
return; |
||||
|
||||
IsLoadingModules = true; |
||||
try |
||||
{ |
||||
var modules = await explorer.GetModulesAsync(process.Process, cts.Token); |
||||
if (generation != modulesGeneration) |
||||
return; |
||||
|
||||
ErrorMessage = null; |
||||
foreach (var module in modules) |
||||
Modules.Add(new ProcessModuleRowViewModel(module)); |
||||
SetEntryAssemblyPath(process.Process.ResolveEntryAssemblyPath(modules)); |
||||
} |
||||
catch (OperationCanceledException) |
||||
{ |
||||
// The selection moved on or the dialog closed.
|
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
if (generation != modulesGeneration) |
||||
return; |
||||
ErrorMessage = ex.Message; |
||||
// The command line still names an assembly worth offering even when the
|
||||
// process refused to list what it has loaded.
|
||||
SetEntryAssemblyPath(process.Process.ResolveEntryAssemblyPath(Array.Empty<ProcessModuleInfo>())); |
||||
} |
||||
finally |
||||
{ |
||||
if (generation == modulesGeneration) |
||||
IsLoadingModules = false; |
||||
} |
||||
} |
||||
|
||||
void SetEntryAssemblyPath(string? path) |
||||
{ |
||||
entryAssemblyPath = path; |
||||
AddEntryAssemblyCommand.NotifyCanExecuteChanged(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
|
||||
// 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 ICSharpCode.ILSpy.Processes; |
||||
|
||||
using Loc = ICSharpCode.ILSpy.Properties.Resources; |
||||
|
||||
namespace ICSharpCode.ILSpy.ViewModels |
||||
{ |
||||
/// <summary>
|
||||
/// One row of the assembly list: an assembly loaded in the selected process. Assemblies
|
||||
/// that were loaded from a byte array or emitted at run time have no file to open, which
|
||||
/// the location column states in place of a path.
|
||||
/// </summary>
|
||||
public sealed class ProcessModuleRowViewModel |
||||
{ |
||||
public ProcessModuleRowViewModel(ProcessModuleInfo module) |
||||
{ |
||||
Module = module; |
||||
} |
||||
|
||||
public ProcessModuleInfo Module { get; } |
||||
|
||||
public string Name => Module.Name; |
||||
|
||||
public string? Path => Module.Path; |
||||
|
||||
public bool IsInMemory => Module.IsInMemory; |
||||
|
||||
public string Location => Module.Path ?? Loc.OpenFromProcess_InMemoryAssembly; |
||||
} |
||||
} |
||||
@ -0,0 +1,79 @@
@@ -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.Globalization; |
||||
|
||||
using ICSharpCode.ILSpy.Processes; |
||||
|
||||
namespace ICSharpCode.ILSpy.ViewModels |
||||
{ |
||||
/// <summary>
|
||||
/// One row of the process list: a running .NET process, in the shape the grid displays
|
||||
/// and the filter box searches.
|
||||
/// </summary>
|
||||
public sealed class ProcessRowViewModel |
||||
{ |
||||
public ProcessRowViewModel(RunningDotNetProcess process) |
||||
{ |
||||
Process = process; |
||||
} |
||||
|
||||
public RunningDotNetProcess Process { get; } |
||||
|
||||
public int Pid => Process.Pid; |
||||
|
||||
public string ProcessName => Process.ProcessName; |
||||
|
||||
public string? Architecture => Process.Architecture; |
||||
|
||||
public string? EntryAssembly => Process.EntryAssemblyName; |
||||
|
||||
public string? CommandLine => Process.CommandLine; |
||||
|
||||
/// <summary>
|
||||
/// The runtime flavor and version, e.g. ".NET 10.0.3" or ".NET Framework 4.8.9032.0".
|
||||
/// </summary>
|
||||
public string Runtime { |
||||
get { |
||||
string product = Process.Kind == RuntimeKind.NetFramework ? ".NET Framework" : ".NET"; |
||||
return string.IsNullOrWhiteSpace(Process.RuntimeVersion) |
||||
? product |
||||
: product + " " + Process.RuntimeVersion; |
||||
} |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Whether this process answers to what the user typed in the filter box. The pid is
|
||||
/// matched as well as the names, since it is often the only thing that tells two
|
||||
/// instances of the same program apart.
|
||||
/// </summary>
|
||||
public bool Matches(string filter) |
||||
{ |
||||
if (string.IsNullOrWhiteSpace(filter)) |
||||
return true; |
||||
filter = filter.Trim(); |
||||
return Contains(ProcessName, filter) |
||||
|| Contains(EntryAssembly, filter) |
||||
|| Contains(Pid.ToString(CultureInfo.InvariantCulture), filter); |
||||
} |
||||
|
||||
static bool Contains(string? value, string filter) |
||||
=> value != null && value.Contains(filter, StringComparison.OrdinalIgnoreCase); |
||||
} |
||||
} |
||||
@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
|
||||
<Window xmlns="https://github.com/avaloniaui" |
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||
xmlns:vm="using:ICSharpCode.ILSpy.ViewModels" |
||||
x:Class="ICSharpCode.ILSpy.Views.OpenFromProcessDialog" |
||||
x:DataType="vm:OpenFromProcessDialogViewModel" |
||||
Width="850" Height="560" MinWidth="500" MinHeight="360" |
||||
WindowStartupLocation="CenterOwner" |
||||
CanResize="True"> |
||||
<Grid Margin="12,8" RowDefinitions="Auto,2*,Auto,Auto,3*,Auto,Auto,Auto" RowSpacing="6"> |
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto"> |
||||
<Label Grid.Column="0" Name="FilterLabel" Target="{Binding #FilterBox}" |
||||
VerticalAlignment="Center" Margin="0,0,8,0" /> |
||||
<TextBox Grid.Column="1" Name="FilterBox" Text="{Binding FilterText}" /> |
||||
<Button Grid.Column="2" Name="RefreshButton" MinWidth="90" Margin="6,0,0,0" |
||||
Command="{Binding RefreshCommand}" /> |
||||
</Grid> |
||||
|
||||
<DataGrid Grid.Row="1" Name="ProcessesGrid" |
||||
ItemsSource="{Binding Processes}" |
||||
SelectedItem="{Binding SelectedProcess}" |
||||
AutoGenerateColumns="False" |
||||
CanUserResizeColumns="True" |
||||
CanUserSortColumns="True" |
||||
GridLinesVisibility="None" |
||||
HeadersVisibility="Column" |
||||
IsReadOnly="True" |
||||
SelectionMode="Single"> |
||||
<DataGrid.Columns> |
||||
<DataGridTextColumn Width="200" CanUserSort="True" x:CompileBindings="False" |
||||
Binding="{Binding ProcessName}" /> |
||||
<DataGridTextColumn Width="70" CanUserSort="True" x:CompileBindings="False" |
||||
Binding="{Binding Pid}" /> |
||||
<DataGridTextColumn Width="150" CanUserSort="True" x:CompileBindings="False" |
||||
Binding="{Binding Runtime}" /> |
||||
<DataGridTextColumn Width="90" CanUserSort="True" x:CompileBindings="False" |
||||
Binding="{Binding Architecture}" /> |
||||
<DataGridTextColumn Width="*" CanUserSort="True" x:CompileBindings="False" |
||||
Binding="{Binding EntryAssembly}" /> |
||||
</DataGrid.Columns> |
||||
</DataGrid> |
||||
|
||||
<ProgressBar Grid.Row="1" Name="ProcessesLoadingBar" Height="6" |
||||
VerticalAlignment="Bottom" IsIndeterminate="True" |
||||
IsVisible="{Binding IsLoadingProcesses}" /> |
||||
|
||||
<TextBlock Grid.Row="2" Name="ModulesHeader" Margin="0,4,0,0" /> |
||||
|
||||
<Grid Grid.Row="4" RowDefinitions="*,Auto"> |
||||
<DataGrid Grid.Row="0" Name="ModulesGrid" |
||||
ItemsSource="{Binding Modules}" |
||||
AutoGenerateColumns="False" |
||||
CanUserResizeColumns="True" |
||||
CanUserSortColumns="True" |
||||
GridLinesVisibility="None" |
||||
HeadersVisibility="Column" |
||||
IsReadOnly="True" |
||||
SelectionMode="Extended"> |
||||
<DataGrid.Columns> |
||||
<DataGridTextColumn Width="260" CanUserSort="True" x:CompileBindings="False" |
||||
Binding="{Binding Name}" /> |
||||
<DataGridTextColumn Width="*" CanUserSort="True" x:CompileBindings="False" |
||||
Binding="{Binding Location}" /> |
||||
</DataGrid.Columns> |
||||
</DataGrid> |
||||
<ProgressBar Grid.Row="0" Name="ModulesLoadingBar" Height="6" |
||||
VerticalAlignment="Bottom" IsIndeterminate="True" |
||||
IsVisible="{Binding IsLoadingModules}" /> |
||||
</Grid> |
||||
|
||||
<Border Grid.Row="5" Name="ErrorBar" Padding="8,4" Margin="0,4,0,0" |
||||
Background="#33FF0000" CornerRadius="3" |
||||
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}"> |
||||
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" /> |
||||
</Border> |
||||
|
||||
<TextBlock Grid.Row="6" Name="VisibilityHint" TextWrapping="Wrap" Opacity="0.7" Margin="0,2,0,0" /> |
||||
|
||||
<StackPanel Grid.Row="7" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6"> |
||||
<Button Name="AddEntryAssemblyButton" MinWidth="140" |
||||
Command="{Binding AddEntryAssemblyCommand}" /> |
||||
<Button Name="AddSelectedButton" IsDefault="True" MinWidth="110" |
||||
Command="{Binding AddSelectedModulesCommand}" /> |
||||
<Button Name="CancelButton" IsCancel="True" MinWidth="72" /> |
||||
</StackPanel> |
||||
</Grid> |
||||
</Window> |
||||
@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
|
||||
// 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 Avalonia.Controls; |
||||
using Avalonia.Markup.Xaml; |
||||
|
||||
using ICSharpCode.ILSpy.Processes; |
||||
using ICSharpCode.ILSpy.ViewModels; |
||||
|
||||
// Alias the shared Resources class - Window inherits an IResourceDictionary Resources
|
||||
// property that would otherwise shadow ICSharpCode.ILSpy.Properties.Resources.
|
||||
using Loc = ICSharpCode.ILSpy.Properties.Resources; |
||||
|
||||
namespace ICSharpCode.ILSpy.Views |
||||
{ |
||||
/// <summary>
|
||||
/// "Open from Running Process" dialog: lists the running .NET processes, shows the
|
||||
/// assemblies loaded in the selected one, and closes with the paths to open - either the
|
||||
/// assemblies picked in the grid or the process's entry assembly, which for a modern app
|
||||
/// is the dll behind its native host. Closes with null when cancelled; all behavior lives
|
||||
/// in <see cref="OpenFromProcessDialogViewModel"/>.
|
||||
/// </summary>
|
||||
public partial class OpenFromProcessDialog : Window |
||||
{ |
||||
readonly OpenFromProcessDialogViewModel viewModel; |
||||
|
||||
// Runtime-loader/designer constructor; production callers and tests use the overload
|
||||
// below to inject the explorer (a fake one, in tests).
|
||||
public OpenFromProcessDialog() |
||||
: this(new ProcessExplorer()) |
||||
{ |
||||
} |
||||
|
||||
public OpenFromProcessDialog(IProcessExplorer explorer) |
||||
{ |
||||
InitializeComponent(); |
||||
|
||||
viewModel = new OpenFromProcessDialogViewModel(explorer); |
||||
DataContext = viewModel; |
||||
|
||||
Title = Loc.OpenFromProcess_Title; |
||||
this.FindControl<Label>("FilterLabel")!.Content = Loc.OpenFromProcess_Filter; |
||||
this.FindControl<Button>("RefreshButton")!.Content = Loc.OpenFromProcess_Refresh; |
||||
this.FindControl<TextBlock>("ModulesHeader")!.Text = Loc.OpenFromProcess_Assemblies; |
||||
this.FindControl<TextBlock>("VisibilityHint")!.Text = Loc.OpenFromProcess_VisibilityHint; |
||||
this.FindControl<Button>("AddEntryAssemblyButton")!.Content = Loc.OpenFromProcess_AddEntryAssembly; |
||||
this.FindControl<Button>("AddSelectedButton")!.Content = Loc.OpenFromProcess_AddSelected; |
||||
var cancelButton = this.FindControl<Button>("CancelButton")!; |
||||
cancelButton.Content = Loc.Cancel; |
||||
|
||||
SetColumnHeaders(); |
||||
|
||||
// A DataGrid's multi-selection is not bindable, so the grid pushes it into the
|
||||
// view model, which is what the Add button's command acts on.
|
||||
var modulesGrid = this.FindControl<DataGrid>("ModulesGrid")!; |
||||
modulesGrid.SelectionChanged += (_, _) => { |
||||
viewModel.SelectedModules.Clear(); |
||||
foreach (var module in modulesGrid.SelectedItems.OfType<ProcessModuleRowViewModel>()) |
||||
viewModel.SelectedModules.Add(module); |
||||
}; |
||||
|
||||
cancelButton.Click += (_, _) => Close(null); |
||||
viewModel.CloseRequested += paths => Close(paths); |
||||
Closed += (_, _) => viewModel.CancelAllOperations(); |
||||
|
||||
// Scanning takes a moment, so it starts as the dialog appears rather than
|
||||
// waiting for the user to ask for a list that is always wanted.
|
||||
Opened += (_, _) => viewModel.RefreshCommand.Execute(null); |
||||
Opened += (_, _) => this.FindControl<TextBox>("FilterBox")!.Focus(); |
||||
} |
||||
|
||||
void SetColumnHeaders() |
||||
{ |
||||
var processes = this.FindControl<DataGrid>("ProcessesGrid")!; |
||||
processes.Columns[0].Header = Loc.OpenFromProcess_Process; |
||||
processes.Columns[1].Header = Loc.OpenFromProcess_Pid; |
||||
processes.Columns[2].Header = Loc.OpenFromProcess_Runtime; |
||||
processes.Columns[3].Header = Loc.OpenFromProcess_Architecture; |
||||
processes.Columns[4].Header = Loc.OpenFromProcess_EntryAssembly; |
||||
|
||||
var modules = this.FindControl<DataGrid>("ModulesGrid")!; |
||||
modules.Columns[0].Header = Loc.Assembly; |
||||
modules.Columns[1].Header = Loc.OpenFromProcess_Location; |
||||
} |
||||
|
||||
void InitializeComponent() => AvaloniaXamlLoader.Load(this); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue