From cd7dd0db3a319f3fe537aa91d9d3064514db4acf Mon Sep 17 00:00:00 2001 From: Steve Pfister Date: Wed, 2 Sep 2026 18:27:52 -0400 Subject: [PATCH 1/2] Stabilize SOS test harness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/SOS/Strike/util.cpp | 2 +- src/tests/Debuggees.proj | 14 ++ src/tests/SOS.TestHarness/BoundedProcess.cs | 139 ++++++++++++++++++ .../SOS.TestHarness/ChildEngineClient.cs | 60 ++++++-- src/tests/SOS.TestHarness/DbgEngCapturer.cs | 9 +- src/tests/SOS.TestHarness/DbgEngLiveHost.cs | 4 +- src/tests/SOS.TestHarness/DumpSession.cs | 29 ++-- src/tests/SOS.TestHarness/HostSlot.cs | 39 ++++- src/tests/SOS.TestHarness/LldbHostBase.cs | 40 ++++- src/tests/SOS.TestHarness/LldbLiveHost.cs | 7 + src/tests/SOS.TestHarness/RepoLayout.cs | 4 + .../SOS.TestHarness/SOS.TestHarness.csproj | 1 + src/tests/SOS.TestHarness/SnapshotStore.cs | 84 +++++++++-- src/tests/SOS.TestHarness/Targets.cs | 6 +- src/tests/SOS.TestHarness/TestConfig.cs | 64 +++++--- src/tests/SOS.TestHarness/ToolPaths.cs | 80 ++++++++-- src/tests/SOS.Tests/BoundedProcessTests.cs | 133 +++++++++++++++++ src/tests/SOS.Tests/HostSlotTests.cs | 104 +++++++++++++ .../SOS.Tests/TestConfigValidityTests.cs | 81 ++++++++++ .../Debuggees/Directory.Build.props | 3 + .../SosHarnessScenarios.csproj | 4 + .../SosHarnessScenarios/TestHarness.cs | 21 +-- src/tests/dirs.proj | 2 +- 23 files changed, 827 insertions(+), 103 deletions(-) create mode 100644 src/tests/SOS.TestHarness/BoundedProcess.cs create mode 100644 src/tests/SOS.Tests/BoundedProcessTests.cs create mode 100644 src/tests/SOS.Tests/HostSlotTests.cs create mode 100644 src/tests/SOS.Tests/TestConfigValidityTests.cs 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/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..f59222726a --- /dev/null +++ b/src/tests/SOS.Tests/TestConfigValidityTests.cs @@ -0,0 +1,81 @@ +// 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 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)); + } + + 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 @@ - From 9c0f24048d8b10943eadfd215fdcd1cbd7876782 Mon Sep 17 00:00:00 2001 From: Steve Pfister Date: Fri, 4 Sep 2026 08:38:19 -0400 Subject: [PATCH 2/2] Use explicit registry view for dump settings Open the logical HKLM SOFTWARE path through the registry view matching the test process bitness, avoiding duplicate WOW6432Node redirection in x86 runs. Add focused coverage for both registry views. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5281acb1-f8c2-4264-9af1-d8489323b9ba --- .../SOS.TestHarness/DumpGenerationRequirements.cs | 14 ++++++++++---- src/tests/SOS.Tests/TestConfigValidityTests.cs | 11 +++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) 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.Tests/TestConfigValidityTests.cs b/src/tests/SOS.Tests/TestConfigValidityTests.cs index f59222726a..1b550c33f9 100644 --- a/src/tests/SOS.Tests/TestConfigValidityTests.cs +++ b/src/tests/SOS.Tests/TestConfigValidityTests.cs @@ -2,6 +2,8 @@ // 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; @@ -68,6 +70,15 @@ public void Net8LinuxArm64CreatedumpPermissionFailureIsKnown() 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,