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("", slotEnd, StringComparison.Ordinal);
+ if (valEnd < 0)
+ {
+ return;
+ }
+
+ into[slot] = line.Substring(slotEnd + 1, valEnd - slotEnd - 1).Trim();
+ }
+
+ private static CoreVersion ReadSupportedFrameworksFromProps()
+ {
+ CoreVersion result = 0;
+ string props = Path.Combine(RepoLayout.Root, "Directory.Build.props");
+ if (File.Exists(props))
+ {
+ foreach (string line in File.ReadLines(props))
+ {
+ const string tag = "";
+ int open = line.IndexOf(tag, StringComparison.Ordinal);
+ if (open < 0)
+ {
+ continue;
+ }
+
+ int close = line.IndexOf("", open, StringComparison.Ordinal);
+ string value = line.Substring(open + tag.Length, close - open - tag.Length);
+ foreach (string tfm in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase) &&
+ tfm.EndsWith(".0", StringComparison.Ordinal) &&
+ int.TryParse(tfm.AsSpan(3, tfm.Length - 5), out int major))
+ {
+ result |= (CoreVersion)(1u << major);
+ }
+ }
+
+ break;
+ }
+ }
+
+ // Last-ditch default matching the current servicing+preview set.
+ return result != 0 ? result : CoreVersion.Net8 | CoreVersion.Net9 | CoreVersion.Net10 | CoreVersion.Net11;
+ }
+}
diff --git a/src/tests/SOS.TestHarness/Dac.cs b/src/tests/SOS.TestHarness/Dac.cs
new file mode 100644
index 0000000000..63a6c0d2e5
--- /dev/null
+++ b/src/tests/SOS.TestHarness/Dac.cs
@@ -0,0 +1,28 @@
+// 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;
+
+///
+/// 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(@"^(?