Browse Source

Fix unreachable-process and selection defects in the process dialog

A refused unix socket raises SocketException, which derives from
Win32Exception rather than IOException, so it escaped the filter meant to
skip one unreachable process and failed the whole concurrent enumeration
instead: a machine where any .NET process exits between the port scan and
the connect showed an empty list. The classification is now a named
predicate covering both transports.

Two dialog defects shared a shape - state left behind by a query nobody is
waiting for any more. Rebuilding the bound collection on every filter
keystroke made the grid drop its selection and write that null back,
discarding the assemblies of a process the new filter still matched; and the
branch taken when nothing is selected cleared no loading flag, while the
query it superseded was no longer allowed to, so the progress bar animated
over an empty pane. Relatedly, the two-second command budget expired into a
process listed with null metadata, which made a slow machine look like a
runtime that answered with nothing; it is longer now, and expiry names the
process it gave up on, since the only thing that ever reaches the far end of
that budget is a runtime which will never answer.

The test gaps are closed the same way: a real dynamic assembly pins the
in-memory classification, the managed-only assertion is stated as the
property instead of a list of native names to exclude, and the .NET
Framework path gets live tests. Those showed that a desktop CLR process
mostly reports NGen native images rather than the IL assemblies behind them,
which is now recorded as the fidelity gap it is.

Assisted-by: Claude:claude-opus-5:Claude Code
pull/3943/head
Christoph Wille 2 months ago
parent
commit
d6bf916398
  1. 214
      ILSpy.Tests.Windows/Processes/NetFrameworkProcessesTests.cs
  2. 31
      ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs
  3. 9
      ILSpy.Tests/Processes/FakeProcessExplorer.cs
  4. 71
      ILSpy.Tests/Processes/HungDiagnosticsEndpoint.cs
  5. 74
      ILSpy.Tests/Processes/NettraceRundownReaderTests.cs
  6. 41
      ILSpy.Tests/Processes/OpenFromProcessDialogViewModelTests.cs
  7. 74
      ILSpy.Tests/Processes/ProcessExplorerTests.cs
  8. 1
      ILSpy.Tests/Views/MainMenuTests.cs
  9. 49
      ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs
  10. 1
      ILSpy/Assets/Icons/Process.svg
  11. 2
      ILSpy/Commands/FileCommands.cs
  12. 1
      ILSpy/Images.cs
  13. 152
      ILSpy/Processes/DiagnosticsIpcClient.cs
  14. 36
      ILSpy/Processes/NettraceRundownReader.cs
  15. 26
      ILSpy/Processes/ProcessExplorer.cs
  16. 86
      ILSpy/ViewModels/OpenFromProcessDialogViewModel.cs
  17. 2
      ILSpy/ViewModels/ProcessRowViewModel.cs

214
ILSpy.Tests.Windows/Processes/NetFrameworkProcessesTests.cs

@ -0,0 +1,214 @@ @@ -0,0 +1,214 @@
// 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.Threading;
using System.Threading.Tasks;
using AwesomeAssertions;
using ICSharpCode.ILSpy.Processes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Windows.Processes;
/// <summary>
/// The .NET Framework half of the process explorer, which exists on Windows only: those
/// processes predate the diagnostics endpoint every CoreCLR process serves, so they are found
/// by the desktop CLR in their OS module list and their assemblies are read from that same
/// list. Windows PowerShell is the fixture - a .NET Framework 4.x application present on
/// every Windows installation, including the CI runner.
/// </summary>
[TestFixture]
[Platform("Win", Reason = ".NET Framework and the OS module list this path reads exist on Windows only.")]
public class NetFrameworkProcessesTests
{
static Process? host;
static string WindowsPowerShellPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.System),
"WindowsPowerShell", "v1.0", "powershell.exe");
[OneTimeSetUp]
public void StartADotNetFrameworkProcess()
{
File.Exists(WindowsPowerShellPath).Should().BeTrue(
"Windows PowerShell 5.1 is the .NET Framework process this fixture inspects");
host = Process.Start(new ProcessStartInfo(WindowsPowerShellPath,
"-NoProfile -NonInteractive -Command \"Start-Sleep -Seconds 300\"") {
UseShellExecute = false,
CreateNoWindow = true,
});
host.Should().NotBeNull();
// The runtime is loaded a moment after the process exists, and nothing about this path
// works before it is.
WaitUntilTheDesktopClrIsLoaded(host!);
}
[OneTimeTearDown]
public void StopIt()
{
if (host is { HasExited: false })
host.Kill(entireProcessTree: true);
host?.Dispose();
}
static void WaitUntilTheDesktopClrIsLoaded(Process process)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
while (DateTime.UtcNow < deadline)
{
process.Refresh();
if (!process.HasExited && LoadedModuleNames(process).Contains("clr.dll"))
return;
Thread.Sleep(100);
}
Assert.Fail("Windows PowerShell did not load clr.dll within a minute.");
}
static HashSet<string> LoadedModuleNames(Process process)
{
try
{
return process.Modules.Cast<ProcessModule>()
.Select(m => m.ModuleName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
}
static RunningDotNetProcess TheHostAsListed(ISet<int>? alreadyListed = null)
{
var listed = NetFrameworkProcesses.Enumerate(
alreadyListed ?? new HashSet<int>(), CancellationToken.None).ToList();
return listed.Should().ContainSingle(p => p.Pid == host!.Id,
"a running .NET Framework process is what this path exists to find").Subject;
}
[Test]
public void A_Desktop_Clr_Process_Is_Found_And_Described()
{
var listed = TheHostAsListed();
listed.Kind.Should().Be(RuntimeKind.NetFramework);
listed.ProcessName.Should().Be("powershell");
listed.RuntimeVersion.Should().StartWith("4.", "the desktop CLR in use is version 4");
listed.Architecture.Should().Be(Environment.Is64BitOperatingSystem ? "x64" : "x86",
"the architecture follows from which Framework directory the CLR was loaded out of");
// Unlike a modern app, a .NET Framework executable is itself managed - there is no
// native host in front of it, so the entry assembly is the exe.
listed.EntryAssemblyName.Should().Be("powershell");
}
[Test]
public void Processes_The_Diagnostics_Scan_Already_Listed_Are_Not_Listed_Twice()
{
// The two halves of the explorer are merged, and a process that answered on the
// diagnostics endpoint must not appear again from the module scan.
var listed = NetFrameworkProcesses.Enumerate(
new HashSet<int> { host!.Id }, CancellationToken.None);
listed.Should().NotContain(p => p.Pid == host!.Id);
}
[Test]
public void Only_The_Managed_Modules_Of_A_Desktop_Clr_Process_Are_Listed()
{
var modules = NetFrameworkProcesses.GetModules(host!.Id);
modules.Should().Contain(m => IsAssembly(m, "System.Management.Automation"),
"PowerShell's own assembly is loaded from the GAC, not from beside the exe");
// The OS module list mixes native libraries in, and clr.dll is the very entry that
// identified the process - so this proves the filtering runs, not merely that it exists.
LoadedModuleNames(host!).Should().Contain("clr.dll");
modules.Should().NotContain(m => string.Equals(m.Name, "clr.dll", StringComparison.OrdinalIgnoreCase));
// Stated as the property itself rather than as a list of names to keep out: any native
// library the filter starts admitting fails this, not just the ones thought of here.
foreach (var module in modules)
{
module.IsInMemory.Should().BeFalse("everything on this path comes from a file");
ProcessExplorer.IsManagedAssembly(module.Path!).Should().BeTrue(
$"'{module.Name}' was listed as an assembly loaded in the process");
}
}
[Test]
public void The_Core_Library_Is_Listed_However_The_Runtime_Loaded_It()
{
var modules = NetFrameworkProcesses.GetModules(host!.Id);
var mscorlib = modules.Should().ContainSingle(m => IsAssembly(m, "mscorlib"),
"every .NET Framework process has the core library loaded").Subject;
// Normally this is mscorlib.ni.dll out of the NGen cache rather than the IL assembly
// in the GAC, because Windows ships pre-compiled native images for the framework. The
// image carries metadata and is genuinely the module the process loaded, so it is
// listed - but its IL lives in the assembly it was compiled from, and the OS module
// list does not say where that is. Opening a native image is therefore of limited use,
// which is the fidelity gap of this path rather than a defect in it.
ProcessExplorer.IsManagedAssembly(mscorlib.Path!).Should().BeTrue();
File.Exists(mscorlib.Path).Should().BeTrue();
}
/// <summary>
/// Whether a listed module is the given assembly, in either of the two forms the desktop
/// loader reports: the IL assembly, or the NGen native image compiled from it.
/// </summary>
static bool IsAssembly(ProcessModuleInfo module, string simpleName)
=> string.Equals(module.Name, simpleName + ".dll", StringComparison.OrdinalIgnoreCase)
|| string.Equals(module.Name, simpleName + ".ni.dll", StringComparison.OrdinalIgnoreCase);
[Test]
public async Task The_Explorer_Routes_A_Framework_Process_To_The_Module_Scan()
{
// The facade decides per process which mechanism answers; a .NET Framework process
// must not be sent down the diagnostics path, where it has no endpoint at all.
var explorer = new ProcessExplorer();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var processes = await explorer.GetProcessesAsync(cts.Token);
var listed = processes.Should().ContainSingle(p => p.Pid == host!.Id).Subject;
listed.Kind.Should().Be(RuntimeKind.NetFramework);
var modules = await explorer.GetModulesAsync(listed, cts.Token);
modules.Should().Contain(m => IsAssembly(m, "mscorlib"));
}
[Test]
public void A_Process_That_Has_Exited_Yields_No_Modules()
{
using var shortLived = Process.Start(new ProcessStartInfo(WindowsPowerShellPath,
"-NoProfile -NonInteractive -Command \"exit\"") { UseShellExecute = false, CreateNoWindow = true })!;
shortLived.WaitForExit();
NetFrameworkProcesses.GetModules(shortLived.Id).Should().BeEmpty(
"a process that exits mid-scan is skipped, not reported as an error");
}
}

31
ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs

@ -108,4 +108,35 @@ public class DiagnosticsIpcClientTests @@ -108,4 +108,35 @@ public class DiagnosticsIpcClientTests
"ProcessInfo2 reports the managed entry-point assembly, not the native host");
info.ClrVersion.Should().NotBeNullOrWhiteSpace();
}
[Test]
public async Task An_Endpoint_That_Accepts_But_Never_Answers_Is_Reported_As_A_Timeout()
{
// A suspended runtime behaves this way, and so does one whose diagnostics server is
// wedged. The budget must expire into something that names the process, rather than
// into a bare cancellation that reads like the caller changed its mind.
using var endpoint = new HungDiagnosticsEndpoint(pid: HungEndpointPid);
var query = async () => await DiagnosticsIpcClient.GetProcessInfoAsync(
HungEndpointPid, TimeSpan.FromMilliseconds(250), CancellationToken.None);
(await query.Should().ThrowAsync<TimeoutException>())
.WithMessage($"*{HungEndpointPid}*", "the message must say which process went quiet");
}
[Test]
public async Task Caller_Cancellation_Is_Not_Disguised_As_A_Timeout()
{
using var endpoint = new HungDiagnosticsEndpoint(pid: HungEndpointPid + 1);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var query = async () => await DiagnosticsIpcClient.GetProcessInfoAsync(
HungEndpointPid + 1, TimeSpan.FromMinutes(5), cts.Token);
await query.Should().ThrowAsync<OperationCanceledException>(
"the dialog closing is not the target's fault");
}
// No process has this id; the endpoint below is a fake one standing at its name.
const int HungEndpointPid = 0x7FFF_0000;
}

9
ILSpy.Tests/Processes/FakeProcessExplorer.cs

@ -41,7 +41,12 @@ sealed class FakeProcessExplorer : IProcessExplorer @@ -41,7 +41,12 @@ sealed class FakeProcessExplorer : IProcessExplorer
public Exception? ProcessesException { get; set; }
public Exception? ModulesException { get; set; }
/// <summary>When set, module queries wait for it before returning.</summary>
/// <summary>
/// When set, module queries wait for it before returning. The wait deliberately ignores
/// the cancellation token: the real explorer parks on socket I/O on a thread-pool thread,
/// so cancelling it does not complete the query inline on the cancelling thread. A gate
/// that did would hide state a cancelled query leaves behind.
/// </summary>
public TaskCompletionSource? ModulesGate { get; set; }
public CancellationToken LastModulesToken { get; private set; }
@ -69,7 +74,7 @@ sealed class FakeProcessExplorer : IProcessExplorer @@ -69,7 +74,7 @@ sealed class FakeProcessExplorer : IProcessExplorer
if (ModulesException != null)
throw ModulesException;
if (ModulesGate != null)
await ModulesGate.Task.WaitAsync(cancellationToken);
await ModulesGate.Task;
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
return ModulesByPid.TryGetValue(process.Pid, out var modules)

71
ILSpy.Tests/Processes/HungDiagnosticsEndpoint.cs

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
// 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.IO.Pipes;
using System.Net.Sockets;
namespace ICSharpCode.ILSpy.Tests.Processes;
/// <summary>
/// A diagnostics endpoint that accepts a connection and then says nothing, standing at the
/// transport name a CoreCLR process of the given pid would use. It is how a suspended or
/// wedged runtime behaves, and the only way to make the client's command budget expire
/// without waiting out the production one.
/// </summary>
/// <remarks>
/// Both transports are created unconditionally rather than under <c>#if</c>, matching how the
/// production scanner keeps its Windows and unix halves in one always-compiled type; only the
/// one this OS uses is actually opened.
/// </remarks>
sealed class HungDiagnosticsEndpoint : IDisposable
{
readonly NamedPipeServerStream? pipe;
readonly Socket? socket;
readonly string? socketPath;
public HungDiagnosticsEndpoint(int pid)
{
if (OperatingSystem.IsWindows())
{
pipe = new NamedPipeServerStream("dotnet-diagnostic-" + pid, PipeDirection.InOut,
maxNumberOfServerInstances: 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
// Accepting is all it does: the connected client waits for a response that never
// comes. The task is deliberately not awaited and needs no result.
_ = pipe.WaitForConnectionAsync();
}
else
{
socketPath = Path.Combine(Path.GetTempPath(), $"dotnet-diagnostic-{pid}-1-socket");
File.Delete(socketPath);
socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
socket.Bind(new UnixDomainSocketEndPoint(socketPath));
socket.Listen(1);
_ = socket.AcceptAsync();
}
}
public void Dispose()
{
pipe?.Dispose();
socket?.Dispose();
if (socketPath != null)
File.Delete(socketPath);
}
}

74
ILSpy.Tests/Processes/NettraceRundownReaderTests.cs

@ -20,6 +20,7 @@ using System; @@ -20,6 +20,7 @@ using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -43,16 +44,38 @@ public class NettraceRundownReaderTests @@ -43,16 +44,38 @@ public class NettraceRundownReaderTests
{
static MemoryStream? rundown;
// Kept alive for the fixture's lifetime: an assembly the runtime has collected is no
// longer in the rundown.
static Assembly? inMemoryFixture;
const string InMemoryFixtureName = "ILSpy.Tests.InMemoryRundownFixture";
/// <summary>
/// Collecting a rundown takes a moment, so every test in this fixture shares one.
/// Collecting a rundown takes a moment, so every test in this fixture shares one. The
/// in-memory assembly is emitted first, so the one rundown covers both the ordinary
/// file-backed modules and an assembly that has no file anywhere.
/// </summary>
[OneTimeSetUp]
public async Task CollectRundownOfTheTestHost()
{
inMemoryFixture = EmitAssemblyWithNoFile();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
rundown = await DiagnosticsIpcClient.CollectModuleRundownAsync(Environment.ProcessId, cts.Token);
}
/// <summary>
/// An assembly that exists only in this process's memory - the case a user hits with
/// <c>Assembly.Load(byte[])</c>, a dynamic proxy, or a source generator's scratch
/// assembly. It is what the reader must classify as unopenable.
/// </summary>
static Assembly EmitAssemblyWithNoFile()
{
var name = new AssemblyName(InMemoryFixtureName);
var builder = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run);
builder.DefineDynamicModule(InMemoryFixtureName);
return builder;
}
[OneTimeTearDown]
public void DisposeRundown() => rundown?.Dispose();
@ -106,12 +129,51 @@ public class NettraceRundownReaderTests @@ -106,12 +129,51 @@ public class NettraceRundownReaderTests
// 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.Should().NotContain(m => string.Equals(m.Path, NativeRuntimeHost.FullPath, StringComparison.OrdinalIgnoreCase));
// Stated as the property itself rather than as a list of names to keep out: any native
// module the reader starts admitting fails this, not just the ones thought of here.
foreach (var module in modules.Where(m => !m.IsInMemory))
{
ProcessExplorer.IsManagedAssembly(module.Path!).Should().BeTrue(
$"'{module.Name}' was listed as an assembly loaded in the process");
}
}
[Test]
public void An_Assembly_With_No_File_Is_Listed_But_Marked_Unopenable()
{
var modules = NettraceRundownReader.ReadModules(Rundown());
inMemoryFixture.Should().NotBeNull("the fixture assembly is emitted before the rundown is collected");
var emitted = modules.Should().ContainSingle(m => m.Name.StartsWith(InMemoryFixtureName, StringComparison.Ordinal),
"an assembly the runtime holds only in memory is still worth showing").Subject;
emitted.IsInMemory.Should().BeTrue();
emitted.Path.Should().BeNull("there is no file to open, which is what the dialog tells the user");
}
[Test]
public void A_Payload_That_Ends_Inside_A_String_Is_Rejected()
{
// "ab", filling the payload exactly, with no terminator - what a payload whose layout
// does not match what its event id promised looks like. The reader must stop at the
// payload's end rather than hunt for a zero word through the rest of the stream.
var unterminated = new byte[] { 0x61, 0, 0x62, 0 };
using var reader = new BinaryReader(new MemoryStream(unterminated));
var read = () => NettraceRundownReader.ReadUtf16NullTerminated(reader, unterminated.Length);
read.Should().Throw<InvalidDataException>().WithMessage("*unterminated*");
}
[Test]
public void A_Terminated_String_Inside_Its_Payload_Reads_Back()
{
var terminated = new byte[] { 0x61, 0, 0x62, 0, 0, 0, 0xFF, 0xFF };
using var reader = new BinaryReader(new MemoryStream(terminated));
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));
NettraceRundownReader.ReadUtf16NullTerminated(reader, terminated.Length).Should().Be("ab");
reader.BaseStream.Position.Should().Be(6, "the terminator is consumed and the rest is left alone");
}
[Test]

41
ILSpy.Tests/Processes/OpenFromProcessDialogViewModelTests.cs

@ -260,6 +260,47 @@ public class OpenFromProcessDialogViewModelTests @@ -260,6 +260,47 @@ public class OpenFromProcessDialogViewModelTests
closed.Should().BeFalse();
}
[AvaloniaTest]
public async Task Refreshing_While_Assemblies_Are_Loading_Stops_The_Progress_Bar()
{
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(() => vm.IsLoadingModules);
// Refresh drops the selection, so no assembly list is being loaded any more - while
// the query that was in flight has been superseded and can no longer report that it
// stopped. Something has to turn the indicator off, or it animates over an empty pane
// until the user selects another process.
explorer.ModulesGate = null;
vm.RefreshCommand.Execute(null);
await Waiters.WaitForAsync(() => vm.SelectedProcess == null && vm.Processes.Count == 2);
vm.IsLoadingModules.Should().BeFalse("nothing is loading, so nothing may animate");
}
[AvaloniaTest]
public async Task A_Failed_Scan_Leaves_Nothing_Selected_To_Act_On()
{
var (vm, explorer) = CreateViewModel();
vm.RefreshCommand.Execute(null);
await Waiters.WaitForAsync(() => vm.Processes.Count == 2);
vm.SelectedProcess = vm.Processes[0];
await Waiters.WaitForAsync(() => vm.AddEntryAssemblyCommand.CanExecute(null));
explorer.ProcessesException = new IOException("the diagnostics port is unreachable");
vm.RefreshCommand.Execute(null);
await Waiters.WaitForAsync(() => vm.ErrorMessage != null);
// The list the selection pointed into has just been emptied; leaving the selection
// behind keeps the entry-assembly button armed against a row nobody can see.
vm.Processes.Should().BeEmpty();
vm.SelectedProcess.Should().BeNull();
vm.AddEntryAssemblyCommand.CanExecute(null).Should().BeFalse();
}
[AvaloniaTest]
public async Task Closing_The_Dialog_Cancels_Work_Still_In_Flight()
{

74
ILSpy.Tests/Processes/ProcessExplorerTests.cs

@ -20,6 +20,7 @@ using System; @@ -20,6 +20,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Sockets;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@ -52,8 +53,77 @@ public class ProcessExplorerTests @@ -52,8 +53,77 @@ public class ProcessExplorerTests
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();
// A null here means the process was listed but did not answer: the diagnostics query
// failed or ran out its budget. It is not an "unknown entry assembly" - the runtime
// always knows its own.
self.EntryAssemblyName.Should().Be(Assembly.GetEntryAssembly()!.GetName().Name,
"the runtime answered the process-info query");
self.RuntimeVersion.Should().NotBeNullOrWhiteSpace(
"the runtime answered the process-info query");
}
[Test]
public void Every_Way_An_Endpoint_Can_Fail_To_Answer_Counts_As_Unreachable()
{
// One process that cannot be reached must cost that one row, not the listing: the
// queries run concurrently, so an exception that escapes the classification fails the
// whole enumeration and the dialog shows an empty machine.
ProcessExplorer.IsUnreachable(new SocketException((int)SocketError.ConnectionRefused))
.Should().BeTrue("a refused unix socket - a stale file, or a runtime already torn down");
ProcessExplorer.IsUnreachable(new IOException("broken pipe")).Should().BeTrue();
ProcessExplorer.IsUnreachable(new EndOfStreamException()).Should().BeTrue();
ProcessExplorer.IsUnreachable(new TimeoutException()).Should().BeTrue("a named pipe with no server");
ProcessExplorer.IsUnreachable(new UnauthorizedAccessException()).Should().BeTrue("another user's endpoint");
ProcessExplorer.IsUnreachable(new InvalidDataException("the reader misread a block"))
.Should().BeFalse("a defect in this code must not be disguised as an unreachable process");
}
[Test]
[Platform(Exclude = "Win", Reason = "Unix domain sockets are the transport being planted; Windows uses named pipes.")]
public async Task A_Socket_That_Refuses_The_Connection_Costs_One_Row_Not_The_Listing()
{
// The reachable case is covered above; this is the same scan with one endpoint that
// exists as a file but has nobody listening behind it - what a process leaves behind
// when it exits between the port scan and the connect.
using var child = StartNonDotNetChildProcess();
string socketPath = Path.Combine(Path.GetTempPath(), $"dotnet-diagnostic-{child.Id}-1-socket");
using var abandoned = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
abandoned.Bind(new UnixDomainSocketEndPoint(socketPath));
try
{
// Not listening, so a connect is refused. Asserted directly, because a planted
// socket that failed some other way would make the scan below prove nothing.
var connect = async () => await DiagnosticsIpcClient.ConnectAsync(child.Id, CancellationToken.None);
await connect.Should().ThrowAsync<SocketException>(
"a bound socket with no listener refuses connections");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var processes = await Explorer.GetProcessesAsync(cts.Token);
processes.Should().Contain(p => p.Pid == Environment.ProcessId,
"the reachable processes must still be listed");
processes.Should().Contain(p => p.Pid == child.Id && p.EntryAssemblyName == null,
"the unreachable one is listed without the metadata only its runtime could give");
}
finally
{
abandoned.Close();
File.Delete(socketPath);
child.Kill(entireProcessTree: true);
}
}
/// <summary>
/// A live process that hosts no .NET runtime, so the only diagnostics endpoint it appears
/// to have is the one the test plants for it.
/// </summary>
static System.Diagnostics.Process StartNonDotNetChildProcess()
{
var child = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
"/bin/sh", "-c \"sleep 120\"") { UseShellExecute = false });
child.Should().NotBeNull();
return child!;
}
[Test]

1
ILSpy.Tests/Views/MainMenuTests.cs

@ -91,6 +91,7 @@ public class MainMenuTests @@ -91,6 +91,7 @@ public class MainMenuTests
process.Should().NotBeNull("the File menu must contain an 'Open from running process' item");
process!.IsEnabled.Should().BeTrue(
"the runtime's diagnostics endpoint answers on Windows, Linux and macOS alike");
process.Icon.Should().NotBeNull("the other entries of the open-from group all carry an icon");
var fileSubmenu = menu!.Items.OfType<NativeMenuItem>()
.Select(i => i.Menu)

49
ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs

@ -127,6 +127,55 @@ public class OpenFromProcessDialogStructureTests @@ -127,6 +127,55 @@ public class OpenFromProcessDialogStructureTests
"the grid's selection is what the Add button acts on");
}
[AvaloniaTest]
public async Task Filtering_Keeps_A_Selected_Process_That_Still_Matches()
{
// Only reproducible with the grid attached: the view model alone never sees the
// selection being written back, because it is the grid that pushes it.
var explorer = new FakeProcessExplorer();
explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(100, "ILSpy", "ILSpy"));
explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(200, "dotnet", "MyTool"));
explorer.ModulesByPid[100] = new[] {
new ICSharpCode.ILSpy.Processes.ProcessModuleInfo("A.dll", @"C:\a\A.dll", IsInMemory: false),
};
var dialog = CreateDialog(explorer);
dialog.Show();
var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!;
await Waiters.WaitForAsync(() => vm.Processes.Count == 2);
dialog.FindControl<DataGrid>("ProcessesGrid")!.SelectedItem = vm.Processes[0];
await Waiters.WaitForAsync(() => vm.Modules.Count == 1);
// One keystroke that narrows the list without excluding the selected row.
vm.FilterText = "ILSpy";
vm.Processes.Should().ContainSingle().Which.Pid.Should().Be(100);
vm.SelectedProcess.Should().NotBeNull("the selected process still matches what was typed");
vm.Modules.Should().ContainSingle("its assembly list must survive a keystroke");
}
[AvaloniaTest]
public async Task Filtering_Out_The_Selected_Process_Clears_Its_Assemblies()
{
var explorer = new FakeProcessExplorer();
explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(100, "ILSpy", "ILSpy"));
explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(200, "dotnet", "MyTool"));
explorer.ModulesByPid[100] = new[] {
new ICSharpCode.ILSpy.Processes.ProcessModuleInfo("A.dll", @"C:\a\A.dll", IsInMemory: false),
};
var dialog = CreateDialog(explorer);
dialog.Show();
var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!;
await Waiters.WaitForAsync(() => vm.Processes.Count == 2);
dialog.FindControl<DataGrid>("ProcessesGrid")!.SelectedItem = vm.Processes[0];
await Waiters.WaitForAsync(() => vm.Modules.Count == 1);
vm.FilterText = "dotnet";
await Waiters.WaitForAsync(() => vm.SelectedProcess == null);
vm.Modules.Should().BeEmpty("the assemblies of a process that is no longer listed are stale");
vm.IsLoadingModules.Should().BeFalse();
}
[AvaloniaTest]
public async Task Error_Bar_Shows_The_ViewModels_Error_Message()
{

1
ILSpy/Assets/Icons/Process.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.st0{opacity:0}.st0,.st1{fill:#f6f6f6}.st2{fill:#424242}.st3{fill:#f0eff1}</style><g id="outline"><path class="st0" d="M0 0h16v16H0z"/><path class="st1" d="M16 5.743l-1.018-.559L16 4.586v-.573l-.101-.354a3.982 3.982 0 0 0-.185-.556 4.286 4.286 0 0 0-.267-.497l-.452-.769-1.294.375.34-1.303-.681-.376A5.432 5.432 0 0 0 12.1.032L11.976 0h-.719l-.561 1.021L10.098 0h-.576l-.363.105c-.182.05-.363.104-.539.18a4.01 4.01 0 0 0-.506.271l-.763.452.376 1.292-1.307-.341-.375.683c-.22.4-.388.824-.5 1.262l-.194.753 1.181.647-1.159.68.014.049C5.259 6.018 5.132 6 5 6c-.183 0-.359.021-.631.059l-.77.099-.139 1.124-.897-.699-.615.477a5.071 5.071 0 0 0-.89.891l-.475.614.698.895-.354.044-.782.191-.099.769C.021 10.641 0 10.816 0 11s.021.359.059.632l.099.768 1.124.14-.698.896.475.614c.257.332.557.632.89.891l.614.477.897-.698.044.354.191.783.769.099c.177.023.353.044.536.044s.359-.021.631-.059l.77-.1.139-1.123.897.698.614-.477c.333-.259.632-.559.89-.891l.476-.614-.698-.894.354-.044.782-.191.099-.769c.025-.177.046-.352.046-.536a3.71 3.71 0 0 0-.029-.398l.196.051.65-1.185.681 1.161.851-.244c.184-.05.367-.104.549-.183.19-.083.362-.184.604-.333l.657-.397-.373-1.283 1.306.34.376-.681c.218-.396.387-.821.501-1.264L16 6.463v-.72z"/></g><g id="icon_x5F_bg"><path class="st2" d="M13.797 5.675a3.041 3.041 0 0 0-.012-.947l1.181-.693c-.051-.178-.093-.357-.168-.531-.076-.175-.177-.328-.271-.487l-1.316.382a3.008 3.008 0 0 0-.68-.658l.347-1.329a4.396 4.396 0 0 0-1.029-.41l-.661 1.205a3.079 3.079 0 0 0-.948.012l-.694-1.184c-.177.051-.356.093-.53.168-.175.076-.328.177-.488.271l.383 1.315a3.037 3.037 0 0 0-.659.681l-1.33-.347c-.179.326-.316.67-.409 1.03l1.204.66c-.044.312-.043.63.012.948l-1.181.693c.051.179.093.357.168.531.076.175.176.328.271.488l1.315-.382c.193.258.424.477.681.658l-.348 1.329c.326.18.67.316 1.03.41l.661-1.205c.311.044.629.042.948-.012l.693 1.181c.177-.051.356-.093.53-.168.175-.076.328-.176.486-.271l-.382-1.314c.259-.194.479-.425.659-.681l1.33.347c.18-.326.315-.67.408-1.03l-1.201-.66zm-2.169 1.59a2.201 2.201 0 0 1-1.743-4.041 2.201 2.201 0 0 1 1.743 4.041z"/><path class="st2" d="M7.714 10.342a2.747 2.747 0 0 0-.331-.795l.767-.984a3.972 3.972 0 0 0-.712-.713l-.985.767a2.786 2.786 0 0 0-.796-.331l-.153-1.235C5.337 7.029 5.173 7 5 7s-.337.029-.504.051l-.153 1.235a2.786 2.786 0 0 0-.796.331l-.985-.767a3.972 3.972 0 0 0-.712.713l.768.984c-.149.244-.263.51-.331.795l-1.235.154C1.029 10.663 1 10.827 1 11s.029.337.051.504l1.235.154c.068.285.182.551.331.795l-.767.985c.207.268.445.506.712.713l.985-.768c.243.148.51.262.796.331l.153 1.235c.167.022.331.051.504.051s.337-.029.504-.051l.153-1.235c.286-.069.553-.183.796-.331l.985.768c.267-.207.505-.445.712-.713l-.768-.984c.149-.244.263-.51.331-.795l1.235-.154C8.971 11.337 9 11.173 9 11s-.029-.337-.051-.504l-1.235-.154zM5 13a2 2 0 1 1 .001-4.001A2 2 0 0 1 5 13z"/><circle class="st2" cx="5" cy="11" r="1"/><ellipse transform="rotate(-23.296 10.757 5.244)" class="st2" cx="10.757" cy="5.244" rx="1.1" ry="1.1"/></g><g id="icon_x5F_fg"><path class="st3" d="M5 9a2 2 0 1 0 .001 4.001A2 2 0 0 0 5 9zm0 3.05a1.05 1.05 0 1 1 .001-2.101A1.05 1.05 0 0 1 5 12.05zM12.777 4.374a2.2 2.2 0 1 0-4.042 1.742 2.2 2.2 0 0 0 4.042-1.742zm-1.564 1.931a1.155 1.155 0 1 1-.913-2.121 1.155 1.155 0 0 1 .913 2.121z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

2
ILSpy/Commands/FileCommands.cs

@ -154,7 +154,7 @@ namespace ICSharpCode.ILSpy.Commands @@ -154,7 +154,7 @@ namespace ICSharpCode.ILSpy.Commands
}
}
[ExportMainMenuCommand(ParentMenuID = nameof(Resources._File), Header = nameof(Resources.OpenFrom_RunningProcess), MenuCategory = nameof(Resources.Open), MenuOrder = 1.7)]
[ExportMainMenuCommand(ParentMenuID = nameof(Resources._File), Header = nameof(Resources.OpenFrom_RunningProcess), MenuIcon = "Images/Process", MenuCategory = nameof(Resources.Open), MenuOrder = 1.7)]
[Shared]
[method: ImportingConstructor]
sealed class OpenFromRunningProcessCommand(AssemblyTreeModel assemblyTreeModel) : SimpleCommand

1
ILSpy/Images.cs

@ -69,6 +69,7 @@ namespace ICSharpCode.ILSpy @@ -69,6 +69,7 @@ namespace ICSharpCode.ILSpy
public static readonly IImage Search = LoadSvg(nameof(Search));
public static readonly IImage Library = LoadSvg(nameof(Library));
public static readonly IImage NuGet = LoadPng(nameof(NuGet));
public static readonly IImage Process = LoadSvg(nameof(Process));
public static readonly IImage MetadataFile = LoadSvg(nameof(MetadataFile));
public static readonly IImage WebAssemblyFile = LoadSvg("WebAssembly");
public static readonly IImage ProgramDebugDatabase = LoadSvg(nameof(ProgramDebugDatabase));

152
ILSpy/Processes/DiagnosticsIpcClient.cs

@ -74,52 +74,85 @@ namespace ICSharpCode.ILSpy.Processes @@ -74,52 +74,85 @@ namespace ICSharpCode.ILSpy.Processes
// ample; oversizing it would make the target runtime reserve memory for nothing.
const uint CircularBufferMB = 16;
static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(2);
// Generous for what it covers - a healthy runtime answers a status query in
// milliseconds - because the only thing on the other side of it is a runtime that will
// never answer at all: one suspended on a startup diagnostic port, or wedged. A tighter
// budget buys nothing and turns a loaded machine into a process listed without its
// metadata.
static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
// 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)
public static Task<DotNetProcessInfo> GetProcessInfoAsync(int pid, CancellationToken cancellationToken)
=> GetProcessInfoAsync(pid, CommandTimeout, cancellationToken);
/// <summary>
/// Overload with an explicit budget, so a test can pin the timeout behavior without
/// waiting out the production one.
/// </summary>
internal static async Task<DotNetProcessInfo> GetProcessInfoAsync(
int pid, TimeSpan commandTimeout, CancellationToken cancellationToken)
{
try
{
return await QueryProcessInfoAsync(pid, ProcessInfo2CommandId, cancellationToken).ConfigureAwait(false);
return await QueryProcessInfoAsync(pid, ProcessInfo2CommandId, commandTimeout, 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);
return await QueryProcessInfoAsync(pid, ProcessInfoCommandId, commandTimeout, cancellationToken).ConfigureAwait(false);
}
}
static async Task<DotNetProcessInfo> QueryProcessInfoAsync(int pid, byte commandId, CancellationToken cancellationToken)
static Task<DotNetProcessInfo> QueryProcessInfoAsync(
int pid, byte commandId, TimeSpan commandTimeout, CancellationToken cancellationToken)
=> WithBudgetAsync(pid, commandTimeout, cancellationToken, async token => {
Stream stream = await ConnectAsync(pid, token).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
byte[] request = DiagnosticsIpcMessage.EncodeRequest(ProcessCommandSet, commandId, ReadOnlySpan<byte>.Empty);
await stream.WriteAsync(request, token).ConfigureAwait(false);
byte[] payload = await DiagnosticsIpcMessage.ReadResponseAsync(stream, 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 one command under a budget, turning an expired budget into a
/// <see cref="TimeoutException"/> that names the process. The conversion is what keeps
/// the two outcomes apart: callers are expected to swallow cancellation, because that
/// is the dialog closing, and a target that never answered must not vanish with it.
/// </summary>
static async Task<T> WithBudgetAsync<T>(int pid, TimeSpan budget,
CancellationToken cancellationToken, Func<CancellationToken, Task<T>> command)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(CommandTimeout);
Stream stream = await ConnectAsync(pid, timeout.Token).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
timeout.CancelAfter(budget);
try
{
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);
return await command(timeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException(
$"Process {pid} did not answer its diagnostics endpoint within {budget.TotalSeconds:0.###} seconds.");
}
}
@ -143,42 +176,39 @@ namespace ICSharpCode.ILSpy.Processes @@ -143,42 +176,39 @@ namespace ICSharpCode.ILSpy.Processes
}
}
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);
static Task<MemoryStream> CollectAsync(int pid, byte commandId, CancellationToken cancellationToken)
=> WithBudgetAsync(pid, RundownTimeout, cancellationToken, async token => {
Stream session = await ConnectAsync(pid, token).ConfigureAwait(false);
await using (session.ConfigureAwait(false))
{
byte[] request = DiagnosticsIpcMessage.EncodeRequest(
EventPipeCommandSet, commandId, BuildCollectTracingPayload(commandId));
await session.WriteAsync(request, 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);
byte[] response = await DiagnosticsIpcMessage.ReadResponseAsync(session, 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);
// 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, token);
try
{
await StopTracingAsync(pid, sessionId, token).ConfigureAwait(false);
await drain.ConfigureAwait(false);
}
catch
{
await trace.DisposeAsync().ConfigureAwait(false);
throw;
}
trace.Position = 0;
return trace;
}
catch
{
await trace.DisposeAsync().ConfigureAwait(false);
throw;
}
trace.Position = 0;
return trace;
}
}
});
static async Task StopTracingAsync(int pid, ulong sessionId, CancellationToken cancellationToken)
{

36
ILSpy/Processes/NettraceRundownReader.cs

@ -102,10 +102,10 @@ namespace ICSharpCode.ILSpy.Processes @@ -102,10 +102,10 @@ namespace ICSharpCode.ILSpy.Processes
SkipTraceObject(reader);
break;
case "MetadataBlock":
ReadBlock(reader, (payload, id, _) => ReadMetadataEvent(payload, id, metadata));
ReadBlock(reader, (payload, id, end) => ReadMetadataEvent(payload, id, end, metadata));
break;
case "EventBlock":
ReadBlock(reader, (payload, id, _) => ReadEvent(payload, id, metadata, modules, assemblyNames));
ReadBlock(reader, (payload, id, end) => ReadEvent(payload, id, end, metadata, modules, assemblyNames));
break;
default:
// StackBlock, SPBlock and any block type added later: the block is
@ -182,9 +182,10 @@ namespace ICSharpCode.ILSpy.Processes @@ -182,9 +182,10 @@ namespace ICSharpCode.ILSpy.Processes
/// <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.
/// <paramref name="onEvent"/> together with its metadata id and the stream position
/// where that payload ends - the bound every field read out of it must respect.
/// </summary>
static void ReadBlock(BinaryReader reader, Action<BinaryReader, int, int> onEvent)
static void ReadBlock(BinaryReader reader, Action<BinaryReader, int, long> onEvent)
{
long blockEnd = BeginBlock(reader, out bool compressed);
@ -203,7 +204,7 @@ namespace ICSharpCode.ILSpy.Processes @@ -203,7 +204,7 @@ namespace ICSharpCode.ILSpy.Processes
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);
onEvent(reader, metadataId, payloadEnd);
reader.BaseStream.Seek(payloadEnd, SeekOrigin.Begin);
if (!compressed)
@ -307,20 +308,20 @@ namespace ICSharpCode.ILSpy.Processes @@ -307,20 +308,20 @@ namespace ICSharpCode.ILSpy.Processes
/// 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)
static void ReadMetadataEvent(BinaryReader reader, int metadataId, long payloadEnd, 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);
string providerName = ReadUtf16NullTerminated(reader, payloadEnd);
int eventId = reader.ReadInt32();
ReadUtf16NullTerminated(reader); // event name, empty for manifest-based providers
ReadUtf16NullTerminated(reader, payloadEnd); // 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,
static void ReadEvent(BinaryReader reader, int metadataId, long payloadEnd, Dictionary<int, EventMetadata> metadata,
List<ModuleRecord> modules, Dictionary<long, string> assemblyNames)
{
if (!metadata.TryGetValue(metadataId, out var meta))
@ -335,8 +336,8 @@ namespace ICSharpCode.ILSpy.Processes @@ -335,8 +336,8 @@ namespace ICSharpCode.ILSpy.Processes
long assemblyId = reader.ReadInt64();
reader.ReadInt32(); // module flags
reader.ReadInt32(); // reserved
string ilPath = ReadUtf16NullTerminated(reader);
string nativePath = ReadUtf16NullTerminated(reader);
string ilPath = ReadUtf16NullTerminated(reader, payloadEnd);
string nativePath = ReadUtf16NullTerminated(reader, payloadEnd);
modules.Add(new ModuleRecord(assemblyId, ilPath, nativePath));
}
else if (IsAssemblyEvent(meta))
@ -346,7 +347,7 @@ namespace ICSharpCode.ILSpy.Processes @@ -346,7 +347,7 @@ namespace ICSharpCode.ILSpy.Processes
if (meta.Version >= 1)
reader.ReadInt64(); // binding id
reader.ReadInt32(); // assembly flags
string fullName = ReadUtf16NullTerminated(reader);
string fullName = ReadUtf16NullTerminated(reader, payloadEnd);
if (fullName.Length > 0)
{
// "Foo, Version=1.0.0.0, Culture=..." - the simple name is enough to
@ -369,16 +370,23 @@ namespace ICSharpCode.ILSpy.Processes @@ -369,16 +370,23 @@ namespace ICSharpCode.ILSpy.Processes
_ => false,
};
static string ReadUtf16NullTerminated(BinaryReader reader)
/// <summary>
/// Reads a UTF-16 string terminated by a zero word, refusing to read past
/// <paramref name="payloadEnd"/>. The bound is what keeps a payload whose layout does
/// not match what its event id promised from being followed out of its own event and
/// through the rest of the stream in search of a terminator.
/// </summary>
internal static string ReadUtf16NullTerminated(BinaryReader reader, long payloadEnd)
{
var builder = new StringBuilder();
while (true)
while (reader.BaseStream.Position + sizeof(ushort) <= payloadEnd)
{
ushort c = reader.ReadUInt16();
if (c == 0)
return builder.ToString();
builder.Append((char)c);
}
throw new InvalidDataException("A Nettrace event payload ends inside an unterminated string.");
}
static IReadOnlyList<ProcessModuleInfo> BuildModuleList(

26
ILSpy/Processes/ProcessExplorer.cs

@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
@ -76,19 +77,13 @@ namespace ICSharpCode.ILSpy.Processes @@ -76,19 +77,13 @@ namespace ICSharpCode.ILSpy.Processes
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)
catch (Exception ex) when (IsUnreachable(ex))
{
// 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(
@ -119,6 +114,23 @@ namespace ICSharpCode.ILSpy.Processes @@ -119,6 +114,23 @@ namespace ICSharpCode.ILSpy.Processes
}
}
/// <summary>
/// Whether an exception means "that process did not answer" rather than "this code is
/// wrong". Such a process is still listed, only without the metadata that none but its
/// own runtime could supply; anything else propagates, because a process explorer that
/// swallows its own defects reports an empty machine.
/// </summary>
/// <remarks>
/// The endpoints are queried concurrently, so this classification decides between
/// losing one row and losing the listing. Both transports are covered:
/// <see cref="Win32Exception"/> is the base of <see cref="System.Net.Sockets.SocketException"/>
/// and thus not an <see cref="IOException"/> - a unix socket file whose runtime is gone
/// refuses the connection and raises it - while a Windows named pipe with no server
/// behind it surfaces as <see cref="TimeoutException"/>.
/// </remarks>
internal static bool IsUnreachable(Exception ex)
=> ex is IOException or Win32Exception or TimeoutException or UnauthorizedAccessException;
/// <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

86
ILSpy/ViewModels/OpenFromProcessDialogViewModel.cs

@ -90,10 +90,11 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -90,10 +90,11 @@ namespace ICSharpCode.ILSpy.ViewModels
partial void OnFilterTextChanged(string value) => ApplyFilter();
partial void OnSelectedProcessChanged(ProcessRowViewModel? value) => _ = LoadModulesAsync(value);
partial void OnSelectedProcessChanged(ProcessRowViewModel? value)
=> LoadModulesAsync(value).HandleExceptions();
[RelayCommand]
void Refresh() => _ = RefreshAsync();
void Refresh() => RefreshAsync().HandleExceptions();
bool CanAddSelectedModules => SelectedModules.Any(m => !m.IsInMemory);
@ -120,13 +121,28 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -120,13 +121,28 @@ namespace ICSharpCode.ILSpy.ViewModels
/// <summary>Cancels every in-flight query; called when the dialog closes.</summary>
public void CancelAllOperations()
{
refreshCts?.Cancel();
modulesCts?.Cancel();
CancelAndDispose(ref refreshCts);
CancelAndDispose(ref modulesCts);
}
/// <summary>
/// Cancels a query's token source and lets go of it. Disposing straight after
/// cancelling is safe because the cancellation callbacks have already run by then, and
/// the method that owns the source only ever reads
/// <see cref="CancellationTokenSource.IsCancellationRequested"/> afterwards, which
/// stays valid on a disposed source.
/// </summary>
static void CancelAndDispose(ref CancellationTokenSource? source)
{
var previous = source;
source = null;
previous?.Cancel();
previous?.Dispose();
}
async Task RefreshAsync()
{
refreshCts?.Cancel();
CancelAndDispose(ref refreshCts);
var cts = refreshCts = new CancellationTokenSource();
IsLoadingProcesses = true;
try
@ -136,10 +152,7 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -136,10 +152,7 @@ namespace ICSharpCode.ILSpy.ViewModels
return;
ErrorMessage = null;
SelectedProcess = null;
allProcesses.Clear();
allProcesses.AddRange(processes.Select(p => new ProcessRowViewModel(p)));
ApplyFilter();
ReplaceProcesses(processes);
}
catch (OperationCanceledException)
{
@ -148,8 +161,7 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -148,8 +161,7 @@ namespace ICSharpCode.ILSpy.ViewModels
catch (Exception ex)
{
ErrorMessage = ex.Message;
allProcesses.Clear();
ApplyFilter();
ReplaceProcesses(Array.Empty<RunningDotNetProcess>());
}
finally
{
@ -158,16 +170,54 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -158,16 +170,54 @@ namespace ICSharpCode.ILSpy.ViewModels
}
}
/// <summary>
/// Puts a freshly scanned list in place of the current one. The selection goes first:
/// the rows it pointed into are about to be gone, and a selection left behind keeps the
/// entry-assembly button armed against a process nobody can see any more. The failure
/// path empties the list the same way, for the same reason.
/// </summary>
void ReplaceProcesses(IReadOnlyList<RunningDotNetProcess> processes)
{
SelectedProcess = null;
allProcesses.Clear();
allProcesses.AddRange(processes.Select(p => new ProcessRowViewModel(p)));
ApplyFilter();
}
/// <summary>
/// Brings the bound collection in line with what the filter admits, by removing and
/// inserting rows rather than rebuilding it. Clearing it would make the grid drop its
/// selection and write that null back through the two-way binding - discarding the
/// assembly list of a process the newly typed filter still matches.
/// </summary>
void ApplyFilter()
{
Processes.Clear();
foreach (var process in allProcesses.Where(p => p.Matches(FilterText)))
Processes.Add(process);
var matching = allProcesses.Where(p => p.Matches(FilterText)).ToList();
var admitted = new HashSet<ProcessRowViewModel>(matching);
// A selection the filter no longer admits is dropped here rather than left to the
// grid, which holds on to a removed row: the assembly pane must not go on
// describing a process that is no longer in the list.
if (SelectedProcess != null && !admitted.Contains(SelectedProcess))
SelectedProcess = null;
for (int i = Processes.Count - 1; i >= 0; i--)
{
if (!admitted.Contains(Processes[i]))
Processes.RemoveAt(i);
}
// What is left is the matching rows in their original relative order, so any row
// missing at position i belongs exactly there.
for (int i = 0; i < matching.Count; i++)
{
if (i >= Processes.Count)
Processes.Add(matching[i]);
else if (!ReferenceEquals(Processes[i], matching[i]))
Processes.Insert(i, matching[i]);
}
}
async Task LoadModulesAsync(ProcessRowViewModel? process)
{
modulesCts?.Cancel();
CancelAndDispose(ref modulesCts);
var cts = modulesCts = new CancellationTokenSource();
int generation = ++modulesGeneration;
@ -175,7 +225,13 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -175,7 +225,13 @@ namespace ICSharpCode.ILSpy.ViewModels
SelectedModules.Clear();
SetEntryAssemblyPath(null);
if (process == null)
{
// Nothing is selected, so nothing is loading. The query just cancelled cannot
// say so on its way out: its generation has been superseded, which is what the
// guard below tests.
IsLoadingModules = false;
return;
}
IsLoadingModules = true;
try

2
ILSpy/ViewModels/ProcessRowViewModel.cs

@ -44,8 +44,6 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -44,8 +44,6 @@ namespace ICSharpCode.ILSpy.ViewModels
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>

Loading…
Cancel
Save