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 @@ -