// 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.Reflection.Emit;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AwesomeAssertions;
using ICSharpCode.ILSpy.Processes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Processes;
///
/// Covers the loaded-assembly half of the process explorer: an EventPipe rundown session
/// against the test host itself, and the scoped nettrace reader that turns the resulting
/// stream into module entries. The test host is the one process guaranteed to be running
/// a known set of assemblies on every OS, so it doubles as the fixture.
///
[TestFixture]
public class NettraceRundownReaderTests
{
static MemoryStream? rundown;
// 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";
///
/// 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.
///
[OneTimeSetUp]
public async Task CollectRundownOfTheTestHost()
{
inMemoryFixture = EmitAssemblyWithNoFile();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
rundown = await DiagnosticsIpcClient.CollectModuleRundownAsync(Environment.ProcessId, cts.Token);
}
///
/// An assembly that exists only in this process's memory - the case a user hits with
/// Assembly.Load(byte[]), a dynamic proxy, or a source generator's scratch
/// assembly. It is what the reader must classify as unopenable.
///
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();
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.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().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));
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]
public void Modules_Are_Reported_Once_Each()
{
var modules = NettraceRundownReader.ReadModules(Rundown());
modules.Where(m => !m.IsInMemory).Select(m => m.Path)
.Should().OnlyHaveUniqueItems("a module loaded once must not be listed twice");
}
[Test]
public void A_Stream_That_Is_Not_Nettrace_Is_Rejected_Clearly()
{
using var garbage = new MemoryStream(Encoding.ASCII.GetBytes("this is not a trace"));
var read = () => NettraceRundownReader.ReadModules(garbage);
read.Should().Throw().WithMessage("*Nettrace*");
}
}