diff --git a/build.sln b/build.sln index 2b073da928..7e47e54da9 100644 --- a/build.sln +++ b/build.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "build", "build.proj", "{8AEC426C-BB15-4924-9E39-9C2A4C11D602}" EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "dbgshim", "src\dbgshim\dbgshim.vcxproj", "{BD779298-8631-3F5D-AA59-82897E5454A7}" @@ -123,6 +123,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApp3", "src\tests\SOS.Un EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SOS.UnitTests", "src\tests\SOS.UnitTests\SOS.UnitTests.csproj", "{3EE8CB83-EC2F-415D-A681-997AC6FB0B9A}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SOS.Tests", "src\tests\SOS.Tests\SOS.Tests.csproj", "{78F49599-5A2B-4D40-8D0D-08A539F80E95}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SOS.TestHarness", "src\tests\SOS.TestHarness\SOS.TestHarness.csproj", "{6F9C3623-C03B-4139-B37C-DCC38218E661}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SOS.TestHarness.EngineHost", "src\tests\SOS.TestHarness.EngineHost\SOS.TestHarness.EngineHost.csproj", "{93CA6105-938C-4074-B8FF-7123346D0AE4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SOS.TestHarness.Capturer", "src\tests\SOS.TestHarness.Capturer\SOS.TestHarness.Capturer.csproj", "{529DB77C-1E13-41BF-BBDC-51D04EE27D03}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SOS.TestHarness.SourceGen", "src\tests\SOS.TestHarness.SourceGen\SOS.TestHarness.SourceGen.csproj", "{AD8E5B2E-813D-40FB-8749-513008C0F756}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StackTracee", "src\tests\StackTracee\StackTracee.csproj", "{614E442A-A8FD-4E14-9C3E-1F312864D9E0}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestExtension", "src\tests\TestExtension\TestExtension.csproj", "{B5378BE2-284A-4614-9415-7FB6A7B9D452}" @@ -1315,6 +1325,96 @@ Global {3EE8CB83-EC2F-415D-A681-997AC6FB0B9A}.RelWithDebInfo|Any CPU.ActiveCfg = Debug|Any CPU {3EE8CB83-EC2F-415D-A681-997AC6FB0B9A}.RelWithDebInfo|x64.ActiveCfg = Debug|Any CPU {3EE8CB83-EC2F-415D-A681-997AC6FB0B9A}.RelWithDebInfo|x86.ActiveCfg = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Debug|x64.ActiveCfg = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Debug|x64.Build.0 = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Debug|x86.ActiveCfg = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Debug|x86.Build.0 = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Release|Any CPU.Build.0 = Release|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Release|x64.ActiveCfg = Release|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Release|x64.Build.0 = Release|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Release|x86.ActiveCfg = Release|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Release|x86.Build.0 = Release|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Checked|Any CPU.ActiveCfg = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Checked|x64.ActiveCfg = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.Checked|x86.ActiveCfg = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.RelWithDebInfo|Any CPU.ActiveCfg = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.RelWithDebInfo|x64.ActiveCfg = Debug|Any CPU + {78F49599-5A2B-4D40-8D0D-08A539F80E95}.RelWithDebInfo|x86.ActiveCfg = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Debug|x64.ActiveCfg = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Debug|x64.Build.0 = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Debug|x86.ActiveCfg = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Debug|x86.Build.0 = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Release|Any CPU.Build.0 = Release|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Release|x64.ActiveCfg = Release|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Release|x64.Build.0 = Release|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Release|x86.ActiveCfg = Release|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Release|x86.Build.0 = Release|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Checked|Any CPU.ActiveCfg = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Checked|x64.ActiveCfg = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.Checked|x86.ActiveCfg = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.RelWithDebInfo|Any CPU.ActiveCfg = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.RelWithDebInfo|x64.ActiveCfg = Debug|Any CPU + {6F9C3623-C03B-4139-B37C-DCC38218E661}.RelWithDebInfo|x86.ActiveCfg = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Debug|x64.ActiveCfg = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Debug|x64.Build.0 = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Debug|x86.ActiveCfg = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Debug|x86.Build.0 = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Release|Any CPU.Build.0 = Release|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Release|x64.ActiveCfg = Release|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Release|x64.Build.0 = Release|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Release|x86.ActiveCfg = Release|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Release|x86.Build.0 = Release|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Checked|Any CPU.ActiveCfg = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Checked|x64.ActiveCfg = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.Checked|x86.ActiveCfg = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.RelWithDebInfo|Any CPU.ActiveCfg = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.RelWithDebInfo|x64.ActiveCfg = Debug|Any CPU + {93CA6105-938C-4074-B8FF-7123346D0AE4}.RelWithDebInfo|x86.ActiveCfg = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Debug|x64.ActiveCfg = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Debug|x64.Build.0 = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Debug|x86.ActiveCfg = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Debug|x86.Build.0 = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Release|Any CPU.Build.0 = Release|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Release|x64.ActiveCfg = Release|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Release|x64.Build.0 = Release|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Release|x86.ActiveCfg = Release|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Release|x86.Build.0 = Release|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Checked|Any CPU.ActiveCfg = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Checked|x64.ActiveCfg = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.Checked|x86.ActiveCfg = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.RelWithDebInfo|Any CPU.ActiveCfg = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.RelWithDebInfo|x64.ActiveCfg = Debug|Any CPU + {529DB77C-1E13-41BF-BBDC-51D04EE27D03}.RelWithDebInfo|x86.ActiveCfg = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Debug|x64.ActiveCfg = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Debug|x64.Build.0 = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Debug|x86.ActiveCfg = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Debug|x86.Build.0 = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Release|Any CPU.Build.0 = Release|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Release|x64.ActiveCfg = Release|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Release|x64.Build.0 = Release|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Release|x86.ActiveCfg = Release|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Release|x86.Build.0 = Release|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Checked|Any CPU.ActiveCfg = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Checked|x64.ActiveCfg = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.Checked|x86.ActiveCfg = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.RelWithDebInfo|Any CPU.ActiveCfg = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.RelWithDebInfo|x64.ActiveCfg = Debug|Any CPU + {AD8E5B2E-813D-40FB-8749-513008C0F756}.RelWithDebInfo|x86.ActiveCfg = Debug|Any CPU {614E442A-A8FD-4E14-9C3E-1F312864D9E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {614E442A-A8FD-4E14-9C3E-1F312864D9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU {614E442A-A8FD-4E14-9C3E-1F312864D9E0}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1579,6 +1679,11 @@ Global {AEDAD92F-C123-4880-A4EB-569074A31DE7} = {C02DE277-7341-4C63-B105-BA695F40277F} {C02DE277-7341-4C63-B105-BA695F40277F} = {2E5032C3-87A7-43F7-B729-6B1ECDC84B32} {3EE8CB83-EC2F-415D-A681-997AC6FB0B9A} = {2E5032C3-87A7-43F7-B729-6B1ECDC84B32} + {78F49599-5A2B-4D40-8D0D-08A539F80E95} = {2E5032C3-87A7-43F7-B729-6B1ECDC84B32} + {6F9C3623-C03B-4139-B37C-DCC38218E661} = {2E5032C3-87A7-43F7-B729-6B1ECDC84B32} + {93CA6105-938C-4074-B8FF-7123346D0AE4} = {2E5032C3-87A7-43F7-B729-6B1ECDC84B32} + {529DB77C-1E13-41BF-BBDC-51D04EE27D03} = {2E5032C3-87A7-43F7-B729-6B1ECDC84B32} + {AD8E5B2E-813D-40FB-8749-513008C0F756} = {2E5032C3-87A7-43F7-B729-6B1ECDC84B32} {2E5032C3-87A7-43F7-B729-6B1ECDC84B32} = {59E97F80-3F6D-4E83-9962-236C98411375} {DE21994B-542A-437E-8405-F62A5A3F41D4} = {59E97F80-3F6D-4E83-9962-236C98411375} {CEDB87DB-22B9-4466-B79F-AA2A40BE6AA9} = {59E97F80-3F6D-4E83-9962-236C98411375} diff --git a/eng/build.ps1 b/eng/build.ps1 index 64db3d9c63..87ae7fd401 100644 --- a/eng/build.ps1 +++ b/eng/build.ps1 @@ -53,6 +53,7 @@ $logdir = Join-Path $artifactsdir "log" $logdir = Join-Path $logdir Windows_NT.$architecture.$configuration $bl = if ($binaryLog) { '-binaryLog' } else { '' } +$unprocessedBuildArgs = @($remainingargs) if ($ci) { $remainingargs = "-ci " + $remainingargs @@ -170,7 +171,8 @@ if ($test) { /p:RuntimeSourceFeed="$runtimesourcefeed" ` /p:RuntimeSourceFeedKey="$runtimesourcefeedkey" ` /p:LiveRuntimeDir="$liveRuntimeDir" ` - $testFilterArg + $testFilterArg ` + @unprocessedBuildArgs if ($lastExitCode -ne 0) { exit $lastExitCode diff --git a/eng/build.sh b/eng/build.sh index be3b35785f..f00babc626 100755 --- a/eng/build.sh +++ b/eng/build.sh @@ -401,7 +401,8 @@ if [[ "$__Test" == 1 ]]; then /p:RuntimeSourceFeedKey="$__RuntimeSourceFeedKey" \ /p:LiveRuntimeDir="$__LiveRuntimeDir" \ "$__TestFilterArg" \ - $__CommonMSBuildArgs + $__CommonMSBuildArgs \ + $__UnprocessedBuildArgs if [ $? != 0 ]; then exit 1 diff --git a/src/tests/Debuggees.proj b/src/tests/Debuggees.proj index 3f112eba95..ed5a0abc50 100644 --- a/src/tests/Debuggees.proj +++ b/src/tests/Debuggees.proj @@ -7,4 +7,39 @@ + + + + + + + + + + + + + + + + <_SOSSingleFileRuntime Include="@(RuntimeTestVersions)" + Condition="'%(RuntimeTestVersions.TargetFramework)' != ''" /> + + + + + + + + diff --git a/src/tests/SOS.TestHarness.Capturer/Program.cs b/src/tests/SOS.TestHarness.Capturer/Program.cs new file mode 100644 index 0000000000..106b7ad89d --- /dev/null +++ b/src/tests/SOS.TestHarness.Capturer/Program.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; + +// Out-of-process desktop-dump capturer. Runs the risky in-process dbgeng live-debugging work in +// a short-lived child process so it can never crash the test host. See CaptureCli for usage. +return CaptureCli.Run(args); diff --git a/src/tests/SOS.TestHarness.Capturer/SOS.TestHarness.Capturer.csproj b/src/tests/SOS.TestHarness.Capturer/SOS.TestHarness.Capturer.csproj new file mode 100644 index 0000000000..e47f1be839 --- /dev/null +++ b/src/tests/SOS.TestHarness.Capturer/SOS.TestHarness.Capturer.csproj @@ -0,0 +1,17 @@ + + + + Exe + $(NetCoreAppTestTargetFramework) + enable + enable + latest + SOS.TestHarness.Capturer + $(TargetRid) + + + + + + + diff --git a/src/tests/SOS.TestHarness.EngineHost/Program.cs b/src/tests/SOS.TestHarness.EngineHost/Program.cs new file mode 100644 index 0000000000..5fe6fc1871 --- /dev/null +++ b/src/tests/SOS.TestHarness.EngineHost/Program.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; + +// The EngineHost child process: hosts dbgeng IN THIS PROCESS (reusing the in-process host +// classes) and serves commands over a stdin/stdout REPL. The test host spawns one of these per +// target and talks to it via EngineProtocol, so the test host itself never loads dbgeng/SOS/DAC — +// which is what was crashing it (ExecutionEngineException). If the engine corrupts THIS process, +// only this child dies and the test sees a clean failure. +// +// Usage: +// EngineHost dump +// EngineHost live + +return EngineHostMain.Run(args); + +internal static class EngineHostMain +{ + public static int Run(string[] args) + { + if (args.Length < 1) + { + Console.Error.WriteLine("usage: EngineHost | live >"); + return 2; + } + + IDebuggerHost host; + DbgEngLiveHost? liveHost = null; + try + { + switch (args[0]) + { + case "dump" when args.Length == 2: + host = new DbgEngDumpHost(args[1]); + host.LoadSos(); + break; + + case "live" when args.Length == 2: + liveHost = new DbgEngLiveHost(args[1]); // launches, breaks, loads SOS + host = liveHost; + break; + + default: + Console.Error.WriteLine("invalid arguments"); + return 2; + } + } + catch (Exception ex) + { + Console.Error.WriteLine("engine open failed: " + ex); + return 1; + } + + // Signal that the target is open and SOS is ready. + Console.Out.WriteLine(EngineProtocol.Ready); + Console.Out.Flush(); + + try + { + string? line; + while ((line = Console.In.ReadLine()) is not null) + { + if (line.Length == 0) + { + continue; + } + + try + { + SosOutput result; + if (line.StartsWith(EngineProtocol.RunToBpmdPrefix, StringComparison.Ordinal)) + { + if (liveHost is null) + { + throw new InvalidOperationException("runtobpmd is only valid for a live engine."); + } + + string[] parts = line[EngineProtocol.RunToBpmdPrefix.Length..].Split(' ', 2); + result = liveHost.RunToBpmd(parts[0], parts[1]); + } + else if (line == EngineProtocol.RunToCrash) + { + if (liveHost is null) + { + throw new InvalidOperationException("runtocrash is only valid for a live engine."); + } + + result = liveHost.RunToCrash(); + } + else if (line == EngineProtocol.RunToBreak) + { + if (liveHost is null) + { + throw new InvalidOperationException("runtobreak is only valid for a live engine."); + } + + result = liveHost.RunToBreakpoint(); + } + else + { + result = host.Execute(line); + } + + Console.Out.Write(result.Text); + if (!result.Text.EndsWith('\n')) + { + Console.Out.Write('\n'); + } + + Console.Out.WriteLine(EngineProtocol.End); + Console.Out.Flush(); + } + catch (Exception ex) + { + Console.Out.WriteLine(ex.Message); + Console.Out.WriteLine(EngineProtocol.Error); + Console.Out.Flush(); + } + } + } + finally + { + host.Dispose(); + } + + return 0; + } +} diff --git a/src/tests/SOS.TestHarness.EngineHost/SOS.TestHarness.EngineHost.csproj b/src/tests/SOS.TestHarness.EngineHost/SOS.TestHarness.EngineHost.csproj new file mode 100644 index 0000000000..2cce79d93c --- /dev/null +++ b/src/tests/SOS.TestHarness.EngineHost/SOS.TestHarness.EngineHost.csproj @@ -0,0 +1,17 @@ + + + + Exe + $(NetCoreAppTestTargetFramework) + enable + enable + latest + SOS.TestHarness.EngineHost + $(TargetRid) + + + + + + + diff --git a/src/tests/SOS.TestHarness.SourceGen/MirrorGenerator.cs b/src/tests/SOS.TestHarness.SourceGen/MirrorGenerator.cs new file mode 100644 index 0000000000..81297f9ec0 --- /dev/null +++ b/src/tests/SOS.TestHarness.SourceGen/MirrorGenerator.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace TestTargetsGen; + +/// +/// Source generator that mirrors the public const and public static readonly +/// (literal-initialized) fields of the test targets into the test project, so tests can assert +/// against the target's own declared values with full intellisense: +/// pe.Field("Message").Equals(TestTargets.NestedExceptions.OuterMessage); +/// +/// The test-target source files are supplied as AdditionalFiles. Only literal-initialized +/// fields are mirrored (the value is known at compile time), which is exactly the const / +/// static-readonly-constant case — no runtime/ClrMD read is needed. Mirrors are emitted under a +/// single fixed namespace () regardless of where the source type +/// lives, since the test targets declare their types in the global namespace. +/// +[Generator] +public sealed class TestTargetsMirrorGenerator : IIncrementalGenerator +{ + private const string MirrorNamespace = "TestTargets"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValueProvider> sources = context.AdditionalTextsProvider + .Where(static a => a.Path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + .Select(static (a, ct) => a.GetText(ct)?.ToString() ?? string.Empty) + .Collect(); + + context.RegisterSourceOutput(sources, static (spc, files) => + { + Dictionary types = new(StringComparer.Ordinal); + foreach (string file in files) + { + foreach (MirrorType type in ParseFile(file)) + { + types[type.Name] = type; // last writer wins; names are unique across targets + } + } + + if (types.Count == 0) + { + return; + } + + spc.AddSource("TestTargetsMirror.g.cs", SourceText.From(Emit(types.Values), Encoding.UTF8)); + }); + } + + private static string Emit(IEnumerable types) + { + StringBuilder sb = new(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.Append("namespace ").AppendLine(MirrorNamespace); + sb.AppendLine("{"); + + foreach (MirrorType type in types.OrderBy(t => t.Name, StringComparer.Ordinal)) + { + sb.AppendLine($" /// Compile-time mirror of const / static-readonly fields of test target type '{type.Name}'."); + sb.AppendLine($" public static class {type.Name}"); + sb.AppendLine(" {"); + foreach (MirrorField field in type.Fields) + { + string kind = field.IsConst ? "const" : "static readonly"; + sb.AppendLine($" public {kind} {field.Type} {field.Name} = {field.Initializer};"); + } + + sb.AppendLine(" }"); + } + + sb.AppendLine("}"); + return sb.ToString(); + } + + private static IEnumerable ParseFile(string source) + { + SyntaxNode root = CSharpSyntaxTree.ParseText(source).GetRoot(); + + foreach (TypeDeclarationSyntax type in root.DescendantNodes().OfType()) + { + if (!type.Modifiers.Any(SyntaxKind.PublicKeyword)) + { + continue; + } + + List fields = new(); + foreach (FieldDeclarationSyntax fd in type.Members.OfType()) + { + SyntaxTokenList mods = fd.Modifiers; + if (!mods.Any(SyntaxKind.PublicKeyword)) + { + continue; + } + + bool isConst = mods.Any(SyntaxKind.ConstKeyword); + bool isStaticReadonly = mods.Any(SyntaxKind.StaticKeyword) && mods.Any(SyntaxKind.ReadOnlyKeyword); + if (!isConst && !isStaticReadonly) + { + continue; + } + + string typeText = fd.Declaration.Type.ToString(); + foreach (VariableDeclaratorSyntax v in fd.Declaration.Variables) + { + if (v.Initializer is null || !IsLiteralLike(v.Initializer.Value)) + { + continue; + } + + fields.Add(new MirrorField(isConst, typeText, v.Identifier.Text, v.Initializer.Value.ToString())); + } + } + + if (fields.Count > 0) + { + yield return new MirrorType(type.Identifier.Text, fields); + } + } + } + + /// True for expressions whose value is known at compile time (literals and operators over them). + private static bool IsLiteralLike(ExpressionSyntax expr) => expr switch + { + LiteralExpressionSyntax => true, + PrefixUnaryExpressionSyntax u => IsLiteralLike(u.Operand), + ParenthesizedExpressionSyntax p => IsLiteralLike(p.Expression), + BinaryExpressionSyntax b => IsLiteralLike(b.Left) && IsLiteralLike(b.Right), + _ => false, + }; + + private sealed class MirrorType + { + public MirrorType(string name, List fields) + { + Name = name; + Fields = fields; + } + + public string Name { get; } + + public List Fields { get; } + } + + private sealed class MirrorField + { + public MirrorField(bool isConst, string type, string name, string initializer) + { + IsConst = isConst; + Type = type; + Name = name; + Initializer = initializer; + } + + public bool IsConst { get; } + + public string Type { get; } + + public string Name { get; } + + public string Initializer { get; } + } +} diff --git a/src/tests/SOS.TestHarness.SourceGen/SOS.TestHarness.SourceGen.csproj b/src/tests/SOS.TestHarness.SourceGen/SOS.TestHarness.SourceGen.csproj new file mode 100644 index 0000000000..5a6a6b3d89 --- /dev/null +++ b/src/tests/SOS.TestHarness.SourceGen/SOS.TestHarness.SourceGen.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + enable + true + true + false + false + + + + + + + + diff --git a/src/tests/SOS.TestHarness/CaptureCli.cs b/src/tests/SOS.TestHarness/CaptureCli.cs new file mode 100644 index 0000000000..d6145bdb0d --- /dev/null +++ b/src/tests/SOS.TestHarness/CaptureCli.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// Entry point for the out-of-process capture helper (the Capturer exe). Captures the +/// desktop .NET Framework dumps for one (target) using in-process dbgeng — but in a +/// short-lived child process, so the heavy/risky live-debugging dbgeng work never runs +/// inside the test host (where it can corrupt the host CLR; see the design note's subprocess +/// recommendation). On success the dumps land in the given directory and the process exits 0. +/// +public static class CaptureCli +{ + /// Args: <exePath> <targetName> <dumpDir> <dumpKind>. + public static int Run(string[] args) + { + if (args.Length != 4) + { + Console.Error.WriteLine("usage: Capturer "); + return 2; + } + + string exePath = args[0]; + string targetName = args[1]; + string dumpDir = args[2]; + if (!Enum.TryParse(args[3], ignoreCase: true, out DumpKind dumpKind)) + { + Console.Error.WriteLine($"Unknown dump kind '{args[3]}'."); + return 2; + } + + try + { + TargetDefinition target = TargetCatalog.Get(targetName); + Directory.CreateDirectory(dumpDir); + DbgEngCapturer.Capture(exePath, target, dumpDir, dumpKind); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + return 1; + } + } +} diff --git a/src/tests/SOS.TestHarness/ChildEngineClient.cs b/src/tests/SOS.TestHarness/ChildEngineClient.cs new file mode 100644 index 0000000000..3d1fa037d8 --- /dev/null +++ b/src/tests/SOS.TestHarness/ChildEngineClient.cs @@ -0,0 +1,220 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; + +namespace SOS.TestHarness; + +/// +/// The test-host side of the subprocess dbgeng backend: spawns an EngineHost child that +/// hosts dbgeng in-process and drives it over . From the test host's +/// perspective this is just another child-process REPL (like ), so the +/// test host never loads dbgeng/SOS/DAC and can't be crashed by them. Because each target is its +/// own child process, many can be alive at once — lifting the single-instance limit that +/// in-process dbgeng imposed. +/// +/// The child blocks on stdin between commands (no busy-wait), so idle clients are cheap. +/// +public sealed class ChildEngineClient : ILiveDebuggerHost +{ + private readonly Process _process; + private readonly StreamWriter _stdin; + private readonly BlockingCollection _lines = new(); + private readonly Thread _reader; + + public string Name { get; } + + private ChildEngineClient(string name, string mode, IReadOnlyList modeArgs, string? dacDir, Dac dac, Flavor flavor) + { + Name = name; + + ProcessStartInfo psi = new() + { + FileName = RepoLayout.DotNetExe, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.ArgumentList.Add(SnapshotStore.EngineHostDll); + psi.ArgumentList.Add(mode); + foreach (string a in modeArgs) + { + psi.ArgumentList.Add(a); + } + + // _NT_SYMBOL_PATH is ALWAYS fully replaced with a harness-constructed value - never inherited + // (the dev's ambient _NT_SYMBOL_PATH may point at the Azure-authed symweb, which crashes SOS host + // init and makes tests network-dependent). + Directory.CreateDirectory(RepoLayout.SymbolCache); + string symbolPath = RepoLayout.SymbolCache; + + // For self-contained single-file the runtime (coreclr.dll) and the DAC (mscordaccore.dll) are + // bundled in the exe, so dbgeng can't find them on disk. Point it at the runtime pack that has + // both: on the symbol/image path so dbgeng can index coreclr, and via SOSHARNESS_DAC_DIR so the + // EngineHost runs `.cordll -lp ` (DAC load path) before `.load sos`. + if (!string.IsNullOrEmpty(dacDir)) + { + symbolPath += ";" + dacDir; + psi.Environment["_NT_EXECUTABLE_IMAGE_PATH"] = dacDir; + psi.Environment["SOSHARNESS_DAC_DIR"] = dacDir; + } + + psi.Environment["_NT_SYMBOL_PATH"] = symbolPath; + + if (mode == "live") + { + // bpmd patches JIT code when binding managed breakpoints. Disable W^X for live + // debuggees so those writes do not trigger access violations and wedge the target. + psi.Environment["DOTNET_EnableWriteXorExecute"] = "0"; + } + + // The live debuggee is launched by the EngineHost (via CreateProcessAndAttach) and inherits its + // environment. For a framework-dependent (Core) live target, point the apphost at the multi-version + // test runtime install so it binds the runtime matching its target framework (net8 -> 8.0.x, + // net9 -> 9.0.x, net11 -> the installed preview). Without this the debuggee inherits the ambient + // DOTNET_ROOT (the product .dotnet, which carries only the repo's own runtime, e.g. net10), so a + // net9/net11 apphost fails framework resolution ("Framework 'Microsoft.NETCore.App' version 'x' not + // found") and exits at launch — before bpmd can bind — surfacing as "Process exited without hitting + // a breakpoint". Dump mode launches nothing, and SingleFile/Framework don't use a shared runtime, so + // this only applies to a live Core target. Mirrors LldbLiveHost and the dump-capture path + // (SnapshotStore.ApplyRuntimeRoot). MULTILEVEL_LOOKUP=0 keeps resolution strictly within the test + // install (no machine-wide fallback), so the debuggee's coreclr is the deterministic on-disk one. + if (mode == "live" && flavor == Flavor.Core) + { + psi.Environment["DOTNET_ROOT"] = RepoLayout.DotnetTestRoot; + psi.Environment["DOTNET_ROOT(x86)"] = RepoLayout.DotnetTestRoot; + psi.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; + } + + _process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start EngineHost"); + _stdin = _process.StandardInput; + + _reader = new Thread(ReadLoop) { IsBackground = true, Name = $"enginehost-reader-{name}" }; + _reader.Start(); + + WaitForReady(TimeSpan.FromSeconds(120)); + + // Select the DAC for this config's Dac axis (Legacy => false, CDac on .NET 11+ => true). SOS is + // already loaded by the time the child reports ready, so issue it now. The same dump is reused + // across both DAC values — only this debug-time toggle differs. + Send("!runtimes --usecdac " + DacPolicy.UseCDac(dac)); + } + + /// A child engine over a crash/snapshot dump. + public static ChildEngineClient ForDump(string hostName, string dumpPath, string? dacDir = null, Dac dac = Dac.Legacy) => + new(hostName, "dump", new[] { dumpPath }, dacDir, dac, Flavor.Core); + + /// A live child engine that launches the target (parked at the loader break, SOS loaded). + public static ChildEngineClient ForLive(string hostName, string exePath, string? dacDir = null, Dac dac = Dac.Legacy, Flavor flavor = Flavor.Core) => + new(hostName, "live", new[] { exePath }, dacDir, dac, flavor); + + public void LoadSos() + { + // The child already loads SOS when it opens the target; nothing to do. + } + + public SosOutput Execute(string command) => new(Name, command, Send(command)); + + public SosOutput Sos(string command) => new(Name, command, Send("!" + command)); + + /// Live only: set a managed breakpoint and run to it (handled inside the child). + public SosOutput RunToBpmd(string module, string method) => + new(Name, $"bpmd {module} {method}", Send(EngineProtocol.RunToBpmdPrefix + module + " " + method)); + + /// Live only: run the process to its second-chance crash (handled inside the child). + public SosOutput RunToCrash() => + new(Name, "run-to-crash", Send(EngineProtocol.RunToCrash)); + + /// Live only: resume to the next breakpoint (handled inside the child). + public SosOutput RunToBreakpoint() => + new(Name, "run-to-breakpoint", Send(EngineProtocol.RunToBreak)); + + private string Send(string command) + { + _stdin.WriteLine(command); + _stdin.Flush(); + return DrainToEnd(TimeSpan.FromSeconds(120), command); + } + + private void WaitForReady(TimeSpan timeout) + { + while (true) + { + if (!_lines.TryTake(out string? line, (int)timeout.TotalMilliseconds, HarnessCancellation.Token)) + { + throw new TimeoutException("EngineHost did not become ready in time."); + } + + if (line == EngineProtocol.Ready) + { + return; + } + } + } + + private string DrainToEnd(TimeSpan timeout, string command) + { + StringBuilder sb = new(); + while (true) + { + if (!_lines.TryTake(out string? line, (int)timeout.TotalMilliseconds, HarnessCancellation.Token)) + { + throw new TimeoutException($"EngineHost did not return output for '{command}' within {timeout}."); + } + + if (line == EngineProtocol.End) + { + break; + } + + if (line == EngineProtocol.Error) + { + // The child threw while processing this command (e.g. RunToBreakpoint hit a crash or + // the process exited). Surface it as an exception rather than returning silently with + // a dead session that later commands would fail against. + throw new InvalidOperationException( + $"EngineHost command '{command}' failed: {sb.ToString().TrimEnd()}"); + } + + sb.AppendLine(line); + } + + return sb.ToString(); + } + + private void ReadLoop() + { + string? line; + while ((line = _process.StandardOutput.ReadLine()) is not null) + { + _lines.Add(line); + } + } + + public void Dispose() + { + try + { + if (!_process.HasExited) + { + _stdin.Close(); // EOF -> child's ReadLine returns null -> clean exit + if (!_process.WaitForExit(5000)) + { + _process.Kill(entireProcessTree: true); + } + } + } + catch + { + // best effort + } + finally + { + _process.Dispose(); + } + } +} diff --git a/src/tests/SOS.TestHarness/CoreVersion.cs b/src/tests/SOS.TestHarness/CoreVersion.cs new file mode 100644 index 0000000000..87194ff1cb --- /dev/null +++ b/src/tests/SOS.TestHarness/CoreVersion.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// The .NET Core runtime version a target is built and dumped against — a test-matrix axis. The bit for a +/// version is its major number (so is bit 8, bit 10, ...), which +/// keeps the values self-describing and leaves room up to .NET 31. +/// +/// Semantics: this is a DISABLE mechanism, not a positive declaration of support. A test +/// defaults to (every bit set) and runs against every version the harness actually +/// builds and installs (see ); bits for versions that aren't built are +/// silently ignored. To stop a test running on a version that hits an unfixable runtime/DAC bug, mask the +/// bit off — e.g. CoreVersion.All & ~CoreVersion.Net8 ("everything except .NET 8"). Never use it +/// to positively enumerate "the versions this works on"; the built set is the source of truth for what +/// runs, and the mask only ever removes from it. +/// +[Flags] +public enum CoreVersion : uint +{ + /// + /// No .NET Core version. Used for the desktop flavor, whose runtime is + /// desktop .NET Framework (clr.dll), not a .NET Core version — the axis is inert there, so every Framework + /// config collapses to this single value instead of fanning out one meaningless row per Core version. + /// + None = 0, + + /// .NET 8. + Net8 = 1u << 8, + + /// .NET 9. + Net9 = 1u << 9, + + /// .NET 10. + Net10 = 1u << 10, + + /// .NET 11. + Net11 = 1u << 11, + + /// .NET 12 (not yet built; reserved so a test can pre-emptively opt in/out). + Net12 = 1u << 12, + + /// + /// Every version. Intersected with at matrix-expansion time, so a + /// test left at the default runs against exactly the versions the harness builds and installs — minus the + /// out-of-support set (), which is excluded from the default + /// matrix unless opted into via or named explicitly in + /// SOSHARNESS_ONLY_COREVERSIONS. + /// + All = uint.MaxValue, +} diff --git a/src/tests/SOS.TestHarness/CoreVersions.cs b/src/tests/SOS.TestHarness/CoreVersions.cs new file mode 100644 index 0000000000..243d4bc03d --- /dev/null +++ b/src/tests/SOS.TestHarness/CoreVersions.cs @@ -0,0 +1,176 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// Maps the matrix axis onto the concrete runtimes the repo build installed: +/// the set actually present (), each version's target framework moniker +/// (), and — for the self-contained single-file DAC lookup — its exact runtime patch +/// version (). +/// +/// The source of truth is artifacts/dotnet-test/Debugger.Tests.Versions.txt, the manifest +/// eng/InstallRuntimes.proj writes when it acquires the RuntimeTestVersions (8/9/10/11). If +/// the manifest isn't present (runtimes not yet installed), falls back to the +/// versions in Directory.Build.props's SupportedSubProcessTargetFrameworks. +/// +public static class CoreVersions +{ + // tfm major -> exact runtime version, parsed once from the install manifest. + private static readonly IReadOnlyDictionary s_runtimeVersions = ReadManifest(); + + /// + /// The versions the harness actually builds debuggees for and has runtimes installed for. The matrix + /// only ever runs versions in this set; a requested bit outside it is + /// silently dropped (the axis is a disable mechanism — see ). + /// + public static CoreVersion Available { get; } = ComputeAvailable(); + + /// + /// The .NET Core versions that are out of support. They are excluded from the default test matrix + /// (see ) and only run when opted in via + /// or explicitly named in + /// SOSHARNESS_ONLY_COREVERSIONS. + /// + public static CoreVersion OutOfSupport => CoreVersion.Net9; + + /// + /// Whether out-of-support versions are opted into the default matrix, via + /// SOSHARNESS_TEST_OUT_OF_SUPPORT_CORE=1. + /// + public static bool TestOutOfSupportCore => + Environment.GetEnvironmentVariable("SOSHARNESS_TEST_OUT_OF_SUPPORT_CORE") == "1"; + + /// + /// The net*.0 target framework moniker for a single bit, or + /// netfx for (the desktop .NET Framework flavor, which has no Core + /// version — used as a stable dump/output folder segment). + /// + public static string Tfm(CoreVersion version) => version == CoreVersion.None ? "netfx" : $"net{Major(version)}.0"; + + /// The major version number (8, 9, 10, ...) for a single bit. + public static int Major(CoreVersion version) + { + uint v = (uint)version; + if (v == 0 || (v & (v - 1)) != 0) + { + throw new ArgumentException($"Expected a single CoreVersion flag, got '{version}'.", nameof(version)); + } + + return System.Numerics.BitOperations.Log2(v); + } + + /// + /// The exact runtime patch version (e.g. 8.0.25, 11.0.0-preview.6.26318.108) for a + /// version, from the install manifest. Returns null if that version wasn't installed. + /// + public static string? RuntimeVersion(CoreVersion version) => + s_runtimeVersions.TryGetValue(Major(version), out string? v) ? v : null; + + private static CoreVersion ComputeAvailable() + { + // Prefer what's actually installed (the manifest); fall back to the props-declared set. + CoreVersion fromManifest = 0; + foreach (int major in s_runtimeVersions.Keys) + { + fromManifest |= (CoreVersion)(1u << major); + } + + return fromManifest != 0 ? fromManifest : ReadSupportedFrameworksFromProps(); + } + + private static IReadOnlyDictionary ReadManifest() + { + Dictionary map = new(); + string manifest = Path.Combine(RepoLayout.Root, "artifacts", "dotnet-test", "Debugger.Tests.Versions.txt"); + if (!File.Exists(manifest)) + { + return map; + } + + // The manifest pairs net{N}.0 with {version} for + // each slot (Latest, Servicing1, ...). Collect both, then join on slot. + Dictionary tfms = new(StringComparer.OrdinalIgnoreCase); // slot -> netN.0 + Dictionary versions = new(StringComparer.OrdinalIgnoreCase); // slot -> version + foreach (string line in File.ReadLines(manifest)) + { + CollectTagged(line, "TargetFramework", tfms); + CollectTagged(line, "RuntimeVersion", versions); + } + + foreach ((string slot, string tfm) in tfms) + { + if (versions.TryGetValue(slot, out string? version) && + tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase) && + tfm.EndsWith(".0", StringComparison.Ordinal) && + int.TryParse(tfm.AsSpan(3, tfm.Length - 5), out int major)) + { + map[major] = version; + } + } + + return map; + } + + /// If is <{prefix}{slot}>value</...>, record slot->value. + private static void CollectTagged(string line, string prefix, Dictionary into) + { + string open = $"<{prefix}"; + int start = line.IndexOf(open, StringComparison.Ordinal); + if (start < 0) + { + return; + } + + int slotStart = start + open.Length; + int slotEnd = line.IndexOf('>', slotStart); + if (slotEnd < 0) + { + return; + } + + string slot = line.Substring(slotStart, slotEnd - slotStart); + int valEnd = line.IndexOf(""; + int open = line.IndexOf(tag, StringComparison.Ordinal); + if (open < 0) + { + continue; + } + + int close = line.IndexOf(" +/// Which DAC (the data-access component SOS reads runtime state through) to debug a target with — a +/// test-matrix axis that applies only to .NET 11+. The same dump is reused across both +/// values; only the DAC SOS loads at debug time differs (driven by runtimes --usecdac), so this +/// axis never multiplies dump capture. +/// +/// Default is . prunes for any +/// version earlier than .NET 11 (the cDAC doesn't exist there), so a default config collapses to +/// -only on ≤net10 and runs both on net11+. Mask a value off (e.g. +/// Dac.All & ~Dac.CDac) to disable a DAC that hits an unfixable bug for a given test. +/// +[Flags] +public enum Dac +{ + /// The classic native DAC (libmscordaccore), selected by the dump's coreclr build. + Legacy = 1, + + /// The cDAC (managed contract reader). Only valid on .NET 11+. + CDac = 2, + + /// Both DAC kinds (pruned to what's valid for the version by ). + All = Legacy | CDac, +} diff --git a/src/tests/SOS.TestHarness/DacPolicy.cs b/src/tests/SOS.TestHarness/DacPolicy.cs new file mode 100644 index 0000000000..f1e616f9a4 --- /dev/null +++ b/src/tests/SOS.TestHarness/DacPolicy.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// Resolves the runtimes --usecdac argument a host issues for a config's axis: +/// true, false. +/// +/// SOSHARNESS_USECDAC is a global clamp (off by default; never set in CI). When set it +/// overrides the per-config axis so the whole run forces one DAC — the escape hatch for a dev box whose +/// installed runtimes are skewed such that the cDAC can't load. Setting it collapses both DAC matrix rows +/// onto the same DAC, so the cDAC axis is only meaningful with it unset. +/// +internal static class DacPolicy +{ + public static string UseCDac(Dac dac) + { + string? clamp = Environment.GetEnvironmentVariable("SOSHARNESS_USECDAC"); + if (!string.IsNullOrEmpty(clamp)) + { + return clamp; + } + + return dac == Dac.CDac ? "true" : "false"; + } +} diff --git a/src/tests/SOS.TestHarness/DbgEngCapturer.cs b/src/tests/SOS.TestHarness/DbgEngCapturer.cs new file mode 100644 index 0000000000..b526e17fd6 --- /dev/null +++ b/src/tests/SOS.TestHarness/DbgEngCapturer.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.Diagnostics.Runtime.Utilities.DbgEng; + +namespace SOS.TestHarness; + +/// +/// Captures dumps with in-process dbgeng for flavors that can't self-collect: desktop +/// (no diagnostics IPC) and self-contained +/// crash dumps (the single-file bundle doesn't ship/launch +/// createdump). Launches the debuggee under dbgeng once and drives it through every stop point: +/// stops are reached via a managed bpmd breakpoint on the +/// marker method and dumped with dbgeng's .dump command; the stop is +/// reached by running to the second-chance exception. +/// +/// Holds the dbgeng exclusive lease for the duration so it never collides with a shared cdb host. +/// +public static class DbgEngCapturer +{ + public static void Capture(string exePath, TargetDefinition target, string dumpDir, DumpKind dumpKind) + { + using IDisposable lease = HostSlot.DbgEng.AcquireExclusive(); + + using IDisposable clientDisposable = IDebugClient.Create(ToolPaths.DbgEngDirectory); + IDebugClient client = (IDebugClient)clientDisposable; + IDebugControl control = (IDebugControl)clientDisposable; + + StringBuilder buffer = new(); + using DbgEngOutputHolder output = new(client); + output.OutputReceived += (text, _) => buffer.Append(text); + + string Run(string command) + { + buffer.Clear(); + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, command, DEBUG_EXECUTE.DEFAULT); + return buffer.ToString(); + } + + try + { + control.AddEngineOptions(DEBUG_ENGOPT.INITIAL_BREAK); + + DEBUG_CREATE_PROCESS_OPTIONS options = new() { CreateFlags = DEBUG_CREATE_PROCESS.DEBUG_ONLY_THIS_PROCESS }; + int hr = client.CreateProcessAndAttach($"\"{exePath}\"", Path.GetDirectoryName(exePath), DEBUG_ATTACH.DEFAULT, in options); + if (hr < 0) + { + throw new InvalidOperationException($"CreateProcessAndAttach('{exePath}') failed: 0x{hr:x8}"); + } + + control.WaitForEvent(TimeSpan.FromSeconds(60)); // initial loader break + Run($".load {ToolPaths.SosPath}"); + + // For desktop, the managed module is the EXE itself (e.g. GcPromotion.exe), not the .dll. + string bpmdModule = Path.GetFileName(exePath); + + foreach (StopPoint stop in target.StopPoints) + { + string dumpPath = Path.Combine(dumpDir, stop.Name + ".dmp"); + + if (stop.Kind == StopKind.Snapshot) + { + if (stop.Method is null) + { + throw new InvalidOperationException($"Snapshot stop '{stop.Name}' has no bpmd method."); + } + + Run($"!bpmd {bpmdModule} {stop.Method}"); + RunToBreak(control, $"bpmd {stop.Method}"); + } + else // Crash + { + RunToBreak(control, "second-chance crash"); + } + + Run($".dump /o {DbgEngDumpOption(dumpKind)} \"{dumpPath}\""); + if (!File.Exists(dumpPath)) + { + throw new InvalidOperationException($"DbgEng capture failed to write '{dumpPath}'."); + } + } + } + finally + { + try + { + client.EndSession(DEBUG_END.ACTIVE_TERMINATE); + } + catch + { + // best effort + } + } + } + + private static string DbgEngDumpOption(DumpKind dumpKind) => dumpKind switch + { + DumpKind.Full => "/ma", + DumpKind.Heap => "/mw", + DumpKind.Mini => "/m", + _ => throw new ArgumentOutOfRangeException(nameof(dumpKind), dumpKind, "Unsupported dump kind"), + }; + + private static void RunToBreak(IDebugControl control, string what) + { + const int MaxResumes = 40; + for (int i = 0; i < MaxResumes; i++) + { + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, "g", DEBUG_EXECUTE.DEFAULT); + control.WaitForEvent(TimeSpan.FromSeconds(60)); + control.GetExecutionStatus(out DEBUG_STATUS status); + + if (status == DEBUG_STATUS.BREAK) + { + return; + } + + if (status == DEBUG_STATUS.NO_DEBUGGEE) + { + throw new InvalidOperationException($"Debuggee exited before reaching {what}."); + } + } + + throw new InvalidOperationException($"Did not reach {what} after {MaxResumes} resumes."); + } +} diff --git a/src/tests/SOS.TestHarness/DbgEngDumpHost.cs b/src/tests/SOS.TestHarness/DbgEngDumpHost.cs new file mode 100644 index 0000000000..e7890a97ab --- /dev/null +++ b/src/tests/SOS.TestHarness/DbgEngDumpHost.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// The "cdb" host over a snapshot/crash dump: hosts dbgeng in-process and opens the dump. +/// SOS output is captured through dbgeng's output callbacks rather than scraping stdout. +/// +public sealed class DbgEngDumpHost : DbgEngHostBase +{ + private readonly string _dumpPath; + + public override string Name => "cdb"; + + public DbgEngDumpHost(string dumpPath) + { + _dumpPath = dumpPath; + Initialize(); + } + + protected override void OnOpen() + { + int hr = Client.OpenDumpFile(_dumpPath); + if (hr < 0) + { + throw new InvalidOperationException($"OpenDumpFile('{_dumpPath}') failed: 0x{hr:x8}"); + } + + Control.WaitForEvent(TimeSpan.FromSeconds(60)); + } +} diff --git a/src/tests/SOS.TestHarness/DbgEngHostBase.cs b/src/tests/SOS.TestHarness/DbgEngHostBase.cs new file mode 100644 index 0000000000..e95ac59cfe --- /dev/null +++ b/src/tests/SOS.TestHarness/DbgEngHostBase.cs @@ -0,0 +1,232 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Text; +using Microsoft.Diagnostics.Runtime.Utilities.DbgEng; + +namespace SOS.TestHarness; + +/// +/// Shared machinery for the dbgeng-backed hosts (dump and live). Hosts dbgeng in-process, +/// captures command output via (no stdout sentinel scraping), +/// and pins every engine call to one dedicated worker thread — honoring dbgeng's +/// single-threaded, one-target-at-a-time nature. Derived classes only implement how the +/// target is opened (open a dump vs. launch-and-run-to-a-breakpoint). +/// +/// This type does NOT enforce the "one in-process dbgeng per process" limit itself; that is +/// the job of , which decides when hosts may exist. +/// +public abstract class DbgEngHostBase : IDebuggerHost +{ + private readonly BlockingCollection _work = new(); + private readonly Thread _worker; + private readonly StringBuilder _buffer = new(); + + private IDisposable _clientDisposable = null!; + private DbgEngOutputHolder _output = null!; + private bool _sosLoaded; + + /// The dbgeng client. Only touch from the worker thread. + protected IDebugClient Client { get; private set; } = null!; + + /// The dbgeng control. Only touch from the worker thread. + protected IDebugControl Control { get; private set; } = null!; + + public abstract string Name { get; } + + protected DbgEngHostBase() + { + _worker = new Thread(WorkerLoop) { IsBackground = true, Name = "dbgeng-host" }; + _worker.Start(); + } + + /// Derived ctors call this after their fields are set; runs on the worker. + protected void Initialize() + { + try + { + Invoke(() => { + _clientDisposable = IDebugClient.Create(ToolPaths.DbgEngDirectory); + Client = (IDebugClient)_clientDisposable; + Control = (IDebugControl)_clientDisposable; + + _output = new DbgEngOutputHolder(Client); + _output.OutputReceived += (text, _) => _buffer.Append(text); + + // Raw dbgeng leaves SYMOPT_LOAD_LINES off by default, so SOS suppresses the + // [file @ line] annotation on managed frames (clrstack, !pe, etc). cdb-based hosts + // (and the legacy SOSRunner) turn line loading on; do the same so source/line + // resolution matches the dotnet-dump host. Without this, !clrstack never shows + // source lines under cdb even when the PDB is found. + RunCore(".lines -e"); + + OnOpen(); + }); + } + catch + { + // Release the dbgeng gate and tear down so a failed open doesn't wedge the + // other dbgeng-backed collection forever. + Dispose(); + throw; + } + } + + /// Open the target (dump or live). Runs on the worker thread; engine is ready. + protected abstract void OnOpen(); + + public void LoadSos() => Invoke(LoadSosCore); + + /// Worker-thread SOS load. Safe to call from . + protected void LoadSosCore() + { + if (_sosLoaded) + { + return; + } + + // cdb auto-loads an SOS when it opens a managed dump - notably the classic desktop + // .NET Framework SOS from C:\Windows\Microsoft.NET\... for desktop dumps, which only + // exposes the old command names (e.g. `threads`, not `clrthreads`). Unload any such + // pre-loaded SOS first so the SOS under test is unambiguously OUR build, then load ours + // and verify via .chain that ours is the one in the chain. + EnsureNoSosLoaded(); + + // For self-contained single-file dumps the runtime (and DAC) is bundled in the exe, so dbgeng + // can't find mscordaccore.dll on disk. When the harness tells us where the matching DAC lives, + // set its load path before loading SOS so the DAC is available when SOS initializes. (Mirrors + // `.cordll -ve -u -lp ` from the use-local-sos workflow.) + string? dacDir = Environment.GetEnvironmentVariable("SOSHARNESS_DAC_DIR"); + if (!string.IsNullOrEmpty(dacDir)) + { + RunCore($".cordll -ve -u -lp {dacDir}"); + + // .cordll can cause dbgeng to auto-load an SOS after the initial cleanup. Re-assert a clean + // extension chain immediately before loading the build under test. + EnsureNoSosLoaded(); + } + + RunCore($".load {ToolPaths.SosPath}"); + VerifyOurSosLoaded(); + + _sosLoaded = true; + } + + /// + /// Remove any SOS cdb auto-loaded, looping over .chain until none remain. Throws if an SOS is + /// still present after the unload attempts - we must not .load ours on top of a stale SOS we + /// can't remove, because commands could route to the wrong SOS. + /// + private void EnsureNoSosLoaded() + { + const int MaxUnloads = 5; + for (int i = 0; i <= MaxUnloads; i++) + { + string chain = RunCore(".chain"); + if (!ChainContainsSos(chain)) + { + return; // chain is clean - safe to load ours + } + + if (i == MaxUnloads) + { + throw new InvalidOperationException( + $"Could not unload pre-loaded SOS before loading ours; .chain still shows an SOS after {MaxUnloads} attempts:\n{chain}"); + } + + // .unload sos removes the extension named "sos" (cdb's auto-loaded one). Harmless if + // none is loaded - cdb just reports it and we ignore that. + RunCore(".unload sos"); + } + } + + /// Confirm our SOS (and only ours) is in the extension chain after loading it. + private void VerifyOurSosLoaded() + { + string chain = RunCore(".chain"); + if (chain.IndexOf(ToolPaths.SosPath, StringComparison.OrdinalIgnoreCase) < 0) + { + throw new InvalidOperationException( + $"Expected our SOS '{ToolPaths.SosPath}' to be loaded, but .chain was:\n{chain}"); + } + } + + private static bool ChainContainsSos(string chain) => + chain.Contains("sos.dll", StringComparison.OrdinalIgnoreCase) + || chain.Contains("\\sos:", StringComparison.OrdinalIgnoreCase); + + public SosOutput Execute(string command) => new(Name, command, Invoke(() => RunCore(command))); + + public SosOutput Sos(string command) => new(Name, command, Invoke(() => RunCore("!" + command))); + + /// Worker-thread command execution returning captured output. + protected string RunCore(string command) + { + _buffer.Clear(); + Control.Execute(DEBUG_OUTCTL.THIS_CLIENT, command, DEBUG_EXECUTE.DEFAULT); + return _buffer.ToString(); + } + + public void Dispose() + { + try + { + Invoke(() => { + _output?.Dispose(); + Client?.EndSession(DEBUG_END.ACTIVE_TERMINATE); + _clientDisposable?.Dispose(); + }); + } + catch + { + // best effort teardown + } + finally + { + _work.CompleteAdding(); + } + } + + // ---- worker marshaling --------------------------------------------------------------- + + private void WorkerLoop() + { + foreach (Action action in _work.GetConsumingEnumerable()) + { + action(); + } + } + + private protected void Invoke(Action action) + { + using ManualResetEventSlim done = new(); + Exception? error = null; + _work.Add(() => { + try + { + action(); + } + catch (Exception ex) + { + error = ex; + } + finally + { + done.Set(); + } + }); + done.Wait(); + if (error is not null) + { + throw new InvalidOperationException($"{Name} host operation failed: {error.Message}", error); + } + } + + private protected T Invoke(Func func) + { + T result = default!; + Invoke(() => { result = func(); }); + return result; + } +} diff --git a/src/tests/SOS.TestHarness/DbgEngLiveHost.cs b/src/tests/SOS.TestHarness/DbgEngLiveHost.cs new file mode 100644 index 0000000000..a6012fc009 --- /dev/null +++ b/src/tests/SOS.TestHarness/DbgEngLiveHost.cs @@ -0,0 +1,175 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Diagnostics.Runtime.Utilities.DbgEng; + +namespace SOS.TestHarness; + +/// +/// The live "cdb" host: launches the debuggee under in-process dbgeng and stops at the loader +/// breakpoint with SOS loaded, ready to set managed breakpoints. Unlike the dump hosts this is +/// a stateful, advancing target — it is owned exclusively by one test, never shared. +/// +/// sets a managed breakpoint on a method and runs to it, which is how +/// both the stop-point-driven navigation () and raw bpmd +/// tests reach a precise point. +/// +public sealed class DbgEngLiveHost : DbgEngHostBase +{ + private readonly string _commandLine; + private readonly string _workingDirectory; + + public override string Name => "cdb-live"; + + public DbgEngLiveHost(string exePath) + { + _commandLine = $"\"{exePath}\""; + _workingDirectory = Path.GetDirectoryName(exePath)!; + Initialize(); + } + + protected override void OnOpen() + { + // Stop at the loader breakpoint so we can load SOS and arm bpmd before the app runs. + Control.AddEngineOptions(DEBUG_ENGOPT.INITIAL_BREAK); + + DEBUG_CREATE_PROCESS_OPTIONS options = new() { CreateFlags = DEBUG_CREATE_PROCESS.DEBUG_ONLY_THIS_PROCESS }; + int hr = Client.CreateProcessAndAttach(_commandLine, _workingDirectory, DEBUG_ATTACH.DEFAULT, in options); + if (hr < 0) + { + throw new InvalidOperationException($"CreateProcessAndAttach('{_commandLine}') failed: 0x{hr:x8}"); + } + + Control.WaitForEvent(TimeSpan.FromSeconds(60)); + RequireStatus(DEBUG_STATUS.BREAK, "initial break"); + + LoadSosCore(); + } + + /// + /// Set a managed breakpoint on ! and run + /// until it is hit. Throws if the process exits first or the breakpoint is never reached. + /// + public SosOutput RunToBpmd(string module, string method) + { + string bpmdOutput = string.Empty; + + Invoke(() => { + // Clear any breakpoints left from a previous stop point. Otherwise a stale bpmd (e.g. the + // still-pending AtGen0 breakpoint) gets resolved and re-hit the moment we resume toward + // the next one, stranding us at the old location instead of advancing. + RunCore("!bpmd -clearall"); + RunCore("bc *"); + + bpmdOutput = RunCore($"!bpmd {module} {method}"); + + // bpmd reaches the method in two breaks (a JIT/prestub notification, then the entry), so + // resume until clrstack confirms we are actually stopped at the requested method. + const int MaxResumes = 50; + for (int i = 0; i < MaxResumes; i++) + { + RunCore("g"); + Control.WaitForEvent(TimeSpan.FromSeconds(60)); + Control.GetExecutionStatus(out DEBUG_STATUS status); + + if (status == DEBUG_STATUS.NO_DEBUGGEE) + { + throw new InvalidOperationException($"Debuggee exited before hitting bpmd {module}!{method}."); + } + + if (status == DEBUG_STATUS.BREAK && StoppedAtMethod(method)) + { + return; + } + } + + throw new InvalidOperationException($"Did not reach bpmd {module}!{method} after {MaxResumes} resumes."); + }); + + return new SosOutput(Name, $"bpmd {module} {method}", bpmdOutput); + } + + /// Worker-thread check: is the managed call stack currently topped by ? + private bool StoppedAtMethod(string method) + { + string stack = RunCore("!clrstack"); + return stack.Contains(method, StringComparison.Ordinal); + } + + /// + /// Run the process until it crashes (a second-chance exception break). Throws if it exits + /// cleanly without crashing. Used by the live GoToCrash path. + /// + public SosOutput RunToCrash() + { + Invoke(() => { + // Drop any bpmd breakpoints from earlier stop points so they don't break before the crash. + RunCore("!bpmd -clearall"); + RunCore("bc *"); + + const int MaxResumes = 25; + for (int i = 0; i < MaxResumes; i++) + { + RunCore("g"); + Control.WaitForEvent(TimeSpan.FromSeconds(60)); + Control.GetExecutionStatus(out DEBUG_STATUS status); + + if (status == DEBUG_STATUS.BREAK) + { + return; // second-chance crash break + } + + if (status == DEBUG_STATUS.NO_DEBUGGEE) + { + throw new InvalidOperationException("Process exited without crashing."); + } + } + + throw new InvalidOperationException($"Process did not crash after {MaxResumes} resumes."); + }); + + return new SosOutput(Name, "run-to-crash", string.Empty); + } + + /// + /// Resume the process to the next breakpoint. Throws if the process exits without hitting one, + /// or if it stops on a second-chance exception (a crash) rather than a breakpoint. The caller is + /// responsible for arming the breakpoint (e.g. Sos("bpmd …")); this sets/clears nothing. + /// + public SosOutput RunToBreakpoint() + { + const uint StatusBreakpoint = 0x80000003; // int3 — a real breakpoint, not a crash + + Invoke(() => { + RunCore("g"); + Control.WaitForEvent(TimeSpan.FromSeconds(60)); + Control.GetExecutionStatus(out DEBUG_STATUS status); + + if (status == DEBUG_STATUS.NO_DEBUGGEE) + { + throw new InvalidOperationException("Process exited without hitting a breakpoint."); + } + + // GetLastEvent is true only when the stop was an exception. A second-chance exception + // whose code isn't a break instruction is a crash, not the breakpoint we ran to. + if (Control.GetLastEvent(out DEBUG_LAST_EVENT_INFO_EXCEPTION ex, out _, out string? description) + && ex.FirstChance == 0 + && ex.ExceptionRecord.ExceptionCode != StatusBreakpoint) + { + throw new InvalidOperationException( + $"Hit a second-chance exception (0x{ex.ExceptionRecord.ExceptionCode:x8}), not a breakpoint: {description}"); + } + }); + + return new SosOutput(Name, "run-to-breakpoint", string.Empty); + } + + private void RequireStatus(DEBUG_STATUS expected, string phase) + { + Control.GetExecutionStatus(out DEBUG_STATUS status); + if (status != expected) + { + throw new InvalidOperationException($"Expected status {expected} at {phase}, but was {status}."); + } + } +} diff --git a/src/tests/SOS.TestHarness/DeadTarget.cs b/src/tests/SOS.TestHarness/DeadTarget.cs new file mode 100644 index 0000000000..b35e7271a7 --- /dev/null +++ b/src/tests/SOS.TestHarness/DeadTarget.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// A dump-backed target. It is a lightweight cursor: each / +/// resolves the (process-wide memoized, read-only) +/// for that point and makes it current, so many tests navigating to the same point share one loaded +/// dump host. Because nothing advances, points can be visited in any order and revisited. +/// throws until the first navigation, since there is no dump loaded yet. +/// +public sealed class DeadTarget : Target +{ + private readonly TestConfig _config; + private DumpSession? _current; + + internal DeadTarget(TestConfig config) + : base(config.Host, config.Target, config.Flavor) + { + _config = config; + } + + public override string DumpPath => Current.DumpPath; + + protected override void GoToStopPointCore(string stopName) + { + StopPoint stop = TargetCatalog.Get(TargetName).Stop(stopName); + _current = Targets.ResolveSession(_config, stop.Name); + } + + protected override void GoToCrashCore() + { + StopPoint crash = TargetCatalog.Get(TargetName).StopPoints.Single(s => s.Kind == StopKind.Crash); + _current = Targets.ResolveSession(_config, crash.Name); + } + + protected override SosOutput SosCore(string command) => Current.Sos(command); + + protected override SosOutput ExecuteCore(string command) => Current.Execute(command); + + internal override HostDiagnostics? CurrentDiagnostics => _current?.Diagnostics; + + private DumpSession Current => + _current ?? throw new InvalidOperationException( + "Target is not at a point yet; call GoToStopPoint(...) or GoToCrash() before Sos."); + + // Sessions are owned and disposed by the Targets registry; the cursor owns nothing. +} diff --git a/src/tests/SOS.TestHarness/DotNetDumpHost.cs b/src/tests/SOS.TestHarness/DotNetDumpHost.cs new file mode 100644 index 0000000000..f8d32d4622 --- /dev/null +++ b/src/tests/SOS.TestHarness/DotNetDumpHost.cs @@ -0,0 +1,252 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; + +namespace SOS.TestHarness; + +/// +/// The "dotnet-dump" host: drives dotnet-dump analyze <dump> as a child process and +/// talks to its REPL over stdin/stdout. Per-command output is delimited by the +/// <END_COMMAND_OUTPUT> marker that the dotnet-dump REPL emits natively after the +/// banner and after every command — the same marker the legacy SOS harness keys on. +/// +/// SOS commands are bare here (no ! prefix), so passes the command +/// through unchanged while the dbgeng host adds the !. +/// +public sealed class DotNetDumpHost : IDebuggerHost, IDiagnosticHost +{ + private const string EndMarker = ""; + private const string ErrorMarker = ""; + + private readonly Process _process; + private readonly StreamWriter _stdin; + private readonly BlockingCollection _lines = new(); + private readonly Thread _reader; + private readonly Thread? _stderrReader; + private readonly HostDiagnostics? _diagnostics; + private readonly Flavor _flavor; + private readonly Dac _dac; + private readonly CoreVersion _coreVersion; + + public string Name => "dotnet-dump"; + + /// Captured stdout/stderr and crash dumps for this host (see ). + public HostDiagnostics? Diagnostics => _diagnostics; + + public DotNetDumpHost(string dumpPath, Flavor flavor, Dac dac = Dac.Legacy, CoreVersion coreVersion = CoreVersion.Net10, HostDiagnostics? diagnostics = null) + { + _flavor = flavor; + _dac = dac; + _coreVersion = coreVersion; + _diagnostics = diagnostics; + ProcessStartInfo psi = new() + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // Drive the repo-built dotnet-dump as `dotnet analyze ` so the harness always + // validates the freshly-built tool, not a machine-wide install. + psi.FileName = RepoLayout.DotNetExe; + psi.ArgumentList.Add(ToolPaths.DotNetDumpDll); + psi.ArgumentList.Add("analyze"); + psi.ArgumentList.Add(dumpPath); + + // Hermetic, local-only symbols (the dev's _NT_SYMBOL_PATH may point at the Azure-authed symweb). + // NOTE: unlike the cdb/lldb hosts, `dotnet-dump analyze` does NOT honor _NT_SYMBOL_PATH — its + // Analyzer unconditionally adds the public msdl symbol server on startup (see dotnet-dump + // Analyzer.cs). We still scrub the env for good measure, but the real seal is the + // `setsymbolserver -disable` issued in LoadSos below; otherwise a command that resolves symbols + // (e.g. `!clrstack -gc`) would synchronously download PDBs from msdl and intermittently stall + // past the harness command timeout, breaking hermeticity and flaking the suite. + Directory.CreateDirectory(RepoLayout.SymbolCache); + psi.Environment["_NT_SYMBOL_PATH"] = RepoLayout.SymbolCache; + + // dotnet-dump is itself a .NET process, so the standard crash-dump environment makes a fatal fault + // in it (or in the SOS code it hosts) write a full dump we can surface as an artifact. + _diagnostics?.ConfigureCrashDumps(psi); + + _process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start dotnet-dump"); + _stdin = _process.StandardInput; + _diagnostics?.RecordProcess(_process); + + _reader = new Thread(ReadLoop) { IsBackground = true, Name = "dotnet-dump-reader" }; + _reader.Start(); + + // Drain stderr on its own thread (previously redirected but never read) so a crash's diagnostics + // are retained for the replay instead of discarded. + if (_diagnostics is not null) + { + _stderrReader = new Thread(StderrLoop) { IsBackground = true, Name = "dotnet-dump-stderr" }; + _stderrReader.Start(); + } + + // Drain the startup banner up to the first marker so the host is ready for commands. + DrainToMarker(TimeSpan.FromSeconds(120)); + } + + public void LoadSos() + { + // SOS is built into dotnet-dump's analyze host; nothing to load. + + // Seal the host against the network: `dotnet-dump analyze` auto-adds the public msdl symbol + // server on startup, so clear it before any command runs. This keeps the suite hermetic and + // prevents the intermittent multi-minute hang where a symbol-resolving command (e.g. + // `!clrstack -gc`) blocks on a PDB download from msdl. + Run("setsymbolserver -disable"); + + // Self-contained single-file bundles carry coreclr inside the exe, so there is no runtime + // directory on disk next to which SOS can find the matching DAC — `analyze` was relying on the + // (now-disabled) msdl server to download it. Point SOS's symbol store at the runtime pack's + // native directory (a *local directory*, no network) so it resolves the DAC for the dump's + // coreclr build locally and the session stays hermetic. Mirrors LldbCliHost. Other flavors find + // their DAC next to the on-disk runtime and need no override. + string? dacDir = _dac == Dac.CDac ? ToolPaths.CDacOverrideDirectory : null; + dacDir ??= _flavor == Flavor.SingleFile ? ToolPaths.SingleFileDacDirectory(_coreVersion) : null; + if (dacDir is { Length: > 0 }) + { + Run($"setsymbolserver -directory \"{dacDir}\""); + } + + // Select the DAC for this config's Dac axis: Legacy => `--usecdac false`, CDac (.NET 11+ only) => + // `--usecdac true`. The same dump is reused across both, so only this debug-time toggle differs. + // SOSHARNESS_USECDAC (off by default; never set in CI) is a global clamp that overrides the axis on + // a dev box whose installed runtimes are skewed such that the cDAC can't load. (Mirrors LldbCliHost.) + // + // Also disable DAC authenticode signature verification. dotnet-dump enables it by default on + // Windows (Analyzer.cs), so the legacy-DAC load path runs WinVerifyTrust on mscordaccore.dll. The + // test runtimes come from the locally-acquired artifacts/dotnet-test install, and a preview/CI + // runtime's DAC may be unsigned — WinVerifyTrust then fails with 0x800B0100 (TRUST_E_NOSIGNATURE), + // which surfaces as "Failed to load data access module, 0x80004002" and breaks EVERY legacy-DAC + // command on that runtime (e.g. net11 preview). Verification is a production safeguard against + // loading an untrusted DAC downloaded from a symbol server; it's inappropriate for a trusted local + // test DAC, which is exactly why the cDAC path and non-Windows already skip it. Turn it off here so + // Windows matches that behavior and the legacy DAC loads for unsigned preview runtimes. + Run($"runtimes --usecdac {DacPolicy.UseCDac(_dac)} --DacSignatureVerification false"); + } + + public SosOutput Execute(string command) => new(Name, command, Run(command)); + + public SosOutput Sos(string command) => new(Name, command, Run(command)); + + private string Run(string command) + { + _stdin.WriteLine(command); + _stdin.Flush(); + return DrainToMarker(TimeSpan.FromSeconds(120), command); + } + + /// + /// Collect output lines until the end marker. Strips the echoed prompt line + /// ("> command") that dotnet-dump prints when stdin is redirected. + /// + private string DrainToMarker(TimeSpan timeout, string? command = null) + { + StringBuilder sb = new(); + while (true) + { + if (!_lines.TryTake(out string? line, (int)timeout.TotalMilliseconds, HarnessCancellation.Token)) + { + throw new TimeoutException($"dotnet-dump did not return output for '{command ?? ""}' within {timeout}."); + } + + string trimmed = line.TrimEnd(); + if (trimmed.EndsWith(EndMarker, StringComparison.Ordinal) || trimmed.EndsWith(ErrorMarker, StringComparison.Ordinal)) + { + break; + } + + // Skip the echoed prompt+command line. + if (command is not null && IsPromptEcho(line, command)) + { + continue; + } + + sb.AppendLine(line); + } + + return sb.ToString(); + } + + private static bool IsPromptEcho(string line, string command) + { + string trimmed = line.TrimStart('>', ' '); + return trimmed == command; + } + + private void ReadLoop() + { + try + { + string? line; + while ((line = _process.StandardOutput.ReadLine()) is not null) + { + _diagnostics?.AppendStdout(line); + _lines.Add(line); + } + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException) + { + // Best effort: Dispose can race with the reader thread during host teardown. + } + finally + { + _lines.CompleteAdding(); + } + } + + private void StderrLoop() + { + try + { + string? line; + while ((line = _process.StandardError.ReadLine()) is not null) + { + _diagnostics?.AppendStderr(line); + } + } + catch + { + // Best effort: the process may die mid-read. Whatever we captured is still available. + } + } + + public void Dispose() + { + try + { + if (!_process.HasExited) + { + _stdin.WriteLine("exit"); + _stdin.Flush(); + if (!_process.WaitForExit(5000)) + { + _process.Kill(entireProcessTree: true); + } + } + } + catch + { + // best effort + } + finally + { + try + { + _process.WaitForExit(10000); + } + catch + { + // best effort + } + + _process.Dispose(); + } + } +} diff --git a/src/tests/SOS.TestHarness/DumpGenerationRequirements.cs b/src/tests/SOS.TestHarness/DumpGenerationRequirements.cs new file mode 100644 index 0000000000..f80ce54ddd --- /dev/null +++ b/src/tests/SOS.TestHarness/DumpGenerationRequirements.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security; +using Microsoft.Win32; + +namespace SOS.TestHarness; + +/// +/// Handles the Windows machine prerequisite for capturing a reduced (Heap/Mini) .NET Core dump of an +/// unsigned test runtime. +/// +/// Why the prerequisite exists. On Windows a reduced dump is written by createdump via +/// MiniDumpWriteDump, which only captures MEM_PRIVATE read/write pages directly. The CLR's +/// loader-allocator heaps — which hold the MethodTable/Module structures SOS and ClrMD need +/// to enumerate modules, types and the GC heap — are MEM_MAPPED (the double-mapped executable +/// allocator), so they are captured only through dbghelp's auxiliary DAC provider (dbghelp loads +/// mscordaccore.dll and calls ICLRDataEnumMemoryRegions::EnumMemoryRegions). dbghelp refuses +/// to load a DAC that isn't Authenticode-signed unless +/// HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\MiniDumpSettings\DisableAuxProviderSignatureCheck +/// is set to 1. The locally-built and preview (net11) test runtimes ship an unsigned DAC, so without +/// that value their loader heaps are silently omitted from a reduced dump and every module/type/heap +/// command then fails with "Unable to create a ClrHeap …". (Released runtimes such as net8–net10 have a +/// signed DAC; desktop Framework and single-file/Full captures do not use this path.) +/// +/// Why we only check, never set. That value lives under HKLM, so writing it requires +/// elevation and changes machine-wide state. When it is absent, Heap requests are captured as Full dumps, +/// which preserve the required data without changing the machine. Mini rows are skipped because substituting +/// a Full dump would not test Mini-dump behavior. +/// +internal static class DumpGenerationRequirements +{ + private static readonly string s_root = RuntimeInformation.ProcessArchitecture == Architecture.X86 ? @"SOFTWARE\WOW6432Node\" : @"SOFTWARE\"; + private static readonly string s_settingsNode = s_root + @"Microsoft\Windows NT\CurrentVersion\MiniDumpSettings"; + private const string DisableCheckValue = "DisableAuxProviderSignatureCheck"; + + // Read the registry value at most once per process (cheap, read-only; reading HKLM needs no elevation). + private static readonly Lazy s_signatureCheckDisabled = new(ReadSignatureCheckDisabled); + + /// + /// Resolves the dump kind that can be captured on this machine. Only a reduced (Heap/Mini) + /// Core dump goes + /// through that path: Full dumps capture all memory directly, single-file snapshots are always collected + /// Full, and desktop Framework is captured via dbgeng using the signed in-box DAC — none of those need + /// the bypass, so their requested kind is returned unchanged (as it is on non-Windows). + /// + internal static DumpKind ResolveCaptureKind(Flavor flavor, DumpKind dumpKind) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return dumpKind; + } + + if (dumpKind == DumpKind.Full || flavor == Flavor.Framework || flavor == Flavor.SingleFile) + { + return dumpKind; + } + + if (s_signatureCheckDisabled.Value) + { + return dumpKind; + } + + if (dumpKind == DumpKind.Mini) + { + HarnessSkipException.Now( + $@"Mini dump capture requires HKLM\{s_settingsNode}\{DisableCheckValue}=1 so dbghelp can " + + "load the unsigned test DAC"); + } + + return DumpKind.Full; + } + + private static bool ReadSignatureCheckDisabled() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return true; + } + + return ReadSignatureCheckDisabledWindows(); + } + + [SupportedOSPlatform("windows")] + private static bool ReadSignatureCheckDisabledWindows() + { + try + { + using RegistryKey? key = Registry.LocalMachine.OpenSubKey(s_settingsNode); + return key?.GetValue(DisableCheckValue) is int value && value == 1; + } + catch (Exception ex) when (ex is SecurityException or UnauthorizedAccessException or IOException) + { + return false; + } + } +} diff --git a/src/tests/SOS.TestHarness/DumpKind.cs b/src/tests/SOS.TestHarness/DumpKind.cs new file mode 100644 index 0000000000..b2e207a44a --- /dev/null +++ b/src/tests/SOS.TestHarness/DumpKind.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// The kind of dump captured for a target — a matrix axis (flags, like ). Selects how +/// much of the process the dump contains, which lets tests validate SOS against reduced dumps. +/// +/// — a full dump (createdump type 4 / --type Full) used by tests that need +/// data omitted from reduced dumps. +/// — the default dump type (createdump type 2 / --type Heap). +/// — a heap-less minidump (createdump type 1 / --type Mini) used only by +/// commands that can validate meaningful data from the reduced dump. +/// +/// +[Flags] +public enum DumpKind +{ + /// Heap dump (createdump type 2 / --type Heap) — the default capture type. + Heap = 1, + + /// Minidump (createdump type 1 / --type Mini) without managed heap memory. + Mini = 2, + + /// Full dump (createdump type 4 / --type Full) with all memory. + Full = 4, + + /// The reduced dump kinds used by default expanded dump-kind coverage. + All = Heap | Mini, +} diff --git a/src/tests/SOS.TestHarness/DumpSession.cs b/src/tests/SOS.TestHarness/DumpSession.cs new file mode 100644 index 0000000000..3f0c842650 --- /dev/null +++ b/src/tests/SOS.TestHarness/DumpSession.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; + +namespace SOS.TestHarness; + +/// +/// An internal, read-only debug session over a specific dump (one (target, stopPoint, flavor)) +/// loaded into a host, with SOS ready. Because the dump is immutable, a session is safe to reuse +/// across many assertions, and is memoized by and reused by every +/// cursor that navigates to the same point. +/// +/// Host lifetime differs by backend: +/// +/// cdb runs dbgeng in its own child process. +/// Each child is independent and blocks on stdin when idle, so many can be alive at once — the +/// single-instance limit that in-process dbgeng imposed is gone. The host is created once and +/// kept. Because one session may be reused by several tests at once, concurrent commands +/// on its single child are serialized on a per-session gate. +/// dotnet-dump children busy-wait on stdin at ~100% CPU, so keeping many alive would +/// saturate the machine. They route through a capacity-1 (most-recently-used +/// stays open, reopened on demand). +/// +/// +internal sealed class DumpSession : IPooledHost, IDisposable +{ + private readonly Host _hostKind; + private readonly bool _pooled; // dotnet-dump: route through the single slot + private readonly HostSlot? _slot; + private readonly object _gate = new(); // serializes concurrent commands on this shared child + private IDebuggerHost? _host; // kept-alive host for non-pooled (cdb child) targets + + // One diagnostics collector for the life of this session (survives the pooled dotnet-dump host being + // closed and reopened), for the child-process hosts that support capture. Null for the cdb child host. + private readonly HostDiagnostics? _diagnostics; + + public Host Host { get; } + public string TargetName { get; } + public string StopName { get; } + public Flavor Flavor { get; } + public string DumpPath { get; } + public CoreVersion CoreVersion { get; } + public Dac Dac { get; } + + /// Captured stdout/stderr and crash dumps for this session's host, or null for the cdb child + /// host (which does not support capture). Surfaced in a failing test's replay. + public HostDiagnostics? Diagnostics => _diagnostics; + + internal DumpSession(Host hostKind, string targetName, string stopName, Flavor flavor, string dumpPath, + CoreVersion coreVersion = CoreVersion.Net10, Dac dac = Dac.Legacy) + { + _hostKind = hostKind; + Host = hostKind; + TargetName = targetName; + StopName = stopName; + Flavor = flavor; + DumpPath = dumpPath; + CoreVersion = coreVersion; + Dac = dac; + + // dotnet-dump children spin on stdin -> bound to one via the slot. cdb children block + // when idle -> keep alive concurrently (no slot), which is the subprocess-backend payoff. + _pooled = hostKind == Host.DotnetDump; + _slot = _pooled ? HostSlot.DotNetDump : null; + + // The child-process hosts (lldb, dotnet-dump) capture their stdout/stderr and crash dumps; the cdb + // child host runs dbgeng out-of-process and is not wired for capture. + _diagnostics = hostKind is Host.Lldb or Host.DotnetDump ? new HostDiagnostics(hostKind.ToString().ToLowerInvariant()) : null; + + if (!_pooled) + { + _host = HostFactory.CreateDumpHost(hostKind, flavor, dumpPath, dac, coreVersion, + SnapshotStore.TargetExe(flavor, targetName, coreVersion), _diagnostics); + _host.LoadSos(); + } + } + + /// + /// Run a SOS command against this target (host prefixing handled by the host). A shared target + /// may be handed to several tests at once (it is memoized by host/target/stop/flavor), and the + /// cdb backend is a single child process whose stdin/stdout pipe is not safe for concurrent + /// callers — so non-pooled commands are serialized on a per-target gate. The dotnet-dump path + /// serializes itself on the slot lock. + /// + public SosOutput Sos(string command) => + RunCommand("SOS", command, h => h.Sos(command)); + + /// Run a raw debugger command against this target. + public SosOutput Execute(string command) => + RunCommand("Execute", command, h => h.Execute(command)); + + private SosOutput RunCommand(string kind, string command, Func action) + { + try + { + return _pooled ? _slot!.Run(this, action) : RunGuarded(action); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or TimeoutException) + { + throw new InvalidOperationException( + $"Dump session command failed:{Environment.NewLine}" + + $"Host={Host}{Environment.NewLine}" + + $"Target={TargetName}{Environment.NewLine}" + + $"Stop={StopName}{Environment.NewLine}" + + $"Flavor={Flavor}{Environment.NewLine}" + + $"CoreVersion={CoreVersion}{Environment.NewLine}" + + $"Dac={Dac}{Environment.NewLine}" + + $"DumpPath={DumpPath}{Environment.NewLine}" + + $"CommandKind={kind}{Environment.NewLine}" + + $"Command={command}", + ex); + } + } + + private SosOutput RunGuarded(Func action) + { + lock (_gate) + { + return action(_host!); + } + } + + // IPooledHost — used only for the pooled (dotnet-dump) path. + + IDebuggerHost IPooledHost.Host => _host!; + + void IPooledHost.OpenHost() + { + _host = HostFactory.CreateDumpHost(_hostKind, Flavor, DumpPath, Dac, CoreVersion, + SnapshotStore.TargetExe(Flavor, TargetName, CoreVersion), _diagnostics); + _host.LoadSos(); + } + + void IPooledHost.CloseHost() + { + _host?.Dispose(); + _host = null; + } + + public void Dispose() + { + if (_pooled) + { + // The slot owns the pooled host's lifetime; closed at teardown via the slot. + return; + } + + _host?.Dispose(); + _host = null; + } +} diff --git a/src/tests/SOS.TestHarness/EngineProtocol.cs b/src/tests/SOS.TestHarness/EngineProtocol.cs new file mode 100644 index 0000000000..a396d501fe --- /dev/null +++ b/src/tests/SOS.TestHarness/EngineProtocol.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// The tiny line protocol between the test host and the EngineHost child process that hosts +/// dbgeng. The child prints once the target is open, then for each command line +/// it receives it prints the captured output followed by on its own line. The +/// special request asks the child to set a managed breakpoint and run +/// to it. Markers are deliberately unlikely to appear in SOS output. +/// +internal static class EngineProtocol +{ + public const string Ready = "<<>>"; + public const string End = "<<>>"; + public const string Error = "<<>>"; + + /// Request: @runtobpmd <module> <method>. + public const string RunToBpmdPrefix = "@runtobpmd "; + + /// Request: @runtocrash — run the live process to its second-chance crash. + public const string RunToCrash = "@runtocrash"; + + /// Request: @runtobreak — resume the live process to the next breakpoint. + public const string RunToBreak = "@runtobreak"; +} diff --git a/src/tests/SOS.TestHarness/Flavor.cs b/src/tests/SOS.TestHarness/Flavor.cs new file mode 100644 index 0000000000..ec5ef83760 --- /dev/null +++ b/src/tests/SOS.TestHarness/Flavor.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// The runtime flavor a target is built and dumped as — the third test axis (after host and +/// stop point). Mirrors what ClrMD's test suite covers. +/// +[Flags] +public enum Flavor +{ + /// Framework-dependent .NET (net10.0). Self-snapshots via dotnet-dump collect. + Core = 1, + + /// Self-contained single-file publish. Self-snapshots via dotnet-dump collect (no bundled createdump). + SingleFile = 2, + + /// Desktop .NET Framework (net48, Windows-only). No diagnostics IPC — dumps captured externally via DbgEng. + Framework = 4, + + /// + /// All flavors valid for the platform. + /// + AllValid = Core | SingleFile | Framework, +} diff --git a/src/tests/SOS.TestHarness/GcType.cs b/src/tests/SOS.TestHarness/GcType.cs new file mode 100644 index 0000000000..ae965152fe --- /dev/null +++ b/src/tests/SOS.TestHarness/GcType.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// The GC flavor the debuggee runs under when captured/launched — a matrix axis (flags, like +/// ). Server GC produces a multi-heap GC (a fixed heap count with DATAS off, so it +/// can't collapse back to a single heap), which the eeheap parser and the generation/region tests +/// exercise; Workstation is the single-heap default. +/// +[Flags] +public enum GcType +{ + /// Single-heap workstation GC (the runtime default). + Workstation = 1, + + /// Multi-heap server GC (forced to a deterministic heap count via env vars at capture/launch). + Server = 2, + + /// Both GC modes. + AllValid = Workstation | Server, +} diff --git a/src/tests/SOS.TestHarness/HarnessCancellation.cs b/src/tests/SOS.TestHarness/HarnessCancellation.cs new file mode 100644 index 0000000000..62be55f6c6 --- /dev/null +++ b/src/tests/SOS.TestHarness/HarnessCancellation.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace SOS.TestHarness; + +/// +/// Bridges the ambient xunit/MTP test cancellation into the harness's blocking debugger-host waits. +/// +/// The debugger-host REPLs (cdb/dotnet-dump/lldb) drive a child process and block on its output with +/// a generous per-command timeout (up to ~2 minutes, since a live process continue can legitimately +/// run that long under a saturated matrix). Those waits used to ignore cancellation, so when the user hit +/// Ctrl+C the runner printed "Canceling the test session..." and then appeared to hang until the in-flight +/// command's timeout elapsed (or it happened to complete). Observing this token in the blocking waits lets a +/// canceled run unwind promptly — the wait throws , the test's +/// using target/host disposes (which tears the child process down), and the session ends. +/// +internal static class HarnessCancellation +{ + /// + /// The ambient test cancellation token, signaled by the runner on Ctrl+C / session cancellation / + /// global timeout. Returns when there is no active test context + /// (e.g. one-time fixture teardown), so callers can use it unconditionally. + /// + public static CancellationToken Token => TestContext.Current?.CancellationToken ?? CancellationToken.None; +} diff --git a/src/tests/SOS.TestHarness/HarnessSkip.cs b/src/tests/SOS.TestHarness/HarnessSkip.cs new file mode 100644 index 0000000000..012d4a8feb --- /dev/null +++ b/src/tests/SOS.TestHarness/HarnessSkip.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit.v3; + +namespace SOS.TestHarness; + +/// +/// Thrown by the harness to turn a structural host/flavor limitation into an xUnit dynamic skip +/// (rather than a failure) from inside harness code, where the per-test Assert.Skip APIs are not +/// available. xUnit v3 recognizes a skip by the prefix on the +/// exception message, so any test that lets this exception propagate is reported as skipped with +/// . +/// +/// Use this only for limitations that are intrinsic to a (host, flavor, liveness) combination and apply +/// uniformly to every test exercising it — i.e. there is no per-test variation to express. +/// +public sealed class HarnessSkipException : Exception +{ + public HarnessSkipException(string reason) + : base(DynamicSkipToken.Value + reason) + { + } + + /// Throw a to skip the current test with . + public static void Now(string reason) => throw new HarnessSkipException(reason); +} diff --git a/src/tests/SOS.TestHarness/HostDiagnostics.cs b/src/tests/SOS.TestHarness/HostDiagnostics.cs new file mode 100644 index 0000000000..12fe81156c --- /dev/null +++ b/src/tests/SOS.TestHarness/HostDiagnostics.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text; + +namespace SOS.TestHarness; + +/// +/// Captures everything a launched debugger host process (lldb, dotnet-dump) emitted — its command line +/// and the raw stdout/stderr transcript — plus any crash dump it dropped, so a failing test's replay can +/// show what the underlying process actually did before it wedged or died. The two symptoms this exists +/// to diagnose are a broken stdin pipe (the host process crashed out from under us) and a command timeout +/// (the host stopped answering); in both cases the useful evidence is the tail of stderr and a crash dump, +/// neither of which the harness previously retained. +/// +/// The host process is run with the standard .NET crash-dump environment (see +/// ): because SOS's managed extension runs on a real .NET runtime inside +/// lldb (and dotnet-dump is itself a .NET process), a fatal fault in that runtime writes a full dump we can +/// point at with an artifact: line. Dumps for every host land in one shared directory and are +/// attributed back to the right host by the crashing process id (). +/// +/// One instance is owned per (so it survives the pooled dotnet-dump host being +/// closed and reopened) or per live lldb host. It is thread-safe: the stdout and stderr reader threads +/// append concurrently while the test thread reads snapshots. +/// +public sealed class HostDiagnostics +{ + // Keep only the tail of each stream: a crash's useful context is at the end, and an unbounded buffer + // on a long-lived shared host would grow without limit across the many tests that reuse it. + private const int MaxStreamChars = 128 * 1024; + + private static readonly string s_crashRoot = + Path.Combine(RepoLayout.Root, "artifacts", "replays", "crashdumps"); + + private readonly object _gate = new(); + private readonly StringBuilder _stdout = new(); + private readonly StringBuilder _stderr = new(); + private readonly HashSet _pids = new(); + private bool _stdoutTruncated; + private bool _stderrTruncated; + private string _commandLine = string.Empty; + + public HostDiagnostics(string name) => Name = name; + + /// Short host label, e.g. "lldb" or "dotnet-dump". + public string Name { get; } + + /// The shared directory crash dumps are written to (created on demand). + public static string CrashDumpDirectory => s_crashRoot; + + /// The launched command line (exe + args), captured for the replay. + public string CommandLine + { + get { lock (_gate) { return _commandLine; } } + } + + /// + /// Set the standard .NET crash-dump environment on a host process so a fatal fault in its managed + /// runtime (the SOS extension hosted inside lldb, or dotnet-dump itself) writes a full dump plus a + /// crash report and a createdump log into the shared crash directory. Dumps are named with the crashing + /// process id/time so can attribute them to this host by the ids it records. + /// + public void ConfigureCrashDumps(ProcessStartInfo psi) + { + Directory.CreateDirectory(s_crashRoot); + psi.Environment["DOTNET_DbgEnableMiniDump"] = "1"; + psi.Environment["DOTNET_DbgMiniDumpType"] = "4"; // Full — required for ClrMD/SOS analysis + psi.Environment["DOTNET_DbgMiniDumpName"] = Path.Combine(s_crashRoot, "%e.%p.%t.dmp"); + psi.Environment["DOTNET_EnableCrashReport"] = "1"; + psi.Environment["DOTNET_CreateDumpDiagnostics"] = "1"; + // Send createdump's own diagnostics to a file rather than the host's stderr, so it neither floods + // the transcript nor gets tangled with the SOS output we scrape for command framing. + psi.Environment["DOTNET_CreateDumpLogToFile"] = Path.Combine(s_crashRoot, "createdump.%p.log"); + psi.Environment["DOTNET_DbgCreateDumpToolPath"] = ToolPaths.CreateDumpPath; + } + + /// Record the launched process (its command line and pid) so a dump it writes can be found. + public void RecordProcess(Process process) + { + lock (_gate) + { + _commandLine = $"{process.StartInfo.FileName} {string.Join(' ', process.StartInfo.ArgumentList)}".Trim(); + try + { + _pids.Add(process.Id); + } + catch + { + // Id can throw if the process already exited; the command line is still useful. + } + } + } + + public void AppendStdout(string line) => Append(_stdout, line, ref _stdoutTruncated); + + public void AppendStderr(string line) => Append(_stderr, line, ref _stderrTruncated); + + private void Append(StringBuilder sb, string line, ref bool truncated) + { + lock (_gate) + { + sb.Append(line).Append('\n'); + if (sb.Length > MaxStreamChars) + { + sb.Remove(0, sb.Length - MaxStreamChars); + truncated = true; + } + } + } + + /// The captured stdout tail (empty if nothing was captured). + public string StdoutTail() => Snapshot(_stdout, _stdoutTruncated); + + /// The captured stderr tail (empty if nothing was captured). + public string StderrTail() => Snapshot(_stderr, _stderrTruncated); + + private string Snapshot(StringBuilder sb, bool truncated) + { + lock (_gate) + { + if (sb.Length == 0) + { + return string.Empty; + } + + return (truncated ? "... (truncated, showing tail)\n" : string.Empty) + sb; + } + } + + /// + /// Crash dumps, crash reports, and createdump logs this host produced — matched out of the shared + /// crash directory by the process ids it recorded. Returns full paths for artifact: lines. + /// + public IReadOnlyList Artifacts() + { + int[] pids; + lock (_gate) + { + if (_pids.Count == 0) + { + return Array.Empty(); + } + + pids = _pids.ToArray(); + } + + if (!Directory.Exists(s_crashRoot)) + { + return Array.Empty(); + } + + List hits = new(); + foreach (string file in Directory.EnumerateFiles(s_crashRoot)) + { + string name = Path.GetFileName(file); + // Dump/report names embed the crashing pid as ".." (%e.%p.%t.dmp); the createdump log is + // "createdump..log". Match either form against the ids we launched. + foreach (int pid in pids) + { + if (name.Contains($".{pid}.", StringComparison.Ordinal)) + { + hits.Add(file); + break; + } + } + } + + hits.Sort(StringComparer.Ordinal); + return hits; + } +} diff --git a/src/tests/SOS.TestHarness/HostFactory.cs b/src/tests/SOS.TestHarness/HostFactory.cs new file mode 100644 index 0000000000..6079bea3cb --- /dev/null +++ b/src/tests/SOS.TestHarness/HostFactory.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// Creates the concrete host for a given host kind. +internal static class HostFactory +{ + /// Create a dump-backed host. + public static IDebuggerHost CreateDumpHost(Host host, Flavor flavor, string dumpPath, Dac dac = Dac.Legacy, CoreVersion coreVersion = CoreVersion.Net10, string? targetExe = null, HostDiagnostics? diagnostics = null) => host switch + { + // cdb runs dbgeng in a CHILD process (EngineHost), so the test host never loads dbgeng. + Host.Cdb => ChildEngineClient.ForDump(host.ToString().ToLowerInvariant(), dumpPath, DacDirFor(flavor, coreVersion), dac), + Host.DotnetDump => new DotNetDumpHost(dumpPath, flavor, dac, coreVersion, diagnostics), + Host.Lldb => new LldbCliHost(dumpPath, flavor, dac, coreVersion, targetExe, diagnostics), + _ => throw new ArgumentException($"Unknown host '{host}'."), + }; + + /// A live host (exclusive, advancing). On Windows this is the in-process dbgeng engine driven + /// through a child EngineHost process; on Linux/macOS it drives the lldb CLI directly. + public static ILiveDebuggerHost CreateLiveHost(Host host, Flavor flavor, string exePath, CoreVersion coreVersion = CoreVersion.Net10, Dac dac = Dac.Legacy) => host switch + { + Host.Cdb => ChildEngineClient.ForLive(host.ToString().ToLowerInvariant(), exePath, DacDirFor(flavor, coreVersion), dac, flavor), + Host.Lldb => new LldbLiveHost(exePath, flavor, coreVersion, dac), + Host.DotnetDump => throw new ArgumentException("dotnet-dump is post-mortem only; it has no live host."), + _ => throw new ArgumentException($"Unknown live host '{host}'."), + }; + + /// + /// The DAC directory to make dbgeng load explicitly for a flavor. Self-contained single-file bundles + /// the runtime, so cdb can't find mscordaccore.dll on disk — point it at the runtime pack's + /// DAC for the published version. Other flavors find their DAC next to the runtime, so they need no + /// override. + /// + private static string? DacDirFor(Flavor flavor, CoreVersion coreVersion) => + flavor == Flavor.SingleFile ? ToolPaths.SingleFileDacDirectory(coreVersion) : null; +} diff --git a/src/tests/SOS.TestHarness/HostSlot.cs b/src/tests/SOS.TestHarness/HostSlot.cs new file mode 100644 index 0000000000..8e3215fbfc --- /dev/null +++ b/src/tests/SOS.TestHarness/HostSlot.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// A host whose lifetime a may open and close on demand. +/// +internal interface IPooledHost +{ + IDebuggerHost Host { get; } + + void OpenHost(); + + void CloseHost(); +} + +/// +/// Governs how many live host instances of one kind may exist at once — here, exactly one. +/// +/// Two kinds need this for different reasons: +/// +/// cdb (in-process dbgeng) is genuinely one-instance-per-process (a second client +/// throws). +/// dotnet-dump children each busy-wait on stdin at ~100% CPU; keeping many alive +/// saturates the machine, so we keep at most one. +/// +/// The most-recently-used host stays open and is evicted (disposed) only when a different target +/// of the same kind is needed — so a run of assertions against one dump reuses the open host, and +/// switching dumps reopens (cheap relative to the work). Live targets take an exclusive lease for +/// their lifetime. This single-slot constraint is exactly what a subprocess-per-target backend +/// would lift, without changing the test-facing API. +/// +internal sealed class HostSlot +{ + /// The in-process dbgeng slot (cdb dump hosts and live hosts). + public static readonly HostSlot DbgEng = new(); + + /// The dotnet-dump slot (one analyze child alive at a time). + public static readonly HostSlot DotNetDump = new(); + + private readonly object _lock = new(); + private IPooledHost? _open; + private bool _exclusiveHeld; + + /// + /// Ensure 's host is the one open host for this slot, then run + /// against it. Serializes all work on this slot. + /// + public SosOutput Run(IPooledHost owner, Func action) + { + lock (_lock) + { + while (_exclusiveHeld) + { + System.Threading.Monitor.Wait(_lock); + } + + if (!ReferenceEquals(_open, owner)) + { + _open?.CloseHost(); + owner.OpenHost(); + _open = owner; + } + + return action(owner.Host); + } + } + + /// + /// Acquire exclusive use of this slot for a live target's lifetime. Evicts any open host and + /// blocks other use until the returned lease is disposed. + /// + public IDisposable AcquireExclusive() + { + lock (_lock) + { + while (_exclusiveHeld) + { + System.Threading.Monitor.Wait(_lock); + } + + _open?.CloseHost(); + _open = null; + _exclusiveHeld = true; + } + + return new Lease(this); + } + + /// Close the currently-open host, if any (teardown). + public void CloseCurrent() + { + lock (_lock) + { + _open?.CloseHost(); + _open = null; + } + } + + private sealed class Lease : IDisposable + { + private readonly HostSlot _slot; + private bool _disposed; + + public Lease(HostSlot slot) => _slot = slot; + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + lock (_slot._lock) + { + _slot._exclusiveHeld = false; + System.Threading.Monitor.PulseAll(_slot._lock); + } + } + } +} diff --git a/src/tests/SOS.TestHarness/Hosts.cs b/src/tests/SOS.TestHarness/Hosts.cs new file mode 100644 index 0000000000..8a50e949bd --- /dev/null +++ b/src/tests/SOS.TestHarness/Hosts.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// The known debugger hosts. Tests parameterize over these (xunit serializes enums). +[Flags] +public enum Host +{ + /// In-process dbgeng, run in a child EngineHost (Windows). + Cdb = 1, + + /// LLDB debugger host (Linux/macOS). + Lldb = 2, + + /// Managed dotnet-dump analyze host (all OSes). + DotnetDump = 4, + + /// + /// All hosts that can be used for the given liveness on this platform. + /// + AllValid = Cdb | Lldb | DotnetDump, +} diff --git a/src/tests/SOS.TestHarness/IDebuggerHost.cs b/src/tests/SOS.TestHarness/IDebuggerHost.cs new file mode 100644 index 0000000000..522c2cd55f --- /dev/null +++ b/src/tests/SOS.TestHarness/IDebuggerHost.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// One debugger host that SOS can run inside. A test author programs against this +/// surface without depending on whether the backend is dbgeng, LLDB, or dotnet-dump. +/// +/// A host owns exactly one loaded target and is single-threaded with respect to +/// command execution — mirroring the real-world constraint that a dbgeng instance +/// is single-threaded and holds one dump at a time. Parallelism therefore comes +/// from running different hosts/targets concurrently, never from issuing +/// concurrent commands to one host. +/// +public interface IDebuggerHost : IDisposable +{ + /// Short host name, e.g. "cdb" or "dotnet-dump". Drives host-conditional assertions. + string Name { get; } + + /// Load the SOS extension into the host. Idempotent. + void LoadSos(); + + /// + /// Run a raw debugger command exactly as typed (no SOS prefixing). Use for engine + /// commands like .load, ~*k, etc. + /// + SosOutput Execute(string command); + + /// + /// Run a SOS command. The host applies whatever prefixing it needs (dbgeng wants a + /// leading !; dotnet-dump takes the bare command), so the test author writes + /// Sos("clrstack") once and it works everywhere. + /// + SosOutput Sos(string command); +} diff --git a/src/tests/SOS.TestHarness/IDiagnosticHost.cs b/src/tests/SOS.TestHarness/IDiagnosticHost.cs new file mode 100644 index 0000000000..123fcb47e6 --- /dev/null +++ b/src/tests/SOS.TestHarness/IDiagnosticHost.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// Optional capability on a host that captures its underlying process's stdout/stderr and any crash dump +/// (see ). Only the child-process hosts on Linux/macOS (lldb, dotnet-dump) +/// implement it; the in-process dbgeng hosts do not, so callers probe for it with as rather than +/// requiring it on every . +/// +public interface IDiagnosticHost +{ + /// The captured diagnostics for this host, or null if none are being collected. + HostDiagnostics? Diagnostics { get; } +} diff --git a/src/tests/SOS.TestHarness/ILiveDebuggerHost.cs b/src/tests/SOS.TestHarness/ILiveDebuggerHost.cs new file mode 100644 index 0000000000..0ad377e56c --- /dev/null +++ b/src/tests/SOS.TestHarness/ILiveDebuggerHost.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// A live, advancing debugger host: an that additionally drives a running +/// debuggee forward. Where a dump host opens a frozen target, a live host launches the debuggee, parks it +/// at the loader break with SOS loaded, and then advances it on demand — to a managed method +/// (), to its crash (), or to the next armed breakpoint +/// (). +/// +/// This is the seam that lets be backend-agnostic: the Windows backend is the +/// in-process dbgeng engine (driven out-of-process through ), and the +/// Linux/macOS backend drives the lldb CLI. A test author programs against +/// and never sees which one is underneath. +/// +public interface ILiveDebuggerHost : IDebuggerHost +{ + /// Set a managed breakpoint on ! and run to + /// it. Throws if the process exits or crashes before reaching the method. + SosOutput RunToBpmd(string module, string method); + + /// Run the process until it crashes (a second-chance/fatal fault). Throws if it exits + /// cleanly without crashing. + SosOutput RunToCrash(); + + /// Resume to the next breakpoint the caller has already armed (e.g. via Sos("bpmd …")). + /// Sets/clears nothing itself. Throws if the process exits without hitting one. + SosOutput RunToBreakpoint(); +} diff --git a/src/tests/SOS.TestHarness/LiveTarget.cs b/src/tests/SOS.TestHarness/LiveTarget.cs new file mode 100644 index 0000000000..9fe0d4463c --- /dev/null +++ b/src/tests/SOS.TestHarness/LiveTarget.cs @@ -0,0 +1,164 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// A live, advancing target owned exclusively by one test. The debuggee is launched under a child +/// (its own EngineHost process) and parked at the debugger's initial +/// breakpoint - before CoreCLR loads - with SOS loaded. already works there (e.g. +/// bpmd, which sets a pending managed breakpoint), so it does not throw before the +/// first navigation, unlike a . +/// +/// Navigation only moves forward and tracks where we are stopped: re-asking for the current point is +/// a no-op; asking for a later one runs there (skipping intervening stop points); if the process +/// crashes or exits before reaching it, an is thrown. +/// is the raw form for breaking on an arbitrary method. Dispose (use +/// using) to shut the child down. +/// +public sealed class LiveTarget : Target +{ + // Sentinel for "stopped at the crash" - not a real stop-point name (can't collide). + private const string CrashMarker = "\0crash"; + + // Live debugging does not parallelize like dump replay: every live session spawns its own debugger + // (lldb) ptrace-attached to a running debuggee. Letting xunit launch one per core (≈20 here) + // overwhelms the box - lldb sessions begin to busy-spin and never return, stacking command timeouts + // into an apparent hang. Bound the number of concurrent live sessions so a few slow ones can't + // saturate every core. Override with SOSHARNESS_MAX_LIVE; default leaves generous headroom below the + // core count. + private static readonly SemaphoreSlim s_liveGate = new(ComputeMaxConcurrentLive()); + + private readonly TargetDefinition _definition; + private ILiveDebuggerHost? _host; + private bool _gateHeld; + private string? _at; // current stop name, CrashMarker, or null (still at the initial break) + private bool _disposed; + + internal LiveTarget(Host hostKind, TargetDefinition definition, Flavor flavor, string exePath, + CoreVersion coreVersion = CoreVersion.Net10, Dac dac = Dac.Legacy) + : base(hostKind, definition.Name, flavor) + { + _definition = definition; + + // Hold the live-session slot for the entire lifetime of this target (launch -> commands -> + // dispose), not just creation, so the cap actually bounds concurrent live debuggers. Observe the + // ambient test cancellation so a Ctrl+C while queued behind the gate unwinds promptly instead of + // blocking until a slot frees. + s_liveGate.Wait(HarnessCancellation.Token); + _gateHeld = true; + try + { + _host = HostFactory.CreateLiveHost(hostKind, flavor, exePath, coreVersion, dac); + } + catch + { + s_liveGate.Release(); + _gateHeld = false; + throw; + } + } + + private static int ComputeMaxConcurrentLive() + { + if (int.TryParse(Environment.GetEnvironmentVariable("SOSHARNESS_MAX_LIVE"), out int configured) && configured > 0) + { + return configured; + } + + return Math.Clamp(Environment.ProcessorCount / 4, 2, 6); + } + + protected override void GoToStopPointCore(string stopName) + { + StopPoint stop = _definition.Stop(stopName); + if (stop.Method is null) + { + throw new InvalidOperationException( + $"Stop point '{stopName}' has no method to break on (kind {stop.Kind}); use GoToCrash()."); + } + + if (_at == stop.Name) + { + return; // already here + } + + SkipIfLiveBpmdUnsupported(); + + // Runs forward to the marker; throws if the process exits/crashes before reaching it. The + // managed module for bpmd is flavor-specific (desktop's is the EXE, .NET Core's the DLL). + Engine.RunToBpmd(_definition.ModuleFor(Flavor), stop.Method); + _at = stop.Name; + } + + protected override void GoToCrashCore() + { + if (_at == CrashMarker) + { + return; // already at the crash; repeatable no-op + } + + Engine.RunToCrash(); // throws if the process exits without crashing + _at = CrashMarker; + } + + /// + /// Resume the live process until it next hits a breakpoint. Unlike + /// this sets and clears nothing — the caller arms the breakpoint (e.g. + /// Sos("bpmd Module Method")) and this just runs to it. Throws if the process exits first. + /// + public void RunToBreakpoint() + { + SkipIfLiveBpmdUnsupported(); + + Engine.RunToBreakpoint(); + _at = null; // arbitrary, caller-managed location — not a named point + ReplayContext.Current?.Add(ReplayStepKind.Navigate, "RunToBreakpoint()", null); + } + + /// + /// Live bpmd cannot bind in a self-contained single-file image under the lldb host: bpmd arms a + /// JIT/prestub notification breakpoint on a CoreCLR routine, but in a self-contained single-file + /// publish the runtime is statically linked into the (symbol-stripped) app image, so lldb has no + /// symbol to place that breakpoint on and the debuggee simply runs past every managed stop point. The + /// .NET Core flavor keeps CoreCLR as a distinct libcoreclr.so module, so the same notification + /// breakpoint resolves there. This applies uniformly to every live, single-file, lldb test that + /// navigates via a managed stop point, so it is enforced here rather than per test. + /// + private void SkipIfLiveBpmdUnsupported() + { + if (Host == Host.Lldb && Flavor == Flavor.SingleFile) + { + HarnessSkipException.Now( + "live bpmd cannot bind in a single-file image under lldb (CoreCLR is statically linked and " + + "symbol-stripped)"); + } + } + + protected override SosOutput SosCore(string command) => Engine.Sos(command); + + protected override SosOutput ExecuteCore(string command) => Engine.Execute(command); + + internal override HostDiagnostics? CurrentDiagnostics => (_host as IDiagnosticHost)?.Diagnostics; + + private ILiveDebuggerHost Engine => + _host ?? throw new ObjectDisposedException(nameof(LiveTarget)); + + public override void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _host?.Dispose(); + _host = null; + + if (_gateHeld) + { + _gateHeld = false; + s_liveGate.Release(); + } + } +} diff --git a/src/tests/SOS.TestHarness/Liveness.cs b/src/tests/SOS.TestHarness/Liveness.cs new file mode 100644 index 0000000000..d98a0af335 --- /dev/null +++ b/src/tests/SOS.TestHarness/Liveness.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +[Flags] +public enum Liveness +{ + Live = 1, + Dump = 2, + AllValid = Live | Dump, +} diff --git a/src/tests/SOS.TestHarness/LldbCliHost.cs b/src/tests/SOS.TestHarness/LldbCliHost.cs new file mode 100644 index 0000000000..a376d20d75 --- /dev/null +++ b/src/tests/SOS.TestHarness/LldbCliHost.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// The dump (post-mortem) "lldb" host (Linux/macOS): opens a core file under lldb and runs SOS +/// against it. The lldb-driving machinery (spawn, runcommand framing, sentinel draining, dispose) +/// lives in ; this type only opens the core and points SOS at the right DAC. +/// +/// SOS is the native lldb plugin (libsosplugin.so/.dylib), loaded via plugin load; +/// its managed extension is hosted on the runtime named by sethostruntime. SOS commands are +/// dispatched through the plugin's universal sos <command> entry (the lldb analogue of +/// dbgeng's !command), so a test author writes Sos("clrstack") once and it works on every +/// host. +/// +public sealed class LldbCliHost : LldbHostBase +{ + private readonly Flavor _flavor; + private readonly Dac _dac; + private readonly CoreVersion _coreVersion; + + public override string Name => "lldb"; + + public LldbCliHost(string dumpPath, Flavor flavor, Dac dac = Dac.Legacy, CoreVersion coreVersion = CoreVersion.Net10, string? targetExe = null, HostDiagnostics? diagnostics = null) + { + _flavor = flavor; + _dac = dac; + _coreVersion = coreVersion; + StartLldb(diagnostics: diagnostics, captureCrashDumps: true); + + // Load the core. Pass the target executable as the module so lldb can map the program image — + // essential for self-contained single-file bundles (coreclr is embedded in the exe) and for + // createdump-generated crash cores, whose notes alone don't let lldb locate the single-file + // module (SOS then reports "Failed to find runtime module (libcoreclr.so)"). Mirrors the legacy + // SOSRunner, which always passed the host exe. SOS is loaded later in LoadSos. + string create = string.IsNullOrEmpty(targetExe) + ? $"target create --core \"{dumpPath}\"" + : $"target create --core \"{dumpPath}\" \"{targetExe}\""; + Run(create, LoadTimeout); + } + + public override void LoadSos() + { + if (_dac == Dac.CDac) + { + ToolPaths.EnsureLldbPluginCDacOverride(); + } + + Run($"plugin load \"{ToolPaths.LldbPluginPath}\""); + Run($"sethostruntime \"{ToolPaths.HostRuntimeDirectory}\""); + + // Self-contained single-file bundles carry coreclr inside the exe, so there is no runtime + // directory on disk next to which SOS can find the matching DAC. Point SOS's symbol store at the + // runtime pack's native directory (which ships the DAC the publish resolved against); SOS then + // resolves the DAC for the dump's coreclr build-id from there. This is a *local directory* + // (no network), so the session stays hermetic. Other flavors find their DAC next to the on-disk + // runtime and need no override. (cdb does the equivalent via `.cordll -lp`.) + string? dacDir = _dac == Dac.CDac ? ToolPaths.CDacOverrideDirectory : null; + dacDir ??= _flavor == Flavor.SingleFile ? ToolPaths.SingleFileDacDirectory(_coreVersion) : null; + if (dacDir is { Length: > 0 }) + { + Run($"setsymbolserver -directory \"{dacDir}\""); + } + + // Select the DAC for this config's Dac axis: Legacy => `--usecdac false`, CDac (.NET 11+ only) => + // `--usecdac true`. The same dump is reused across both, so only this debug-time toggle differs. + // SOSHARNESS_USECDAC (off by default; never set in CI) is a global clamp that overrides the axis on + // a dev box whose installed runtimes are skewed such that the cDAC can't load. + Run($"runtimes --usecdac {DacPolicy.UseCDac(_dac)}"); + } +} diff --git a/src/tests/SOS.TestHarness/LldbHostBase.cs b/src/tests/SOS.TestHarness/LldbHostBase.cs new file mode 100644 index 0000000000..29c74f1d4b --- /dev/null +++ b/src/tests/SOS.TestHarness/LldbHostBase.cs @@ -0,0 +1,398 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; + +namespace SOS.TestHarness; + +/// +/// Shared machinery for the lldb-CLI hosts (dump and live). Spawns the lldb binary as a child +/// process, imports lldbhelper.py (which adds a runcommand command), and frames every +/// command as runcommand <cmd> so each one is delimited by a sentinel that also carries a real +/// success bit (<END_COMMAND_OUTPUT> / <END_COMMAND_ERROR> from +/// SBCommandReturnObject.Succeeded()). SOS itself is the native lldb plugin +/// (libsosplugin.so/.dylib); its managed extension runs on the runtime named by +/// sethostruntime. Derived hosts differ only in how they create the target (a core file vs. a +/// launched process) and how they advance it. +/// +public abstract class LldbHostBase : IDebuggerHost, IDiagnosticHost +{ + private const string EndMarker = ""; + private const string ErrorMarker = ""; + + private static readonly string? s_trace = Environment.GetEnvironmentVariable("SOSHARNESS_LLDB_TRACE"); + private static readonly object s_traceLock = new(); + + private Process _process = null!; + private StreamWriter _stdin = null!; + private readonly BlockingCollection _lines = new(); + private Thread _reader = null!; + private Thread? _stderrReader; + private HostDiagnostics? _diagnostics; + private string? _commandInFlight; + + public abstract string Name { get; } + + /// Captured stdout/stderr and crash dumps for this host (see ). + public HostDiagnostics? Diagnostics => _diagnostics; + + /// + /// How long to wait for a single command's output before declaring lldb wedged. Dump hosts answer from + /// a static core and are uniformly fast, so the default is tight. The live host overrides this higher: + /// its process continue must let the debuggee actually run to a managed stop point, which under a + /// saturated full-matrix run can be briefly CPU-starved and legitimately slow (observed ~2 min), so a + /// tight timeout would flake on contention rather than catch a real wedge. + /// + protected virtual TimeSpan CommandTimeout => TimeSpan.FromSeconds(120); + + /// + /// How long to wait for the heavy, contention-sensitive startup steps — spawning lldb and draining its + /// banner, and (for the dump host) target create --core. Loading a multi-hundred-MB core under a + /// saturated full-matrix run can take noticeably longer than a single command (observed ~93s isolated, + /// >120s under contention), and it shares none of the wedge risk of an interactive command, so it gets + /// its own, looser budget. Per-command execution keeps the tighter so a + /// genuinely wedged command still surfaces promptly. Override with SOSHARNESS_LLDB_LOAD_TIMEOUT + /// (seconds). + /// + protected virtual TimeSpan LoadTimeout { get; } = + int.TryParse(Environment.GetEnvironmentVariable("SOSHARNESS_LLDB_LOAD_TIMEOUT"), out int s) && s > 0 + ? TimeSpan.FromSeconds(s) + : TimeSpan.FromSeconds(300); + + /// + /// Spawn lldb, import the command helper, and drain the startup banner so the host is ready for + /// commands. Derived constructors call this first, then create/advance their target. + /// runs against the before launch (e.g. to + /// set debuggee environment variables a live host needs inherited). , if + /// supplied, captures the process's stdout/stderr; when is also + /// set the process runs with the .NET crash-dump environment so a fatal fault in the hosted SOS runtime + /// writes a dump (only the dump host opts into this — a live host would otherwise also dump its + /// debuggee's intentional crashes). + /// + protected void StartLldb(Action? configure = null, HostDiagnostics? diagnostics = null, bool captureCrashDumps = false) + { + _diagnostics = diagnostics; + + string helper = Path.Combine(AppContext.BaseDirectory, "lldbhelper.py"); + if (!File.Exists(helper)) + { + throw new FileNotFoundException($"lldb command helper not found at '{helper}'.", helper); + } + + ProcessStartInfo psi = new() + { + FileName = ToolPaths.LldbExe, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // --no-lldbinit: ignore the dev's ~/.lldbinit so the session is hermetic. + // disable-aslr false: toggling ASLR needs ptrace perms we may not have; keep it off so target + // creation/launch never fails on that. + // prompt-on-quit false: never block waiting for a y/n on shutdown. + psi.ArgumentList.Add("--no-lldbinit"); + psi.ArgumentList.Add("-o"); + psi.ArgumentList.Add("settings set target.disable-aslr false"); + psi.ArgumentList.Add("-o"); + psi.ArgumentList.Add("settings set interpreter.prompt-on-quit false"); + psi.ArgumentList.Add("-o"); + psi.ArgumentList.Add($"command script import {helper}"); + + // Hermetic symbols: scrub any inherited _NT_SYMBOL_PATH (a dev's may point at the Azure-authed + // symweb). We *remove* it rather than point it at a local cache: the native lldb SOS plugin treats + // a set _NT_SYMBOL_PATH as the only search root and stops falling back to the on-disk runtime + // modules, which is how SOS locates the DAC for a locally captured target. Leaving it unset keeps + // that on-disk resolution working. + psi.Environment.Remove("_NT_SYMBOL_PATH"); + + // Run the host with the .NET crash-dump environment so a fatal fault in the SOS managed runtime + // hosted inside lldb writes a full dump we can surface as an artifact. Do this before configure so + // a derived host could still override it if needed. + if (captureCrashDumps) + { + diagnostics?.ConfigureCrashDumps(psi); + } + + configure?.Invoke(psi); + + _process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start lldb"); + _stdin = _process.StandardInput; + _diagnostics?.RecordProcess(_process); + + _reader = new Thread(ReadLoop) { IsBackground = true, Name = "lldb-reader" }; + _reader.Start(); + + // Drain stderr on its own thread: lldb prints crash diagnostics, python errors, and unhandled + // managed-exception traces there. It was previously redirected but never read, so a full stderr + // pipe could even block the host — and, more importantly, the evidence for a crash was discarded. + if (_diagnostics is not null) + { + _stderrReader = new Thread(StderrLoop) { IsBackground = true, Name = "lldb-stderr" }; + _stderrReader.Start(); + } + + // Drain the startup banner up to the marker the helper prints from __lldb_init_module. + DrainToMarker(LoadTimeout); + } + + public abstract void LoadSos(); + + /// Run a raw lldb command verbatim (no SOS dispatch). + public SosOutput Execute(string command) => new(Name, command, Run(command)); + + /// Run a SOS command via the plugin's universal sos <command> dispatcher. + public SosOutput Sos(string command) => new(Name, command, Run("sos " + command)); + + /// Send a command through the runcommand helper and return its output up to the sentinel. + protected string Run(string command) => Run(command, CommandTimeout); + + /// + /// As , but with an explicit timeout — used for the slow, contention-sensitive + /// load steps (e.g. target create --core) that warrant the looser . + /// + protected string Run(string command, TimeSpan timeout) + { + _commandInFlight = command; + try + { + _stdin.WriteLine("runcommand " + command); + _stdin.Flush(); + string outp = DrainToMarker(timeout, command); + AppendTrace($"\n>>> lldb={ToolPaths.LldbExe}\n>>> plugin={ToolPaths.LldbPluginPath}\n>>> rt={ToolPaths.HostRuntimeDirectory}\n(lldb) runcommand {command}\n{outp}\n"); + return outp; + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + AppendTrace($"--- lldb command failed ---{Environment.NewLine}command={command}{Environment.NewLine}{LldbProcessState()}{Environment.NewLine}"); + throw CreateLldbFailure("lldb command failed", command, ex); + } + finally + { + _commandInFlight = null; + } + } + + /// + /// Collect output lines until the sentinel. Strips lldb's prompt-echo lines ((lldb) ...), which + /// lldb writes for every command when stdin is redirected; SOS output never begins with that prefix, so + /// this is safe. + /// + private string DrainToMarker(TimeSpan timeout, string? command = null) + { + StringBuilder sb = new(); + while (true) + { + if (!_lines.TryTake(out string? line, (int)timeout.TotalMilliseconds, HarnessCancellation.Token)) + { + if (_lines.IsCompleted || HasExited()) + { + AppendTrace($"--- lldb stdout closed ---{Environment.NewLine}command={command ?? ""}{Environment.NewLine}{LldbProcessState()}{Environment.NewLine}"); + throw CreateLldbFailure("lldb stdout closed", command, null); + } + + AppendTrace($"--- lldb command timed out ---{Environment.NewLine}command={command ?? ""}{Environment.NewLine}timeout={timeout}{Environment.NewLine}{LldbProcessState()}{Environment.NewLine}"); + throw CreateLldbFailure("lldb command timed out", command, null); + } + + string trimmed = line.TrimEnd(); + if (trimmed.EndsWith(EndMarker, StringComparison.Ordinal) || trimmed.EndsWith(ErrorMarker, StringComparison.Ordinal)) + { + break; + } + + if (line.StartsWith("(lldb) ", StringComparison.Ordinal)) + { + continue; + } + + sb.AppendLine(line); + } + + return sb.ToString(); + } + + private void ReadLoop() + { + try + { + string? line; + while ((line = _process.StandardOutput.ReadLine()) is not null) + { + _diagnostics?.AppendStdout(line); + _lines.Add(line); + } + } + finally + { + _lines.CompleteAdding(); + AppendTrace($"--- lldb stdout eof ---{Environment.NewLine}{LldbProcessState()}{Environment.NewLine}"); + } + } + + private void StderrLoop() + { + try + { + string? line; + while ((line = _process.StandardError.ReadLine()) is not null) + { + _diagnostics?.AppendStderr(line); + } + } + catch + { + // Best effort: the process may die mid-read. Whatever we captured is still available. + } + } + + private InvalidOperationException CreateLldbFailure(string phase, string? command, Exception? inner) + { + StringBuilder sb = new(); + sb.AppendLine($"phase={phase}"); + sb.AppendLine(LldbProcessState()); + sb.AppendLine($"command={command ?? ""}"); + sb.AppendLine($"commandInFlight={_commandInFlight ?? ""}"); + sb.AppendLine($"ToolPaths.LldbExe={ToolPaths.LldbExe}"); + sb.AppendLine($"ToolPaths.LldbPluginPath={ToolPaths.LldbPluginPath}"); + sb.AppendLine($"ToolPaths.HostRuntimeDirectory={ToolPaths.HostRuntimeDirectory}"); + sb.AppendLine($"crashDumpDirectory={HostDiagnostics.CrashDumpDirectory}"); + sb.AppendLine($"SOSHARNESS_LLDB_TRACE={s_trace ?? ""}"); + + if (_diagnostics is not null) + { + sb.AppendLine($"commandLine={_diagnostics.CommandLine}"); + sb.AppendLine("--- lldb stderr tail ---"); + AppendOrEmpty(sb, _diagnostics.StderrTail()); + sb.AppendLine("--- lldb stdout tail ---"); + AppendOrEmpty(sb, _diagnostics.StdoutTail()); + } + + return new InvalidOperationException(sb.ToString(), inner); + } + + private static void AppendOrEmpty(StringBuilder sb, string text) + { + if (text.Length == 0) + { + sb.AppendLine("(empty)"); + return; + } + + sb.Append(text); + if (!text.EndsWith('\n')) + { + sb.AppendLine(); + } + } + + private bool HasExited() + { + try + { + return _process.HasExited; + } + catch + { + return false; + } + } + + private string LldbProcessState() + { + StringBuilder sb = new(); + try + { + sb.AppendLine($"pid={_process.Id}"); + bool hasExited = _process.HasExited; + sb.AppendLine($"hasExited={hasExited}"); + if (hasExited) + { + sb.AppendLine($"exitCode={_process.ExitCode}"); + } + } + catch (Exception ex) + { + sb.AppendLine($"processStateError={ex.GetType().Name}: {ex.Message}"); + } + + return sb.ToString().TrimEnd(); + } + + private static void AppendTrace(string text) + { + if (s_trace is not { Length: > 0 }) + { + return; + } + + try + { + string? directory = Path.GetDirectoryName(s_trace); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + lock (s_traceLock) + { + File.AppendAllText(s_trace, text); + } + } + catch + { + // Trace output is diagnostic only. + } + } + + public void Dispose() + { + try + { + if (!_process.HasExited) + { + // Ask lldb to quit. A *wedged* lldb (busy-spinning on its inferior, not reading stdin) + // never sees this, so don't wait long before escalating to a hard kill. + try + { + _stdin.WriteLine("quit"); + _stdin.Flush(); + } + catch + { + // stdin may already be closed; fall through to the kill path. + } + + if (!_process.WaitForExit(3000)) + { + _process.Kill(entireProcessTree: true); + } + } + } + catch + { + // best effort + } + finally + { + // Reap the child (and its debuggee, killed via the process tree above). Without this the + // killed lldb/debuggee linger as unreaped zombies; across a long multi-version run they + // accumulate, saturate the box, and wedge later live sessions. A bounded wait keeps teardown + // from blocking if the kill is still propagating. + try + { + _process.WaitForExit(10000); + } + catch + { + // best effort + } + + _process.Dispose(); + } + } +} diff --git a/src/tests/SOS.TestHarness/LldbLiveHost.cs b/src/tests/SOS.TestHarness/LldbLiveHost.cs new file mode 100644 index 0000000000..54b37ac6b8 --- /dev/null +++ b/src/tests/SOS.TestHarness/LldbLiveHost.cs @@ -0,0 +1,257 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// The live "lldb" host (Linux/macOS): launches the debuggee under lldb, parks it at the program +/// entry (before CoreCLR is up) with SOS loaded, and advances it on demand. It is the lldb analogue of +/// : a stateful, advancing target owned exclusively by one test. +/// +/// Unlike the Windows engine — which runs in-process and is therefore driven out-of-process through a +/// child — lldb is already its own process, so this host drives it +/// directly through (the shared spawn/runcommand/sentinel machinery). +/// +/// Stop detection is by text: process continue runs synchronously and its output reports either a +/// stop ("Process N stopped") or an exit ("Process N exited"); the precise managed location +/// is confirmed with clrstack, exactly as the dbgeng host confirms with its own clrstack. +/// +public sealed class LldbLiveHost : LldbHostBase, ILiveDebuggerHost +{ + // A navigation resumes the debuggee through however many internal SOS notification breakpoints (JIT / + // prestub) it takes to reach the requested managed method. In the healthy case those auto-continue so + // the method is reached on the first resume; under heavy CPU contention that auto-continue can degrade + // into many separate stops. We therefore bound the walk by a wall clock (see CommandTimeout) rather + // than a small fixed resume count, and keep this only as a safety net against a pathological + // instantly-returning continue so the loop can never spin forever. + private const int MaxResumes = 10000; + + private readonly Flavor _flavor; + private readonly Dac _dac; + private readonly CoreVersion _coreVersion; + + public override string Name => "lldb-live"; + + // Live navigation resumes the debuggee and waits for it to reach a managed stop point; under a + // saturated full-matrix run that can be briefly CPU-starved and take ~2 min, so give it more headroom + // than the (uniformly fast) dump hosts to avoid flaking on contention. Override with SOSHARNESS_LIVE_TIMEOUT + // (seconds). + protected override TimeSpan CommandTimeout { get; } = + int.TryParse(Environment.GetEnvironmentVariable("SOSHARNESS_LIVE_TIMEOUT"), out int s) && s > 0 + ? TimeSpan.FromSeconds(s) + : TimeSpan.FromSeconds(300); + + public LldbLiveHost(string exePath, Flavor flavor, CoreVersion coreVersion = CoreVersion.Net10, Dac dac = Dac.Legacy) + { + _flavor = flavor; + _dac = dac; + _coreVersion = coreVersion; + + // The debuggee inherits the lldb process environment. Disable W^E so SOS's bpmd can patch JIT-ed + // code (see dotnet/diagnostics#3126), matching what the legacy live lldb harness set. For a + // framework-dependent (Core) debuggee, point its apphost at the multi-version test runtime install + // so it binds the runtime matching its target framework (net8 -> 8.0.x, net11 -> the preview). + // + // Capture the host's stdout/stderr for diagnosability, but do NOT enable crash-dump collection + // here: the env would be inherited by the launched debuggee and dump on every intentional crash a + // "run to crash" test triggers. Only the post-mortem lldb host (no debuggee) opts into dumps. + StartLldb(psi => + { + psi.Environment["DOTNET_EnableWriteXorExecute"] = "0"; + if (_flavor == Flavor.Core) + { + psi.Environment["DOTNET_ROOT"] = RepoLayout.DotnetTestRoot; + psi.Environment["DOTNET_ROOT(x86)"] = RepoLayout.DotnetTestRoot; + psi.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; + } + }, diagnostics: new HostDiagnostics(Name)); + + Run($"target create \"{exePath}\""); + + // Stop at the program entry so we can load SOS and arm bpmd before the app runs. + Run("process launch -s"); + + // A managed fault (divide-by-zero -> SIGFPE, null-deref -> SIGSEGV, etc.) is first delivered to the + // runtime's signal handler, which turns it into a managed exception. We must therefore pass those + // signals through to the debuggee without stopping; an *unhandled* managed exception then tears the + // process down via abort() (SIGABRT), which is the point a live "run to crash" should stop at. + Run("process handle -s false -n false -p true SIGFPE"); + Run("process handle -s false -n false -p true SIGSEGV"); + Run("process handle -s true -n true -p true SIGABRT"); + + LoadSos(); + } + + public override void LoadSos() + { + if (_dac == Dac.CDac) + { + ToolPaths.EnsureLldbPluginCDacOverride(); + } + + Run($"plugin load \"{ToolPaths.LldbPluginPath}\""); + Run($"sethostruntime \"{ToolPaths.HostRuntimeDirectory}\""); + + string? dacDir = _dac == Dac.CDac ? ToolPaths.CDacOverrideDirectory : null; + dacDir ??= _flavor == Flavor.SingleFile ? ToolPaths.SingleFileDacDirectory(_coreVersion) : null; + if (dacDir is { Length: > 0 }) + { + Run($"setsymbolserver -directory \"{dacDir}\""); + } + + // Select the DAC for this config's Dac axis (Legacy => false, CDac on .NET 11+ => true). The + // SOSHARNESS_USECDAC clamp (off by default; never in CI) overrides it on a skewed dev box. + Run($"runtimes --usecdac {DacPolicy.UseCDac(_dac)}"); + } + + /// + /// Set a managed breakpoint on ! and run until it is + /// hit. Throws if the process exits first or the breakpoint is never reached. + /// + public SosOutput RunToBpmd(string module, string method) + { + ClearBreakpoints(); + string bpmdOutput = Sos($"bpmd {module} {method}").Text; + + // bpmd reaches the method in stages (a JIT/prestub notification, then the entry), so resume until + // clrstack confirms we are actually stopped at the requested method. Normally SOS's notification + // breakpoints auto-continue and the method is reached on the very first resume; under a saturated + // full-matrix run that auto-continue can degrade into many individual stops, so bound the walk by + // a wall clock (CommandTimeout) instead of a small fixed count — a slow-but-progressing navigation + // must still complete, not fail at an arbitrary Nth resume. + DateTime deadline = DateTime.UtcNow + CommandTimeout; + int resumes = 0; + while (DateTime.UtcNow < deadline && resumes < MaxResumes) + { + string cont = Execute("process continue").Text; + resumes++; + if (StoppedAtMethod(method)) + { + return new SosOutput(Name, $"bpmd {module} {method}", bpmdOutput); + } + + if (HasExited(cont) || ProcessIsDead()) + { + throw new InvalidOperationException( + $"Debuggee exited before hitting bpmd {module}!{method} (after {resumes} resume(s))."); + } + } + + throw new InvalidOperationException( + $"Did not reach bpmd {module}!{method} within {CommandTimeout} ({resumes} resume(s))."); + } + + /// + /// Run the process until it crashes (the runtime aborts on an unhandled managed exception, i.e. + /// SIGABRT). Throws if it exits cleanly without crashing. + /// + public SosOutput RunToCrash() + { + ClearBreakpoints(); + + DateTime deadline = DateTime.UtcNow + CommandTimeout; + int resumes = 0; + while (DateTime.UtcNow < deadline && resumes < MaxResumes) + { + string cont = Execute("process continue").Text; + resumes++; + if (StoppedOnSignal(cont)) + { + return new SosOutput(Name, "run-to-crash", cont); + } + + if (HasExited(cont) || ProcessIsDead()) + { + throw new InvalidOperationException("Process exited without crashing."); + } + } + + throw new InvalidOperationException($"Process did not crash within {CommandTimeout} ({resumes} resume(s))."); + } + + /// + /// Resume to the next breakpoint the caller has already armed (e.g. via Sos("bpmd …")). Sets and + /// clears nothing itself. Throws if the process exits without hitting one. + /// + public SosOutput RunToBreakpoint() + { + string cont = Execute("process continue").Text; + if (HasExited(cont)) + { + throw new InvalidOperationException("Process exited without hitting a breakpoint."); + } + + if (StoppedOnSignal(cont)) + { + throw new InvalidOperationException($"Hit a fatal signal, not a breakpoint:\n{cont}"); + } + + return new SosOutput(Name, "run-to-breakpoint", cont); + } + + /// Drop any breakpoints left from a previous stop point so they don't re-trigger on resume. + private void ClearBreakpoints() + { + Sos("bpmd -clearall"); + Execute("breakpoint delete --force"); + } + + /// Is the managed call stack currently topped by ? + private bool StoppedAtMethod(string method) + { + string stack = Sos("clrstack").Text; + return stack.Contains(method, StringComparison.Ordinal); + } + + /// + /// True if a process continue reported the debuggee exiting. Covers the normal exit line plus the + /// symptoms lldb prints when the inferior died out from under a command under contention (a lost gdb- + /// remote connection, or a subsequent command complaining the process is gone) — any of which means the + /// debuggee is no longer resumable and we must stop, not keep resuming. + /// + private static bool HasExited(string continueOutput) => + continueOutput.Contains("exited with status", StringComparison.OrdinalIgnoreCase) || + continueOutput.Contains(" exited ", StringComparison.OrdinalIgnoreCase) || + continueOutput.Contains("lost connection", StringComparison.OrdinalIgnoreCase) || + continueOutput.Contains("process must be launched", StringComparison.OrdinalIgnoreCase); + + /// + /// Authoritatively ask lldb whether the debuggee is gone, instead of only scraping the (human-readable, + /// and under contention occasionally mis-framed) process continue text. Queries the live + /// SBProcess state via the script interpreter and treats the terminal states — invalid, + /// unloaded, detached, exited — as dead. Defensive: if the state can't be read/parsed it returns false + /// and callers fall back to . + /// + private bool ProcessIsDead() + { + // lldb.eStateType: 0 invalid, 1 unloaded, 2 connected, 3 attaching, 4 launching, 5 stopped, + // 6 running, 7 stepping, 8 crashed, 9 detached, 10 exited, 11 suspended. + const string Tag = "SOSHARNESS_STATE="; + string outp = Execute( + $"script print('{Tag}%d' % lldb.debugger.GetSelectedTarget().GetProcess().GetState())").Text; + + int idx = outp.LastIndexOf(Tag, StringComparison.Ordinal); + if (idx < 0) + { + return false; // couldn't determine — let text-based HasExited decide + } + + int start = idx + Tag.Length; + int end = start; + while (end < outp.Length && char.IsDigit(outp[end])) + { + end++; + } + + if (end == start || !int.TryParse(outp.AsSpan(start, end - start), out int state)) + { + return false; + } + + return state is 0 or 1 or 9 or 10; // invalid, unloaded, detached, exited + } + + /// True if a process continue stopped on a signal (the runtime's abort = a crash). + private static bool StoppedOnSignal(string continueOutput) => + continueOutput.Contains("stop reason = signal", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/tests/SOS.TestHarness/ReplayContext.cs b/src/tests/SOS.TestHarness/ReplayContext.cs new file mode 100644 index 0000000000..532c16370c --- /dev/null +++ b/src/tests/SOS.TestHarness/ReplayContext.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace SOS.TestHarness; + +/// The kind of a captured . +public enum ReplayStepKind +{ + /// A navigation (GoToStopPoint/GoToCrash/RunToBreakpoint). + Navigate, + + /// A SOS command run via . + Sos, + + /// A raw debugger command run via . + Execute, +} + +/// One recorded action against a : a navigation or a command, plus the +/// dump file it ran against (null for live targets, which have no dump). +public sealed class ReplayStep +{ + public ReplayStep(ReplayStepKind kind, string text, string? dumpPath) + { + Kind = kind; + Text = text; + DumpPath = dumpPath; + } + + public ReplayStepKind Kind { get; } + public string Text { get; } + public string? DumpPath { get; } +} + +/// +/// A running, per-test record of everything a did — the host/flavor/liveness it +/// was, and the ordered list of navigations and SOS/debugger commands (each tagged with the dump it +/// ran against). It is captured unconditionally and cheaply during the using Target window; a +/// failing test's after-hook reads it back to emit a replay artifact. Tests never touch this +/// directly — capture is automatic. +/// +/// Captures are keyed by the running test's UniqueID in a private table (NOT in xUnit's +/// KeyValueStorage, which is shared across the context hierarchy and would let parallel tests +/// clobber one another). The after-hook removes the entry, so the table never grows past the set of +/// in-flight tests. +/// +public sealed class ReplayContext +{ + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_byTest = new(); + + private readonly List _steps = new(); + private readonly HashSet _hosts = new(); + + private ReplayContext(TestConfig config, bool live) + { + TargetName = config.Target; + Host = config.Host; + Flavor = config.Flavor; + Config = config.ToString(); + Live = live; + } + + public string TargetName { get; } + public Host Host { get; } + public Flavor Flavor { get; } + public string Config { get; } + public bool Live { get; } + public IReadOnlyList Steps => _steps; + + /// The host diagnostics (captured stdout/stderr and crash dumps) of every host this test + /// touched, deduplicated. A failing test's replay renders these. + public IReadOnlyCollection Hosts + { + get { lock (_hosts) { return _hosts.ToArray(); } } + } + + internal void Add(ReplayStepKind kind, string text, string? dumpPath) => + _steps.Add(new ReplayStep(kind, text, dumpPath)); + + /// Note that this test used a host, so its captured diagnostics can be surfaced on failure. + /// No-ops for a null host (e.g. the cdb child host, which is not wired for capture). + internal void AttachHost(HostDiagnostics? host) + { + if (host is null) + { + return; + } + + lock (_hosts) + { + _hosts.Add(host); + } + } + + /// + /// Begin capturing for the current test, replacing any prior capture for it. Keyed by the test's + /// UniqueID, so it is isolated per test even under cross-class parallelism. No-ops (returns + /// null) when not running inside a test. + /// + public static ReplayContext? Start(TestConfig config, bool live) + { + string? id = TestContext.Current.Test?.UniqueID; + if (id is null) + { + return null; + } + + ReplayContext replay = new(config, live); + s_byTest[id] = replay; + return replay; + } + + /// The capture for the current test, or null if none was started. + public static ReplayContext? Current + { + get + { + string? id = TestContext.Current.Test?.UniqueID; + return id is not null && s_byTest.TryGetValue(id, out ReplayContext? replay) ? replay : null; + } + } + + /// Fetch and remove the capture for a test (the after-hook calls this for every test, so + /// the table is bounded by the in-flight test set). + public static ReplayContext? Take(string testUniqueId) => + s_byTest.TryRemove(testUniqueId, out ReplayContext? replay) ? replay : null; +} diff --git a/src/tests/SOS.TestHarness/RepoLayout.cs b/src/tests/SOS.TestHarness/RepoLayout.cs new file mode 100644 index 0000000000..8893befc3c --- /dev/null +++ b/src/tests/SOS.TestHarness/RepoLayout.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Runtime.InteropServices; + +namespace SOS.TestHarness; + +/// +/// Locates the diagnostics repo root and the well-known build output locations the harness +/// consumes (repo-built native SOS, repo-built dotnet-dump, the pre-built debuggees, and the +/// scratch dump directory). The root is found by walking up from the test output directory and +/// looking for the repo markers (global.json alongside Build.cmd), so the harness +/// works regardless of where the test assembly is run from. +/// +public static class RepoLayout +{ + /// The build configuration of the repo-built tools (native SOS, dotnet-dump). This is embedded + /// by MSBuild in the harness assembly; SOSHARNESS_ARTIFACTS_CONFIG is available as a local override. + public static string ArtifactsConfiguration { get; } = + Environment.GetEnvironmentVariable("SOSHARNESS_ARTIFACTS_CONFIG") is { Length: > 0 } c + ? c + : typeof(RepoLayout).Assembly + .GetCustomAttributes() + .Single(a => a.Key == "SOS.TestHarness.Configuration") + .Value!; + + /// The target framework of the running harness, such as net10.0. + public static string TestTargetFramework { get; } = + typeof(RepoLayout).Assembly + .GetCustomAttributes() + .Single(a => a.Key == "SOS.TestHarness.TargetFramework") + .Value!; + + /// The repo root (the directory containing global.json and Build.cmd). + public static string Root { get; } = FindRoot(); + + /// artifacts/bin under the repo root. + public static string ArtifactsBin => Path.Combine(Root, "artifacts", "bin"); + + /// The native build output directory, e.g. artifacts/bin/Windows_NT.x64.Debug. + public static string ArtifactsBinNative => + Path.Combine(ArtifactsBin, $"{TargetOS}.{TargetArch}.{ArtifactsConfiguration}"); + + /// The processor architecture token used in repo artifact paths (x64/x86/arm64). + public static string TargetArch { get; } = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.X86 => "x86", + Architecture.Arm64 => "arm64", + Architecture.Arm => "arm", + _ => "x64", + }; + + /// The OS token used in repo native artifact paths. The native build lowercases the OS token + /// for non-Windows (e.g. linux.x64.Debug, osx.arm64.Debug) but keeps Windows_NT on + /// Windows, so match that casing here or the native output directory won't be found. + public static string TargetOS { get; } = + OperatingSystem.IsWindows() ? "Windows_NT" : + OperatingSystem.IsMacOS() ? "osx" : "linux"; + + /// The runtime identifier of the current test leg (e.g. win-x64 or + /// linux-musl-x64), embedded by the build so artifact lookup preserves RID distinctions that + /// cannot be inferred from . + public static string Rid { get; } = + typeof(RepoLayout).Assembly + .GetCustomAttributes() + .Single(a => a.Key == "SOS.TestHarness.TargetRid") + .Value!; + + /// The repo's locally-acquired .NET host (.dotnet/dotnet.exe) used to shell out builds. + public static string DotNetExe => Path.Combine(Root, ".dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); + + /// The platform suffix for an apphost executable: .exe on Windows, none elsewhere + /// (Linux/macOS apphosts have no extension). + public static string ExeSuffix => OperatingSystem.IsWindows() ? ".exe" : string.Empty; + + /// Path to a debuggee project under the SOS.UnitTests Debuggees tree. + public static string DebuggeeProject(string name) => + Path.Combine(Root, "src", "tests", "SOS.UnitTests", "Debuggees", name, name + ".csproj"); + + /// The pre-built Core output directory for a debuggee and target framework (e.g. + /// net8.0/net11.0), as produced by Debuggees.proj. + public static string CoreDebuggeeDir(string name, string tfm) => + Path.Combine(ArtifactsBin, name, ArtifactsConfiguration, tfm); + + /// The build-produced self-contained single-file publish directory for a debuggee. + public static string SingleFileDebuggeeDir(string name, string tfm) => + Path.Combine(ArtifactsBin, name, ArtifactsConfiguration, tfm, Rid, "publish"); + + /// + /// The repo's locally-acquired multi-version test .NET install (artifacts/dotnet-test), which + /// eng/InstallRuntimes.proj populates with every RuntimeTestVersions runtime (8/9/10/11). + /// Used as DOTNET_ROOT when launching a debuggee so its apphost resolves the matching runtime + /// version (the repo's .dotnet only carries the build SDK's runtime). + /// + public static string DotnetTestRoot { get; } = Path.Combine(Root, "artifacts", "dotnet-test"); + + /// The multi-version test .NET host (artifacts/dotnet-test/dotnet[.exe]). This is the + /// net11-capable SDK that Debuggees.proj uses to pre-build the debuggees, so local Core fallback + /// builds must use it too — the repo's .dotnet build SDK (e.g. 10.0.x) refuses to target newer + /// frameworks (NETSDK1045). + public static string DotnetTestExe => Path.Combine(DotnetTestRoot, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); + + /// Scratch directory for harness-produced artifacts (on-the-fly builds, captured dumps). + public static string Scratch { get; } = + Path.Combine(Root, "artifacts", "tmp", "sos-harness", ArtifactsConfiguration); + + /// + /// A hermetic, local-only symbol path for the SOS host child processes. The dev machine's + /// _NT_SYMBOL_PATH often points at the Azure-authed symweb server, which makes SOS's + /// host init pull in Azure.Identity (and fail loading its closure) and would make tests depend on + /// the network. We point the children at a local cache only — debuggee PDBs are found next to the + /// module, so managed source/line resolution still works. + /// + public static string SymbolCache { get; } = Path.Combine(Scratch, "symcache"); + + private static string FindRoot() + { + string? dir = AppContext.BaseDirectory; + while (dir is not null) + { + if (File.Exists(Path.Combine(dir, "global.json")) && + File.Exists(Path.Combine(dir, "Build.cmd"))) + { + return dir; + } + + dir = Path.GetDirectoryName(dir); + } + + throw new DirectoryNotFoundException( + "Could not locate the diagnostics repo root (global.json + Build.cmd) by walking up from " + + AppContext.BaseDirectory); + } +} diff --git a/src/tests/SOS.TestHarness/SOS.TestHarness.csproj b/src/tests/SOS.TestHarness/SOS.TestHarness.csproj new file mode 100644 index 0000000000..5b9e472167 --- /dev/null +++ b/src/tests/SOS.TestHarness/SOS.TestHarness.csproj @@ -0,0 +1,48 @@ + + + + $(NetCoreAppTestTargetFramework) + enable + enable + true + + + + + + + + + + + + + + + + + + + + + + + <_Parameter1>SOS.TestHarness.Configuration + <_Parameter2>$(Configuration) + + + <_Parameter1>SOS.TestHarness.TargetFramework + <_Parameter2>$(TargetFramework) + + + <_Parameter1>SOS.TestHarness.TargetRid + <_Parameter2>$(TargetRid) + + + + diff --git a/src/tests/SOS.TestHarness/SnapshotStore.cs b/src/tests/SOS.TestHarness/SnapshotStore.cs new file mode 100644 index 0000000000..8acbf5123f --- /dev/null +++ b/src/tests/SOS.TestHarness/SnapshotStore.cs @@ -0,0 +1,534 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace SOS.TestHarness; + +/// +/// Locates and memoizes the runnable debuggee and produces the dump for each (flavor, target, coreVersion, +/// stopPoint). Each (flavor, target, coreVersion) is acquired once and captured once into its +/// own dump directory — so no two tests ever write the same artifact. The DAC axis is not a +/// capture dimension: legacy and cDAC reuse the same dump (only runtimes --usecdac differs at debug +/// time), so it never appears in these keys. +/// +/// Debuggee acquisition follows the repo's build model: +/// +/// Core (net8.0–net11.0) is the pre-built debuggee produced by the repo build +/// (Debuggees.proj) under artifacts/bin/<Name>/<Config>/net{N}.0; the harness +/// consumes it directly (and builds the single project on demand for local development if it isn't there +/// yet), launching it against the multi-version test runtime install so its apphost binds the matching +/// runtime. +/// SingleFile is pre-published by Debuggees.proj once per tested runtime, RID, and +/// configuration. Tests only locate and consume that immutable output. +/// Framework (net462) is produced on the fly in the harness scratch tree, matching the +/// legacy harness's cli build process. +/// +/// +/// Capture mechanism depends on the flavor and stop kind: +/// +/// Snapshot stops (Core / SingleFile) self-snapshot mid-run from inside the debuggee via +/// the repo-built dotnet-dump collect. +/// Crash stop, Core lets the runtime's createdump write the dump. +/// Crash stop, SingleFile on Windows can't use createdump, so it's captured with dbgeng like desktop. +/// Framework (desktop) is always captured externally by . +/// +/// +public static class SnapshotStore +{ + // One acquisition per (flavor, target, coreVersion); thread-safe via Lazy. + private static readonly ConcurrentDictionary<(Flavor Flavor, string Target, CoreVersion CoreVersion), Lazy> s_targetExe = new(); + + // One capture per (flavor, target, gcType, dumpKind, coreVersion) (distinct dump dirs); thread-safe via + // Lazy. The DAC axis is deliberately absent: the same dump is reused for both legacy and cDAC (only + // `runtimes --usecdac` differs at debug time), so capture must not be keyed on it. + private static readonly ConcurrentDictionary<(Flavor Flavor, string Target, GcType GcType, DumpKind DumpKind, CoreVersion CoreVersion), Lazy> s_captured = new(); + + // The out-of-process desktop capturer, located/built once. + private static readonly Lazy s_capturerDll = new(() => SubprocessDll("SOS.TestHarness.Capturer")); + + private static string CapturerDll => s_capturerDll.Value; + + // The out-of-process dbgeng engine host, located/built once. + private static readonly Lazy s_engineHostDll = new(() => SubprocessDll("SOS.TestHarness.EngineHost")); + private static readonly object s_subprocessBuildLock = new(); + + /// Path to the built EngineHost.dll (the subprocess dbgeng backend), produced on first use. + public static string EngineHostDll => s_engineHostDll.Value; + + /// Path to the dump for one stop point of a target in a flavor/GC/dump-kind/version, producing it on first use. + public static string GetDump(Flavor flavor, string targetName, string stopName, GcType gcType = GcType.Workstation, DumpKind dumpKind = DumpKind.Heap, CoreVersion coreVersion = CoreVersion.Net10) + { + TargetDefinition target = TargetCatalog.Get(targetName); + target.Stop(stopName); // validate + + string dumpDir = s_captured + .GetOrAdd((flavor, targetName, gcType, dumpKind, coreVersion), key => new Lazy(() => CaptureTarget(key.Flavor, TargetCatalog.Get(key.Target), key.GcType, key.DumpKind, key.CoreVersion))) + .Value; + + string dump = Path.Combine(dumpDir, stopName + ".dmp"); + if (!File.Exists(dump)) + { + throw new InvalidOperationException( + $"Capture of {flavor}/{gcType}/{dumpKind}/{CoreVersions.Tfm(coreVersion)}/{targetName} did not produce a dump for stop '{stopName}' at '{dump}'."); + } + + return dump; + } + + /// Path to the runnable executable for a target in a flavor/version, producing it on first use. + public static string TargetExe(Flavor flavor, string targetName, CoreVersion coreVersion = CoreVersion.Net10) + { + string exe = s_targetExe + .GetOrAdd((flavor, targetName, coreVersion), k => new Lazy(() => AcquireTarget(k.Flavor, TargetCatalog.Get(k.Target), k.CoreVersion))) + .Value; + EnsureExecutable(exe); + return exe; + } + + private static void EnsureExecutable(string path) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + UnixFileMode mode = File.GetUnixFileMode(path); + UnixFileMode execute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + if ((mode & execute) != execute) + { + File.SetUnixFileMode(path, mode | execute); + } + } + + private static string DumpDir(Flavor flavor, string target, GcType gcType, DumpKind dumpKind, CoreVersion coreVersion) => + Path.Combine(RepoLayout.Scratch, "dumps", flavor.ToString().ToLowerInvariant(), + gcType.ToString().ToLowerInvariant(), dumpKind.ToString().ToLowerInvariant(), + CoreVersions.Tfm(coreVersion), target); + + private static string CaptureTarget(Flavor flavor, TargetDefinition target, GcType gcType, DumpKind dumpKind, CoreVersion coreVersion) + { + string dumpDir = DumpDir(flavor, target.Name, gcType, dumpKind, coreVersion); + Directory.CreateDirectory(dumpDir); + + // Resolve (build if needed) the debuggee first, then reuse the cached dumps only if they were + // captured from THIS exe (i.e. are at least as new as it). A rebuilt exe has a fresh PDB whose + // GUID won't match an older dump, so a stale dump must be re-captured. + string exe = TargetExe(flavor, target.Name, coreVersion); + DateTime exeTime = File.GetLastWriteTimeUtc(exe); + if (target.StopPoints.All(s => IsUpToDate(Path.Combine(dumpDir, s.Name + ".dmp"), exeTime))) + { + return dumpDir; + } + + // On Windows, a reduced Core dump of an unsigned runtime needs a machine-wide dbghelp setting. + // Use a Full dump when that setting is absent so normal test runs remain self-contained. + DumpKind captureKind = DumpGenerationRequirements.ResolveCaptureKind(flavor, dumpKind); + + bool isCrash = target.StopPoints.Any(s => s.Kind == StopKind.Crash); + + if (flavor == Flavor.Framework) + { + // Desktop: no diagnostics IPC; dbgeng captures both snapshot (bpmd) and crash (second-chance). + // Run it out-of-process so a dbgeng crash dies with the child, not the test host. + CaptureWithDbgEng(TargetExe(flavor, target.Name, coreVersion), target, dumpDir, gcType, captureKind); + } + else if (isCrash && flavor == Flavor.SingleFile && OperatingSystem.IsWindows()) + { + // Self-contained single-file on Windows doesn't ship/launch createdump, so capture its crash + // with dbgeng like desktop (also out-of-process). On Linux/macOS the bundled runtime's + // createdump handles single-file crashes, so we fall through to CaptureCrashViaCreatedump. + CaptureWithDbgEng(TargetExe(flavor, target.Name, coreVersion), target, dumpDir, gcType, captureKind); + } + else if (isCrash) + { + // .NET Core crash: let the runtime's createdump write the dump. + CaptureCrashViaCreatedump(flavor, target, dumpDir, gcType, captureKind, coreVersion); + } + else + { + // Snapshot stops on Core / SingleFile: self-snapshot mid-run via markers. + SelfCollectCapture(flavor, target, dumpDir, gcType, captureKind, coreVersion); + } + + return dumpDir; + } + + /// Run the Capturer child to produce dumps via in-process dbgeng (desktop, or single-file crash). + private static void CaptureWithDbgEng(string exePath, TargetDefinition target, string dumpDir, GcType gcType, DumpKind dumpKind) + { + ProcessStartInfo psi = new(RepoLayout.DotNetExe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + psi.ArgumentList.Add(CapturerDll); + psi.ArgumentList.Add(exePath); + psi.ArgumentList.Add(target.Name); + psi.ArgumentList.Add(dumpDir); + psi.ArgumentList.Add(dumpKind.ToString()); + + // Hermetic, local-only symbols: the Capturer hosts dbgeng+SOS, and the dev's _NT_SYMBOL_PATH may + // point at the Azure-authed symweb, which crashes SOS host init (loading Azure.Identity's closure). + Directory.CreateDirectory(RepoLayout.SymbolCache); + psi.Environment["_NT_SYMBOL_PATH"] = RepoLayout.SymbolCache; + ApplyGcType(psi, gcType); + + using Process p = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start Capturer"); + string stdout = p.StandardOutput.ReadToEnd(); + string stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(); + if (p.ExitCode != 0) + { + throw new InvalidOperationException($"Capturer failed for {target.Name} ({p.ExitCode}):\n{stdout}\n{stderr}"); + } + } + + /// + /// Core/SingleFile crash capture: launch the target with the runtime's crash-dump env vars set and let + /// it crash. The runtime's createdump writes the dump to the crash stop's path; the process exits + /// non-zero (it crashed), so we + /// verify the dump exists rather than the exit code. + /// + private static void CaptureCrashViaCreatedump(Flavor flavor, TargetDefinition target, string dumpDir, GcType gcType, DumpKind dumpKind, CoreVersion coreVersion) + { + string exe = TargetExe(flavor, target.Name, coreVersion); + StopPoint crash = target.StopPoints.Single(s => s.Kind == StopKind.Crash); + string dumpPath = Path.Combine(dumpDir, crash.Name + ".dmp"); + + ProcessStartInfo psi = new(exe) + { + WorkingDirectory = Path.GetDirectoryName(exe), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + psi.Environment["DOTNET_DbgEnableMiniDump"] = "1"; + psi.Environment["DOTNET_DbgMiniDumpType"] = CreatedumpType(flavor, dumpKind); + psi.Environment["DOTNET_DbgMiniDumpName"] = dumpPath; + psi.Environment["DOTNET_CreateDumpDiagnostics"] = "1"; + ApplyRuntimeRoot(psi, flavor); + ApplyMacOsDumpConfig(psi); + ApplyGcType(psi, gcType); + + using Process p = Process.Start(psi) ?? throw new InvalidOperationException("Failed to launch target"); + string stdout = p.StandardOutput.ReadToEnd(); + string stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(); + + if (!File.Exists(dumpPath)) + { + throw new InvalidOperationException( + $"createdump did not produce '{dumpPath}' for {target.Project} ({flavor}); exit {p.ExitCode}.\n{stdout}\n{stderr}"); + } + } + + /// Core/SingleFile snapshot capture: run the target once; its markers self-snapshot mid-run. + /// + /// macOS-only dump configuration applied to every debuggee we capture a dump from (createdump on crash, + /// or the in-process dotnet-dump collect self-snapshot). createdump on macOS defaults to a Mach-O + /// core, which SOS/ClrMD cannot read; DOTNET_DbgEnableElfDumpOnMacOS=1 makes it emit an ELF core + /// instead (matching the legacy harness). The diagnostic-IPC socket the runtime opens lives under + /// $TMPDIR, and macOS's default $TMPDIR (/var/folders/…) routinely blows past the + /// 104-byte Unix-domain-socket path limit, so point the debuggee at a short TMPDIR. No-op off macOS. + /// + private static void ApplyMacOsDumpConfig(ProcessStartInfo psi) + { + if (!OperatingSystem.IsMacOS()) + { + return; + } + + psi.Environment["DOTNET_DbgEnableElfDumpOnMacOS"] = "1"; + psi.Environment["TMPDIR"] = "/tmp"; + } + + /// + /// Point a framework-dependent (Core) debuggee at the multi-version test runtime install so its apphost + /// resolves the runtime matching its target framework (e.g. a net8 debuggee binds 8.0.x, net11 binds the + /// installed preview). Self-contained single-file and desktop Framework debuggees carry / don't use a + /// shared runtime, so this is a no-op for them. DOTNET_MULTILEVEL_LOOKUP=0 keeps resolution + /// strictly within the test install (no machine-wide fallback), so the dump's coreclr — and therefore the + /// DAC SOS later loads — is the deterministic, on-disk one. + /// + private static void ApplyRuntimeRoot(ProcessStartInfo psi, Flavor flavor) + { + if (flavor != Flavor.Core) + { + return; + } + + psi.Environment["DOTNET_ROOT"] = RepoLayout.DotnetTestRoot; + psi.Environment["DOTNET_ROOT(x86)"] = RepoLayout.DotnetTestRoot; + psi.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; + } + + /// Apply the GC-mode env vars to a debuggee launch. Server forces a deterministic multi-heap + /// GC (a fixed heap count with DATAS off, so it can't collapse back to a single heap). + private static void ApplyGcType(ProcessStartInfo psi, GcType gcType) + { + if (gcType == GcType.Server) + { + psi.Environment["DOTNET_gcServer"] = "1"; + psi.Environment["DOTNET_GCHeapCount"] = "4"; + psi.Environment["DOTNET_GCDynamicAdaptationMode"] = "0"; + } + } + + /// + /// The createdump/DOTNET_DbgMiniDumpType value for a dump kind: Full=4, Heap=2, Mini=1, + /// except single-file crash dumps which must use Full=4. + /// + private static string CreatedumpType(Flavor flavor, DumpKind dumpKind) + { + if (dumpKind == DumpKind.Full || flavor == Flavor.SingleFile) + { + // Single-file crash dumps cannot use createdump's reduced dump modes: Heap/Mini require DAC + // region enumeration, but the single-file app does not have a loadable DAC beside it. Keep the + // test matrix's Heap row, but capture it with the only supported createdump mode. + return "4"; + } + + return dumpKind == DumpKind.Mini ? "1" : "2"; + } + + /// + /// The dotnet-dump collect --type value for a dump kind. Single-file self-snapshots use Full + /// because reduced dumps require the same unsupported DAC region enumeration as single-file crashes. + /// + private static string CollectType(Flavor flavor, DumpKind dumpKind) => + dumpKind == DumpKind.Full || flavor == Flavor.SingleFile ? "Full" : dumpKind.ToString(); + + private static void SelfCollectCapture(Flavor flavor, TargetDefinition target, string dumpDir, GcType gcType, DumpKind dumpKind, CoreVersion coreVersion) + { + string exe = TargetExe(flavor, target.Name, coreVersion); + ProcessStartInfo psi = new(exe) + { + WorkingDirectory = Path.GetDirectoryName(exe), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + psi.Environment["SOSHARNESS_CAPTURE_DIR"] = dumpDir; + // Tell the debuggee's stop-point helper which dotnet-dump to self-collect with (the repo-built one). + psi.Environment["SOSHARNESS_DOTNET"] = RepoLayout.DotNetExe; + psi.Environment["SOSHARNESS_DOTNETDUMP_DLL"] = ToolPaths.DotNetDumpDll; + psi.Environment["SOSHARNESS_DUMP_TYPE"] = CollectType(flavor, dumpKind); + ApplyRuntimeRoot(psi, flavor); + ApplyMacOsDumpConfig(psi); + ApplyGcType(psi, gcType); + + using Process p = Process.Start(psi) ?? throw new InvalidOperationException("Failed to launch target"); + string stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(); + + if (p.ExitCode != 0) + { + throw new InvalidOperationException($"Target '{target.Project}' ({flavor}) failed ({p.ExitCode}):\n{stderr}"); + } + } + + /// + /// Resolve the runnable debuggee for a flavor. Core and SingleFile are repo build outputs; Framework + /// is built on demand from the repo debuggee csproj. + /// + private static string AcquireTarget(Flavor flavor, TargetDefinition target, CoreVersion coreVersion) => flavor switch + { + Flavor.Core => AcquireCore(target, coreVersion), + Flavor.Framework => BuildFramework(target), + Flavor.SingleFile => AcquireSingleFile(target, coreVersion), + _ => throw new ArgumentOutOfRangeException(nameof(flavor)), + }; + + /// Consume the repo-built Core debuggee for the requested version; build the single project on + /// demand if it's absent or older than the debuggee source (so a local debuggee edit is picked up). + private static string AcquireCore(TargetDefinition target, CoreVersion coreVersion) + { + string tfm = CoreVersions.Tfm(coreVersion); + string exe = Path.Combine(RepoLayout.CoreDebuggeeDir(target.Project, tfm), target.Project + RepoLayout.ExeSuffix); + string project = RepoLayout.DebuggeeProject(target.Project); + if (IsUpToDate(exe, NewestSourceWriteTime(project))) + { + return exe; + } + + // Missing or stale relative to source — build just this debuggee for the requested framework (lands + // at the same conventional artifacts path). Lock per project (not per framework): different TFMs of + // one csproj share its obj/ and project.assets.json, so concurrent restores would corrupt each other. + lock (BuildLockFor(project)) + { + if (!IsUpToDate(exe, NewestSourceWriteTime(project))) + { + RunToCompletion(RepoLayout.DotnetTestExe, + $"build \"{project}\" -p:BuildProjectFramework={tfm} -c {RepoLayout.ArtifactsConfiguration}"); + } + } + + if (!File.Exists(exe)) + { + throw new InvalidOperationException($"Core build of {target.Project} ({tfm}) did not produce '{exe}'."); + } + + return exe; + } + + /// Consume the self-contained single-file debuggee published by the repo build. + private static string AcquireSingleFile(TargetDefinition target, CoreVersion coreVersion) + { + string tfm = CoreVersions.Tfm(coreVersion); + string outputDir = RepoLayout.SingleFileDebuggeeDir(target.Project, tfm); + string exe = Path.Combine(outputDir, target.Project + RepoLayout.ExeSuffix); + string runtimeVersionFile = Path.Combine(outputDir, "runtime.version"); + string? expectedRuntimeVersion = CoreVersions.RuntimeVersion(coreVersion); + string? actualRuntimeVersion = File.Exists(runtimeVersionFile) + ? File.ReadAllText(runtimeVersionFile).Trim() + : null; + + if (!File.Exists(exe)) + { + throw new FileNotFoundException( + $"Pre-published single-file debuggee '{target.Project}' ({tfm}/{RepoLayout.Rid}) was not found at '{exe}'. " + + "Build src/tests/Debuggees.proj before running SOS tests.", + exe); + } + + if (expectedRuntimeVersion is null || + !string.Equals(actualRuntimeVersion, expectedRuntimeVersion, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Pre-published single-file debuggee '{target.Project}' ({tfm}/{RepoLayout.Rid}) has runtime version " + + $"'{actualRuntimeVersion ?? ""}', but the installed test runtime manifest requires " + + $"'{expectedRuntimeVersion ?? ""}'."); + } + + return exe; + } + + /// Build the Framework debuggee into the scratch tree, reusing the cached exe when it is newer + /// than the debuggee source. + private static string BuildFramework(TargetDefinition target) + { + string project = RepoLayout.DebuggeeProject(target.Project); + string outDir = Path.Combine(RepoLayout.Scratch, "targets", "framework", target.Name); + string exe = Path.Combine(outDir, target.Project + RepoLayout.ExeSuffix); + DateTime sourceWriteTime = NewestSourceWriteTime(project); + + if (IsUpToDate(exe, sourceWriteTime)) + { + return exe; + } + + string config = RepoLayout.ArtifactsConfiguration; + // Desktop SOS resolves source lines from a classic Windows PDB (read via DIA), not a + // portable/embedded one — the repo's global props default DebugType to embedded, so force + // a full (Windows) PDB next to the exe for the source-line tests. + string args = + $"build \"{project}\" -p:BuildProjectFramework=net462 -p:DebugType=full -p:DebugSymbols=true -c {config} -o \"{outDir}\""; + + // Rebuild only when stale (above). Different frameworks of one csproj share its obj/ (and + // project.assets.json), so serialize fallback builds per project. + lock (BuildLockFor(project)) + { + if (!IsUpToDate(exe, sourceWriteTime)) + { + RunToCompletion(RepoLayout.DotnetTestExe, args); + } + } + + if (!File.Exists(exe)) + { + throw new InvalidOperationException($"Framework build of {target.Project} did not produce '{exe}'."); + } + + return exe; + } + + private static readonly ConcurrentDictionary s_projectBuildLocks = new(StringComparer.OrdinalIgnoreCase); + + private static object BuildLockFor(string projectPath) => + s_projectBuildLocks.GetOrAdd(projectPath, _ => new object()); + + /// Newest write time of the debuggee's sources (its .cs files + csproj), so a build is + /// re-run only when the source actually changed (an unchanged build keeps a stable exe/PDB, which keeps + /// the cached dumps — captured against that exe's PDB — valid). + private static DateTime NewestSourceWriteTime(string projectFile) + { + string dir = Path.GetDirectoryName(projectFile)!; + DateTime newest = File.GetLastWriteTimeUtc(projectFile); + foreach (string cs in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories)) + { + DateTime t = File.GetLastWriteTimeUtc(cs); + if (t > newest) + { + newest = t; + } + } + + return newest; + } + + /// True if exists and is at least as new as . + private static bool IsUpToDate(string output, DateTime inputUtc) => + File.Exists(output) && File.GetLastWriteTimeUtc(output) >= inputUtc; + + /// + /// Locate a subprocess host (EngineHost / Capturer), building it on demand for local development when + /// the normal repository build has not produced it. + /// + private static string SubprocessDll(string name) + { + string dll = Path.Combine(RepoLayout.ArtifactsBin, name, RepoLayout.ArtifactsConfiguration, RepoLayout.TestTargetFramework, RepoLayout.Rid, name + ".dll"); + string project = Path.Combine(RepoLayout.Root, "src", "tests", name, name + ".csproj"); + + if (!File.Exists(dll)) + { + // Both helper projects reference SOS.TestHarness and can be initialized concurrently by + // different test rows. Serialize their fallback builds so their MSBuild nodes do not write + // the shared harness intermediate assembly at the same time. + lock (s_subprocessBuildLock) + { + if (!File.Exists(dll)) + { + RunToCompletion( + RepoLayout.DotNetExe, + $"build \"{project}\" -c {RepoLayout.ArtifactsConfiguration} -p:TargetArch={RepoLayout.TargetArch}"); + } + } + } + + if (!File.Exists(dll)) + { + throw new InvalidOperationException($"Build of {name} did not produce '{dll}'."); + } + + return dll; + } + + private static void RunToCompletion(string fileName, string arguments) + { + ProcessStartInfo psi = new() + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = RepoLayout.Root, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using Process p = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {fileName}"); + string stdout = p.StandardOutput.ReadToEnd(); + string stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(); + if (p.ExitCode != 0) + { + throw new InvalidOperationException($"'{fileName} {arguments}' failed ({p.ExitCode}):\n{stdout}\n{stderr}"); + } + } +} diff --git a/src/tests/SOS.TestHarness/SosAssertException.cs b/src/tests/SOS.TestHarness/SosAssertException.cs new file mode 100644 index 0000000000..bebf8cd6b0 --- /dev/null +++ b/src/tests/SOS.TestHarness/SosAssertException.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// Thrown when a fluent SOS assertion fails. The message is intentionally rich — +/// it names the host, the command, what was expected, and echoes the full captured +/// output. This is the single biggest day-to-day improvement over the legacy +/// VERIFY: model, whose failures only ever said +/// "Debugger output did not match the expression: ...". +/// +public sealed class SosAssertException : Exception +{ + public SosAssertException(string host, string command, string expectation, string actualOutput) + : base(Build(host, command, expectation, actualOutput)) + { + } + + private static string Build(string host, string command, string expectation, string actualOutput) + { + string body = string.IsNullOrEmpty(actualOutput) ? "(no output)" : actualOutput.TrimEnd(); + return $""" + SOS assertion failed. + host: {host} + command: {command} + expected: {expectation} + --- actual output --- + {body} + --------------------- + """; + } +} diff --git a/src/tests/SOS.TestHarness/SosCell.cs b/src/tests/SOS.TestHarness/SosCell.cs new file mode 100644 index 0000000000..b478b7e79d --- /dev/null +++ b/src/tests/SOS.TestHarness/SosCell.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// One cell of a . It converts implicitly to its value string (so +/// row["Name"] == "System.Int32[]" and Assert.Equal(..., row["Name"]) just work) and +/// adds value helpers like to parse the value as a number, and +/// : +/// +/// SosRow row = table.Single(r => r["Name"] == "System.Int32[]"); +/// ulong obj = row["Object"].AsUInt64(Sos.Addr); +/// +/// Cells sliced out of a table by the parser carry the full captured output for rich failure +/// messages. Free-standing data cells (built by a table data extractor via the public +/// constructor) carry only their source line, which is +/// used as the failure context instead. +/// +public sealed class SosCell +{ + private readonly SosOutput? _output; + private readonly string? _sourceLine; + + public string Column { get; } + public string Value { get; } + + internal SosCell(SosOutput output, string column, string value) + { + _output = output; + Column = column; + Value = value; + } + + /// + /// Build a free-standing cell from a raw — used by table data + /// extractors to attach extra values (registers, GC roots, …) parsed out of the lines between + /// aligned table rows. The source line becomes the failure context for the As* helpers. + /// + public SosCell(string sourceLine, string column, string value) + { + _sourceLine = sourceLine; + Column = column; + Value = value; + } + + /// The cell value, so a cell drops straight into string comparisons / Assert.Equal. + public static implicit operator string(SosCell cell) => cell.Value; + + /// Parse the value as an unsigned 64-bit number per (hex/decimal). + public ulong AsUInt64(SosToken token) => SosConvert.UInt64(Column, Value, token, Fail); + + /// Parse the value as an unsigned 32-bit number per . + public uint AsUInt32(SosToken token) => SosConvert.UInt32(Column, Value, token, Fail); + + /// Parse the value as an unsigned 16-bit number per . + public ushort AsUInt16(SosToken token) => SosConvert.UInt16(Column, Value, token, Fail); + + /// Parse the value as a signed 64-bit number per . + public long AsInt64(SosToken token) => SosConvert.Int64(Column, Value, token, Fail); + + /// Parse the value as a signed 32-bit number per . + public int AsInt32(SosToken token) => SosConvert.Int32(Column, Value, token, Fail); + + /// Parse the value as a signed 16-bit number per . + public short AsInt16(SosToken token) => SosConvert.Int16(Column, Value, token, Fail); + + /// Parse the value as a byte per . + public byte AsByte(SosToken token) => SosConvert.Byte(Column, Value, token, Fail); + + /// Parse the value as a boolean (true/false or 1/0). + public bool AsBoolean() => SosConvert.Boolean(Column, Value, Fail); + + /// True if the value contains . + public bool Contains(string substring) => Value.Contains(substring, StringComparison.Ordinal); + + public override string ToString() => Value; + + private Exception Fail(string expectation) => + _output is not null + ? _output.Fail(expectation) + : new SosAssertException("(table data)", _sourceLine ?? string.Empty, expectation, _sourceLine ?? string.Empty); +} diff --git a/src/tests/SOS.TestHarness/SosColumn.cs b/src/tests/SOS.TestHarness/SosColumn.cs new file mode 100644 index 0000000000..2cbf84cc74 --- /dev/null +++ b/src/tests/SOS.TestHarness/SosColumn.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// A column spec for : a header name, an optional +/// every cell must match, and the column's alignment. SOS prints fixed-width columns that are either +/// left- or right-aligned (addresses/SPs are usually right-aligned, type names left); the parser +/// slices cells using that alignment. Implicit conversions let a left-aligned column be written as a +/// bare name or a (name, token) pair; right-aligned columns use : +/// +/// pe.Table("SP", "IP", "Function"); // all left, no validation +/// dso.Table(ColumnAlignment.Right("SP/REG"), ColumnAlignment.Right("Object"), "Name"); +/// dso.Table(ColumnAlignment.Right("Object", Sos.Addr), "Name"); // right-aligned + validated +/// +/// +public readonly struct SosColumn +{ + public SosColumn(string header, SosToken? token = null, bool rightAligned = false) + { + Header = header; + Token = token; + RightAligned = rightAligned; + } + + /// The column header name. + public string Header { get; } + + /// The token every cell must match, or null to skip validation for this column. + public SosToken? Token { get; } + + /// True if the column's values are right-aligned under the header (default left). + public bool RightAligned { get; } + + /// A bare, left-aligned header name with no per-cell validation. + public static implicit operator SosColumn(string header) => new(header); + + /// A left-aligned header name paired with the token its cells must match. + public static implicit operator SosColumn((string Header, SosToken Token) column) => + new(column.Header, column.Token); +} + +/// +/// Builds aligned specs. Only left and right are meaningful for SOS's +/// fixed-width output; is the default behavior (a bare string also yields it), +/// and marks a right-aligned column. +/// +public static class ColumnAlignment +{ + /// A left-aligned column (the default), optionally validated against . + public static SosColumn Left(string header, SosToken? token = null) => new(header, token, rightAligned: false); + + /// A right-aligned column (e.g. addresses/SPs), optionally validated against . + public static SosColumn Right(string header, SosToken? token = null) => new(header, token, rightAligned: true); +} diff --git a/src/tests/SOS.TestHarness/SosConvert.cs b/src/tests/SOS.TestHarness/SosConvert.cs new file mode 100644 index 0000000000..13f81392ed --- /dev/null +++ b/src/tests/SOS.TestHarness/SosConvert.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// Shared value converters for and . The +/// supplies the regex/number format (hex vs decimal); the method name picks +/// the .NET type (named to match BitConverter/Convert: Int32, UInt64, +/// Boolean, …). The caller supplies a fail delegate that turns an expectation message +/// into the right exception (a rich with the full captured output +/// for parser cells, or a line-scoped one for free-standing data cells), so the converters don't +/// depend on a directly. +/// +internal static class SosConvert +{ + public static ulong UInt64(string name, string value, SosToken token, Func fail) + { + if (!token.TryParseNumber(value, out ulong number)) + { + throw fail($"'{name}' to be a parseable {token} value (was \"{value}\")"); + } + + return number; + } + + public static long Int64(string name, string value, SosToken token, Func fail) + { + if (!token.TryParseSigned(value, out long number)) + { + throw fail($"'{name}' to be a parseable signed {token} value (was \"{value}\")"); + } + + return number; + } + + public static uint UInt32(string name, string value, SosToken token, Func fail) => + Checked(name, value, fail, () => checked((uint)UInt64(name, value, token, fail))); + + public static int Int32(string name, string value, SosToken token, Func fail) => + Checked(name, value, fail, () => checked((int)Int64(name, value, token, fail))); + + public static ushort UInt16(string name, string value, SosToken token, Func fail) => + Checked(name, value, fail, () => checked((ushort)UInt64(name, value, token, fail))); + + public static short Int16(string name, string value, SosToken token, Func fail) => + Checked(name, value, fail, () => checked((short)Int64(name, value, token, fail))); + + public static byte Byte(string name, string value, SosToken token, Func fail) => + Checked(name, value, fail, () => checked((byte)UInt64(name, value, token, fail))); + + public static bool Boolean(string name, string value, Func fail) + { + string v = value.Trim(); + if (bool.TryParse(v, out bool b)) + { + return b; + } + + if (v == "1") + { + return true; + } + + if (v == "0") + { + return false; + } + + throw fail($"'{name}' to be a boolean (was \"{value}\")"); + } + + private static T Checked(string name, string value, Func fail, Func convert) + { + try + { + return convert(); + } + catch (OverflowException) + { + throw fail($"'{name}' = \"{value}\" does not fit in {typeof(T).Name}"); + } + } +} diff --git a/src/tests/SOS.TestHarness/SosDataExtractor.cs b/src/tests/SOS.TestHarness/SosDataExtractor.cs new file mode 100644 index 0000000000..b803900f62 --- /dev/null +++ b/src/tests/SOS.TestHarness/SosDataExtractor.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// Claims an internal data line that belongs to — the most recent +/// aligned row (e.g. a GC root from !clrstack -gc or a register from !clrstack -r). +/// Called for each line after the first aligned row is matched. Attach values to the row either as +/// extra scalar columns (, the -r register shape — so +/// row["rip"] works) or as structured sub-records ( with a +/// , the -gc roots shape). Return true to consume the line, or +/// false to let it fall through to normal row matching. +/// +public delegate bool SosDataExtractor(string line, SosRow row); diff --git a/src/tests/SOS.TestHarness/SosDataRow.cs b/src/tests/SOS.TestHarness/SosDataRow.cs new file mode 100644 index 0000000000..12aceb3453 --- /dev/null +++ b/src/tests/SOS.TestHarness/SosDataRow.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// One structured sub-record attached to a by a table data extractor — the +/// "array of structs" shape of internal-data rows. Each !clrstack -gc root line, or each +/// -a/-l/-p parameter/local, becomes one of these: a small bag of named +/// s with the same this[column] + .As*() ergonomics as a table +/// row, but a free-standing set of fields rather than columns shared across rows. Contrast with +/// , which is for the "extra scalar columns" shape (-r +/// registers), where each name occurs once per row. +/// +public sealed class SosDataRow +{ + private readonly string _sourceLine; + private readonly List _cells = new(); + + /// Create an empty record whose failure messages cite . + public SosDataRow(string sourceLine) + { + _sourceLine = sourceLine; + } + + /// The raw output line this record was parsed from. + public string SourceLine => _sourceLine; + + /// All fields of this record, in insertion order. + public IReadOnlyList Cells => _cells; + + /// Add a named field. Throws if is already present. + public SosDataRow Add(string column, string value) + { + if (_cells.Any(c => c.Column == column)) + { + throw new ArgumentException($"Data row already has a field '{column}'. Fields: {string.Join(", ", _cells.Select(c => c.Column))}"); + } + + _cells.Add(new SosCell(_sourceLine, column, value)); + return this; + } + + /// True if this record has the named field. + public bool Has(string column) => _cells.Any(c => c.Column == column); + + /// The field with the given name. + public SosCell this[string column] => + _cells.FirstOrDefault(c => c.Column == column) + ?? throw new ArgumentException($"Data row has no field '{column}'. Fields: {string.Join(", ", _cells.Select(c => c.Column))}"); + + /// The raw source line, so a record renders usefully in any Assert.* failure. + public override string ToString() => _sourceLine; +} diff --git a/src/tests/SOS.TestHarness/SosDataRowExtensions.cs b/src/tests/SOS.TestHarness/SosDataRowExtensions.cs new file mode 100644 index 0000000000..9df889a2b7 --- /dev/null +++ b/src/tests/SOS.TestHarness/SosDataRowExtensions.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// Output-capturing assertions over a set of records (the "array of structs" +/// internal-data shape — !clrstack -gc roots, or -a/-l/-p +/// parameters/locals). Prefer these over Assert.Contains(records, predicate): on failure they +/// throw echoing the source lines of every record considered, plus a +/// human description of what was sought, instead of a bare "Filter not matched in collection". +/// +public static class SosDataRowExtensions +{ + /// Assert at least one record satisfies and return the first match. + public static SosDataRow AssertContains(this IEnumerable records, Func predicate, string description) + { + SosDataRow[] all = records.ToArray(); + SosDataRow? match = all.FirstOrDefault(predicate); + if (match is null) + { + throw Fail(all, $"at least one record where {description}"); + } + + return match; + } + + /// Assert exactly one record satisfies and return it. + public static SosDataRow AssertSingle(this IEnumerable records, Func predicate, string description) + { + SosDataRow[] all = records.ToArray(); + SosDataRow[] matches = all.Where(predicate).ToArray(); + if (matches.Length != 1) + { + throw Fail(all, $"exactly one record where {description} (found {matches.Length})"); + } + + return matches[0]; + } + + private static SosAssertException Fail(IEnumerable records, string expectation) + { + string body = string.Join('\n', records.Select(r => r.SourceLine)); + return new SosAssertException("(table data)", "records", expectation, body); + } +} diff --git a/src/tests/SOS.TestHarness/SosField.cs b/src/tests/SOS.TestHarness/SosField.cs new file mode 100644 index 0000000000..48223c1172 --- /dev/null +++ b/src/tests/SOS.TestHarness/SosField.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; + +namespace SOS.TestHarness; + +/// +/// A single "Key: Value" field pulled from SOS output by or the +/// indexer. It exposes the parsed / +/// , an implicit conversion to the value string (so it drops straight into +/// Assert.Equal/Assert.NotEmpty), rich shape assertions, bool predicates, and numeric +/// parsing: +/// +/// pe["Exception object"].AssertValid(Sos.Addr); // rich shape assert (full output on failure) +/// Assert.NotEmpty(pe["Exception type"]); // presence (implicit string -> IEnumerable) +/// Assert.Equal(TestTargets.NestedExceptions.OuterMessage, pe["Message"]); // value (implicit string) +/// Assert.True(pe["InnerException"].Contains("System.FormatException")); // bool predicate +/// Assert.Equal(0x80131509u, pe.Field("HResult").AsUInt32(Sos.Hex)); // numeric (token says how to parse) +/// +/// Predicates () return plain values to compose with Assert.*; +/// the Assert* helpers throw (with the full captured output) +/// on failure. +/// +public sealed class SosField +{ + private readonly SosOutput _output; + + public string Key { get; } + public string Value { get; } + + internal SosField(SosOutput output, string key, string value) + { + _output = output; + Key = key; + Value = value; + } + + /// The field's value, so a field drops straight into Assert.Equal(expected, pe["x"]). + public static implicit operator string(SosField field) => field.Value; + + /// Assert the value has the shape of (e.g. a hex address). + public SosField AssertValid(SosToken token) + { + if (!token.Matches(Value)) + { + throw _output.Fail($"field '{Key}' to be a {token} value (was \"{Value}\")"); + } + + return this; + } + + /// Assert the value equals exactly (literal text, not a regex). + public SosField Equals(string expected) + { + if (!string.Equals(Value, expected, StringComparison.Ordinal)) + { + throw _output.Fail($"field '{Key}' == \"{expected}\" (was \"{Value}\")"); + } + + return this; + } + + public bool Contains(string substring) => Value.Contains(substring, StringComparison.Ordinal); + public bool Contains(string substring, StringComparison comparison) => Value.Contains(substring, comparison); + + /// + /// Assert the value contains . Prefer this over + /// Assert.True(field.Contains(...)): on failure it throws + /// with the full captured output, instead of a bare "Expected: True". + /// + public SosField AssertContains(string substring) + { + if (!Value.Contains(substring, StringComparison.Ordinal)) + { + throw _output.Fail($"field '{Key}' to contain \"{substring}\" (was \"{Value}\")"); + } + + return this; + } + + /// Assert the value matches as a regex. + public SosField MatchesRegex(string pattern) + { + if (!Regex.IsMatch(Value, pattern)) + { + throw _output.Fail($"field '{Key}' to match /{pattern}/ (was \"{Value}\")"); + } + + return this; + } + + /// Parse the value as an unsigned 64-bit number per (hex/decimal). + public ulong AsUInt64(SosToken token) => SosConvert.UInt64(Key, Value, token, _output.Fail); + + /// Parse the value as an unsigned 32-bit number per . + public uint AsUInt32(SosToken token) => SosConvert.UInt32(Key, Value, token, _output.Fail); + + /// Parse the value as an unsigned 16-bit number per . + public ushort AsUInt16(SosToken token) => SosConvert.UInt16(Key, Value, token, _output.Fail); + + /// Parse the value as a signed 64-bit number per . + public long AsInt64(SosToken token) => SosConvert.Int64(Key, Value, token, _output.Fail); + + /// Parse the value as a signed 32-bit number per . + public int AsInt32(SosToken token) => SosConvert.Int32(Key, Value, token, _output.Fail); + + /// Parse the value as a signed 16-bit number per . + public short AsInt16(SosToken token) => SosConvert.Int16(Key, Value, token, _output.Fail); + + /// Parse the value as a byte per . + public byte AsByte(SosToken token) => SosConvert.Byte(Key, Value, token, _output.Fail); + + /// Parse the value as a boolean (true/false or 1/0). + public bool AsBoolean() => SosConvert.Boolean(Key, Value, _output.Fail); + + /// + /// Extract the first -shaped value embedded in this field's value and + /// parse it to a number — e.g. pull the inner-exception address out of + /// "System.FormatException, Use !printexception 0000026F5649D200 to see more." The token is + /// matched on word boundaries so spurious short hex runs inside words are ignored. + /// + public ulong Extract(SosToken token) + { + Match m = Regex.Match(Value, $@"\b(?:{token.Pattern})\b"); + if (!m.Success || !token.TryParseNumber(m.Value, out ulong number)) + { + throw _output.Fail($"field '{Key}' to contain a {token} value to extract (was \"{Value}\")"); + } + + return number; + } +} diff --git a/src/tests/SOS.TestHarness/SosOutput.cs b/src/tests/SOS.TestHarness/SosOutput.cs new file mode 100644 index 0000000000..c4de0239b6 --- /dev/null +++ b/src/tests/SOS.TestHarness/SosOutput.cs @@ -0,0 +1,424 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; + +namespace SOS.TestHarness; + +/// +/// The captured output of a single SOS/debugger command, plus a fluent, chainable +/// assertion surface. This is the modern replacement for a wall of VERIFY: +/// regex lines. +/// +/// Three assertion styles are offered side by side, on purpose: +/// +/// Field lookup + value assertions.Field(name) returns a structured +/// (Key/Value). Assert shape inline with a token +/// (.Field("Exception object", Sos.Addr)) and/or an exact value on the returned field +/// (.Field("Exception type").Matches("System.InvalidOperationException"), +/// .Field("Exception object").Matches(0x1234, Sos.Addr), +/// .Field("InnerException").MatchesRegex(@"System\.FormatException")). One assertion per +/// statement keeps each on its own stack-trace line. +/// Structural line assertions.Frame("Program.Main"), +/// .ContainsLineMatching(...). +/// Raw regex escape hatch.Matches(@"HResult:\s+80131509") / +/// .DoesNotMatch(...), for the cases where a pattern really is the clearest thing. +/// +/// All assertions chain (return this) and throw with the +/// full captured output on failure. +/// +public sealed class SosOutput +{ + public string Host { get; } + public string Command { get; } + public string Text { get; } + + private readonly string[] _lines; + + public SosOutput(string host, string command, string text) + { + Host = host; + Command = command; + Text = text ?? string.Empty; + _lines = Text.Replace("\r\n", "\n").Split('\n'); + } + + public IReadOnlyList Lines => _lines; + + public override string ToString() => Text; + + // ---- Field lookup -------------------------------------------------------------------- + + /// + /// Look up a "Name: value" line and return it as a structured . The field + /// carries the value assertions (, , + /// numeric parsing) and converts implicitly to its value string for Assert.Equal. Throws + /// if the field is absent. The indexer is shorthand for this. + /// + public SosField Field(string name) => new(this, name, RequireField(name)); + + /// Shorthand for : pe["Message"]. + public SosField this[string name] => Field(name); + + private string RequireField(string name) => + FindFieldValue(name) ?? throw Fail($"a field named '{name}'"); + + // ---- Tables -------------------------------------------------------------------------- + + /// + /// Parse the columnar block whose header row is (e.g. the + /// printexception StackTrace table's SP/IP/Function), asserting the + /// header row is present. Cells are sliced at fixed header start columns (tables are aligned), so + /// space-containing values (signatures) survive in any column. A column written as + /// (name, token) has every cell validated against that token up front; a bare name is not + /// validated here (use later if you want). Columns may be + /// freely mixed — see . + /// + public SosTable Table(params SosColumn[] columns) => BuildTable(columns, null); + + /// + /// Parse a table whose aligned rows are interleaved with internal data lines that belong + /// to the row above them — e.g. !clrstack -gc (GC roots under each frame) or + /// !clrstack -r (a register dump under each frame). After the first aligned row is matched, + /// each subsequent line is offered to first: if it returns true the + /// line is consumed and any cells it added to the most recent row (via + /// ) show up in ; if it returns + /// false the line falls through to normal row matching (a new row, or the end of the table). + /// Data is never collected before the first row. + /// + public SosTable Table(SosColumn[] columns, SosDataExtractor dataExtractor) => BuildTable(columns, dataExtractor); + + /// + /// Build a table from cells a caller already parsed, for commands whose layout the generic + /// parser can't handle — e.g. !clrthreads, whose header spans two + /// physical lines (the "Lock" of "Lock Count" floats above "Count") and whose trailing column is + /// usually empty. The harness still owns / construction + /// (so the rows carry this output for rich failures); the caller supplies the column names and one + /// string[] of cells per row (each the length of ). + /// + public SosTable TableFromRows(string[] columns, IEnumerable rows) => + new(this, columns, rows as string[][] ?? rows.ToArray()); + + private SosTable BuildTable(SosColumn[] columns, SosDataExtractor? data) + { + string[] headers = columns.Select(c => c.Header).ToArray(); + bool[] rightAligned = columns.Select(c => c.RightAligned).ToArray(); + SosTable table = ParseTable(headers, rightAligned, data); + + for (int c = 0; c < columns.Length; c++) + { + SosToken? token = columns[c].Token; + if (token is null) + { + continue; + } + + for (int r = 0; r < table.Length; r++) + { + string cell = table.Row(r)[c]; + if (!token.Matches(cell)) + { + throw Fail($"every '{headers[c]}' cell to be a {token} value (row {r} was \"{cell}\")"); + } + } + } + + return table; + } + + private SosTable ParseTable(string[] headers, bool[] rightAligned, SosDataExtractor? data) + { + int headerLine = FindHeaderLine(headers, out int[] starts, out int[] ends); + if (headerLine < 0) + { + throw Fail($"a table with header row [{string.Join(", ", headers)}]"); + } + + // Fixed left boundaries of each column, derived from header positions + alignment. A column + // begins at: its own header start if it's left-aligned, else the previous column's header + // end (a right-aligned column's data right-aligns under its header, extending left of it). + int[] bounds = new int[headers.Length]; + bounds[0] = 0; + for (int c = 1; c < headers.Length; c++) + { + bounds[c] = rightAligned[c] ? ends[c - 1] : starts[c]; + } + + List rows = new(); + for (int i = headerLine + 1; i < _lines.Length; i++) + { + string line = _lines[i]; + + // Internal data: once we have a row to attach to, let the extractor claim the line. If it + // does, the cells it added belong to that row, and we don't try to match a new row. + if (data is not null && rows.Count > 0 && data(line, rows[^1])) + { + continue; + } + + // The table ends at the first line that isn't an aligned data row (a blank line, or a + // differently-shaped trailing line). A row must line up with EVERY column's anchor. + if (!LooksLikeRow(line, starts, ends, rightAligned)) + { + break; + } + + rows.Add(new SosRow(this, headers, SliceColumns(line, bounds))); + } + + return new SosTable(this, headers, rows.ToArray()); + } + + /// + /// Find the header row and the character start/end index of each header. SOS tables are + /// fixed-width/aligned; combined with each column's alignment, those positions let cells be + /// sliced so space-containing values (signatures, type names) stay intact in any column. + /// + private int FindHeaderLine(string[] headers, out int[] starts, out int[] ends) + { + starts = Array.Empty(); + ends = Array.Empty(); + for (int i = 0; i < _lines.Length; i++) + { + if (TryLocateHeaders(_lines[i], headers, out int[] s, out int[] e)) + { + starts = s; + ends = e; + return i; + } + } + + return -1; + } + + /// + /// Locate each header as an in-order substring of , each found at or after + /// the previous header's end. Substring matching (rather than whitespace tokenizing) lets headers + /// contain spaces — e.g. !clrstack's "Child SP" and "Call Site" — while a header must sit on + /// a whitespace/edge boundary so a short header like "IP" can't match inside a longer word. + /// + private static bool TryLocateHeaders(string line, string[] headers, out int[] starts, out int[] ends) + { + starts = new int[headers.Length]; + ends = new int[headers.Length]; + int from = 0; + for (int k = 0; k < headers.Length; k++) + { + int at = from; + while (true) + { + at = line.IndexOf(headers[k], at, StringComparison.Ordinal); + if (at < 0) + { + return false; + } + + bool leftEdge = at == 0 || char.IsWhiteSpace(line[at - 1]); + int end = at + headers[k].Length; + bool rightEdge = end >= line.Length || char.IsWhiteSpace(line[end]); + if (leftEdge && rightEdge) + { + starts[k] = at; + ends[k] = end; + from = end; + break; + } + + at = end; + } + } + + return true; + } + + private static string[] SliceColumns(string line, int[] bounds) + { + string[] cells = new string[bounds.Length]; + for (int c = 0; c < bounds.Length; c++) + { + int begin = Math.Min(bounds[c], line.Length); + int end = c + 1 < bounds.Length ? Math.Min(bounds[c + 1], line.Length) : line.Length; + cells[c] = line[begin..Math.Max(begin, end)].Trim(); + } + + return cells; + } + + /// + /// A line is a data row only if it lines up with the column layout at every column's alignment + /// anchor: a left-aligned column has a content character at its header start (and whitespace + /// before it); a right-aligned column has a content character at its header's right edge (and + /// whitespace after it). Checking every column — not just the first — keeps a differently-shaped + /// trailing line (e.g. "StackTraceString: <none>") from being mistaken for a row. + /// + private static bool LooksLikeRow(string line, int[] starts, int[] ends, bool[] rightAligned) + { + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + // A left-aligned first column implies a blank left margin (preserves prior behavior). + if (!rightAligned[0]) + { + for (int i = 0; i < starts[0]; i++) + { + if (i >= line.Length || !char.IsWhiteSpace(line[i])) + { + return false; + } + } + } + + for (int c = 0; c < starts.Length; c++) + { + if (rightAligned[c]) + { + int anchor = ends[c] - 1; // data right edge aligns with the header's last char + if (anchor < 0 || anchor >= line.Length || char.IsWhiteSpace(line[anchor])) + { + return false; + } + + if (ends[c] < line.Length && !char.IsWhiteSpace(line[ends[c]])) + { + return false; + } + } + else + { + int anchor = starts[c]; // data left edge aligns with the header start + if (anchor >= line.Length || char.IsWhiteSpace(line[anchor])) + { + return false; + } + + if (anchor > 0 && !char.IsWhiteSpace(line[anchor - 1])) + { + return false; + } + } + } + + return true; + } + + // ---- Structural line assertions ------------------------------------------------------ + + + /// True if the output contains the given literal substring (compose with Assert.True). + public bool Contains(string literal) => Text.Contains(literal, StringComparison.Ordinal); + + /// + /// Assert the output contains . Prefer this over + /// Assert.True(output.Contains(...)): on failure it throws + /// with the host, command, and full captured output, instead of a bare "Expected: True". + /// + public SosOutput AssertContains(string literal) + { + if (!Text.Contains(literal, StringComparison.Ordinal)) + { + throw Fail($"output to contain \"{literal}\""); + } + + return this; + } + + + // ---- Column-oriented helpers --------------------------------------------------------- + + /// + /// Return the whitespace-split tokens of the first data line whose first token is a hex value + /// equal to (tolerant of dbgeng's backtick split). Useful for + /// column-oriented commands like gcwhere/dumpheap. Throws if no such row exists. + /// + public string[] RowByAddress(ulong address) + { + foreach (string line in _lines) + { + string[] tokens = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length > 0 && TryParseHex(tokens[0], out ulong value) && value == address) + { + return tokens; + } + } + + throw Fail($"a row whose first column is address 0x{address:x}"); + } + + /// + /// Header-relative column access for column-oriented output. Finds the header row (the line + /// containing one of as a token), locates that column's + /// index, then returns the value at that index from the data row whose first column is + /// . This tolerates host/flavor layout differences — e.g. SOS prints + /// gcwhere's generation column as "Generation" (col 3) under dotnet-dump but as "Gen" + /// (col 1) under cdb on desktop — without the test hard-coding a column index. + /// + public string Column(ulong address, params string[] headerAliases) + { + int headerIndex = -1; + foreach (string line in _lines) + { + string[] tokens = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + foreach (string alias in headerAliases) + { + int idx = Array.FindIndex(tokens, tok => string.Equals(tok, alias, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + { + headerIndex = idx; + break; + } + } + + if (headerIndex >= 0) + { + break; + } + } + + if (headerIndex < 0) + { + throw Fail($"a header column named one of [{string.Join(", ", headerAliases)}]"); + } + + string[] row = RowByAddress(address); + if (headerIndex >= row.Length) + { + throw Fail($"column {headerIndex} ([{string.Join(", ", headerAliases)}]) in row [{string.Join(' ', row)}]"); + } + + return row[headerIndex]; + } + + private static bool TryParseHex(string token, out ulong value) => + ulong.TryParse(token.Replace("`", string.Empty), System.Globalization.NumberStyles.HexNumber, null, out value); + + // ---- internals ----------------------------------------------------------------------- + + private string? FindFieldValue(string name) + { + foreach (string line in _lines) + { + int colon = line.IndexOf(':'); + if (colon <= 0) + { + continue; + } + + string key = line[..colon].Trim(); + if (string.Equals(key, name, StringComparison.Ordinal)) + { + return line[(colon + 1)..].Trim(); + } + } + + return null; + } + + /// + /// Build (don't throw) a for , + /// carrying this output's host, command, and full text. The primitive every fluent assertion and + /// custom command parser uses to produce a rich failure: throw output.Fail("a thread table"). + /// + public SosAssertException Fail(string expectation) => new(Host, Command, expectation, Text); +} diff --git a/src/tests/SOS.TestHarness/SosRow.cs b/src/tests/SOS.TestHarness/SosRow.cs new file mode 100644 index 0000000000..2b50566148 --- /dev/null +++ b/src/tests/SOS.TestHarness/SosRow.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// One data row of a . Cells are addressable by column name +/// (row["Function"]) or by index, so tests can read like the table they assert against: +/// table.Any(row => row["Function"].Contains("ThrowNested")). +/// +/// A table data extractor can attach two distinct shapes of internal-data to a row: +/// +/// Extra scalar columns via — one value per name, promoted +/// onto the row so row["rip"] works alongside row["IP"] (the !clrstack -r +/// registers). +/// Structured sub-records via — a list of multi-field +/// s in (the !clrstack -gc roots, or +/// -a/-l/-p parameters/locals). +/// +/// +public sealed class SosRow +{ + private readonly SosOutput _output; + private readonly string[] _columns; + private readonly string[] _cells; + private readonly List _addedColumns = new(); + private readonly List _data = new(); + + internal SosRow(SosOutput output, string[] columns, string[] cells) + { + _output = output; + _columns = columns; + _cells = cells; + } + + /// The cell in the named column (an original table column or one added via ). + public SosCell this[string column] + { + get + { + int i = Array.IndexOf(_columns, column); + if (i >= 0) + { + return new SosCell(_output, column, _cells[i]); + } + + SosCell? added = _addedColumns.FirstOrDefault(c => c.Column == column); + if (added is not null) + { + return added; + } + + throw new ArgumentException($"Row has no column '{column}'. Columns: {string.Join(", ", Columns)}"); + } + } + + /// The cell at the given column index (original table columns only). + public SosCell this[int index] => new(_output, _columns[index], _cells[index]); + + /// All cell values in column order (original table columns only). + public IReadOnlyList Cells => _cells; + + /// Every column name on this row: the original table columns plus any added via . + public IReadOnlyList Columns => _columns.Concat(_addedColumns.Select(c => c.Column)).ToArray(); + + /// True if this row has the named column (original or added). + public bool HasColumn(string column) => + Array.IndexOf(_columns, column) >= 0 || _addedColumns.Any(c => c.Column == column); + + /// + /// Promote an extra scalar column onto this row (the "extra columns" internal-data shape, e.g. an + /// !clrstack -r register), so row["rip"] reads it like any other column. Throws if a + /// column with this name already exists, so an extractor can never silently shadow a real column. + /// + public void AddColumn(string column, string value) + { + if (HasColumn(column)) + { + throw new ArgumentException($"Row already has a column '{column}'. Columns: {string.Join(", ", Columns)}"); + } + + _addedColumns.Add(new SosCell(_output, column, value)); + } + + /// + /// The structured sub-records attached to this row (the "array of structs" internal-data shape, + /// e.g. the GC roots from !clrstack -gc). Empty unless the table was parsed with an + /// extractor that called . + /// + public IReadOnlyList Data => _data; + + /// Attach a structured sub-record (one parsed internal-data line) to this row. + public void AddData(SosDataRow row) => _data.Add(row); +} diff --git a/src/tests/SOS.TestHarness/SosTable.cs b/src/tests/SOS.TestHarness/SosTable.cs new file mode 100644 index 0000000000..7560d98277 --- /dev/null +++ b/src/tests/SOS.TestHarness/SosTable.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; + +namespace SOS.TestHarness; + +/// +/// A parsed columnar block of SOS output — e.g. the StackTrace (generated) table in +/// printexception with its SP/IP/Function columns. Produced by +/// . Columns are identified by header name and cells are +/// sliced at fixed header start columns (SOS tables are aligned), so values that contain spaces (a +/// function signature like Method(System.String, int)+0x8a) stay intact in any column. +/// +/// The table is itself an of , so LINQ reads +/// naturally: table.Any(row => row["Function"].Contains("ThrowNested")). +/// +public sealed class SosTable : IEnumerable +{ + private readonly SosOutput _output; + private readonly string[] _columns; + private readonly SosRow[] _rows; + + internal SosTable(SosOutput output, string[] columns, string[][] rows) + { + _output = output; + _columns = columns; + _rows = Array.ConvertAll(rows, r => new SosRow(output, columns, r)); + } + + internal SosTable(SosOutput output, string[] columns, SosRow[] rows) + { + _output = output; + _columns = columns; + _rows = rows; + } + + /// The header names, in order. + public IReadOnlyList Columns => _columns; + + /// The number of data rows. + public int Length => _rows.Length; + + /// One data row, by index. + public SosRow Row(int index) => _rows[index]; + + /// + /// Assert that every cell of each column matches the column's token, by position — one token per + /// column, in column order. This is the post-hoc form of the per-column validation + /// does for (name, token) columns, for when the table was + /// parsed with bare header names: table.AssertValid(Sos.Addr, Sos.Addr, Sos.ModuleFunctionWithOffset). + /// + public SosTable AssertValid(params SosToken[] columnTokens) + { + if (columnTokens.Length != _columns.Length) + { + throw _output.Fail($"AssertValid to be given {_columns.Length} token(s) for columns [{string.Join(", ", _columns)}], got {columnTokens.Length}"); + } + + for (int c = 0; c < columnTokens.Length; c++) + { + for (int r = 0; r < _rows.Length; r++) + { + string cell = _rows[r][c]; + if (!columnTokens[c].Matches(cell)) + { + throw _output.Fail($"every '{_columns[c]}' cell to be a {columnTokens[c]} value (row {r} was \"{cell}\")"); + } + } + } + + return this; + } + + /// All cell values in the named column, top to bottom. + public IEnumerable Column(string header) + { + foreach (SosRow row in _rows) + { + yield return row[header].Value; + } + } + + /// + /// Assert at least one row satisfies . Prefer this over + /// Assert.Contains(table, predicate): on failure it throws + /// with the full captured output (a bare predicate Assert.Contains can't say what it wanted + /// or echo the SOS output). names what was sought, e.g. + /// "Function contains NestedExceptions.ThrowNested". + /// + public SosTable AssertContainsRow(Func predicate, string description) + { + if (!_rows.Any(predicate)) + { + throw _output.Fail($"a row where {description}"); + } + + return this; + } + + /// + /// Assert every row satisfies . Prefer this over + /// Assert.All(table, row => Assert.True(...)) when the check is a simple predicate: + /// table.AssertAll(row => row["GC Mode"] == "Preemptive", "every thread is Preemptive"). + /// On failure it throws naming the first offending row and echoing + /// the full captured output. + /// + public SosTable AssertAll(Func predicate, string description) + { + for (int r = 0; r < _rows.Length; r++) + { + if (!predicate(_rows[r])) + { + throw _output.Fail($"every row to satisfy: {description} (row {r} did not: [{string.Join(" | ", _rows[r].Cells)}])"); + } + } + + return this; + } + + /// + /// Assert exactly one row satisfies and return it. Prefer this over + /// LINQ table.Single(predicate): on failure (zero or many matches) it throws + /// with the full captured output. + /// + public SosRow SingleRow(Func predicate, string description) + { + SosRow[] matches = _rows.Where(predicate).ToArray(); + if (matches.Length != 1) + { + throw _output.Fail($"exactly one row where {description} (found {matches.Length})"); + } + + return matches[0]; + } + + public IEnumerator GetEnumerator() => ((IEnumerable)_rows).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _rows.GetEnumerator(); +} diff --git a/src/tests/SOS.TestHarness/SosToken.cs b/src/tests/SOS.TestHarness/SosToken.cs new file mode 100644 index 0000000000..29957bfb2a --- /dev/null +++ b/src/tests/SOS.TestHarness/SosToken.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text.RegularExpressions; + +namespace SOS.TestHarness; + +/// +/// A named, reusable pattern for a class of nondeterministic value that +/// appears in SOS output — pointers, addresses, counts, HResults, etc. These are +/// the modern, readable replacement for the old <HEXVAL>/<DECVAL> +/// regex tokens: instead of hand-writing [A-Fa-f0-9]+ soup, a test says the +/// field "should be a hex value" and the harness checks the shape while ignoring the +/// volatile value. +/// +/// A token also knows how to parse its values to a number (hex vs decimal), so an exact +/// value assertion can say "this field, read as an address, equals 0x1234". +/// +public sealed class SosToken +{ + public string Name { get; } + + /// The inner regex fragment (no anchors), so tokens can be embedded in larger patterns. + public string Pattern { get; } + + private readonly Regex _anchored; + private readonly NumberStyles? _numberStyles; + + public SosToken(string name, string pattern, NumberStyles? numberStyles = null) + { + Name = name; + Pattern = pattern; + _anchored = new Regex("^(?:" + pattern + ")$", RegexOptions.Compiled); + _numberStyles = numberStyles; + } + + /// True if is entirely this kind of token. + public bool Matches(string value) => _anchored.IsMatch(value.Trim()); + + /// + /// Parse to a number using this token's numeric style (hex for + /// addresses/hex, decimal for counts), tolerating dbgeng backticks and thousands separators. + /// Returns false for non-numeric tokens or unparseable input. + /// + public bool TryParseNumber(string value, out ulong number) + { + number = 0; + if (_numberStyles is null) + { + return false; + } + + string normalized = StripHexPrefix(value.Trim().Replace("`", string.Empty).Replace(",", string.Empty)); + return ulong.TryParse(normalized, _numberStyles.Value, CultureInfo.InvariantCulture, out number); + } + + /// Signed counterpart of (for AsInt32/AsInt64). + public bool TryParseSigned(string value, out long number) + { + number = 0; + if (_numberStyles is null) + { + return false; + } + + string normalized = StripHexPrefix(value.Trim().Replace("`", string.Empty).Replace(",", string.Empty)); + return long.TryParse(normalized, _numberStyles.Value, CultureInfo.InvariantCulture, out number); + } + + // NumberStyles.HexNumber doesn't accept a "0x" prefix, but SOS prints some hex columns 0x-prefixed + // (e.g. an OS thread id), so drop it before parsing. Harmless for decimal tokens (they never carry it). + private static string StripHexPrefix(string value) => + value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? value.Substring(2) : value; + + public override string ToString() => $"<{Name}>"; +} + +/// +/// The vocabulary of nondeterministic value shapes. This is deliberately small and +/// named so assertions read like English: Field("HResult", Sos.Hex). +/// +public static class Sos +{ + /// A hex value, optionally 0x-prefixed and/or with dbgeng's backtick high/low split + /// (e.g. 00007ffd`213c498a, 0x2394). + public static readonly SosToken Hex = new("hex", "(?:0[xX])?[0-9A-Fa-f]+(?:`[0-9A-Fa-f]+)?", NumberStyles.HexNumber); + + /// A decimal value, optionally with thousands separators. + public static readonly SosToken Dec = new("dec", "[0-9]+(?:,[0-9]+)*", NumberStyles.None); + + /// A pointer/address — same shape as , but named for intent at call sites. + public static readonly SosToken Addr = new("addr", "(?:0[xX])?[0-9A-Fa-f]+(?:`[0-9A-Fa-f]+)?", NumberStyles.HexNumber); + + /// + /// A decimal value with optional `,`. + /// + public static readonly SosToken Integer = new("integer", "[0-9]+(?:,[0-9]+)*", NumberStyles.None); + + /// + /// A range of memory hex-hex. + /// + public static readonly SosToken MemRange = new("memrange", "[0-9A-Fa-f]+-[0-9A-Fa-f]+", NumberStyles.HexNumber); + + /// + /// A managed frame as SOS prints it: Module!Namespace.Type.Method(args)+0xOFFSET + /// (e.g. NestedExceptions!NestedExceptions.ThrowNested()+0x8a). + /// + public static readonly SosToken ModuleFunctionWithOffset = + new("module!function+offset", @"[^!\s]+!.+\+0x[0-9A-Fa-f]+"); +} diff --git a/src/tests/SOS.TestHarness/Target.cs b/src/tests/SOS.TestHarness/Target.cs new file mode 100644 index 0000000000..f2bbe6abe2 --- /dev/null +++ b/src/tests/SOS.TestHarness/Target.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// A debug target a test drives, obtained from . There is one +/// surface for both worlds; only the semantics of navigation differ: +/// +/// Dead (live: false, ) is backed by immutable cached +/// dumps. throws until you navigate to a point; after that you may +/// / among points in any order, repeatedly. +/// Live (live: true, ) is a single advancing process, +/// parked at the debugger's initial breakpoint (before CoreCLR loads). already +/// works there (e.g. bpmd); navigation only moves forward. +/// +/// +public abstract class Target : IDisposable +{ + protected Target(Host host, string targetName, Flavor flavor) + { + Host = host; + TargetName = targetName; + Flavor = flavor; + } + + public Host Host { get; } + public string TargetName { get; } + public Flavor Flavor { get; } + + /// The dump file currently backing this target (dead targets only). + public virtual string DumpPath => + throw new NotSupportedException("A live target has no dump file."); + + /// Navigate to the named stop point. Dead: load that point's dump. Live: run forward to its marker. + public void GoToStopPoint(string stopName) + { + GoToStopPointCore(stopName); + ReplayContext.Current?.Add(ReplayStepKind.Navigate, $"GoToStopPoint(\"{stopName}\")", SafeDumpPath()); + ReplayContext.Current?.AttachHost(CurrentDiagnostics); + } + + /// Navigate to the target's crash. Dead: load the crash dump. Live: run forward to the crash. + public void GoToCrash() + { + GoToCrashCore(); + ReplayContext.Current?.Add(ReplayStepKind.Navigate, "GoToCrash()", SafeDumpPath()); + ReplayContext.Current?.AttachHost(CurrentDiagnostics); + } + + /// Run a SOS command at the current point. + public SosOutput Sos(string command) + { + // Record before running so a throwing command is still captured; the dump (if any) is the one + // we're currently parked on, which is exactly what the command runs against. Attach the host's + // diagnostics up front too, so a command that crashes the host still surfaces its stdout/stderr + // and crash dump in the replay. + ReplayContext.Current?.Add(ReplayStepKind.Sos, command, SafeDumpPath()); + ReplayContext.Current?.AttachHost(CurrentDiagnostics); + return SosCore(command); + } + + /// Run a raw debugger command at the current point. + public SosOutput Execute(string command) + { + ReplayContext.Current?.Add(ReplayStepKind.Execute, command, SafeDumpPath()); + ReplayContext.Current?.AttachHost(CurrentDiagnostics); + return ExecuteCore(command); + } + + /// Navigate to the named stop point (subclass mechanics; records it). + protected abstract void GoToStopPointCore(string stopName); + + /// Navigate to the crash (subclass mechanics; records it). + protected abstract void GoToCrashCore(); + + /// Run a SOS command (subclass mechanics; records it). + protected abstract SosOutput SosCore(string command); + + /// Run a raw debugger command (subclass mechanics; records it). + protected abstract SosOutput ExecuteCore(string command); + + /// The dump backing this target now, or null if it has none yet / is a live target. + private protected string? SafeDumpPath() + { + try + { + return DumpPath; + } + catch + { + return null; + } + } + + /// + /// The diagnostics (captured stdout/stderr and crash dumps) of the host currently backing this target, + /// or null if none is available. The replay attaches this when a command runs so a failing test can + /// show what the underlying debugger process did. + /// + internal virtual HostDiagnostics? CurrentDiagnostics => null; + + public virtual void Dispose() + { + } +} diff --git a/src/tests/SOS.TestHarness/TargetCatalog.cs b/src/tests/SOS.TestHarness/TargetCatalog.cs new file mode 100644 index 0000000000..ae88c8eff2 --- /dev/null +++ b/src/tests/SOS.TestHarness/TargetCatalog.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// How a named stop point is realized when producing a dump. +public enum StopKind +{ + /// Mid-run self-snapshot (the debuggee dumps itself and continues). + Snapshot, + + /// The final unhandled-exception crash dump produced by the runtime. + Crash, +} + +/// +/// A named location in a target. The same definition drives both worlds: a dump for the +/// snapshot/shared path, and a bpmd breakpoint on for the live path. +/// +/// Stable name used to key the dump and to ask a live target to stop here. +/// How the dump for this stop is produced. +/// Fully-qualified marker method for live bpmd (null for crash stops). +public sealed record StopPoint(string Name, StopKind Kind, string? Method); + +/// A standalone test target (its own program) and its stop points. +/// Target name used by tests, e.g. "divzero". +/// +/// The target's project/assembly name under the repo's SOS.UnitTests/Debuggees tree, e.g. +/// "DivZero". This is the folder, the csproj, and the produced <Project>.exe / +/// <Project>.dll. +/// +/// Ordered named stop points. +/// +/// The flavors this target supports. Defaults to all; e.g. DynamicMethod uses a .NET-Core-only API +/// (DynamicMethod.CreateDelegate<T>) so it can't build for desktop .NET Framework. +/// +public sealed record TargetDefinition(string Name, string Project, IReadOnlyList StopPoints, Flavor Flavors = Flavor.AllValid) +{ + /// Managed module name for bpmd on .NET Core (e.g. "SosHarnessScenarios.dll"). + public string Module => Project + ".dll"; + + /// + /// Managed module name for bpmd in a given flavor. Desktop .NET Framework's managed + /// module is the EXE itself (e.g. "SosHarnessScenarios.exe"); .NET Core's is the DLL. + /// + public string ModuleFor(Flavor flavor) => flavor == Flavor.Framework ? Project + ".exe" : Project + ".dll"; + + public StopPoint Stop(string name) => + StopPoints.FirstOrDefault(s => s.Name == name) + ?? throw new ArgumentException($"Target '{Name}' has no stop point '{name}'. Known: {string.Join(", ", StopPoints.Select(s => s.Name))}"); + + public string DefaultStopName => StopPoints[0].Name; +} + +/// +/// The debuggee targets the SOS test harness knows about, mapped to the diagnostics repo's existing +/// SOS.UnitTests/Debuggees projects plus the one consolidated marker debuggee the harness adds +/// (). Crash targets reproduce an unhandled exception / fault that the runtime +/// turns into a crash dump; the marker debuggee self-snapshots at named TestHarness.Stop points +/// (live tests set a bpmd breakpoint on the same marker method). +/// +public static class TargetCatalog +{ + // --- Repo crash debuggees (unhandled exception / fault -> crash dump). --- + + public const string NestedException = "nestedexception"; + public const string DivZero = "divzero"; + public const string AsyncMain = "asyncmain"; + public const string DynamicMethod = "dynamicmethod"; + public const string Overflow = "overflow"; + public const string LineNums = "linenums"; + public const string SimpleThrow = "simplethrow"; + public const string Reflection = "reflection"; + + // --- The one consolidated marker debuggee the harness adds (Phase 4). Every snapshot/oracle/live + // scenario is a named stop point on this single program (see SosHarnessScenarios). --- + + public const string Scenarios = "scenarios"; + + // Stop-point names on the Scenarios debuggee (kept as constants so tests don't stringly-type them). + public const string StopThinLock = "thinlock"; + public const string StopHeap = "heap"; + public const string StopArgsLocals = "argslocals"; + public const string StopRoots = "roots"; + public const string StopGen0 = "gen0"; + public const string StopGen1 = "gen1"; + public const string StopGen2 = "gen2"; + public const string StopAllThreads = "allthreads"; + + private const string ScenariosProject = "SosHarnessScenarios"; + + // The Scenarios stop points, shared by the workstation and server-GC targets. Heap stays first so + // GoToFirstStop() lands there; the rest are ordered as they occur, and dumps are keyed by name (not + // array order), so the debuggee's call order can differ. + private static readonly StopPoint[] s_scenarioStops = + { + new(StopHeap, StopKind.Snapshot, $"{ScenariosProject}.AtHeap"), + new(StopThinLock, StopKind.Snapshot, $"{ScenariosProject}.AtThinLock"), + new(StopArgsLocals, StopKind.Snapshot, $"{ScenariosProject}.AtArgsLocals"), + new(StopRoots, StopKind.Snapshot, $"{ScenariosProject}.AtRoots"), + new(StopGen0, StopKind.Snapshot, $"{ScenariosProject}.AtGen0"), + new(StopGen1, StopKind.Snapshot, $"{ScenariosProject}.AtGen1"), + new(StopGen2, StopKind.Snapshot, $"{ScenariosProject}.AtGen2"), + new(StopAllThreads, StopKind.Snapshot, $"{ScenariosProject}.AtAllThreads"), + }; + + private static readonly Dictionary s_targets = new[] + { + new TargetDefinition( + NestedException, + Project: "NestedExceptionTest", + StopPoints: new[] { new StopPoint("crash", StopKind.Crash, null) }), + + new TargetDefinition( + DivZero, + Project: "DivZero", + StopPoints: new[] { new StopPoint("crash", StopKind.Crash, null) }), + + new TargetDefinition( + AsyncMain, + Project: "AsyncMain", + StopPoints: new[] { new StopPoint("crash", StopKind.Crash, null) }), + + new TargetDefinition( + DynamicMethod, + Project: "DynamicMethod", + StopPoints: new[] { new StopPoint("crash", StopKind.Crash, null) }, + // DynamicMethod.CreateDelegate() is a .NET-Core-only API, so this debuggee can't build + // for desktop .NET Framework. + Flavors: Flavor.Core | Flavor.SingleFile), + + new TargetDefinition( + Overflow, + Project: "Overflow", + StopPoints: new[] { new StopPoint("crash", StopKind.Crash, null) }), + + new TargetDefinition( + LineNums, + Project: "LineNums", + StopPoints: new[] { new StopPoint("crash", StopKind.Crash, null) }), + + new TargetDefinition( + SimpleThrow, + Project: "SimpleThrow", + StopPoints: new[] { new StopPoint("crash", StopKind.Crash, null) }), + + new TargetDefinition( + Reflection, + Project: "ReflectionTest", + StopPoints: new[] { new StopPoint("crash", StopKind.Crash, null) }), + + // The consolidated marker debuggee: each scenario is a NoInlining marker method that calls + // TestHarness.Stop(name). Ordered so the heap scenario (live/dead objects) is captured before + // any GC runs for the generation-promotion stops. + new TargetDefinition( + Scenarios, + Project: ScenariosProject, + StopPoints: s_scenarioStops), + }.ToDictionary(t => t.Name); + + public static TargetDefinition Get(string name) => + s_targets.TryGetValue(name, out TargetDefinition? t) + ? t + : throw new ArgumentException($"Unknown target '{name}'. Known: {string.Join(", ", s_targets.Keys)}"); + + /// + /// The flavors a target supports, or when is + /// not a known target (some tests pass other tokens — e.g. a stop name — through the matrix's string + /// dimension, which the flavor filter must tolerate). + /// + public static Flavor FlavorsFor(string name) => + s_targets.TryGetValue(name, out TargetDefinition? t) ? t.Flavors : Flavor.AllValid; + + /// + /// Whether reaching this target's stop points requires a live bpmd notification breakpoint + /// (i.e. it has a stop), as opposed to simply running to a crash. + /// Snapshot navigation can't be performed live on a self-contained single-file image under the lldb + /// host, so the matrix prunes that one row for such targets. + /// Unknown tokens are treated as not requiring bpmd. + /// + public static bool NavigatesViaBpmd(string name) => + s_targets.TryGetValue(name, out TargetDefinition? t) && t.StopPoints.Any(s => s.Kind == StopKind.Snapshot); +} diff --git a/src/tests/SOS.TestHarness/Targets.cs b/src/tests/SOS.TestHarness/Targets.cs new file mode 100644 index 0000000000..ac97d7d3d4 --- /dev/null +++ b/src/tests/SOS.TestHarness/Targets.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; + +namespace SOS.TestHarness; + +/// +/// The single entry point tests use to get a debug target. Shared (dump-backed) targets are +/// memoized process-wide by (host, target, stopPoint) so the expensive load happens once +/// and is reused across every test that asks for the same triple. Live targets are exclusive and +/// never memoized — each call hands the caller its own advancing debuggee. +/// +/// Shared hosts (notably the dotnet-dump child processes) must be torn down at the end of the run, +/// or their lingering child processes keep the test host alive. does this; +/// it is wired to and also exposed for an explicit assembly +/// teardown fixture. +/// +public static class Targets +{ + private static readonly ConcurrentDictionary<(Host Host, string Target, string Stop, Flavor Flavor, GcType GcType, DumpKind DumpKind, CoreVersion CoreVersion, Dac Dac), Lazy> s_sessions = new(); + private static readonly ConcurrentBag s_created = new(); + + static Targets() + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => DisposeAll(); + } + + /// + /// Get a debug target for a matrix row (). With + /// = you get a dump-backed + /// (navigate to cached dumps in any order); with + /// you get a launched and parked at the debugger's initial breakpoint. + /// Awaiting is the "gate" — for live it completes once the process is launched and SOS is ready. The + /// row must carry a single Host/Flavor/Liveness/GcType/DumpKind value (the per-case value a theory + /// receives), not a combined AllValid selector. + /// + public static Task GetTargetAsync(TestConfig config) + { + bool live = config.Liveness switch + { + Liveness.Live => true, + Liveness.Dump => false, + _ => throw new ArgumentOutOfRangeException( + nameof(config), config.Liveness, "Expected exactly Liveness.Live or Liveness.Dump."), + }; + + TargetDefinition definition = TargetCatalog.Get(config.Target); + + // Begin capturing this test's replay timeline (host/flavor/liveness + every command/dump). + ReplayContext.Start(config, live); + + if (live) + { + return Task.Run(() => { + string exe = SnapshotStore.TargetExe(config.Flavor, config.Target, config.CoreVersion); + return new LiveTarget(config.Host, definition, config.Flavor, exe, config.CoreVersion, config.Dac); + }); + } + + // Dead targets are cheap cursors; the heavy work (capture + load) happens on first GoTo, + // memoized per point so parallel tests navigating to the same point share one dump host. + return Task.FromResult(new DeadTarget(config)); + } + + /// + /// Resolve (memoized, process-wide) the read-only dump session for one point — produced and SOS + /// loaded on first use, then reused by every that navigates here. + /// + internal static DumpSession ResolveSession(TestConfig config, string stop) + { + return s_sessions + .GetOrAdd((config.Host, config.Target, stop, config.Flavor, config.GcType, config.DumpKind, config.CoreVersion, config.Dac), + key => new Lazy(() => CreateSession(key))) + .Value; + } + + private static DumpSession CreateSession((Host Host, string Target, string Stop, Flavor Flavor, GcType GcType, DumpKind DumpKind, CoreVersion CoreVersion, Dac Dac) key) + { + string dump = SnapshotStore.GetDump(key.Flavor, key.Target, key.Stop, key.GcType, key.DumpKind, key.CoreVersion); + DumpSession session = new(key.Host, key.Target, key.Stop, key.Flavor, dump, key.CoreVersion, key.Dac); + s_created.Add(session); + return session; + } + + /// Dispose every memoized dump session (kills dotnet-dump children, closes dbgeng hosts). + public static void DisposeAll() + { + while (s_created.TryTake(out DumpSession? session)) + { + try + { + session.Dispose(); + } + catch + { + // best effort teardown + } + } + + // Close any pooled (dotnet-dump) host still open. cdb children were disposed above via + // each SharedTarget.Dispose(). + HostSlot.DotNetDump.CloseCurrent(); + } +} diff --git a/src/tests/SOS.TestHarness/TestConfig.cs b/src/tests/SOS.TestHarness/TestConfig.cs new file mode 100644 index 0000000000..718f4e1e17 --- /dev/null +++ b/src/tests/SOS.TestHarness/TestConfig.cs @@ -0,0 +1,353 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; +using Xunit.Sdk; + +namespace SOS.TestHarness; + +/// +/// One row of the test matrix: the full set of axes that define a single debug-target configuration a +/// theory runs against. Replaces the old positional (Host, Flavor, Liveness) tuple so adding axes +/// (GC type, dump kind, ...) doesn't widen every signature. A test takes a single +/// parameter and hands it straight to . +/// +/// Implements so each row has a stable, individually-runnable test +/// id and a legible display name (see ), and has value equality so +/// can de-duplicate rows that collapse onto the same configuration. +/// +public sealed record TestConfig : IXunitSerializable +{ + private string _target = string.Empty; + private Host _host; + private Flavor _flavor; + private Liveness _liveness; + private GcType _gcType; + private CoreVersion _coreVersion; + private Dac _dac; + private DumpKind _dumpKind; + + /// The debuggee target name (e.g. ). + public string Target { get => _target; init => _target = value; } + + /// The debugger host (cdb / dotnet-dump / lldb). + public Host Host { get => _host; init => _host = value; } + + /// The runtime flavor (Core / SingleFile / Framework). + public Flavor Flavor { get => _flavor; init => _flavor = value; } + + /// Live process vs. post-mortem dump. + public Liveness Liveness { get => _liveness; init => _liveness = value; } + + /// Workstation vs. server GC. + public GcType GcType { get => _gcType; init => _gcType = value; } + + /// The .NET Core runtime version the target is built and dumped against (a single flag). + public CoreVersion CoreVersion { get => _coreVersion; init => _coreVersion = value; } + + /// Which DAC SOS debugs with (Legacy / CDac). cDAC is only valid on .NET 11+ (see ). + public Dac Dac { get => _dac; init => _dac = value; } + + /// The dump kind (Heap / Mini). Always for live targets (no dump). + public DumpKind DumpKind { get => _dumpKind; init => _dumpKind = value; } + + /// Parameterless ctor required by ; do not use directly. + public TestConfig() + { + } + + public TestConfig(string target, Host host, Flavor flavor, Liveness liveness, + GcType gcType = GcType.Workstation, DumpKind dumpKind = DumpKind.Heap, + CoreVersion coreVersion = CoreVersion.Net10, Dac dac = Dac.Legacy) + { + Target = target; + Host = host; + Flavor = flavor; + Liveness = liveness; + GcType = gcType; + DumpKind = dumpKind; + CoreVersion = coreVersion; + Dac = dac; + } + + /// True for a live process target; false for a post-mortem dump. + public bool IsLive => Liveness == Liveness.Live; + + /// + /// Generate the cross-product of the requested axes as a single-column theory source, filtered to the + /// valid configurations for the current platform (see ). + /// + /// Axis defaults are deliberate: / default to + /// AllValid (full coverage), but defaults to + /// , to , and + /// to . Live debugging is slow (a debugger + /// ptrace-attached to a running process, one session per core) and almost every command behaves + /// identically against a dump, so live coverage is opt-in: a test that uniquely benefits from a + /// live process (e.g. a stack walk reading live thread contexts, a live GC heap/root scan) passes + /// liveness: Liveness.AllValid to run dump and live; everything else stays dump-only. + /// Server GC and Mini dumps are likewise opt-in so the matrix doesn't explode. + /// + /// Each axis can be narrowed at run time by a comma-separated env allow-list: + /// SOSHARNESS_ONLY_HOSTS, _FLAVORS, _LIVENESS, _GCTYPE, _DUMPKIND, + /// _COREVERSIONS (e.g. Net10,Net11), _DAC (e.g. Legacy). + /// + /// Out-of-support Core versions are excluded from the default matrix; set + /// SOSHARNESS_TEST_OUT_OF_SUPPORT_CORE=1 to include them, or name them explicitly in + /// SOSHARNESS_ONLY_COREVERSIONS (which bypasses the exclusion). + public static TheoryData BuildMatrix( + string[] targets, + Flavor flavor = Flavor.AllValid, + Host host = Host.AllValid, + Liveness liveness = Liveness.Dump, + GcType gcType = GcType.Workstation, + DumpKind dumpKind = DumpKind.Heap, + CoreVersion coreVersion = CoreVersion.All, + Dac dac = Dac.All) + { + TheoryData data = new(); + foreach (TestConfig cfg in Permutations(targets, flavor, host, liveness, gcType, dumpKind, coreVersion, dac)) + { + data.Add(cfg); + } + + return data; + } + + /// + /// The raw valid configurations for the requested axes (what wraps into a + /// theory source). Exposed for theories that need to pair each config with an extra, non-axis column — + /// e.g. a stop-point name — into their own TheoryData<TestConfig, ...>. + /// + public static IEnumerable Permutations( + string[] targets, + Flavor flavor = Flavor.AllValid, + Host host = Host.AllValid, + Liveness liveness = Liveness.Dump, + GcType gcType = GcType.Workstation, + DumpKind dumpKind = DumpKind.Heap, + CoreVersion coreVersion = CoreVersion.All, + Dac dac = Dac.All) + { + HashSet seen = new(); + + // Only ever expand versions the harness actually builds/installs; a requested bit outside the + // available set is silently dropped (the axis disables, it never positively enables — see CoreVersion). + CoreVersion requestedVersions = coreVersion & CoreVersions.Available; + + // Exclude out-of-support versions from the default matrix. They still run when opted in or when + // explicitly named in SOSHARNESS_ONLY_COREVERSIONS (the explicit allow-list is authoritative). + bool explicitVersions = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SOSHARNESS_ONLY_COREVERSIONS")); + if (!CoreVersions.TestOutOfSupportCore && !explicitVersions) + { + requestedVersions &= ~CoreVersions.OutOfSupport; + } + + foreach (string target in targets) + { + foreach (Host h in SingleFlags(host, "SOSHARNESS_ONLY_HOSTS")) + { + foreach (Flavor f in SingleFlags(flavor, "SOSHARNESS_ONLY_FLAVORS")) + { + foreach (Liveness l in SingleFlags(liveness, "SOSHARNESS_ONLY_LIVENESS")) + { + foreach (GcType g in SingleFlags(gcType, "SOSHARNESS_ONLY_GCTYPE")) + { + foreach (DumpKind d in SingleFlags(dumpKind, "SOSHARNESS_ONLY_DUMPKIND")) + { + foreach (CoreVersion cv in SingleFlags(requestedVersions, "SOSHARNESS_ONLY_COREVERSIONS")) + { + foreach (Dac da in SingleFlags(dac, "SOSHARNESS_ONLY_DAC")) + { + // A live target has no dump kind. Collapse it to the canonical + // value so we emit one live row, not one per dump-kind permutation. + DumpKind dk = l == Liveness.Live ? DumpKind.Heap : d; + + // Desktop .NET Framework has no .NET Core version — the axis is inert + // there. Collapse it to CoreVersion.None so every Framework row folds + // into one (via the seen-set dedup below) instead of fanning out an + // identical desktop-Framework config per Core version. + CoreVersion cvEffective = f == Flavor.Framework ? CoreVersion.None : cv; + + TestConfig cfg = new(target, h, f, l, g, dk, cvEffective, da); + if (IsValid(cfg) && seen.Add(cfg)) + { + yield return cfg; + } + } + } + } + } + } + } + } + } + } + + /// + /// Whether a configuration is valid on the current platform. Centralizes every constraint that the old + /// nested-loop BuildMatrix scattered across per-axis continues. + /// + private static bool IsValid(TestConfig c) + { + // Host platform constraints: cdb is Windows-only, lldb is non-Windows-only. + if (c.Host == Host.Cdb && !OperatingSystem.IsWindows()) + { + return false; + } + + if (c.Host == Host.Lldb && OperatingSystem.IsWindows()) + { + return false; + } + + // Desktop .NET Framework is Windows-only. + if (c.Flavor == Flavor.Framework && !OperatingSystem.IsWindows()) + { + return false; + } + + // dotnet-dump is post-mortem only; it has no live host. + if (c.IsLive && c.Host == Host.DotnetDump) + { + return false; + } + + // Live bpmd can't bind in a self-contained single-file image under the lldb host: CoreCLR is + // statically linked into the symbol-stripped app image, so lldb has no symbol on which to set the + // JIT/prestub notification breakpoint (.NET Core keeps CoreCLR as a distinct libcoreclr.so, so it + // works there). Prune the (lldb, single-file, live) row for targets navigated via a managed stop + // point; crash targets, which just run to the fault, keep their live single-file coverage. + if (c.IsLive && c.Host == Host.Lldb && c.Flavor == Flavor.SingleFile && TargetCatalog.NavigatesViaBpmd(c.Target)) + { + return false; + } + + // The target must support the requested flavor (e.g. DynamicMethod can't build for Framework). + if ((TargetCatalog.FlavorsFor(c.Target) & c.Flavor) == 0) + { + return false; + } + + // Server GC is forced via .NET-Core GC env vars (DATAS off + fixed heap count); desktop .NET + // Framework doesn't honor them, so Server is a Core/SingleFile-only axis. + if (c.GcType == GcType.Server && c.Flavor == Flavor.Framework) + { + return false; + } + + // Server GC for a LIVE target would require injecting the GC env vars into the dbgeng-launched + // debuggee process; that isn't wired yet (no consumer), so Server is dump-only for now. + if (c.GcType == GcType.Server && c.IsLive) + { + return false; + } + + // Runtime createdump only supports full dumps for single-file apps when it needs the DAC to + // enumerate reduced-dump regions. Don't generate Mini rows for single-file targets. + if (c.DumpKind == DumpKind.Mini && c.Flavor == Flavor.SingleFile) + { + return false; + } + + // The cDAC (managed contract DAC) is a .NET Core concept; desktop .NET Framework has no cDAC, so + // `runtimes --usecdac true` fails on clr.dll ("no matching cDAC is available for this runtime"). + // Prune the CDac axis for the Framework flavor (its CoreVersion label is meaningless anyway). + if (c.Dac == Dac.CDac && c.Flavor == Flavor.Framework) + { + return false; + } + + // The cDAC (managed contract DAC) only exists on .NET 11+; on earlier runtimes only the legacy + // native DAC is available, so prune the CDac axis there. The same dump is reused across DAC values + // (only `runtimes --usecdac` differs at debug time), so this just removes the invalid debug-time + // variant, never a capture. + if (c.Dac == Dac.CDac && (uint)c.CoreVersion < (uint)CoreVersion.Net11) + { + return false; + } + + // The universal cDAC can identify a single-file runtime and inspect its GC heap, but it cannot + // currently expose the managed execution metadata that SOS commands require (AppDomain/module + // details, MethodDescs, exception stack traces, or stack walks). Keep cDAC coverage on Core, + // where the full command surface is supported, and test SingleFile with its matching legacy DAC. + if (c.Dac == Dac.CDac && c.Flavor == Flavor.SingleFile) + { + return false; + } + + return true; + } + + private static IEnumerable SingleFlags(T value) where T : struct, Enum + { + foreach (T candidate in Enum.GetValues()) + { + long v = Convert.ToInt64(candidate); + if (v != 0 && (v & (v - 1)) == 0 && (Convert.ToInt64(value) & v) != 0) + { + yield return candidate; + } + } + } + + /// + /// Like , but additionally narrowed by an optional comma-separated + /// allow-list in (enum names, case-insensitive). Lets a run be staged onto a + /// subset of the matrix during bring-up, e.g. SOSHARNESS_ONLY_FLAVORS=Core, + /// SOSHARNESS_ONLY_HOSTS=Cdb,DotnetDump, SOSHARNESS_ONLY_GCTYPE=Server. + /// + private static IEnumerable SingleFlags(T value, string envVar) where T : struct, Enum + { + string? only = Environment.GetEnvironmentVariable(envVar); + HashSet? allowed = string.IsNullOrEmpty(only) + ? null + : new HashSet(only.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase); + + foreach (T candidate in SingleFlags(value)) + { + if (allowed is null || allowed.Contains(candidate.ToString())) + { + yield return candidate; + } + } + } + + void IXunitSerializable.Serialize(IXunitSerializationInfo info) + { + info.AddValue(nameof(Target), Target, typeof(string)); + info.AddValue(nameof(Host), Host, typeof(Host)); + info.AddValue(nameof(Flavor), Flavor, typeof(Flavor)); + info.AddValue(nameof(Liveness), Liveness, typeof(Liveness)); + info.AddValue(nameof(GcType), GcType, typeof(GcType)); + info.AddValue(nameof(DumpKind), DumpKind, typeof(DumpKind)); + info.AddValue(nameof(CoreVersion), CoreVersion, typeof(CoreVersion)); + info.AddValue(nameof(Dac), Dac, typeof(Dac)); + } + + void IXunitSerializable.Deserialize(IXunitSerializationInfo info) + { + _target = info.GetValue(nameof(Target))!; + _host = info.GetValue(nameof(Host)); + _flavor = info.GetValue(nameof(Flavor)); + _liveness = info.GetValue(nameof(Liveness)); + _gcType = info.GetValue(nameof(GcType)); + _dumpKind = info.GetValue(nameof(DumpKind)); + _coreVersion = info.GetValue(nameof(CoreVersion)); + _dac = info.GetValue(nameof(Dac)); + } + + /// + /// A legible, deterministic id used for the theory display name and de-duplication, e.g. + /// scenarios/Cdb/Core/net10/Dump/Workstation/Heap (the runtime version is always shown; the dump + /// kind is omitted for live rows; a /cdac suffix marks the cDAC variant. Legacy DAC is the + /// implicit default and isn't tokenized, so single-DAC ids stay terse. + /// + public override string ToString() + { + string version = CoreVersion == CoreVersion.None ? "/netfx" : "/net" + CoreVersions.Major(CoreVersion); + string dump = IsLive ? string.Empty : "/" + DumpKind; + string dac = Dac == Dac.CDac ? "/cdac" : string.Empty; + return $"{Target}/{Host}/{Flavor}{version}/{Liveness}/{GcType}{dump}{dac}"; + } + +} diff --git a/src/tests/SOS.TestHarness/ToolPaths.cs b/src/tests/SOS.TestHarness/ToolPaths.cs new file mode 100644 index 0000000000..300a12d53a --- /dev/null +++ b/src/tests/SOS.TestHarness/ToolPaths.cs @@ -0,0 +1,435 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SOS.TestHarness; + +/// +/// Resolves the external tools the harness drives, pointing every one at the diagnostics repo's +/// own build outputs (so the harness always validates freshly-built SOS, not a stale machine-wide +/// install): +/// +/// dbgeng.dll comes from the restored cdb-sos package, so no WinDbg installation +/// is required. +/// Native sos.dll comes from the repo's native build output. +/// dotnet-dump is the repo-built tool, run as dotnet dotnet-dump.dll. +/// +/// +public static class ToolPaths +{ + /// + /// Directory containing dbgeng.dll, taken from the restored cdb-sos package's + /// runtimes/win-<arch>/native directory. Windows-only; resolved lazily so non-Windows + /// hosts never trigger it. + /// + public static string DbgEngDirectory => s_dbgEngDirectory.Value; + + /// Repo-built native SOS (sos.dll) from . + /// Windows-only (the dbgeng .load target); resolved lazily. + public static string SosPath => s_sosPath.Value; + + /// Repo-built dotnet-dump managed entry point, run as dotnet <dll>. + public static string DotNetDumpDll => s_dotNetDumpDll.Value; + + /// + /// The native lldb plugin (libsosplugin.so on Linux, libsosplugin.dylib on macOS) that + /// SOS loads into lldb via plugin load, taken from . + /// Non-Windows; resolved lazily. + /// + public static string LldbPluginPath => s_lldbPluginPath.Value; + + /// + /// The lldb executable the harness drives. Resolution mirrors eng/build.sh: the + /// LLDB_PATH env var first, then (on macOS) Xcode's lldb at + /// $(xcode-select -p)/usr/bin/lldb (it carries the debugging entitlements), then a plain + /// lldb on PATH. Non-Windows; resolved lazily. + /// + public static string LldbExe => s_lldbExe.Value; + + /// + /// The .NET runtime directory SOS hosts its managed extension on (the sethostruntime target). + /// Points at the repo's locally-acquired .dotnet shared runtime (highest net10 present), so the + /// host runtime is deterministic and hermetic rather than auto-detected from PATH. + /// + public static string HostRuntimeDirectory => s_hostRuntimeDirectory.Value; + + /// + /// Full path to the createdump executable the debugger host's hosted .NET runtime can use to + /// write a crash dump. NativeAOT components (notably the universal cDAC) may not have a neighboring + /// createdump, so the crash-dump environment points explicitly at the one from the host runtime. + /// + public static string CreateDumpPath => s_createDumpPath.Value; + + /// + /// Directory containing the DAC (mscordaccore.dll / libmscordaccore.so / + /// libmscordaccore.dylib) that matches the runtime a self-contained single-file debuggee of the + /// given bundles. Self-contained single-file apps carry the runtime + /// inside the exe, so a native debugger can't find the DAC next to a runtime on disk and (hermetically) + /// can't download it. The cdb host loads it explicitly via .cordll -lp; the lldb host adds it as a + /// local symbol-store directory via setsymbolserver -directory. The version is the runtime patch + /// the single-file publish resolved against (from the install manifest and test runtime installation, + /// or the matching runtime pack cache). Returns null if it can't be located. + /// + public static string? SingleFileDacDirectory(CoreVersion coreVersion) => + s_singleFileDacDirectory.GetOrAdd(coreVersion, ResolveSingleFileDacDirectory); + + /// + /// Optional local directory containing an override universal cDAC + /// (libmscordaccore_universal.so / platform equivalent) for cDAC test rows. Defaults to + /// artifacts/cdac-override/<Configuration> and can be overridden with + /// SOSHARNESS_CDAC_DIR. + /// + public static string? CDacOverrideDirectory => s_cdacOverrideDirectory.Value; + + /// + /// Native SOS resolves the universal cDAC from the SOS module directory. Keep the lldb plugin output + /// directory aligned with the configured override before cDAC rows load SOS. + /// + public static void EnsureLldbPluginCDacOverride() + { + string? overrideDirectory = CDacOverrideDirectory; + if (overrideDirectory is null) + { + return; + } + + string source = Path.Combine(overrideDirectory, CDacFileName); + string destination = Path.Combine(Path.GetDirectoryName(LldbPluginPath)!, CDacFileName); + lock (s_cdacCopyLock) + { + if (!File.Exists(destination) || !FilesEqual(source, destination)) + { + File.Copy(source, destination, overwrite: true); + } + } + } + + // Lazy so each host only resolves the tools it actually needs: the non-Windows lldb/dotnet-dump hosts + // never touch the Windows-only dbgeng/sos.dll resolvers (which would throw for lack of those payloads), + // and the Windows cdb host never touches the lldb resolvers. + private static readonly Lazy s_dbgEngDirectory = new(ResolveDbgEngDirectory); + private static readonly Lazy s_sosPath = new(ResolveSosPath); + private static readonly Lazy s_dotNetDumpDll = new(ResolveDotNetDumpDll); + private static readonly Lazy s_lldbPluginPath = new(ResolveLldbPluginPath); + private static readonly Lazy s_lldbExe = new(ResolveLldbExe); + private static readonly Lazy s_hostRuntimeDirectory = new(ResolveHostRuntimeDirectory); + private static readonly Lazy s_createDumpPath = new(ResolveCreateDumpPath); + private static readonly Lazy s_cdacOverrideDirectory = new(ResolveCDacOverrideDirectory); + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_singleFileDacDirectory = new(); + private static readonly object s_cdacCopyLock = new(); + + private static string ResolveDbgEngDirectory() + { + string relativeNative = Path.Combine("runtimes", $"win-{RepoLayout.TargetArch}", "native"); + + foreach (string root in NuGetPackageRoots()) + { + string pkg = Path.Combine(root, "cdb-sos"); + if (!Directory.Exists(pkg)) + { + continue; + } + + // Prefer the highest version present. + foreach (string versionDir in Directory.GetDirectories(pkg).OrderByDescending(d => d, StringComparer.OrdinalIgnoreCase)) + { + string native = Path.Combine(versionDir, relativeNative); + if (File.Exists(Path.Combine(native, "dbgeng.dll"))) + { + return native; + } + } + } + + throw new FileNotFoundException( + "Could not locate dbgeng.dll from the cdb-sos package. Restore the harness test project so " + + "its PackageDownload populates the NuGet cache."); + } + + private static string ResolveSosPath() + { + string path = Path.Combine(RepoLayout.ArtifactsBinNative, "sos.dll"); + if (!File.Exists(path)) + { + throw new FileNotFoundException( + $"Repo-built native SOS not found at '{path}'. Build the repo (Build.cmd) so the native " + + "SOS is produced for this configuration/architecture.", path); + } + + return path; + } + + private static string ResolveLldbPluginPath() + { + string name = OperatingSystem.IsMacOS() ? "libsosplugin.dylib" : "libsosplugin.so"; + string path = Path.Combine(RepoLayout.ArtifactsBinNative, name); + if (!File.Exists(path)) + { + throw new FileNotFoundException( + $"Repo-built lldb SOS plugin not found at '{path}'. Build the repo (./build.sh) so the native " + + "lldb plugin is produced for this configuration/architecture.", path); + } + + return path; + } + + private static string ResolveLldbExe() + { + // 1) Explicit override (what eng/build.sh exports), if it points at a real file. + string? env = Environment.GetEnvironmentVariable("LLDB_PATH"); + if (!string.IsNullOrEmpty(env) && File.Exists(env)) + { + return env; + } + + // 2) macOS: Xcode's lldb is signed with the debugging entitlements needed to drive a process and + // to load core dumps, so prefer it over anything else. + if (OperatingSystem.IsMacOS()) + { + string? developerDir = TryRun("xcode-select", "-p"); + if (!string.IsNullOrWhiteSpace(developerDir)) + { + string candidate = Path.Combine(developerDir.Trim(), "usr", "bin", "lldb"); + if (File.Exists(candidate)) + { + return candidate; + } + } + } + + // 3) A plain `lldb` on PATH. + string? onPath = FindOnPath("lldb"); + if (onPath is not null) + { + return onPath; + } + + throw new FileNotFoundException( + "Could not locate an 'lldb' executable. Set LLDB_PATH, install lldb on PATH, or (on macOS) " + + "install Xcode."); + } + + private static string ResolveHostRuntimeDirectory() + { + // SOS hosts its managed extension on a .NET runtime; point it at the repo's locally-acquired + // .dotnet shared runtime so it's deterministic. Any recent runtime works as a host (it need not + // match the target's runtime), so pick the highest net10 present. + string sharedRoot = Path.Combine(RepoLayout.Root, ".dotnet", "shared", "Microsoft.NETCore.App"); + if (Directory.Exists(sharedRoot)) + { + string? best = Directory.GetDirectories(sharedRoot) + .Select(Path.GetFileName) + .Where(v => v is not null && v.StartsWith("10.0.", StringComparison.Ordinal)) + .OrderByDescending(v => v, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + if (best is not null) + { + return Path.Combine(sharedRoot, best); + } + } + + throw new DirectoryNotFoundException( + $"Could not locate a net10 host runtime under '{sharedRoot}'. Run ./build.sh so the repo's " + + ".dotnet runtime is acquired."); + } + + private static string ResolveCreateDumpPath() + { + string exe = OperatingSystem.IsWindows() ? "createdump.exe" : "createdump"; + string candidate = Path.Combine(HostRuntimeDirectory, exe); + if (File.Exists(candidate)) + { + return candidate; + } + + throw new FileNotFoundException( + $"createdump not found at '{candidate}'. Run ./build.sh so the repo's .dotnet runtime is acquired.", candidate); + } + + private static string? FindOnPath(string fileName) + { + string? path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) + { + return null; + } + + foreach (string dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + string candidate = Path.Combine(dir, fileName); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + private static string? TryRun(string fileName, string arguments) + { + try + { + using System.Diagnostics.Process? p = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }); + if (p is null) + { + return null; + } + + string output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(5000); + return p.ExitCode == 0 ? output : null; + } + catch + { + return null; + } + } + + private static string ResolveDotNetDumpDll() + { + // dotnet-dump targets net8.0 and is published under the repo artifacts; prefer the published + // copy (self-contained closure) and fall back to the plain build output. + string baseDir = Path.Combine(RepoLayout.ArtifactsBin, "dotnet-dump", RepoLayout.ArtifactsConfiguration, "net8.0"); + string published = Path.Combine(baseDir, "publish", "dotnet-dump.dll"); + if (File.Exists(published)) + { + return published; + } + + string built = Path.Combine(baseDir, "dotnet-dump.dll"); + if (File.Exists(built)) + { + return built; + } + + throw new FileNotFoundException( + $"Repo-built dotnet-dump not found under '{baseDir}'. Build the repo (Build.cmd) so dotnet-dump " + + "is produced.", published); + } + + private static string? ResolveSingleFileDacDirectory(CoreVersion coreVersion) + { + string rid = RepoLayout.Rid; // win-x64 / linux-x64 / osx-arm64 / ... + string packId = $"microsoft.netcore.app.runtime.{rid}"; + string relativeNative = Path.Combine("runtimes", rid, "native"); + string dacFileName = DacFileName; // mscordaccore.dll / libmscordaccore.so / libmscordaccore.dylib + int major = CoreVersions.Major(coreVersion); + + // Preferred: the exact runtime version the single-file publish resolved against (what the install + // manifest recorded for this framework), read straight from the test runtime installation. The + // corresponding runtime pack is not necessarily restored into the user's NuGet cache. + string? pinned = CoreVersions.RuntimeVersion(coreVersion); + if (!string.IsNullOrEmpty(pinned)) + { + string sharedRuntime = Path.Combine( + RepoLayout.DotnetTestRoot, + "shared", + "Microsoft.NETCore.App", + pinned!); + if (File.Exists(Path.Combine(sharedRuntime, dacFileName))) + { + return sharedRuntime; + } + + foreach (string root in NuGetPackageRoots()) + { + string native = Path.Combine(root, packId, pinned!, relativeNative); + if (File.Exists(Path.Combine(native, dacFileName))) + { + return native; + } + } + } + + // Fallback: the highest patch of this major present in the runtime-pack cache. + string majorPrefix = $"{major}.0."; + foreach (string root in NuGetPackageRoots()) + { + string pkg = Path.Combine(root, packId); + if (!Directory.Exists(pkg)) + { + continue; + } + + string? best = Directory.GetDirectories(pkg) + .Select(Path.GetFileName) + .Where(v => v is not null && v.StartsWith(majorPrefix, StringComparison.Ordinal)) + .OrderByDescending(v => v, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + if (best is not null) + { + string native = Path.Combine(pkg, best, relativeNative); + if (File.Exists(Path.Combine(native, dacFileName))) + { + return native; + } + } + } + + return null; + } + + private static string? ResolveCDacOverrideDirectory() + { + string? env = Environment.GetEnvironmentVariable("SOSHARNESS_CDAC_DIR"); + string directory = string.IsNullOrEmpty(env) + ? Path.Combine(RepoLayout.Root, "artifacts", "cdac-override", RepoLayout.ArtifactsConfiguration) + : env; + + return File.Exists(Path.Combine(directory, CDacFileName)) ? directory : null; + } + + private static bool FilesEqual(string leftPath, string rightPath) + { + FileInfo leftInfo = new(leftPath); + FileInfo rightInfo = new(rightPath); + if (leftInfo.Length != rightInfo.Length) + { + return false; + } + + using FileStream left = File.OpenRead(leftPath); + using FileStream right = File.OpenRead(rightPath); + int leftByte; + while ((leftByte = left.ReadByte()) != -1) + { + if (leftByte != right.ReadByte()) + { + return false; + } + } + + return right.ReadByte() == -1; + } + + /// The platform-specific DAC module file name: mscordaccore.dll on Windows, + /// libmscordaccore.dylib on macOS, libmscordaccore.so elsewhere. + private static string DacFileName => + OperatingSystem.IsWindows() ? "mscordaccore.dll" : + OperatingSystem.IsMacOS() ? "libmscordaccore.dylib" : "libmscordaccore.so"; + + private static string CDacFileName => + OperatingSystem.IsWindows() ? "mscordaccore_universal.dll" : + OperatingSystem.IsMacOS() ? "libmscordaccore_universal.dylib" : "libmscordaccore_universal.so"; + + private static IEnumerable NuGetPackageRoots() + { + string? env = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + if (!string.IsNullOrEmpty(env)) + { + yield return env; + } + + yield return Path.Combine(UserProfile, ".nuget", "packages"); + } + + private static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); +} diff --git a/src/tests/SOS.TestHarness/lldbhelper.py b/src/tests/SOS.TestHarness/lldbhelper.py new file mode 100644 index 0000000000..76934fd388 --- /dev/null +++ b/src/tests/SOS.TestHarness/lldbhelper.py @@ -0,0 +1,41 @@ +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# +# lldb command framing for the SOS test harness (the LldbCliHost backend). +# +# A bare lldb REPL gives no reliable per-command "done" delimiter and no success/failure status, so the +# harness never issues commands directly: it imports this helper and runs every command as +# `runcommand `. The helper executes the real command through the command interpreter, streams its +# output, and then prints a sentinel line the host keys on -- when the command +# succeeded, when it failed (so the host gets a real success bit, not a screen-scrape). + +import lldb +import sys + +END_OUTPUT = "" +END_ERROR = "" + + +def __lldb_init_module(debugger, internal_dict): + debugger.HandleCommand("command script add -f lldbhelper.runcommand runcommand") + # Emit the marker once at import time so the host can drain the startup banner up to a known point + # and know the helper is ready before it sends the first command. + sys.stdout.write(END_OUTPUT + "\n") + sys.stdout.flush() + + +def runcommand(debugger, command, result, internal_dict): + interpreter = debugger.GetCommandInterpreter() + + ret = lldb.SBCommandReturnObject() + interpreter.HandleCommand(command, ret) + + # GetOutput()/GetError() already include trailing newlines; use write (not print) to avoid doubling + # them, so the sentinel always lands on its own line. + if ret.GetOutputSize() > 0: + sys.stdout.write(ret.GetOutput()) + if ret.GetErrorSize() > 0: + sys.stdout.write(ret.GetError()) + + sys.stdout.write((END_OUTPUT if ret.Succeeded() else END_ERROR) + "\n") + sys.stdout.flush() diff --git a/src/tests/SOS.Tests/COVERAGE.md b/src/tests/SOS.Tests/COVERAGE.md new file mode 100644 index 0000000000..ca77b10204 --- /dev/null +++ b/src/tests/SOS.Tests/COVERAGE.md @@ -0,0 +1,126 @@ +# SOS test coverage audit + +This audit compares PR #5979 at `40ccc0262a199a1576ed3bec25d82152a32c1bc4` +with the legacy `SOS.UnitTests` entry points and 29 scripts present at the same +commit. The new project contains 89 command-focused test methods after the +focused `dumpgen` addition described below. + +## Result + +The repository's baseline coverage equals or exceeds the previous baseline: +PR #5979 does not delete or disable any legacy test, and adds the new matrix and +89 tests. The stronger claim that `SOS.Tests` alone replaces every legacy +scenario is not supported. Most command behavior is covered more strictly, but +specialized debuggees and debugger transitions remain in `SOS.UnitTests`. +Those tests must remain until their rows marked **gap** below are migrated. + +Status meanings: + +- **Covered**: the same observable command behavior has a direct new test. +- **Improved**: the new test adds a stronger oracle, more options, or broader + host/flavor/runtime coverage. +- **Retained**: the specialized scenario remains intentionally owned by the + legacy suite; the command may also have generic new coverage. +- **Gap**: `SOS.Tests` has no equivalent for a material legacy behavior. The + legacy test is still active, so this is a migration gap rather than a + regression in the PR baseline. + +No legacy script is retired by #5979. Rows that are fully covered or improved +are candidates for later retirement only after CI proves the intended matrix +replacement. + +## Legacy-to-new map + +| Legacy script | Status | New evidence and remaining legacy value | +| --- | --- | --- | +| `AsyncMain.script` | Gap | General stack shape is covered by `ClrStackLinesTests` and `ClrStackAllThreadsTests`; the async-`Main` frame identity has no new oracle. | +| `ClrStackWithNumberOfFrames.script` | Improved | `ClrStackFrameCountTests.ClrStack_FrameCount` compares each `-c N` result with the exact prefix of an unlimited walk and checks an over-limit request across four crash targets. | +| `ConcurrentDictionaries.script` | Improved | `SpecializedInspectionTests.Dcd_DumpsConcurrentDictionary`, `DumpArrayTests`, and `ObjectFieldsTests` provide typed data assertions. Legacy still covers dcd argument errors and its wider generic key/value set. | +| `DivZero.script` | Improved | `PrintExceptionTests`, `ClrThreadsTests`, `ClrStackLinesTests`, `ClrStackICorDebugTests`, and frame-count tests split the monolithic script into data and structure oracles. | +| `DualRuntimes.script` | Retained | Generic stacks, threads, heaps, and runtime listing are covered; loading and switching between two runtimes in one process remains a specialized legacy scenario. | +| `DumpGCData.script` | Covered | `DiagnosticCommandTests.DumpGcData_ReportsGcStatistics` directly exercises `dumpgcdata`. | +| `DumpGen.script` | Improved | `GcInspectionTests.DumpGen_ListsGenerationObjects` asserts a known gen0 object. `DumpGen_ArgumentsAndFilters` adds missing/invalid generation and valid `-type`/`-mt` coverage. Exact legacy gen1/gen2/LOH/POH populations remain retained. | +| `DynamicMethod.script` | Retained | `DumpIlTests` validates IL addresses and instructions and ICorDebug is covered elsewhere; the emitted dynamic-method target remains a legacy scenario. | +| `FindRootsOlderGeneration.script` | Gap | `GcRoot_FindsRootsForLive_NoneForDead` improves ordinary root correctness, but the live `findroots -gen any` notification/continue sequence and older-generation result are not represented. | +| `GCPOH.script` | Improved | `DumpHeapGenerationsTests`, `DumpArrayTests`, `GcHandles`, `VerifyHeap`, `EeHeapTests`, and stack-root tests use deterministic objects and structured assertions; the original POH script remains active. | +| `GCTests.script` | Improved | Object fields, `gcwhere`, stack objects, heap statistics, roots, handles, finalization, and verification are split into focused tests with exact object oracles. | +| `InterpreterStackInterleavedTest.script` | Retained | No generic target can replace the interleaved interpreter/JIT frame sequence; legacy ownership is intentional. | +| `InterpreterStackTest.script` | Retained | Interpreter-frame ordering remains specialized legacy coverage. | +| `LineNums.script` | Improved | `ClrStackLinesTests` checks source file/line behavior and `PrintExceptionTests` checks exception structure/data across the matrix. | +| `MiniDumpLocalVarLookup.script` | Gap | `ClrStackArgsLocalsTests` and `ClrStackICorDebugTests` improve variable data checks, but they use full/heap dumps rather than proving local recovery from a Mini dump. | +| `NestedExceptionTest.script` | Improved | `PrintExceptionTests` verifies exact outer/inner types, messages, HResult, frame data, and inner-address round-trip; stack variants are independently covered. | +| `OtherCommands.script` | Improved | Its broad command set is split across object, module, domain, heap, runtime, memory, code-info, and diagnostic test classes with structured round-trips. | +| `Overflow.script` | Retained | Stack-overflow exception behavior remains a specialized crash/live legacy target; ordinary exception output is covered by `PrintExceptionTests`. | +| `Reflection.script` | Retained | Target-invocation exception and reflection boundary behavior remains legacy; generic nested exception and stack behavior is improved in the new project. | +| `SimpleThrow.script` | Covered | Exception fields, HResult, source lines, threads, and managed stack shape are directly covered by the new exception and stack classes on deterministic crash targets. | +| `StackAndOtherTests.script` | Improved | `RuntimeInfoTests` and the `ClrStack*Tests` classes separately cover runtime selection, plain/line/full/all/register/args/locals stack modes with stronger comparisons. | +| `StackTests.script` | Improved | `ClrStackTests`, `ClrStackFullTests`, `ClrStackAllThreadsTests`, `ClrStackArgsLocalsTests`, `StackInspectionTests`, and `RuntimeInfoTests` replace shape-only checks with tables and address round-trips. | +| `StackTraceFaultingExceptionFrame.script` | Gap | Exception and ordinary stack data are covered; no new assertion requires the synthetic `[FaultingExceptionFrame: ...]` row. | +| `StackTraceSoftwareExceptionFrame.script` | Gap | Exception and ordinary stack data are covered; no new assertion requires the synthetic `[SoftwareExceptionFrame: ...]` row. | +| `TaskNestedException.script` | Gap | New tests cover one inner-exception round-trip, not the AggregateException/task chain and source lines. | +| `TestExtensions.script` | Retained | This validates extension-command interception and dispatch rather than SOS product output; it remains a legacy harness integration test. | +| `ThreadApartment.script` | Gap | `ClrThreadsTests` validates thread rows and counts, but does not assert Windows STA/MTA apartment flags. | +| `VarargPInvokeInteropMD.script` | Retained | Vararg P/Invoke, IL stubs, native breakpointing, `ip2md`, and `clru -il` form one specialized interop scenario. Generic `ip2md` and `clru` have new structured tests. | +| `WebApp.script` | Retained | Timers, ASP.NET/WebApp stacks, args/registers, and GC stress-log behavior remain in the specialized Windows legacy target; generic command equivalents are covered. | + +The focused gap fixed in this review is `dumpgen` argument and filtering +behavior. The new test uses the source-generated scenario marker, resolves its +real MethodTable through `dumpobj`, and proves both `-type` and `-mt` select it. +The remaining gaps require purpose-built targets, Mini-dump policy, or +platform-specific debugger sequencing and are not appropriate baseline-harness +refactors. + +## Product mutation experiment + +### Selection + +A fresh Copilot session performed selection independently from the harness +implementation. It formed eligible candidate IDs from real SOS product output +behaviors with assertions in both suites, excluding harness-only defects and +sites unavailable on macOS arm64. With seed +`0x40ccc0262a199a15`, it sorted candidates by: + +```text +SHA256(seed + NUL + candidate-id) +``` + +and selected the first five: + +1. `dumpmt-type-name` +2. `clrstack-frame-limit` +3. `ip2md-methoddesc` +4. `dumparray-element-count` +5. `printexception-hresult` + +Each mutation was made in `src/SOS/Strike/strike.cpp`, native SOS was rebuilt, +and the mutated `libsos.dylib` was staged beside the repository-built +dotnet-dump. Mutations were tested one at a time and restored before the next. +None is part of the final diff. + +### Host constraint and comparison validity + +On this macOS arm64 machine, the unchanged legacy test driver enters mandatory +live/native LLDB phases that crash before reaching the script assertions. For +this experiment only, an uncommitted adapter selected one Core runtime row and +skipped those LLDB phases while leaving the legacy scripts, dotnet-dump +commands, dumps, and regex assertions unchanged. The corresponding new tests +were narrowed to Core, dotnet-dump, dump, workstation GC, Heap, and legacy DAC. +This compares the same repository-built SOS backend and equivalent product +behavior, without treating an environmental LLDB crash as mutation detection. + +The unmodified legacy probes passed before mutation. Final baseline runs of the +new probes pass after all mutations are restored. + +### Results + +| Mutation | Temporary product defect | Legacy result | New result | Assessment | +| --- | --- | --- | --- | --- | +| `dumpmt-type-name` | Force `dumpmt`'s type name to print ``. | `OtherCommands` failed because `Name: SymbolTestApp.Program` disappeared. | `DumpObj_Mt_Class_Md_Chain` failed because `ThinLockMarker` became ``. | Equal detection; new test uses a deterministic named marker and command round-trip. | +| `clrstack-frame-limit` | Make `clrstack -c N` emit one extra frame. | `ClrStackWithNumberOfFrames` failed its output-line cardinality regex. | `ClrStack_FrameCount` failed all four selected targets; for `-c 1`, expected 1 row and observed 2. | Equal detection, with clearer new diagnostics and multi-target evidence. | +| `ip2md-methoddesc` | Print `pMD + 1` in the `ip2md` MethodDesc field. | `OtherCommands` failed its downstream code-size/method-info assertion after consuming the bad address. | `Ip2md_ResolvesJittedMethodWithSource` failed directly: expected `4535571072`, actual `4535571073`. | Equal detection; the new failure localizes the corrupted field. | +| `dumparray-element-count` | Report `dwNumComponents + 1` from `dumparray`. | `ConcurrentDictionaries` failed `Number of elements 4`. | `DumpArray_StructureStartLengthDetails` failed: expected 8, actual 9. | Equal detection; the new test also checks rank, type, listed rows, slicing, and details. | +| `printexception-hresult` | Report `HResult + 1`. | `NestedExceptionTest` failed `HResult: 80131509`. | `PrintException_Data` failed: expected `0x80131509`, actual `0x8013150a`. | Equal detection; the new test parses the field as a typed `UInt32`. | + +All five new probes were at least as effective as the corresponding legacy +probe: every defect detected by a legacy assertion was also detected by the new +test, usually at a more specific field or row. diff --git a/src/tests/SOS.Tests/ClrStackAllThreadsTests.cs b/src/tests/SOS.Tests/ClrStackAllThreadsTests.cs new file mode 100644 index 0000000000..895387a896 --- /dev/null +++ b/src/tests/SOS.Tests/ClrStackAllThreadsTests.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +/// +/// Coverage for !clrstack -all (every managed thread's stack). The legacy scripts ran -all +/// on WebApp/DualRuntimes/FindRoots but only shape-checked it. The ManagedThreads target parks a fixed +/// number of worker threads at a known method (WorkerPark) via a barrier, so the enumeration is +/// deterministic: there must be exactly the expected number of workers parked in WorkerPark plus the +/// main thread (… AtAllThreads → Main). Self-consistency: the current thread's section in -all +/// matches plain clrstack (same frame IPs). +/// +public sealed class ClrStackAllThreadsTests +{ + private const int ExpectedWorkers = 3; + + public static TheoryData Matrix { get; } = TestMatrices.StackWalk([TargetCatalog.Scenarios]); + + [SosTheory] + [MemberData(nameof(Matrix))] + public async Task ClrStack_AllThreads(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToStopPoint(TargetCatalog.StopAllThreads); + + IReadOnlyList threads = target.ClrstackAllThreads(); + + // Every worker thread is parked in WorkerPark; there are exactly ExpectedWorkers of them. + int workers = threads.Count(t => t.Frames.Any(f => f.Function.Contains("SosHarnessScenarios.WorkerPark", StringComparison.Ordinal))); + Assert.Equal(ExpectedWorkers, workers); + + // Exactly one thread is the main thread, in AtAllThreads below Main. + TargetExtensions.ThreadStack main = Assert.Single( + threads, t => t.Frames.Any(f => f.Function.Contains("SosHarnessScenarios.AtAllThreads", StringComparison.Ordinal))); + Assert.Contains(main.Frames, f => f.Function.Contains("SosHarnessScenarios.Main", StringComparison.Ordinal)); + + // -all enumerates at least the main thread plus the workers. + Assert.True(threads.Count >= ExpectedWorkers + 1, $"expected >= {ExpectedWorkers + 1} threads, got {threads.Count}."); + + // Each OS thread id is distinct. + Assert.Equal(threads.Count, threads.Select(t => t.OsThreadId).Distinct().Count()); + + // Self-consistency: plain clrstack is the current (main) thread, so its frame IPs match the + // main thread's section in -all. + List plainIps = target.Clrstack() + .Select(r => r["IP"].Value.ToUpperInvariant()) + .ToList(); + List mainIps = main.Frames + .Where(f => f.IP.Length > 0) + .Select(f => f.IP.ToUpperInvariant()) + .ToList(); + Assert.Equal(plainIps, mainIps); + } +} diff --git a/src/tests/SOS.Tests/ClrStackArgsLocalsTests.cs b/src/tests/SOS.Tests/ClrStackArgsLocalsTests.cs new file mode 100644 index 0000000000..3f0ff2e2a8 --- /dev/null +++ b/src/tests/SOS.Tests/ClrStackArgsLocalsTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +/// +/// Coverage for !clrstack -p (parameters), -l (locals) and -a (both). The legacy +/// scripts only ever ran -a (and transitively covered -p/-l); here all three are +/// exercised from one stop point. Correctness comes from cross-variant self-consistency — -a's +/// per-frame PARAMETERS equal -p's and its LOCALS equal -l's, while -p shows no +/// locals and -l no parameters — plus sensible value hardcoding and an SOS-native value oracle: +/// a uniquely-typed object's slot value equals its !dumpheap address. +/// +public sealed class ClrStackArgsLocalsTests +{ + public static TheoryData Matrix { get; } + = TestMatrices.StackWalkFullDumpOnCoreVersions( + [ + TargetCatalog.DivZero, + TargetCatalog.Scenarios, + ], + CoreVersion.Net8 | CoreVersion.Net9 | CoreVersion.Net10); + + [SosTheory] + [MemberData(nameof(Matrix))] + public async Task ClrStack_ArgsLocals(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + if (config.Target == TargetCatalog.Scenarios) + { + target.GoToStopPoint(TargetCatalog.StopArgsLocals); + } + else + { + target.GoToFirstStop(); + } + + SosTable p = target.ClrstackArgsLocals(ArgsLocals.Parameters); + SosTable l = target.ClrstackArgsLocals(ArgsLocals.Locals); + SosTable a = target.ClrstackArgsLocals(ArgsLocals.Both); + + // Same stackwalk -> same frames in all three. + Assert.Equal(a.Length, p.Length); + Assert.Equal(a.Length, l.Length); + + // -a == -p (parameters) merged with -l (locals), frame by frame; -p has no locals and -l no + // parameters. + for (int i = 0; i < a.Length; i++) + { + SosRow fa = a.Row(i); + SosRow fp = p.Row(i); + SosRow fl = l.Row(i); + Assert.Equal(fa["IP"].Value, fp["IP"].Value); + Assert.Equal(fa["IP"].Value, fl["IP"].Value); + + Assert.Equal(Values(fa, "PARAMETERS"), Values(fp, "PARAMETERS")); + Assert.Equal(Values(fa, "LOCALS"), Values(fl, "LOCALS")); + Assert.Empty(Records(fp, "LOCALS")); + Assert.Empty(Records(fl, "PARAMETERS")); + } + + // At least one frame actually had parameters and one had locals (the merge above is meaningful). + a.AssertContainsRow(f => Records(f, "PARAMETERS").Any(), "a frame has PARAMETERS"); + a.AssertContainsRow(f => Records(f, "LOCALS").Any(), "a frame has LOCALS"); + + switch (config.Target) + { + case TargetCatalog.DivZero: + // F3's locals are a=1, b=2 (ref-passed, so on the stack); F2's are p=3, q=4. + AssertLocalValues(a, ".F3(", [1, 2]); + AssertLocalValues(a, ".F2(", [3, 4]); + break; + + case TargetCatalog.Scenarios: + // The named int param `number` is 0x2a; a local holds the primitive 0x63. + SosRow method = Frame(a, ".ArgsLocalsMethod("); + Assert.Equal(0x2aul, Named(method, "number").AsUInt64(Sos.Hex)); + Records(method, "LOCALS").AssertContains(r => r["Value"].AsUInt64(Sos.Hex) == 0x63, "a LOCAL with value 0x63"); + + // SOS-native value oracle: the uniquely-typed arg and local slots hold the very objects + // !dumpheap finds for those types. + ulong argObj = target.FindUniqueObject("ArgUniqueMarker"); + ulong localObj = target.FindUniqueObject("LocalUniqueMarker"); + Assert.Equal(argObj, Named(method, "arg").AsUInt64(Sos.Hex)); + Records(method, "LOCALS").AssertContains(r => r["Value"].AsUInt64(Sos.Hex) == localObj, "a LOCAL referencing the LocalUniqueMarker object"); + break; + + default: + // A matrix target with no value assertions would otherwise silently pass on the + // structural checks alone; force adding a case when a target is added. + throw new ArgumentOutOfRangeException(nameof(config.Target), config.Target, "No clrstack args/locals value assertions defined for this target."); + } + } + + private static IEnumerable Records(SosRow frame, string section) => + frame.Data.Where(d => d["Section"].Value == section); + + private static IReadOnlyList Values(SosRow frame, string section) => + Records(frame, section).Select(r => r["Value"].Value).ToList(); + + private static SosRow Frame(SosTable table, string functionSubstring) => + table.SingleRow( + r => !r["InternalFrame"].AsBoolean() && r["Function"].Value.Contains(functionSubstring, StringComparison.Ordinal), + $"a managed frame whose Function contains '{functionSubstring}'"); + + // The parameter named in this frame. + private static SosCell Named(SosRow frame, string name) => + Records(frame, "PARAMETERS").AssertSingle(r => r["Name"].Value == name, $"a PARAMETER named '{name}'")["Value"]; + + private static void AssertLocalValues(SosTable table, string functionSubstring, int[] expected) + { + SosRow frame = Frame(table, functionSubstring); + List locals = Records(frame, "LOCALS").Where(r => r["HasData"].AsBoolean()).Select(r => r["Value"].AsUInt64(Sos.Hex)).ToList(); + foreach (int e in expected) + Assert.Contains((ulong)e, locals); + } +} diff --git a/src/tests/SOS.Tests/ClrStackFrameCountTests.cs b/src/tests/SOS.Tests/ClrStackFrameCountTests.cs new file mode 100644 index 0000000000..21ec9c5eaa --- /dev/null +++ b/src/tests/SOS.Tests/ClrStackFrameCountTests.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +/// +/// Coverage for !clrstack -c <n> (limit the number of printed frames), as the legacy +/// ClrStackWithNumberOfFrames.script did with DivZero. Self-consistency oracle: -c N is exactly +/// the first N rows of the full clrstack (SOS counts every printed row toward the limit, +/// internal frames included), and N larger than the stack prints the whole stack without truncating. +/// Exercised over deep-stacked debuggees. +/// +public sealed class ClrStackFrameCountTests +{ + public static TheoryData Matrix { get; } + = TestMatrices.StackWalk( + [ + TargetCatalog.DivZero, + TargetCatalog.NestedException, + TargetCatalog.LineNums, + TargetCatalog.DynamicMethod, + ]); + + [SosTheory] + [MemberData(nameof(Matrix))] + public async Task ClrStack_FrameCount(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToFirstStop(); + + SosTable full = target.Clrstack(); + Assert.True(full.Length >= 2, "expected a deep enough stack to exercise -c"); + + // -c N is the first N rows of the full stack, for N within the stack... + for (int n = 1; n <= full.Length; n++) + { + SosTable limited = target.ClrstackFrames(n); + AssertSameFrames(full, limited, n); + } + + // ...and N past the end prints the whole stack, no truncation, no padding. + SosTable over = target.ClrstackFrames(full.Length + 5); + AssertSameFrames(full, over, full.Length); + } + + private static void AssertSameFrames(SosTable full, SosTable limited, int expectedCount) + { + Assert.Equal(expectedCount, limited.Length); + for (int i = 0; i < expectedCount; i++) + { + Assert.Equal(full.Row(i)["Child SP"].Value, limited.Row(i)["Child SP"].Value); + Assert.Equal(full.Row(i)["IP"].Value, limited.Row(i)["IP"].Value); + Assert.Equal(full.Row(i)["Call Site"].Value, limited.Row(i)["Call Site"].Value); + } + } +} diff --git a/src/tests/SOS.Tests/ClrStackFullTests.cs b/src/tests/SOS.Tests/ClrStackFullTests.cs new file mode 100644 index 0000000000..5da1210f82 --- /dev/null +++ b/src/tests/SOS.Tests/ClrStackFullTests.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +/// +/// Coverage for !clrstack -f (full / native-interleaved stack). The legacy scripts ran -f +/// on WebApp/StackTests but only shape-checked it. Here the oracle is cross-variant: every managed +/// frame from plain clrstack is preserved in -f (matched by IP) and rendered in the full +/// assembly-qualified Assembly.dll!Method + offset format. The native dimension is host-specific +/// (by design): under cdb, -f must be strictly larger than plain and contain real native runtime +/// frames (coreclr!/clr!/ntdll!/…); under the managed-only dotnet-dump host it contains no native frames. +/// +public sealed class ClrStackFullTests +{ + public static TheoryData Matrix { get; } + = TestMatrices.StackWalk( + [ + TargetCatalog.SimpleThrow, + TargetCatalog.DivZero, + TargetCatalog.NestedException, + ]); + + [SosTheory] + [MemberData(nameof(Matrix))] + public async Task ClrStack_Full(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToFirstStop(); + + SosTable plain = target.Clrstack(); + IReadOnlyList full = target.ClrstackFull(); + + // The IPs of the managed (non-internal) frames in plain clrstack. + HashSet plainManagedIps = plain + .Where(r => !r["InternalFrame"].AsBoolean()) + .Select(r => r["IP"].Value.ToUpperInvariant()) + .ToHashSet(); + Assert.NotEmpty(plainManagedIps); + + HashSet fullIps = full + .Where(f => f.IP.Length > 0) + .Select(f => f.IP.ToUpperInvariant()) + .ToHashSet(); + + // Every managed frame from plain clrstack is preserved in -f. + foreach (string ip in plainManagedIps) + Assert.Contains(ip, fullIps); + + // -f renders managed frames assembly-qualified (Assembly.dll!Method + offset). + Assert.Contains(full, f => f.IsManaged); + + bool nativeHost = config.Host == Host.Cdb || config.Host == Host.Lldb; + if (nativeHost) + { + // Native interleaving: strictly more frames than plain (native frames are woven in). + Assert.True(full.Count > plain.Length, + $"-f ({full.Count}) should have more frames than plain clrstack ({plain.Length}) under a native host."); + + // Real native runtime frames (coreclr!/clr!/ntdll!/…) are only identifiable when the runtime + // is its own module. In a self-contained single-file publish the runtime is statically linked + // into the app executable, so those frames render under the app module name (e.g. + // SimpleThrow!…) and can't be matched by module name. + if (config.Flavor != Flavor.SingleFile) + { + Assert.Contains(full, f => f.IsNativeRuntime); + } + } + else + { + // dotnet-dump is managed-only: no native runtime frames. + Assert.DoesNotContain(full, f => f.IsNativeRuntime); + } + } +} diff --git a/src/tests/SOS.Tests/ClrStackICorDebugTests.cs b/src/tests/SOS.Tests/ClrStackICorDebugTests.cs new file mode 100644 index 0000000000..1dc207890a --- /dev/null +++ b/src/tests/SOS.Tests/ClrStackICorDebugTests.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +/// +/// Coverage for the EXPERIMENTAL !clrstack -i (ICorDebug) and -i -a (variables). The +/// legacy scripts ran -i/-i -a on DivZero/DynamicMethod but only shape-checked them. It is +/// far richer than the non-i path: it recovers real local names and decodes values, which lets us +/// assert concrete parameter/local values and cross-check object references against !dumpheap (the +/// SOS-native oracle). +/// +/// Self-contained single-file is excluded from the matrix: ICorDebug/DBI cannot decode a single-file +/// image's locals (they come back as unnamed IsError slots), and this test specifically validates +/// decoded local names and values. +/// +public sealed class ClrStackICorDebugTests +{ + public static TheoryData Matrix { get; } + = TestConfig.BuildMatrix([TargetCatalog.DivZero, TargetCatalog.Scenarios], Flavor.Core | Flavor.Framework); + + [SosTheory] + [MemberData(nameof(Matrix))] + public async Task ClrStack_ICorDebug(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + if (config.Target == TargetCatalog.Scenarios) + { + target.GoToStopPoint(TargetCatalog.StopArgsLocals); + } + else + { + target.GoToFirstStop(); + } + + // Basic -i: the expected managed methods are present as [DEFAULT] frames. + IReadOnlyList frames = target.ClrstackICorDebug(variables: false); + Assert.Contains(frames, f => f.IsManaged); + foreach (string method in ExpectedMethods(config.Target)) + Assert.Contains(frames, f => f.IsManaged && f.CallSite.Contains(method, StringComparison.Ordinal)); + + // -i -a: same frames, now with parameters and locals decoded. + IReadOnlyList withVars = target.ClrstackICorDebug(variables: true); + foreach (string method in ExpectedMethods(config.Target)) + Assert.Contains(withVars, f => f.IsManaged && f.CallSite.Contains(method, StringComparison.Ordinal)); + + if (config.Target == TargetCatalog.Scenarios) + { + AssertArgsLocalsVariables(target, withVars); + } + } + + private static void AssertArgsLocalsVariables(Target target, IReadOnlyList frames) + { + TargetExtensions.IcorFrame method = Assert.Single(frames, f => f.IsManaged && f.CallSite.Contains("SosHarnessScenarios.ArgsLocalsMethod", StringComparison.Ordinal)); + + // ICorDebug recovers parameter and local names and decodes primitive values (in decimal). Object + // values print as "@ 0x", and that address is the very object !dumpheap reports for the + // uniquely-named type. + Assert.Equal("42", Param(method, "number").Value); + TargetExtensions.IcorVar arg = Param(method, "arg"); + Assert.True(arg.HasAddress); + Assert.Equal(target.FindUniqueObject("ArgUniqueMarker"), arg.Address); + + Assert.Equal("99", Local(method, "localInt").Value); + TargetExtensions.IcorVar localObj = Local(method, "localObj"); + Assert.True(localObj.HasAddress); + Assert.Equal(target.FindUniqueObject("LocalUniqueMarker"), localObj.Address); + } + + // The methods we require on each target's ICorDebug stack. + private static IReadOnlyList ExpectedMethods(string target) => target switch + { + TargetCatalog.DivZero => ["C.DivideByZero", "C.F3", "C.F2", "C.Main"], + TargetCatalog.Scenarios => ["SosHarnessScenarios.ArgsLocalsMethod", "SosHarnessScenarios.AtArgsLocals", "SosHarnessScenarios.Main"], + _ => throw new ArgumentOutOfRangeException(nameof(target)), + }; + + private static TargetExtensions.IcorVar Param(TargetExtensions.IcorFrame frame, string name) => + Assert.Single(frame.Parameters, v => v.Name == name); + + private static TargetExtensions.IcorVar Local(TargetExtensions.IcorFrame frame, string name) => + Assert.Single(frame.Locals, v => v.Name == name); +} diff --git a/src/tests/SOS.Tests/ClrStackLinesTests.cs b/src/tests/SOS.Tests/ClrStackLinesTests.cs new file mode 100644 index 0000000000..7f008ab96b --- /dev/null +++ b/src/tests/SOS.Tests/ClrStackLinesTests.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +/// +/// Coverage for plain !clrstack (base managed stack with source [file @ line]) and +/// -n (suppress source line numbers). The legacy scripts hardcoded method names and exact +/// source file/line (e.g. SimpleThrow.script asserted UserObject.cs @ 19); we do the same kind +/// of sensible hardcoding — assert the expected managed methods appear in caller order and resolve to +/// the expected source file — then use cross-variant self-consistency for -n: it produces the +/// identical frames (same IP / Function per row) but with the source annotation stripped. +/// +/// Two harness fidelity fixes make source lines resolve on every config: the dbgeng host now enables +/// SYMOPT_LOAD_LINES (.lines -e), and net48 targets emit a full Windows PDB (see +/// testtargets/Directory.Build.props) — without these, cdb / desktop showed no [file @ line]. +/// +public sealed class ClrStackLinesTests +{ + private sealed record Frame(string Function, string SourceFile); + + // Hardcoded like the legacy scripts: distinctive method substrings expected on each target's stack + // (in caller order) and the source file each resolves to. + private static IReadOnlyList ExpectedFrames(string target) => target switch + { + TargetCatalog.SimpleThrow => + [new("UseObject", "UserObject.cs"), new("Simple.Main", "SimpleThrow.cs")], + TargetCatalog.LineNums => + [new(".Bar(", "Program.cs"), new(".Foo(", "Program.cs"), new(".Main(", "Program.cs")], + TargetCatalog.DivZero => + [new(".DivideByZero(", "DivZero.cs"), new(".F3(", "DivZero.cs"), new(".F2(", "DivZero.cs")], + TargetCatalog.NestedException => + [new(".Main(", "NestedExceptionTest.cs")], + TargetCatalog.Scenarios => + [new(".ArgsLocalsMethod(", "SosHarnessScenarios.cs"), new(".Main(", "SosHarnessScenarios.cs")], + _ => throw new ArgumentOutOfRangeException(nameof(target), target, "no expected frames"), + }; + + public static TheoryData Matrix { get; } + = TestMatrices.StackWalk( + [ + TargetCatalog.SimpleThrow, + TargetCatalog.LineNums, + TargetCatalog.DivZero, + TargetCatalog.NestedException, + TargetCatalog.Scenarios, + ]); + + [SosTheory] + [MemberData(nameof(Matrix))] + public async Task ClrStack_SourceLines(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + if (config.Target == TargetCatalog.Scenarios) + { + target.GoToStopPoint(TargetCatalog.StopArgsLocals); + } + else + { + target.GoToFirstStop(); + } + + SosTable plain = target.Clrstack(); + + // The expected managed methods appear, in caller order, each resolving to its source file with + // a real (positive) line number. + int searchFrom = 0; + foreach (Frame expected in ExpectedFrames(config.Target)) + { + int at = IndexOfFrame(plain, expected.Function, searchFrom); + Assert.True(at >= 0, $"Expected frame '{expected.Function}' at/after row {searchFrom} in:\n{Dump(plain)}"); + SosRow row = plain.Row(at); + + Assert.Equal(expected.SourceFile, Path.GetFileName(row["SourceFile"].Value)); + Assert.True(row["LineNumber"].AsInt32(Sos.Integer) > 0, $"Expected a positive line number for '{expected.Function}'."); + searchFrom = at + 1; + } + + // -n produces the identical frames (same count, same IP and Function per row) but with the + // source annotation suppressed everywhere. + SosTable noLines = target.Clrstack(suppressLines: true); + Assert.Equal(plain.Length, noLines.Length); + for (int i = 0; i < plain.Length; i++) + { + Assert.Equal(plain.Row(i)["IP"].Value, noLines.Row(i)["IP"].Value); + Assert.Equal(plain.Row(i)["Function"].Value, noLines.Row(i)["Function"].Value); + } + + Assert.All(noLines, r => + { + Assert.Empty(r["SourceFile"].Value); + Assert.Empty(r["LineNumber"].Value); + }); + + // Sanity: plain really did carry source info (so the -n strip above is meaningful). + Assert.Contains(plain, r => r["SourceFile"].Value.Length > 0); + } + + private static int IndexOfFrame(SosTable table, string functionSubstring, int from) + { + for (int i = from; i < table.Length; i++) + { + SosRow row = table.Row(i); + if (!row["InternalFrame"].AsBoolean() && row["Function"].Value.Contains(functionSubstring, StringComparison.Ordinal)) + return i; + } + + return -1; + } + + private static string Dump(SosTable table) => + string.Join("\n", table.Select(r => $"{r["Child SP"].Value} {r["IP"].Value} {r["Call Site"].Value}")); +} diff --git a/src/tests/SOS.Tests/ClrStackTests.cs b/src/tests/SOS.Tests/ClrStackTests.cs new file mode 100644 index 0000000000..b9da87f04c --- /dev/null +++ b/src/tests/SOS.Tests/ClrStackTests.cs @@ -0,0 +1,198 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +/// +/// Coverage for !clrstack -r (register display). The legacy SOS scripts only verified the +/// shape of the output (the "OS Thread Id" banner, the "Child SP / IP / Call Site" header, +/// and that each frame prints a register block). We assert the shape too (see +/// ), then go further and check values: +/// SOS fills the table's Child SP / IP columns from the same per-frame register context it dumps +/// (strike.cpp GetFrameLocation / PrintManagedFrameContext read one context with +/// GetFullContextFlags). So for every frame the IP column must equal the instruction-pointer +/// register (rip/eip/pc), and for every real (non-internal) managed frame the Child SP column must +/// equal the stack-pointer register (rsp/esp/sp). Internal clr!Frames report the Frame address as +/// Child SP instead of the context SP, so only their IP is matched. +/// +public sealed class ClrStackTests +{ + // Instruction-pointer / stack-pointer register names across the platforms SOS prints in + // strike.cpp PrintManagedFrameContext: amd64 rip/rsp, x86 eip/esp, arm & arm64 pc/sp. + private static readonly string[] s_ipRegisters = ["rip", "eip", "pc"]; + private static readonly string[] s_spRegisters = ["rsp", "esp", "sp"]; + + // The legacy !clrstack -r scripts ran against NestedExceptionTest (StackTests.script), + // SymbolTestApp (StackAndOtherTests.script), and WebApp3 (WebApp.script). SymbolTestApp and + // WebApp3 are deferred (multi-assembly / ASP.NET+DualRuntimes). Since -r is target-agnostic, we + // exercise it over a diverse set of already-ported debuggees with deliberately different stack + // shapes / stop kinds, so the SP/IP <-> register invariant is checked across varied frames: + // NestedException - unhandled exception crash (the real -r debuggee) + // DivZero - hardware fault (div by zero), deep non-async call chain + // AsyncMain - async state-machine frames + // DynamicMethod - a dynamic (IL-emitted) method on the stack + // Scenarios - snapshot (marker) stop rather than a crash + public static TheoryData RegistersMatrix { get; } + = TestMatrices.StackWalk( + [ + TargetCatalog.NestedException, + TargetCatalog.DivZero, + TargetCatalog.AsyncMain, + TargetCatalog.DynamicMethod, + TargetCatalog.Scenarios, + ], + // Live opt-in: !clrstack is one of the few commands that genuinely exercises a different path + // live (it unwinds a live thread's stack and reads its register context), so this base + // stackwalk runs dump AND live. Most other clrstack variations stay dump-only. + liveness: Liveness.AllValid); + + [SosTheory] + [MemberData(nameof(RegistersMatrix))] + public async Task ClrStack_Registers(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToFirstStop(); + + SosTable table = target.ClrstackRegisters(); + + bool sawManagedFrame = false; + foreach (SosRow row in table) + { + // The IP column is filled from the frame context's instruction pointer, which -r also + // dumps as a register column, so they must agree for every frame (internal frames included). + ulong ip = row["IP"].AsUInt64(Sos.Addr); + ulong ipRegister = Register(row, s_ipRegisters); + Assert.Equal(ip, ipRegister); + + // Internal clr!Frames report the Frame address as Child SP (not the context SP), so only + // real managed frames have a Child SP that equals the stack-pointer register. + bool internalFrame = row["InternalFrame"].AsBoolean(); + if (!internalFrame) + { + sawManagedFrame = true; + ulong sp = row["Child SP"].AsUInt64(Sos.Addr); + ulong spRegister = Register(row, s_spRegisters); + Assert.Equal(sp, spRegister); + } + } + + Assert.True(sawManagedFrame, "clrstack -r produced no non-internal managed frame to match Child SP against."); + } + + // The value of the row's register column whose name is one of . + private static ulong Register(SosRow row, string[] names) + { + string? name = names.FirstOrDefault(row.HasColumn); + Assert.NotNull(name); + return row[name!].AsUInt64(Sos.Addr); + } + + // !clrstack -gc is not covered by the legacy scripts at all. It must be exercised over stops that + // hold object references in ordinary live locals: whether an object is a GC-reported *stack* root + // depends on the capture point, so crash/throw targets are unreliable (e.g. an exception is only a + // -gc root while the runtime's managed dispatch frame is live, which createdump captures but a + // 2nd-chance/dbgeng capture does not — dso still finds it by scanning stack memory, but -gc + // legitimately reports nothing). The Scenarios marker stops avoid that; we use two with different + // root shapes, both BEFORE any GC.Collect so the live path is safe: + // roots - a normal object + a pinned byte[] + an interior int[] ref live across the marker + // argslocals - a uniquely-typed reference arg and local live across the marker + public static TheoryData GcRootsMatrix { get; } = BuildGcRootsMatrix(); + + // The stop point is an extra (non-axis) column paired with each config, so this is built by hand from + // the raw config permutations rather than the single-column BuildMatrix. + private static TheoryData BuildGcRootsMatrix() + { + TheoryData data = new(); + // Live opt-in: !clrstack -gcroots is fundamentally different from !clrstack (it scans the live + // stack and registers for GC-reported roots), so it runs dump AND live. + foreach (TestConfig config in TestMatrices.StackWalkConfigs([TargetCatalog.Scenarios], liveness: Liveness.AllValid)) + { + data.Add(config, TargetCatalog.StopRoots); + data.Add(config, TargetCatalog.StopArgsLocals); + } + + return data; + } + + [SosTheory] + [MemberData(nameof(GcRootsMatrix))] + public async Task ClrStack_GcRoots(TestConfig config, string stopName) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToStopPoint(stopName); + + // The objects dumpstackobjects finds by scanning stack memory. + SosTable dsoTable = target.DumpStackObjects(); + HashSet dsoObjects = dsoTable.Select(r => r["Object"].AsUInt64(Sos.Addr)).ToHashSet(); + + SosTable gc = target.ClrstackGcRoots(); + + int compared = 0; + foreach (SosRow frame in gc) + { + foreach (SosDataRow root in frame.Data) + { + // Pinned / Interior are always present (default False even when not printed). + bool interior = root["Interior"].AsBoolean(); + _ = root["Pinned"].AsBoolean(); + ulong obj = root["Object"].AsUInt64(Sos.Addr); + + // A non-interior, non-null root that lives in a stack slot holds an object pointer in + // stack memory, so dumpstackobjects (which scans that memory) must have found it too. + // Interior pointers don't point at an object head, so dso won't list them — skip them. + if (interior || obj == 0 || root["Address"].Value.Length == 0) + continue; + + Assert.Contains(obj, dsoObjects); + compared++; + } + } + + Assert.True(compared > 0, "clrstack -gc produced no non-interior stack-slot roots to compare against dumpstackobjects."); + } + + // GcRoots is the dedicated target that deliberately keeps one of each root flavor alive across + // the marker, so the parser's handling of the optional (pinned)/(interior) flags and the + // sometimes-absent type is actually exercised — and so we can assert the SosDataRow always + // carries Pinned/Interior (defaulting to False when the flag isn't printed). + public static TheoryData GcRootsFlagMatrix { get; } = TestMatrices.StackWalkFullDumpOnCoreVersions([TargetCatalog.Scenarios], CoreVersion.Net8 | CoreVersion.Net9 | CoreVersion.Net10); + + [SosTheory] + [MemberData(nameof(GcRootsFlagMatrix))] + public async Task ClrStack_GcRoots_Flags(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToStopPoint("roots"); + + SosTable gc = target.ClrstackGcRoots(); + List roots = gc.SelectMany(f => f.Data).ToList(); + Assert.NotEmpty(roots); + + // Every record always carries both flag fields (False when SOS didn't print the flag). + Assert.All(roots, r => + { + Assert.True(r.Has("Pinned")); + Assert.True(r.Has("Interior")); + }); + + // The fixed byte[] is a pinned root: (pinned), not interior, and SOS still prints its type + // (it points at the object head, so it isn't interior). + SosDataRow pinned = roots.AssertSingle(r => r["Pinned"].AsBoolean(), "a pinned root"); + Assert.False(pinned["Interior"].AsBoolean()); + Assert.NotEqual(0ul, pinned["Object"].AsUInt64(Sos.Addr)); + Assert.Contains("Byte[]", pinned["Type"].Value); + + // The ref into the int[] is an interior root: (interior), and SOS prints no type for it + // (an interior pointer doesn't point at an object head). + roots.AssertContains(r => r["Interior"].AsBoolean() && r["Type"].Value.Length == 0, "an interior root with no Type"); + + // A normal object root: neither flag set, and a type is present. + roots.AssertContains( + r => !r["Pinned"].AsBoolean() && !r["Interior"].AsBoolean() && + r["Object"].AsUInt64(Sos.Addr) != 0 && r["Type"].Contains("System.Object"), + "a normal System.Object root (neither pinned nor interior)"); + } +} diff --git a/src/tests/SOS.Tests/ClrThreadsParsing.cs b/src/tests/SOS.Tests/ClrThreadsParsing.cs new file mode 100644 index 0000000000..bd1ffadeda --- /dev/null +++ b/src/tests/SOS.Tests/ClrThreadsParsing.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; + +namespace SOS.Tests; + +/// +/// Parser for the tabular section of !clrthreads. The generic +/// can't read it: SOS prints the header across two physical lines (the "Lock" of the "Lock Count" +/// column floats on the line above "Count" — see strike.cpp's +/// WriteColumn(8, "Lock") before the WriteRow(...)), and the trailing Exception +/// column is empty for most threads, which would make the fixed-width row detector reject every row. +/// +/// Every column up to Apt is a single whitespace token (the GC Alloc Context is +/// addr:addr with no space), so we tokenize each data line: the first N tokens are the fixed +/// columns and everything after is the free-form Exception column (thread tags like +/// (Finalizer)/(GC)/(Threadpool Worker) and any last-thrown exception). +/// +internal static class ClrThreadsParsing +{ + // Columns in SOS print order (strike.cpp PrintThreadsFromThreadStore). "Lock Count" rejoins the + // two-line header; "Exception" is the trailing free-form column. + private static readonly string[] s_columns = + ["DBG", "ID", "OSID", "ThreadOBJ", "State", "GC Mode", "GC Alloc Context", "Domain", "Lock Count", "Apt", "Exception"]; + + // When the runtime is hosted, SOS inserts a "Fiber" column before "Exception". + private static readonly string[] s_hostedColumns = + ["DBG", "ID", "OSID", "ThreadOBJ", "State", "GC Mode", "GC Alloc Context", "Domain", "Lock Count", "Apt", "Fiber", "Exception"]; + + /// + /// Parse the !clrthreads thread table into a . Rows are addressable by + /// column name (row["GC Mode"], row["Lock Count"], row["Exception"]); cells + /// convert implicitly to their string value, so row["GC Mode"] == "Preemptive" works. + /// + public static SosTable AsThreadsTable(this SosOutput output) + { + string[] columns = IsHosted(output) ? s_hostedColumns : s_columns; + int fixedCount = columns.Length - 1; // everything before the trailing Exception column + + IReadOnlyList lines = output.Lines; + int header = FindHeaderLine(lines); + if (header < 0) + { + throw output.Fail("a !clrthreads thread table (a header row with DBG / ThreadOBJ / Apt)"); + } + + List rows = new(); + for (int i = header + 1; i < lines.Count; i++) + { + string line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) + { + break; // blank line terminates the table + } + + string[] tokens = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < fixedCount) + { + break; // not a data row — end of the table + } + + string[] cells = new string[columns.Length]; + for (int c = 0; c < fixedCount; c++) + { + cells[c] = tokens[c]; + } + + // The remaining tokens (if any) are the free-form Exception column; empty when absent. + cells[fixedCount] = tokens.Length > fixedCount + ? string.Join(' ', tokens[fixedCount..]) + : string.Empty; + + rows.Add(cells); + } + + return output.TableFromRows(columns, rows); + } + + private static bool IsHosted(SosOutput output) + { + foreach (string line in output.Lines) + { + string t = line.Trim(); + if (t.StartsWith("Hosted Runtime:", StringComparison.Ordinal)) + { + return t["Hosted Runtime:".Length..].Trim() != "no"; + } + } + + return false; + } + + private static int FindHeaderLine(IReadOnlyList lines) + { + for (int i = 0; i < lines.Count; i++) + { + string t = lines[i].TrimStart(); + if (t.StartsWith("DBG", StringComparison.Ordinal) && + lines[i].Contains("ThreadOBJ", StringComparison.Ordinal) && + lines[i].Contains("Apt", StringComparison.Ordinal)) + { + return i; + } + } + + return -1; + } +} diff --git a/src/tests/SOS.Tests/ClrThreadsTests.cs b/src/tests/SOS.Tests/ClrThreadsTests.cs new file mode 100644 index 0000000000..9e70d15b63 --- /dev/null +++ b/src/tests/SOS.Tests/ClrThreadsTests.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +/// +/// clrthreads across the full host × target × flavor matrix, with the assertion inline. +/// Demonstrates the cross-product MemberData shape and shared-dump reuse. This works uniformly +/// (including desktop .NET Framework under cdb) because the cdb host unloads any SOS cdb +/// auto-loaded and forces OUR modern SOS - which exposes clrthreads on every flavor. +/// (If we let cdb's auto-loaded desktop Framework SOS answer, it would only expose threads.) +/// +public sealed class ClrThreadsTests +{ + /// + /// A matrix of all combinations of hosts, targets, and flavors. + /// Hosts.DumpHosts = [cdb, dotnet-dump] || [lldb, dotnet-dump] + /// Targets = debuggee targets to test + /// Flavors = e.g. [Flavor.Core, Flavor.SingleFile, Flavor.Framework] + /// + public static TheoryData Matrix { get; } + // Live opt-in: !clrthreads enumerates the live thread list, so this base test runs + // dump AND live as the representative live-thread-enumeration check. + = TestConfig.BuildMatrix( + [TargetCatalog.NestedException, TargetCatalog.Scenarios], + liveness: Liveness.AllValid, + dumpKind: DumpKind.All); + + [SosTheory] + [MemberData(nameof(Matrix))] + public async Task ClrThreads_ReportsThreadCount(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToFirstStop(); + + SosOutput clrthreads = target.Sos("clrthreads"); + + clrthreads["ThreadCount"].AssertValid(Sos.Dec); + Assert.NotEqual(0u, clrthreads["ThreadCount"].AsUInt32(Sos.Dec)); + + SosTable table = clrthreads.AsThreadsTable(); + Assert.NotEmpty(table); + + // The structured ThreadCount field equals the number of rows the table parsed (ties the + // summary field to the per-thread rows). + Assert.Equal(clrthreads["ThreadCount"].AsInt32(Sos.Dec), table.Length); + + // Every fixed column sliced into the right shape — proof the two-line "Lock Count" header and + // the column alignment were handled. A misaligned column would put a non-address in ThreadOBJ, + // or something other than the two GC modes / known apartment states in their columns. + table.AssertAll(row => Sos.Addr.Matches(row["ThreadOBJ"]), "ThreadOBJ is an address"); + table.AssertAll(row => row["GC Mode"] == "Preemptive" || row["GC Mode"] == "Cooperative", "GC Mode is Preemptive or Cooperative"); + table.AssertAll(row => row["Apt"].Value is "MTA" or "STA" or "NTA" or "Ukn", "Apt is a known apartment state"); + + // The trailing free-form Exception column parses: every managed process has a finalizer thread, + // tagged "(Finalizer)" there. + table.AssertContainsRow(row => row["Exception"].Contains("Finalizer"), "the (Finalizer) thread is tagged in the Exception column"); + } +} diff --git a/src/tests/SOS.Tests/ClrUTests.cs b/src/tests/SOS.Tests/ClrUTests.cs new file mode 100644 index 0000000000..32a8acc11d --- /dev/null +++ b/src/tests/SOS.Tests/ClrUTests.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +/// +/// !clru (alias of !u) annotated disassembly and all of its parameters. clru depends on the +/// debugger's native disassembler, which only the dbgeng (cdb) host provides — dotnet-dump reports it as an +/// unrecognized command — so this runs on a cdb-only matrix. The disassembly is architecture-specific, so +/// the assertions are structural (the banner, method name, Begin/size, a non-empty instruction list, +/// source-line presence) plus the interleaving that -gcinfo/-ehinfo/-il add — which is +/// cross-checked against the standalone !gcinfo/!ehinfo output. +/// +public sealed class ClrUTests +{ + public static TheoryData Matrix => TestConfig.BuildMatrix([TargetCatalog.Scenarios], Flavor.AllValid, Host.Cdb); + + // !clru's IL/source-line/EH interleaving relies on per-method debug info the DAC only sees in a Full + // dump on net8-net10 (present in Heap dumps from net11 on); capture Full there. AcceptsInstructionPointer + // needs none of that, so it stays on the default Heap Matrix. + public static TheoryData FullDumpMatrix => TestMatrices.FullDumpOnCoreVersions([TargetCatalog.Scenarios], CoreVersion.Net8 | CoreVersion.Net9 | CoreVersion.Net10, Flavor.AllValid, Host.Cdb); + + [WindowsTheory] + [MemberData(nameof(FullDumpMatrix))] + public async Task ClrU_StructureLinesOffsets(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToStopPoint(TargetCatalog.StopHeap); + EEMatch atHeap = Method(target, config.Flavor, "AtHeap"); + + // Default: banner, method name, Begin/size, a real instruction stream, and source annotations. + ClrUResult plain = target.ClrU(atHeap.MethodDesc!.Value); + Assert.True(plain.HasNormalJitBanner); + Assert.Contains("AtHeap", plain.MethodName, StringComparison.Ordinal); + Assert.NotEqual(0ul, plain.Begin); + Assert.True(plain.Size > 0); + Assert.NotEmpty(plain.Instructions); + Assert.True(plain.SourceLineCount > 0, "expected source-line annotations by default"); + Assert.False(plain.HasOffsets); + + // -n suppresses the source annotations but still disassembles. + ClrUResult noLines = target.ClrU(atHeap.MethodDesc.Value, noLines: true); + Assert.Equal(0, noLines.SourceLineCount); + Assert.NotEmpty(noLines.Instructions); + + // -o prefixes every instruction with its offset. + ClrUResult offsets = target.ClrU(atHeap.MethodDesc.Value, offsets: true); + Assert.True(offsets.HasOffsets); + Assert.Equal(0, offsets.Instructions[0].Offset); + } + + [WindowsTheory] + [MemberData(nameof(FullDumpMatrix))] + public async Task ClrU_InterleavesGcInfoEhInfoIl(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToStopPoint(TargetCatalog.StopHeap); + EEMatch atHeap = Method(target, config.Flavor, "AtHeap"); + EEMatch lockHolder = Method(target, config.Flavor, "LockHolder"); + + // -il interleaves the MSIL. + Assert.Contains("IL_", target.ClrU(atHeap.MethodDesc!.Value, il: true).Output.Text, StringComparison.Ordinal); + + // -gcinfo interleaves the same interruptibility info that standalone gcinfo prints. + ClrUResult withGc = target.ClrU(atHeap.MethodDesc.Value, gcInfo: true); + Assert.Contains("interruptible", withGc.Output.Text, StringComparison.Ordinal); + Assert.NotEmpty(withGc.Instructions); // still a real disassembly, not just the gc dump + Assert.NotEmpty(target.GcInfo(atHeap.MethodDesc.Value).Transitions); + + // -ehinfo interleaves the EH clause markers of a method that has a (finally) handler. + ClrUResult withEh = target.ClrU(lockHolder.MethodDesc!.Value, ehInfo: true); + Assert.Contains("EHHandler", withEh.Output.Text, StringComparison.Ordinal); + Assert.Contains("FINALLY", withEh.Output.Text, StringComparison.Ordinal); + Assert.Contains(target.EhInfo(lockHolder.MethodDesc.Value).Clauses, c => c.Kind.Contains("FINALLY", StringComparison.Ordinal)); + } + + [WindowsTheory] + [MemberData(nameof(Matrix))] + public async Task ClrU_AcceptsInstructionPointer(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToStopPoint(TargetCatalog.StopHeap); + EEMatch atHeap = Method(target, config.Flavor, "AtHeap"); + + // clru disassembles the same method whether given its MethodDesc or an IP inside it. + ClrUResult byMd = target.ClrU(atHeap.MethodDesc!.Value); + ClrUResult byIp = target.ClrU(atHeap.JittedCodeAddress!.Value); + Assert.Equal(byMd.Begin, byIp.Begin); + Assert.Equal(byMd.MethodName, byIp.MethodName); + } + + private static EEMatch Method(Target target, Flavor flavor, string name) + { + string module = TargetCatalog.Get(TargetCatalog.Scenarios).ModuleFor(flavor); + return target.Name2EE($"{module}!SosHarnessScenarios.{name}").Single; + } +} diff --git a/src/tests/SOS.Tests/CodeCommandParsing.cs b/src/tests/SOS.Tests/CodeCommandParsing.cs new file mode 100644 index 0000000000..b3c69066d1 --- /dev/null +++ b/src/tests/SOS.Tests/CodeCommandParsing.cs @@ -0,0 +1,291 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text.RegularExpressions; +using SOS.TestHarness; + +namespace SOS.Tests; + +/// +/// Structured parsers for the code-inspection SOS commands: !clru/!u (annotated +/// disassembly), !ehinfo, !gcinfo, and the related !dumpil (MSIL). The disassembly +/// itself is architecture-specific, so models only the stable structure (the +/// "Normal JIT generated code" banner, method name, Begin/size, the instruction lines, source-line +/// annotations, and the markers that -gcinfo/-ehinfo/-il interleave). Behaviour follows +/// the native implementations in src/SOS/Strike/strike.cpp (DECLARE_API(u/EHInfo/GCInfo/DumpIL)). +/// clru needs the debugger's native disassembler, so it exists only on the dbgeng (cdb) host. +/// +internal static class CodeCommandParsing +{ + public static ClrUResult ClrU( + this Target target, ulong address, bool noLines = false, bool offsets = false, + bool gcInfo = false, bool ehInfo = false, bool il = false, bool map = false) + { + string flags = string.Concat( + noLines ? "-n " : "", offsets ? "-o " : "", gcInfo ? "-gcinfo " : "", + ehInfo ? "-ehinfo " : "", il ? "-il " : "", map ? "-map " : ""); + return new ClrUResult(target.Sos($"clru {flags}{address:x}")); + } + + public static EhInfoResult EhInfo(this Target target, ulong address) => + new(target.Sos($"ehinfo {address:x}")); + + public static GcInfoResult GcInfo(this Target target, ulong address) => + new(target.Sos($"gcinfo {address:x}")); + + public static DumpIlResult DumpIl(this Target target, ulong address, bool ilPointer = false) => + new(target.Sos(ilPointer ? $"dumpil -i {address:x}" : $"dumpil {address:x}")); + + internal static ulong Hex(string value) => + ulong.Parse(value.Replace("`", string.Empty), NumberStyles.HexNumber, CultureInfo.InvariantCulture); +} + +/// One disassembled instruction line from !clru. +public readonly record struct DisasmLine(int? Offset, ulong Address, string Bytes, string Mnemonic, string Operands); + +/// Parsed !clru / !u output. Disassembly is arch-specific so only the stable +/// structure is modelled; is kept for interleave-marker assertions. +public sealed class ClrUResult +{ + private static readonly Regex s_begin = + new(@"^Begin\s+([0-9a-fA-F`]+),\s+size\s+([0-9a-fA-F]+)\s*$", RegexOptions.Compiled); + private static readonly Regex s_source = new(@"@\s+(\d+):\s*$", RegexOptions.Compiled); + private static readonly Regex s_instruction = new( + @"^(?:(?[0-9a-fA-F]{4,8})\s+)?(?[0-9a-fA-F]+`[0-9a-fA-F]+)\s+(?[0-9a-fA-F]+)\s+(?\S+)(?:\s+(?.*\S))?\s*$", + RegexOptions.Compiled); + + public ClrUResult(SosOutput output) + { + Output = output; + HasNormalJitBanner = output.Contains("Normal JIT generated code"); + + List instructions = new(); + int sourceLines = 0; + foreach (string raw in output.Lines) + { + string line = raw.TrimEnd(); + + Match b = s_begin.Match(line); + if (b.Success) + { + Begin = CodeCommandParsing.Hex(b.Groups[1].Value); + Size = (int)CodeCommandParsing.Hex(b.Groups[2].Value); + continue; + } + + if (s_source.IsMatch(line)) + { + sourceLines++; + continue; + } + + Match m = s_instruction.Match(line); + if (m.Success) + { + int? off = m.Groups["off"].Success ? (int)CodeCommandParsing.Hex(m.Groups["off"].Value) : null; + instructions.Add(new DisasmLine( + off, + CodeCommandParsing.Hex(m.Groups["addr"].Value), + m.Groups["bytes"].Value, + m.Groups["mn"].Value, + m.Groups["ops"].Value.Trim())); + continue; + } + + // The method name is the non-empty line that precedes Begin and isn't the banner. + if (line.Length > 0 && Begin == 0 && line != "Normal JIT generated code") + { + MethodName = line.Trim(); + } + } + + Instructions = instructions; + SourceLineCount = sourceLines; + } + + public SosOutput Output { get; } + public bool HasNormalJitBanner { get; } + public string MethodName { get; private set; } = string.Empty; + public ulong Begin { get; private set; } + public int Size { get; private set; } + public IReadOnlyList Instructions { get; } + + /// How many file @ line: source annotations were printed (0 with -n). + public int SourceLineCount { get; } + + /// True if the instruction lines carry the -o offset prefix. + public bool HasOffsets => Instructions.Count > 0 && Instructions.All(i => i.Offset is not null); +} + +/// One exception-handling clause from !ehinfo. +public readonly record struct EhClause(int Index, string Kind, ulong ClauseStart, ulong ClauseEnd, ulong HandlerStart, ulong HandlerEnd); + +/// Parsed !ehinfo output: the method identity plus its EH clauses (empty for a method with +/// no try/catch). +public sealed class EhInfoResult +{ + private static readonly Regex s_handler = new(@"^EHHandler\s+(\d+):\s+(?.+?)\s*$", RegexOptions.Compiled); + private static readonly Regex s_range = + new(@"^(?