diff --git a/src/SOS/Strike/util.cpp b/src/SOS/Strike/util.cpp
index 1740882702..d55df1a6db 100644
--- a/src/SOS/Strike/util.cpp
+++ b/src/SOS/Strike/util.cpp
@@ -3148,7 +3148,7 @@ BOOL GetSOSVersion(VS_FIXEDFILEINFO *pFileInfo)
UINT uLen = 0;
if (VerQueryValueA(pVersionInfo, "\\", (LPVOID *) &pTmpFileInfo, &uLen))
{
- if (pFileInfo->dwFileVersionMS == (DWORD)-1) {
+ if (pTmpFileInfo->dwFileVersionMS == (DWORD)-1) {
return FALSE;
}
*pFileInfo = *pTmpFileInfo; // Copy the info
diff --git a/src/tests/Debuggees.proj b/src/tests/Debuggees.proj
index ed5a0abc50..fa798ad11b 100644
--- a/src/tests/Debuggees.proj
+++ b/src/tests/Debuggees.proj
@@ -19,6 +19,8 @@
+
+
+
+
+ <_SOSFrameworkPlatformProperty Condition="'$(TargetArch)' == 'x86'">PlatformTarget=x86;
+
+
+
+
diff --git a/src/tests/SOS.TestHarness/BoundedProcess.cs b/src/tests/SOS.TestHarness/BoundedProcess.cs
new file mode 100644
index 0000000000..a256fc2080
--- /dev/null
+++ b/src/tests/SOS.TestHarness/BoundedProcess.cs
@@ -0,0 +1,139 @@
+// 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.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+
+namespace SOS.TestHarness;
+
+internal sealed record BoundedProcessResult(int ExitCode, string StandardOutput, string StandardError);
+
+internal static partial class BoundedProcess
+{
+ private const int KillSignal = 9;
+ private static readonly TimeSpan s_terminationTimeout = TimeSpan.FromSeconds(5);
+
+ public static BoundedProcessResult Run(
+ ProcessStartInfo startInfo,
+ TimeSpan timeout,
+ bool isolateLinuxProcessGroup = false,
+ TimeSpan? outputDrainTimeout = null)
+ {
+ if (!startInfo.RedirectStandardOutput || !startInfo.RedirectStandardError)
+ {
+ throw new ArgumentException("Standard output and standard error must both be redirected.", nameof(startInfo));
+ }
+
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeout, TimeSpan.Zero);
+
+ string command = $"{startInfo.FileName} {string.Join(' ', startInfo.ArgumentList)}".Trim();
+ bool hasLinuxProcessGroup = isolateLinuxProcessGroup && OperatingSystem.IsLinux();
+ if (hasLinuxProcessGroup)
+ {
+ WrapWithSetSid(startInfo);
+ }
+
+ using Process process = Process.Start(startInfo)
+ ?? throw new InvalidOperationException($"Failed to start '{command}'.");
+ int processGroupId = process.Id;
+ Task stdoutTask = process.StandardOutput.ReadToEndAsync();
+ Task stderrTask = process.StandardError.ReadToEndAsync();
+
+ if (!process.WaitForExit(TimeoutMilliseconds(timeout)))
+ {
+ Terminate(process, hasLinuxProcessGroup, processGroupId);
+ BoundedProcessResult output = DrainOutput(
+ process,
+ stdoutTask,
+ stderrTask,
+ command,
+ s_terminationTimeout);
+ throw new TimeoutException(
+ $"'{command}' did not exit within {timeout}.{Environment.NewLine}" +
+ $"stdout:{Environment.NewLine}{output.StandardOutput}{Environment.NewLine}" +
+ $"stderr:{Environment.NewLine}{output.StandardError}");
+ }
+
+ // Linux single-file dump helpers can survive the target while retaining its redirected handles.
+ // End the isolated group before waiting for stream EOF so those descendants cannot wedge drainage.
+ if (hasLinuxProcessGroup)
+ {
+ KillProcessGroup(processGroupId);
+ }
+
+ return DrainOutput(
+ process,
+ stdoutTask,
+ stderrTask,
+ command,
+ outputDrainTimeout ?? s_terminationTimeout);
+ }
+
+ private static BoundedProcessResult DrainOutput(
+ Process process,
+ Task stdoutTask,
+ Task stderrTask,
+ string command,
+ TimeSpan timeout)
+ {
+ Task outputTask = Task.WhenAll(stdoutTask, stderrTask);
+ if (!outputTask.Wait(timeout))
+ {
+ throw new TimeoutException(
+ $"'{command}' exited with code {process.ExitCode}, but its redirected output did not close " +
+ $"within {timeout}.");
+ }
+
+ return new BoundedProcessResult(
+ process.ExitCode,
+ stdoutTask.GetAwaiter().GetResult(),
+ stderrTask.GetAwaiter().GetResult());
+ }
+
+ private static void Terminate(Process process, bool hasLinuxProcessGroup, int processGroupId)
+ {
+ if (hasLinuxProcessGroup)
+ {
+ KillProcessGroup(processGroupId);
+ }
+
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ }
+
+ process.WaitForExit(TimeoutMilliseconds(s_terminationTimeout));
+ }
+
+ private static void WrapWithSetSid(ProcessStartInfo startInfo)
+ {
+ string setSid = File.Exists("/usr/bin/setsid") ? "/usr/bin/setsid" :
+ File.Exists("/bin/setsid") ? "/bin/setsid" :
+ throw new FileNotFoundException("Could not locate setsid for Linux process-group isolation.");
+
+ string executable = startInfo.FileName;
+ string[] arguments = startInfo.ArgumentList.ToArray();
+ startInfo.FileName = setSid;
+ startInfo.ArgumentList.Clear();
+ startInfo.ArgumentList.Add(executable);
+ foreach (string argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+ }
+
+ private static void KillProcessGroup(int processGroupId)
+ {
+ _ = KillUnix(-processGroupId, KillSignal);
+ }
+
+ private static int TimeoutMilliseconds(TimeSpan timeout) =>
+ (int)Math.Min(timeout.TotalMilliseconds, int.MaxValue);
+
+ [LibraryImport("libc", EntryPoint = "kill", SetLastError = true)]
+ private static partial int KillUnix(int processId, int signal);
+}
diff --git a/src/tests/SOS.TestHarness/ChildEngineClient.cs b/src/tests/SOS.TestHarness/ChildEngineClient.cs
index 3d1fa037d8..bc40f98bb0 100644
--- a/src/tests/SOS.TestHarness/ChildEngineClient.cs
+++ b/src/tests/SOS.TestHarness/ChildEngineClient.cs
@@ -23,6 +23,7 @@ public sealed class ChildEngineClient : ILiveDebuggerHost
private readonly StreamWriter _stdin;
private readonly BlockingCollection _lines = new();
private readonly Thread _reader;
+ private readonly Task _stderr;
public string Name { get; }
@@ -92,6 +93,7 @@ private ChildEngineClient(string name, string mode, IReadOnlyList modeAr
_process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start EngineHost");
_stdin = _process.StandardInput;
+ _stderr = _process.StandardError.ReadToEndAsync();
_reader = new Thread(ReadLoop) { IsBackground = true, Name = $"enginehost-reader-{name}" };
_reader.Start();
@@ -144,10 +146,10 @@ 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.");
- }
+ string line = ReadLine(
+ timeout,
+ "EngineHost did not become ready in time.",
+ "before becoming ready");
if (line == EngineProtocol.Ready)
{
@@ -161,10 +163,10 @@ 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}.");
- }
+ string line = ReadLine(
+ timeout,
+ $"EngineHost did not return output for '{command}' within {timeout}.",
+ $"while running '{command}'");
if (line == EngineProtocol.End)
{
@@ -186,12 +188,48 @@ private string DrainToEnd(TimeSpan timeout, string command)
return sb.ToString();
}
+ private string ReadLine(TimeSpan timeout, string timeoutMessage, string exitContext)
+ {
+ if (_lines.TryTake(out string? line, (int)timeout.TotalMilliseconds, HarnessCancellation.Token))
+ {
+ return line;
+ }
+
+ if (_lines.IsCompleted || _process.HasExited)
+ {
+ throw CreateEngineHostExitException(exitContext);
+ }
+
+ throw new TimeoutException(timeoutMessage);
+ }
+
+ private InvalidOperationException CreateEngineHostExitException(string context)
+ {
+ bool exited = _process.HasExited || _process.WaitForExit(1000);
+ string exitDescription = exited
+ ? $"exited with code {_process.ExitCode}"
+ : "closed its standard output";
+ string stderr = exited && _stderr.Wait(TimeSpan.FromSeconds(2))
+ ? _stderr.GetAwaiter().GetResult().Trim()
+ : string.Empty;
+ string details = stderr.Length == 0 ? string.Empty : $"{Environment.NewLine}{stderr}";
+
+ return new InvalidOperationException($"EngineHost {exitDescription} {context}.{details}");
+ }
+
private void ReadLoop()
{
- string? line;
- while ((line = _process.StandardOutput.ReadLine()) is not null)
+ try
+ {
+ string? line;
+ while ((line = _process.StandardOutput.ReadLine()) is not null)
+ {
+ _lines.Add(line);
+ }
+ }
+ finally
{
- _lines.Add(line);
+ _lines.CompleteAdding();
}
}
diff --git a/src/tests/SOS.TestHarness/DbgEngCapturer.cs b/src/tests/SOS.TestHarness/DbgEngCapturer.cs
index b526e17fd6..a3ec16c744 100644
--- a/src/tests/SOS.TestHarness/DbgEngCapturer.cs
+++ b/src/tests/SOS.TestHarness/DbgEngCapturer.cs
@@ -71,7 +71,7 @@ string Run(string command)
}
else // Crash
{
- RunToBreak(control, "second-chance crash");
+ RunToBreak(control, "second-chance crash", requireSecondChanceException: true);
}
Run($".dump /o {DbgEngDumpOption(dumpKind)} \"{dumpPath}\"");
@@ -102,7 +102,7 @@ string Run(string command)
_ => throw new ArgumentOutOfRangeException(nameof(dumpKind), dumpKind, "Unsupported dump kind"),
};
- private static void RunToBreak(IDebugControl control, string what)
+ private static void RunToBreak(IDebugControl control, string what, bool requireSecondChanceException = false)
{
const int MaxResumes = 40;
for (int i = 0; i < MaxResumes; i++)
@@ -111,7 +111,10 @@ private static void RunToBreak(IDebugControl control, string what)
control.WaitForEvent(TimeSpan.FromSeconds(60));
control.GetExecutionStatus(out DEBUG_STATUS status);
- if (status == DEBUG_STATUS.BREAK)
+ if (status == DEBUG_STATUS.BREAK
+ && (!requireSecondChanceException
+ || (control.GetLastEvent(out DEBUG_LAST_EVENT_INFO_EXCEPTION exception, out _, out _)
+ && exception.FirstChance == 0)))
{
return;
}
diff --git a/src/tests/SOS.TestHarness/DbgEngLiveHost.cs b/src/tests/SOS.TestHarness/DbgEngLiveHost.cs
index a6012fc009..5d88cf0cf4 100644
--- a/src/tests/SOS.TestHarness/DbgEngLiveHost.cs
+++ b/src/tests/SOS.TestHarness/DbgEngLiveHost.cs
@@ -114,7 +114,9 @@ public SosOutput RunToCrash()
Control.WaitForEvent(TimeSpan.FromSeconds(60));
Control.GetExecutionStatus(out DEBUG_STATUS status);
- if (status == DEBUG_STATUS.BREAK)
+ if (status == DEBUG_STATUS.BREAK
+ && Control.GetLastEvent(out DEBUG_LAST_EVENT_INFO_EXCEPTION exception, out _, out _)
+ && exception.FirstChance == 0)
{
return; // second-chance crash break
}
diff --git a/src/tests/SOS.TestHarness/DumpGenerationRequirements.cs b/src/tests/SOS.TestHarness/DumpGenerationRequirements.cs
index f80ce54ddd..3c26ee5619 100644
--- a/src/tests/SOS.TestHarness/DumpGenerationRequirements.cs
+++ b/src/tests/SOS.TestHarness/DumpGenerationRequirements.cs
@@ -34,8 +34,7 @@ namespace SOS.TestHarness;
///
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 SettingsNode = @"SOFTWARE\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).
@@ -68,7 +67,7 @@ internal static DumpKind ResolveCaptureKind(Flavor flavor, DumpKind dumpKind)
if (dumpKind == DumpKind.Mini)
{
HarnessSkipException.Now(
- $@"Mini dump capture requires HKLM\{s_settingsNode}\{DisableCheckValue}=1 so dbghelp can " +
+ $@"Mini dump capture requires HKLM\{SettingsNode}\{DisableCheckValue}=1 so dbghelp can " +
"load the unsigned test DAC");
}
@@ -90,7 +89,10 @@ private static bool ReadSignatureCheckDisabledWindows()
{
try
{
- using RegistryKey? key = Registry.LocalMachine.OpenSubKey(s_settingsNode);
+ using RegistryKey localMachine = RegistryKey.OpenBaseKey(
+ RegistryHive.LocalMachine,
+ RegistryViewForProcess(Environment.Is64BitProcess));
+ using RegistryKey? key = localMachine.OpenSubKey(SettingsNode);
return key?.GetValue(DisableCheckValue) is int value && value == 1;
}
catch (Exception ex) when (ex is SecurityException or UnauthorizedAccessException or IOException)
@@ -98,4 +100,8 @@ private static bool ReadSignatureCheckDisabledWindows()
return false;
}
}
+
+ [SupportedOSPlatform("windows")]
+ internal static RegistryView RegistryViewForProcess(bool is64BitProcess) =>
+ is64BitProcess ? RegistryView.Registry64 : RegistryView.Registry32;
}
diff --git a/src/tests/SOS.TestHarness/DumpSession.cs b/src/tests/SOS.TestHarness/DumpSession.cs
index 3f0c842650..3d788f78c7 100644
--- a/src/tests/SOS.TestHarness/DumpSession.cs
+++ b/src/tests/SOS.TestHarness/DumpSession.cs
@@ -21,18 +21,20 @@ namespace SOS.TestHarness;
/// - 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).
+/// - lldb children retain their loaded core and hosted SOS runtime. They use a separate
+/// capacity-1 slot so memoized sessions cannot accumulate enough processes to exhaust memory.
///
///
internal sealed class DumpSession : IPooledHost, IDisposable
{
private readonly Host _hostKind;
- private readonly bool _pooled; // dotnet-dump: route through the single slot
+ private readonly bool _pooled;
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.
+ // One diagnostics collector for the life of this session (survives a pooled 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; }
@@ -59,10 +61,10 @@ internal DumpSession(Host hostKind, string targetName, string stopName, Flavor f
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;
+ // Bound resource-heavy LLDB and dotnet-dump children independently. cdb children block when
+ // idle and remain cheap enough to keep per session.
+ _slot = HostSlotFor(hostKind);
+ _pooled = _slot is not 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.
@@ -80,8 +82,8 @@ internal DumpSession(Host hostKind, string targetName, string stopName, Flavor f
/// 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.
+ /// callers — so non-pooled commands are serialized on a per-target gate. Pooled paths serialize
+ /// themselves on their slot locks.
///
public SosOutput Sos(string command) =>
RunCommand("SOS", command, h => h.Sos(command));
@@ -121,7 +123,14 @@ private SosOutput RunGuarded(Func action)
}
}
- // IPooledHost — used only for the pooled (dotnet-dump) path.
+ internal static HostSlot? HostSlotFor(Host hostKind) => hostKind switch
+ {
+ Host.Lldb => HostSlot.Lldb,
+ Host.DotnetDump => HostSlot.DotNetDump,
+ _ => null,
+ };
+
+ // IPooledHost — used only for the pooled LLDB and dotnet-dump paths.
IDebuggerHost IPooledHost.Host => _host!;
diff --git a/src/tests/SOS.TestHarness/HostSlot.cs b/src/tests/SOS.TestHarness/HostSlot.cs
index 8e3215fbfc..c13ce1c53f 100644
--- a/src/tests/SOS.TestHarness/HostSlot.cs
+++ b/src/tests/SOS.TestHarness/HostSlot.cs
@@ -18,12 +18,14 @@ internal interface IPooledHost
///
/// Governs how many live host instances of one kind may exist at once — here, exactly one.
///
-/// Two kinds need this for different reasons:
+/// Debugger backends 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.
+/// - lldb children retain every loaded core and hosted SOS runtime. Keeping one per
+/// memoized dump session can exhaust memory during a large run, so dump sessions share 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
@@ -39,6 +41,9 @@ internal sealed class HostSlot
/// The dotnet-dump slot (one analyze child alive at a time).
public static readonly HostSlot DotNetDump = new();
+ /// The LLDB dump slot (one core-loaded child alive at a time).
+ public static readonly HostSlot Lldb = new();
+
private readonly object _lock = new();
private IPooledHost? _open;
private bool _exclusiveHeld;
@@ -58,8 +63,30 @@ public SosOutput Run(IPooledHost owner, Func action)
if (!ReferenceEquals(_open, owner))
{
- _open?.CloseHost();
- owner.OpenHost();
+ IPooledHost? previous = _open;
+ _open = null;
+ previous?.CloseHost();
+ try
+ {
+ owner.OpenHost();
+ }
+ catch (Exception openException)
+ {
+ try
+ {
+ owner.CloseHost();
+ }
+ catch (Exception closeException)
+ {
+ throw new AggregateException(
+ "Opening the pooled host failed, and cleaning up the partial host also failed.",
+ openException,
+ closeException);
+ }
+
+ throw;
+ }
+
_open = owner;
}
@@ -80,8 +107,9 @@ public IDisposable AcquireExclusive()
System.Threading.Monitor.Wait(_lock);
}
- _open?.CloseHost();
+ IPooledHost? open = _open;
_open = null;
+ open?.CloseHost();
_exclusiveHeld = true;
}
@@ -93,8 +121,9 @@ public void CloseCurrent()
{
lock (_lock)
{
- _open?.CloseHost();
+ IPooledHost? open = _open;
_open = null;
+ open?.CloseHost();
}
}
diff --git a/src/tests/SOS.TestHarness/LldbHostBase.cs b/src/tests/SOS.TestHarness/LldbHostBase.cs
index 29c74f1d4b..4fc9cfe464 100644
--- a/src/tests/SOS.TestHarness/LldbHostBase.cs
+++ b/src/tests/SOS.TestHarness/LldbHostBase.cs
@@ -110,6 +110,24 @@ protected void StartLldb(Action? configure = null, HostDiagnos
// that on-disk resolution working.
psi.Environment.Remove("_NT_SYMBOL_PATH");
+ if (OperatingSystem.IsMacOS())
+ {
+ // Apple LLDB guards its Mach exception ports. The SOS hosting runtime must not replace them
+ // or macOS terminates LLDB with EXC_GUARD (dotnet/diagnostics#4551).
+ psi.Environment["PAL_MachExceptionMode"] = "7";
+
+ // sos-lldb links LLDB.framework through @rpath. Resolve it from the selected Xcode at launch
+ // rather than embedding the build machine's /Applications/Xcode*.app path in the driver.
+ string? sharedFrameworks = ToolPaths.ResolveXcodeSharedFrameworksDirectory();
+ if (sharedFrameworks is not null)
+ {
+ string? inherited = Environment.GetEnvironmentVariable("DYLD_FRAMEWORK_PATH");
+ psi.Environment["DYLD_FRAMEWORK_PATH"] = string.IsNullOrEmpty(inherited)
+ ? sharedFrameworks
+ : sharedFrameworks + Path.PathSeparator + inherited;
+ }
+ }
+
// 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.
@@ -124,7 +142,8 @@ protected void StartLldb(Action? configure = null, HostDiagnos
_stdin = _process.StandardInput;
_diagnostics?.RecordProcess(_process);
- _reader = new Thread(ReadLoop) { IsBackground = true, Name = "lldb-reader" };
+ StreamReader stdout = _process.StandardOutput;
+ _reader = new Thread(() => ReadLoop(stdout)) { IsBackground = true, Name = "lldb-reader" };
_reader.Start();
// Drain stderr on its own thread: lldb prints crash diagnostics, python errors, and unhandled
@@ -132,7 +151,8 @@ protected void StartLldb(Action? configure = null, HostDiagnos
// 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" };
+ StreamReader stderr = _process.StandardError;
+ _stderrReader = new Thread(() => StderrLoop(stderr)) { IsBackground = true, Name = "lldb-stderr" };
_stderrReader.Start();
}
@@ -216,17 +236,21 @@ private string DrainToMarker(TimeSpan timeout, string? command = null)
return sb.ToString();
}
- private void ReadLoop()
+ private void ReadLoop(StreamReader stdout)
{
try
{
string? line;
- while ((line = _process.StandardOutput.ReadLine()) is not null)
+ while ((line = stdout.ReadLine()) is not null)
{
_diagnostics?.AppendStdout(line);
_lines.Add(line);
}
}
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ AppendTrace($"--- lldb stdout read failed ---{Environment.NewLine}{ex}{Environment.NewLine}");
+ }
finally
{
_lines.CompleteAdding();
@@ -234,12 +258,12 @@ private void ReadLoop()
}
}
- private void StderrLoop()
+ private void StderrLoop(StreamReader stderr)
{
try
{
string? line;
- while ((line = _process.StandardError.ReadLine()) is not null)
+ while ((line = stderr.ReadLine()) is not null)
{
_diagnostics?.AppendStderr(line);
}
@@ -392,6 +416,10 @@ public void Dispose()
// best effort
}
+ // The readers can still be inside StreamReader after the process exits. Join them before
+ // disposing Process so teardown cannot invalidate StandardOutput/StandardError mid-read.
+ _reader.Join(10000);
+ _stderrReader?.Join(10000);
_process.Dispose();
}
}
diff --git a/src/tests/SOS.TestHarness/LldbLiveHost.cs b/src/tests/SOS.TestHarness/LldbLiveHost.cs
index 54b37ac6b8..e71188bc95 100644
--- a/src/tests/SOS.TestHarness/LldbLiveHost.cs
+++ b/src/tests/SOS.TestHarness/LldbLiveHost.cs
@@ -68,6 +68,13 @@ public LldbLiveHost(string exePath, Flavor flavor, CoreVersion coreVersion = Cor
Run($"target create \"{exePath}\"");
+ if (OperatingSystem.IsMacOS())
+ {
+ // Keep the debuggee at CoreCLR's normal native-debugger mode. Mode 7 is only for the separate
+ // runtime hosted inside Apple LLDB and must not change the target's managed exception behavior.
+ Run("settings set target.env-vars PAL_MachExceptionMode=2");
+ }
+
// Stop at the program entry so we can load SOS and arm bpmd before the app runs.
Run("process launch -s");
diff --git a/src/tests/SOS.TestHarness/RepoLayout.cs b/src/tests/SOS.TestHarness/RepoLayout.cs
index 8893befc3c..21b3abdf18 100644
--- a/src/tests/SOS.TestHarness/RepoLayout.cs
+++ b/src/tests/SOS.TestHarness/RepoLayout.cs
@@ -88,6 +88,10 @@ public static string CoreDebuggeeDir(string name, string tfm) =>
public static string SingleFileDebuggeeDir(string name, string tfm) =>
Path.Combine(ArtifactsBin, name, ArtifactsConfiguration, tfm, Rid, "publish");
+ /// The pre-built desktop .NET Framework output directory for a debuggee.
+ public static string FrameworkDebuggeeDir(string name) =>
+ Path.Combine(ArtifactsBin, name, ArtifactsConfiguration, "net462");
+
///
/// 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).
diff --git a/src/tests/SOS.TestHarness/SOS.TestHarness.csproj b/src/tests/SOS.TestHarness/SOS.TestHarness.csproj
index 5b9e472167..0ffaa85782 100644
--- a/src/tests/SOS.TestHarness/SOS.TestHarness.csproj
+++ b/src/tests/SOS.TestHarness/SOS.TestHarness.csproj
@@ -13,6 +13,7 @@
+
diff --git a/src/tests/SOS.TestHarness/SnapshotStore.cs b/src/tests/SOS.TestHarness/SnapshotStore.cs
index 8acbf5123f..470701fa30 100644
--- a/src/tests/SOS.TestHarness/SnapshotStore.cs
+++ b/src/tests/SOS.TestHarness/SnapshotStore.cs
@@ -3,6 +3,7 @@
using System.Collections.Concurrent;
using System.Diagnostics;
+using System.Runtime.InteropServices;
namespace SOS.TestHarness;
@@ -22,8 +23,8 @@ namespace SOS.TestHarness;
/// 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.
+/// - Framework (net462) is pre-built on Windows by Debuggees.proj; local development
+/// falls back to an on-demand build when that output is absent.
///
///
/// Capture mechanism depends on the flavor and stop kind:
@@ -37,6 +38,8 @@ namespace SOS.TestHarness;
///
public static class SnapshotStore
{
+ private static readonly TimeSpan s_captureTimeout = TimeSpan.FromMinutes(5);
+
// One acquisition per (flavor, target, coreVersion); thread-safe via Lazy.
private static readonly ConcurrentDictionary<(Flavor Flavor, string Target, CoreVersion CoreVersion), Lazy> s_targetExe = new();
@@ -215,16 +218,49 @@ private static void CaptureCrashViaCreatedump(Flavor flavor, TargetDefinition ta
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();
+ // Windows createdump can outlive the crashing target while retaining its redirected handles.
+ BoundedProcessResult result = BoundedProcess.Run(
+ psi,
+ s_captureTimeout,
+ isolateLinuxProcessGroup: true,
+ outputDrainTimeout: s_captureTimeout);
if (!File.Exists(dumpPath))
{
+ if (IsKnownCreatedumpPermissionFailure(
+ coreVersion,
+ RuntimeInformation.ProcessArchitecture,
+ OperatingSystem.IsLinux(),
+ result.StandardOutput,
+ result.StandardError))
+ {
+ HarnessSkipException.Now(
+ ".NET 8 createdump cannot read /proc//mem on this Linux ARM64 host; " +
+ "this runtime issue is fixed in later .NET versions.");
+ }
+
throw new InvalidOperationException(
- $"createdump did not produce '{dumpPath}' for {target.Project} ({flavor}); exit {p.ExitCode}.\n{stdout}\n{stderr}");
+ $"createdump did not produce '{dumpPath}' for {target.Project} ({flavor}); exit {result.ExitCode}.\n" +
+ $"stdout:\n{result.StandardOutput}\n" +
+ $"stderr:\n{result.StandardError}");
+ }
+ }
+
+ internal static bool IsKnownCreatedumpPermissionFailure(
+ CoreVersion coreVersion,
+ Architecture architecture,
+ bool isLinux,
+ string stdout,
+ string stderr)
+ {
+ if (!isLinux || architecture != Architecture.Arm64 || coreVersion != CoreVersion.Net8)
+ {
+ return false;
}
+
+ string output = stdout + "\n" + stderr;
+ return output.Contains("open(/proc/", StringComparison.Ordinal) &&
+ output.Contains("/mem) FAILED Permission denied (13)", StringComparison.Ordinal);
}
/// Core/SingleFile snapshot capture: run the target once; its markers self-snapshot mid-run.
@@ -323,19 +359,25 @@ private static void SelfCollectCapture(Flavor flavor, TargetDefinition target, s
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();
+ // Windows dump helpers can outlive the target while retaining its redirected handles.
+ BoundedProcessResult result = BoundedProcess.Run(
+ psi,
+ s_captureTimeout,
+ isolateLinuxProcessGroup: true,
+ outputDrainTimeout: s_captureTimeout);
- if (p.ExitCode != 0)
+ if (result.ExitCode != 0)
{
- throw new InvalidOperationException($"Target '{target.Project}' ({flavor}) failed ({p.ExitCode}):\n{stderr}");
+ throw new InvalidOperationException(
+ $"Target '{target.Project}' ({flavor}) failed ({result.ExitCode}):\n" +
+ $"stdout:\n{result.StandardOutput}\n" +
+ $"stderr:\n{result.StandardError}");
}
}
///
- /// Resolve the runnable debuggee for a flavor. Core and SingleFile are repo build outputs; Framework
- /// is built on demand from the repo debuggee csproj.
+ /// Resolve the runnable debuggee for a flavor. All flavors prefer repo build outputs; Framework falls
+ /// back to an on-demand build from the repo debuggee csproj for local development.
///
private static string AcquireTarget(Flavor flavor, TargetDefinition target, CoreVersion coreVersion) => flavor switch
{
@@ -365,7 +407,8 @@ private static string AcquireCore(TargetDefinition target, CoreVersion coreVersi
if (!IsUpToDate(exe, NewestSourceWriteTime(project)))
{
RunToCompletion(RepoLayout.DotnetTestExe,
- $"build \"{project}\" -p:BuildProjectFramework={tfm} -c {RepoLayout.ArtifactsConfiguration}");
+ $"build \"{project}\" -p:BuildProjectFramework={tfm} -p:TargetRid={RepoLayout.Rid} " +
+ $"-p:TargetArch={RepoLayout.TargetArch} -c {RepoLayout.ArtifactsConfiguration}");
}
}
@@ -413,6 +456,12 @@ private static string AcquireSingleFile(TargetDefinition target, CoreVersion cor
/// than the debuggee source.
private static string BuildFramework(TargetDefinition target)
{
+ string prebuilt = Path.Combine(RepoLayout.FrameworkDebuggeeDir(target.Project), target.Project + RepoLayout.ExeSuffix);
+ if (File.Exists(prebuilt))
+ {
+ return prebuilt;
+ }
+
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);
@@ -424,11 +473,14 @@ private static string BuildFramework(TargetDefinition target)
}
string config = RepoLayout.ArtifactsConfiguration;
+ string platform = RepoLayout.TargetArch == "x86" ? " -p:PlatformTarget=x86" : string.Empty;
// 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}\"";
+ $"build \"{project}\" -p:BuildProjectFramework=net462 -p:TargetRid={RepoLayout.Rid} " +
+ $"-p:TargetArch={RepoLayout.TargetArch}{platform} -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.
diff --git a/src/tests/SOS.TestHarness/Targets.cs b/src/tests/SOS.TestHarness/Targets.cs
index ac97d7d3d4..5536149b94 100644
--- a/src/tests/SOS.TestHarness/Targets.cs
+++ b/src/tests/SOS.TestHarness/Targets.cs
@@ -83,7 +83,7 @@ private static DumpSession CreateSession((Host Host, string Target, string Stop,
return session;
}
- /// Dispose every memoized dump session (kills dotnet-dump children, closes dbgeng hosts).
+ /// Dispose every memoized dump session and close pooled debugger children.
public static void DisposeAll()
{
while (s_created.TryTake(out DumpSession? session))
@@ -98,8 +98,8 @@ public static void DisposeAll()
}
}
- // Close any pooled (dotnet-dump) host still open. cdb children were disposed above via
- // each SharedTarget.Dispose().
+ // Close any pooled host still open. cdb children were disposed with their sessions above.
+ HostSlot.Lldb.CloseCurrent();
HostSlot.DotNetDump.CloseCurrent();
}
}
diff --git a/src/tests/SOS.TestHarness/TestConfig.cs b/src/tests/SOS.TestHarness/TestConfig.cs
index 718f4e1e17..ab83e01ca5 100644
--- a/src/tests/SOS.TestHarness/TestConfig.cs
+++ b/src/tests/SOS.TestHarness/TestConfig.cs
@@ -187,7 +187,7 @@ public static IEnumerable Permutations(
/// 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)
+ internal static bool IsValid(TestConfig c)
{
// Host platform constraints: cdb is Windows-only, lldb is non-Windows-only.
if (c.Host == Host.Cdb && !OperatingSystem.IsWindows())
@@ -206,6 +206,13 @@ private static bool IsValid(TestConfig c)
return false;
}
+ // SOS hosts cannot discover the statically linked CoreCLR module in musl single-file processes
+ // or dumps. Keep Core coverage on Alpine while excluding unsupported single-file rows.
+ if (!IsFlavorSupportedOnRid(c.Flavor, RepoLayout.Rid))
+ {
+ return false;
+ }
+
// dotnet-dump is post-mortem only; it has no live host.
if (c.IsLive && c.Host == Host.DotnetDump)
{
@@ -222,6 +229,18 @@ private static bool IsValid(TestConfig c)
return false;
}
+ // A single-file snapshot requires a Full dump because createdump cannot enumerate reduced-dump
+ // regions for a statically linked runtime. On constrained test machines, marker targets produce
+ // several multi-gigabyte dumps and cannot complete reliably. Callers can exclude only those
+ // snapshot rows while preserving single-file crash coverage.
+ if (!c.IsLive &&
+ c.Flavor == Flavor.SingleFile &&
+ TargetCatalog.NavigatesViaBpmd(c.Target) &&
+ ExcludeSingleFileSnapshots(Environment.GetEnvironmentVariable("SOSHARNESS_EXCLUDE_SINGLEFILE_SNAPSHOTS")))
+ {
+ 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)
{
@@ -249,28 +268,7 @@ private static bool IsValid(TestConfig c)
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)
+ if (!IsDacSupported(c))
{
return false;
}
@@ -278,6 +276,26 @@ private static bool IsValid(TestConfig c)
return true;
}
+ ///
+ /// The cDAC is available only for .NET Core 11+; desktop Framework and single-file command coverage
+ /// continue to use the legacy DAC.
+ ///
+ internal static bool IsDacSupported(TestConfig config) =>
+ config.Dac != Dac.CDac ||
+ (config.Flavor is not Flavor.Framework and not Flavor.SingleFile &&
+ (uint)config.CoreVersion >= (uint)CoreVersion.Net11);
+
+ internal static bool IsFlavorSupportedOnRid(Flavor flavor, string rid) =>
+ flavor != Flavor.SingleFile || !rid.StartsWith("linux-musl-", StringComparison.Ordinal);
+
+ internal static bool ExcludeSingleFileSnapshots(string? value) => value switch
+ {
+ null or "" or "0" => false,
+ "1" => true,
+ _ => throw new InvalidOperationException(
+ "SOSHARNESS_EXCLUDE_SINGLEFILE_SNAPSHOTS must be unset, 0, or 1."),
+ };
+
private static IEnumerable SingleFlags(T value) where T : struct, Enum
{
foreach (T candidate in Enum.GetValues())
diff --git a/src/tests/SOS.TestHarness/ToolPaths.cs b/src/tests/SOS.TestHarness/ToolPaths.cs
index 300a12d53a..2c87dd0afb 100644
--- a/src/tests/SOS.TestHarness/ToolPaths.cs
+++ b/src/tests/SOS.TestHarness/ToolPaths.cs
@@ -38,17 +38,18 @@ public static class ToolPaths
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.
+ /// The lldb executable the harness drives. On macOS the repo-built sos-lldb driver is
+ /// preferred because it embeds Xcode's LLDB framework without inheriting the system executable's
+ /// CoreCLR-hosting restriction. SOSHARNESS_LLDB_PATH is the explicit harness override;
+ /// LLDB_PATH and system LLDB remain fallbacks. 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.
+ /// Defaults to 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. Set
+ /// SOSHARNESS_HOST_RUNTIME_DIR to validate SOS against another complete runtime layout.
///
public static string HostRuntimeDirectory => s_hostRuntimeDirectory.Value;
@@ -174,15 +175,32 @@ private static string ResolveLldbPluginPath()
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");
+ // 1) Explicit harness override.
+ string? env = Environment.GetEnvironmentVariable("SOSHARNESS_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.
+ // 2) The repo-built macOS driver uses the selected Xcode's LLDB framework without running inside
+ // Apple's restricted LLDB executable.
+ if (OperatingSystem.IsMacOS())
+ {
+ string driver = Path.Combine(RepoLayout.ArtifactsBinNative, "sos-lldb");
+ if (File.Exists(driver))
+ {
+ return driver;
+ }
+ }
+
+ // 3) Existing build-script override.
+ env = Environment.GetEnvironmentVariable("LLDB_PATH");
+ if (!string.IsNullOrEmpty(env) && File.Exists(env))
+ {
+ return env;
+ }
+
+ // 4) Xcode's LLDB.
if (OperatingSystem.IsMacOS())
{
string? developerDir = TryRun("xcode-select", "-p");
@@ -196,7 +214,7 @@ private static string ResolveLldbExe()
}
}
- // 3) A plain `lldb` on PATH.
+ // 5) A plain `lldb` on PATH.
string? onPath = FindOnPath("lldb");
if (onPath is not null)
{
@@ -204,12 +222,48 @@ private static string ResolveLldbExe()
}
throw new FileNotFoundException(
- "Could not locate an 'lldb' executable. Set LLDB_PATH, install lldb on PATH, or (on macOS) " +
- "install Xcode.");
+ "Could not locate an 'lldb' executable. Set SOSHARNESS_LLDB_PATH or LLDB_PATH, install lldb " +
+ "on PATH, or (on macOS) install Xcode.");
+ }
+
+ internal static string? ResolveXcodeSharedFrameworksDirectory()
+ {
+ string? developerDir = Environment.GetEnvironmentVariable("DEVELOPER_DIR");
+ if (string.IsNullOrWhiteSpace(developerDir))
+ {
+ developerDir = TryRun("xcode-select", "-p");
+ }
+
+ if (string.IsNullOrWhiteSpace(developerDir))
+ {
+ return null;
+ }
+
+ string sharedFrameworks = Path.GetFullPath(Path.Combine(developerDir.Trim(), "..", "SharedFrameworks"));
+ return Directory.Exists(sharedFrameworks) ? sharedFrameworks : null;
}
private static string ResolveHostRuntimeDirectory()
{
+ string? configuredDirectory = Environment.GetEnvironmentVariable("SOSHARNESS_HOST_RUNTIME_DIR");
+ if (!string.IsNullOrEmpty(configuredDirectory))
+ {
+ string directory = Path.GetFullPath(configuredDirectory);
+ string coreClrName = OperatingSystem.IsWindows()
+ ? "coreclr.dll"
+ : OperatingSystem.IsMacOS() ? "libcoreclr.dylib" : "libcoreclr.so";
+ string coreClrPath = Path.Combine(directory, coreClrName);
+ string coreLibPath = Path.Combine(directory, "System.Private.CoreLib.dll");
+ if (File.Exists(coreClrPath) && File.Exists(coreLibPath))
+ {
+ return directory;
+ }
+
+ throw new DirectoryNotFoundException(
+ $"The configured SOS harness host runtime directory '{directory}' must contain " +
+ $"{coreClrName} and System.Private.CoreLib.dll.");
+ }
+
// 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.
diff --git a/src/tests/SOS.Tests/BoundedProcessTests.cs b/src/tests/SOS.Tests/BoundedProcessTests.cs
new file mode 100644
index 0000000000..d1889fa5b8
--- /dev/null
+++ b/src/tests/SOS.Tests/BoundedProcessTests.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.Diagnostics;
+using SOS.TestHarness;
+using Xunit;
+
+namespace SOS.Tests;
+
+public sealed class BoundedProcessTests
+{
+ [Fact]
+ public void DrainsLargeOutputConcurrently()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
+
+ ProcessStartInfo startInfo = Shell(
+ "i=0; while [ $i -lt 10000 ]; do echo stdout-$i; echo stderr-$i >&2; i=$((i+1)); done");
+
+ BoundedProcessResult result = BoundedProcess.Run(startInfo, TimeSpan.FromSeconds(30));
+
+ Assert.Equal(0, result.ExitCode);
+ Assert.True(result.StandardOutput.Length > 64 * 1024);
+ Assert.True(result.StandardError.Length > 64 * 1024);
+ }
+
+ [Fact]
+ public void KillsLinuxProcessGroupOnTimeout()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
+
+ ProcessStartInfo startInfo = Shell("sleep 30 & echo $!; wait");
+
+ Stopwatch stopwatch = Stopwatch.StartNew();
+ TimeoutException error = Assert.Throws(
+ () => BoundedProcess.Run(
+ startInfo,
+ TimeSpan.FromMilliseconds(250),
+ isolateLinuxProcessGroup: true));
+ stopwatch.Stop();
+
+ string childPid = error.Message
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .First(line => int.TryParse(line, out _));
+
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10));
+ Assert.False(IsRunning(childPid));
+ }
+
+ [Fact]
+ public void ClosesInheritedOutputAfterParentExit()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
+
+ ProcessStartInfo startInfo = Shell("sleep 30 & echo $!; exit 0");
+
+ Stopwatch stopwatch = Stopwatch.StartNew();
+ BoundedProcessResult result = BoundedProcess.Run(
+ startInfo,
+ TimeSpan.FromSeconds(10),
+ isolateLinuxProcessGroup: true);
+ stopwatch.Stop();
+
+ string childPid = result.StandardOutput.Trim();
+ Assert.Equal(0, result.ExitCode);
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10));
+ Assert.False(IsRunning(childPid));
+ }
+
+ [Fact]
+ public void WaitsForInheritedOutputWithinConfiguredDeadline()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
+
+ ProcessStartInfo startInfo = Shell("sleep 1 & echo inherited-output; exit 0");
+
+ Stopwatch stopwatch = Stopwatch.StartNew();
+ BoundedProcessResult result = BoundedProcess.Run(
+ startInfo,
+ TimeSpan.FromSeconds(2),
+ outputDrainTimeout: TimeSpan.FromSeconds(3));
+ stopwatch.Stop();
+
+ Assert.Equal(0, result.ExitCode);
+ Assert.Contains("inherited-output", result.StandardOutput);
+ Assert.True(stopwatch.Elapsed >= TimeSpan.FromMilliseconds(500));
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(3));
+ }
+
+ private static ProcessStartInfo Shell(string command)
+ {
+ ProcessStartInfo startInfo = new("/bin/sh")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ startInfo.ArgumentList.Add("-c");
+ startInfo.ArgumentList.Add(command);
+ return startInfo;
+ }
+
+ private static bool IsRunning(string processId)
+ {
+ string processPath = $"/proc/{processId}";
+ string statPath = Path.Combine(processPath, "stat");
+ string stat;
+ try
+ {
+ stat = File.ReadAllText(statPath);
+ }
+ catch (IOException) when (!Directory.Exists(processPath))
+ {
+ return false;
+ }
+
+ int commandEnd = stat.LastIndexOf(')');
+ return commandEnd < 0 || commandEnd + 2 >= stat.Length || stat[commandEnd + 2] != 'Z';
+ }
+}
diff --git a/src/tests/SOS.Tests/HostSlotTests.cs b/src/tests/SOS.Tests/HostSlotTests.cs
new file mode 100644
index 0000000000..c4396f4c83
--- /dev/null
+++ b/src/tests/SOS.Tests/HostSlotTests.cs
@@ -0,0 +1,104 @@
+// 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;
+
+public sealed class HostSlotTests
+{
+ [Fact]
+ public void DumpSessionsUseSeparateBoundedSlots()
+ {
+ Assert.Same(HostSlot.Lldb, DumpSession.HostSlotFor(Host.Lldb));
+ Assert.Same(HostSlot.DotNetDump, DumpSession.HostSlotFor(Host.DotnetDump));
+ Assert.Null(DumpSession.HostSlotFor(Host.Cdb));
+ }
+
+ [Fact]
+ public void SwitchingOwnersEvictsTheOpenHost()
+ {
+ HostSlot slot = new();
+ FakePooledHost first = new();
+ FakePooledHost second = new();
+
+ slot.Run(first, host => host.Sos("first"));
+ slot.Run(first, host => host.Sos("again"));
+
+ Assert.Equal(1, first.OpenCount);
+ Assert.Equal(0, first.CloseCount);
+
+ slot.Run(second, host => host.Sos("second"));
+
+ Assert.Equal(1, first.CloseCount);
+ Assert.Equal(1, second.OpenCount);
+
+ slot.CloseCurrent();
+
+ Assert.Equal(1, second.CloseCount);
+ }
+
+ [Fact]
+ public void FailedReplacementDoesNotPoisonTheSlot()
+ {
+ HostSlot slot = new();
+ FakePooledHost first = new();
+ FakePooledHost failing = new() { ThrowOnOpen = true };
+
+ slot.Run(first, host => host.Sos("first"));
+
+ Assert.Throws(
+ () => slot.Run(failing, host => host.Sos("unreachable")));
+ Assert.Equal(1, first.CloseCount);
+ Assert.Equal(1, failing.CloseCount);
+
+ slot.Run(first, host => host.Sos("reopened"));
+
+ Assert.Equal(2, first.OpenCount);
+ }
+
+ private sealed class FakePooledHost : IPooledHost
+ {
+ private FakeDebuggerHost? _host;
+
+ public int OpenCount { get; private set; }
+ public int CloseCount { get; private set; }
+ public bool ThrowOnOpen { get; init; }
+ public IDebuggerHost Host => _host ?? throw new InvalidOperationException("The host is not open.");
+
+ public void OpenHost()
+ {
+ OpenCount++;
+ _host = new FakeDebuggerHost();
+ if (ThrowOnOpen)
+ {
+ throw new InvalidOperationException("Open failed.");
+ }
+ }
+
+ public void CloseHost()
+ {
+ CloseCount++;
+ _host?.Dispose();
+ _host = null;
+ }
+ }
+
+ private sealed class FakeDebuggerHost : IDebuggerHost
+ {
+ public string Name => "fake";
+
+ public void Dispose()
+ {
+ }
+
+ public void LoadSos()
+ {
+ }
+
+ public SosOutput Execute(string command) => new(Name, command, string.Empty);
+
+ public SosOutput Sos(string command) => new(Name, command, string.Empty);
+ }
+}
diff --git a/src/tests/SOS.Tests/TestConfigValidityTests.cs b/src/tests/SOS.Tests/TestConfigValidityTests.cs
new file mode 100644
index 0000000000..1b550c33f9
--- /dev/null
+++ b/src/tests/SOS.Tests/TestConfigValidityTests.cs
@@ -0,0 +1,92 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using Microsoft.Win32;
+using SOS.TestHarness;
+using Xunit;
+
+namespace SOS.Tests;
+
+public sealed class TestConfigValidityTests
+{
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData("", false)]
+ [InlineData("0", false)]
+ [InlineData("1", true)]
+ public void SingleFileSnapshotExclusionIsStrict(string? value, bool expected)
+ {
+ Assert.Equal(expected, TestConfig.ExcludeSingleFileSnapshots(value));
+ }
+
+ [Theory]
+ [InlineData("true")]
+ [InlineData(" 1")]
+ [InlineData("yes")]
+ public void SingleFileSnapshotExclusionRejectsInvalidValues(string value)
+ {
+ InvalidOperationException error = Assert.Throws(
+ () => TestConfig.ExcludeSingleFileSnapshots(value));
+
+ Assert.Contains("SOSHARNESS_EXCLUDE_SINGLEFILE_SNAPSHOTS", error.Message);
+ }
+
+ [Fact]
+ public void CDacRequiresSupportedCoreConfiguration()
+ {
+ TestConfig config = Config() with { Dac = Dac.CDac, CoreVersion = CoreVersion.Net11 };
+
+ Assert.True(TestConfig.IsDacSupported(config));
+ Assert.False(TestConfig.IsDacSupported(config with { CoreVersion = CoreVersion.Net10 }));
+ Assert.False(TestConfig.IsDacSupported(config with { Flavor = Flavor.Framework }));
+ Assert.False(TestConfig.IsDacSupported(config with { Flavor = Flavor.SingleFile }));
+ Assert.False(TestConfig.IsValid(config with { Flavor = Flavor.SingleFile }));
+ }
+
+ [Theory]
+ [InlineData(Flavor.Core, "linux-musl-x64", true)]
+ [InlineData(Flavor.SingleFile, "linux-x64", true)]
+ [InlineData(Flavor.SingleFile, "linux-musl-x64", false)]
+ [InlineData(Flavor.SingleFile, "linux-musl-arm64", false)]
+ public void MuslExcludesOnlySingleFile(Flavor flavor, string rid, bool expected)
+ {
+ Assert.Equal(expected, TestConfig.IsFlavorSupportedOnRid(flavor, rid));
+ }
+
+ [Fact]
+ public void Net8LinuxArm64CreatedumpPermissionFailureIsKnown()
+ {
+ const string error = "open(/proc/123/mem) FAILED Permission denied (13)";
+
+ Assert.True(SnapshotStore.IsKnownCreatedumpPermissionFailure(
+ CoreVersion.Net8, Architecture.Arm64, isLinux: true, error, string.Empty));
+ Assert.False(SnapshotStore.IsKnownCreatedumpPermissionFailure(
+ CoreVersion.Net11, Architecture.Arm64, isLinux: true, error, string.Empty));
+ Assert.False(SnapshotStore.IsKnownCreatedumpPermissionFailure(
+ CoreVersion.Net8, Architecture.X64, isLinux: true, error, string.Empty));
+ Assert.False(SnapshotStore.IsKnownCreatedumpPermissionFailure(
+ CoreVersion.Net8, Architecture.Arm64, isLinux: true, "unrelated failure", string.Empty));
+ }
+
+ [Theory]
+ [InlineData(false, RegistryView.Registry32)]
+ [InlineData(true, RegistryView.Registry64)]
+ [SupportedOSPlatform("windows")]
+ public void DumpGenerationRegistryViewMatchesProcessBitness(bool is64BitProcess, RegistryView expected)
+ {
+ Assert.Equal(expected, DumpGenerationRequirements.RegistryViewForProcess(is64BitProcess));
+ }
+
+ private static TestConfig Config() =>
+ new(
+ TargetCatalog.DivZero,
+ OperatingSystem.IsWindows() ? Host.Cdb : Host.Lldb,
+ Flavor.Core,
+ Liveness.Dump,
+ GcType.Workstation,
+ DumpKind.Heap,
+ CoreVersion.Net10,
+ Dac.Legacy);
+}
diff --git a/src/tests/SOS.UnitTests/Debuggees/Directory.Build.props b/src/tests/SOS.UnitTests/Debuggees/Directory.Build.props
index b30c592a5d..f840e273ca 100644
--- a/src/tests/SOS.UnitTests/Debuggees/Directory.Build.props
+++ b/src/tests/SOS.UnitTests/Debuggees/Directory.Build.props
@@ -7,6 +7,9 @@
full
true
false
+
+ $(TargetRid)
diff --git a/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/SosHarnessScenarios.csproj b/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/SosHarnessScenarios.csproj
index 8fa168741b..475ed816af 100644
--- a/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/SosHarnessScenarios.csproj
+++ b/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/SosHarnessScenarios.csproj
@@ -9,4 +9,8 @@
$(SupportedSubProcessTargetFrameworks)
+
+
+
+
diff --git a/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/TestHarness.cs b/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/TestHarness.cs
index f8607e1f88..f78ffa9b63 100644
--- a/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/TestHarness.cs
+++ b/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/TestHarness.cs
@@ -5,7 +5,9 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
-using System.Threading.Tasks;
+#if !NETFRAMEWORK
+using SOS.TestHarness;
+#endif
///
/// The one piece of shared machinery the marker debuggee uses. A call to marks a
@@ -65,17 +67,16 @@ public static void Stop(string name)
psi.ArgumentList.Add("-o");
psi.ArgumentList.Add(outPath);
- using Process p = Process.Start(psi) ??
- throw new InvalidOperationException("Failed to start dotnet-dump.");
- Task stdoutTask = p.StandardOutput.ReadToEndAsync();
- Task stderrTask = p.StandardError.ReadToEndAsync();
- p.WaitForExit();
- string stdout = stdoutTask.GetAwaiter().GetResult();
- string stderr = stderrTask.GetAwaiter().GetResult();
- if (p.ExitCode != 0 || !File.Exists(outPath))
+ BoundedProcessResult result = BoundedProcess.Run(
+ psi,
+ TimeSpan.FromMinutes(2),
+ isolateLinuxProcessGroup: true);
+ if (result.ExitCode != 0 || !File.Exists(outPath))
{
throw new InvalidOperationException(
- $"Snapshot '{name}' failed (exit {p.ExitCode}):\n{stdout}\n{stderr}");
+ $"Snapshot '{name}' failed (exit {result.ExitCode}):\n" +
+ $"stdout:\n{result.StandardOutput}\n" +
+ $"stderr:\n{result.StandardError}");
}
#endif
}
diff --git a/src/tests/dirs.proj b/src/tests/dirs.proj
index a37899c9ce..0d35554cd2 100644
--- a/src/tests/dirs.proj
+++ b/src/tests/dirs.proj
@@ -64,7 +64,7 @@
-