diff --git a/diagnostics.yml b/diagnostics.yml index d8e0f5078e..495d65b980 100644 --- a/diagnostics.yml +++ b/diagnostics.yml @@ -26,9 +26,7 @@ pr: autoCancel: true branches: include: - - main - - release/* - - internal/* + - '*' paths: exclude: - documentation/* @@ -52,7 +50,7 @@ extends: parameters: stages: - stage: build - displayName: Build Diagnostics + displayName: Build / Test Diagnostics jobs: ############################ @@ -137,31 +135,46 @@ extends: - template: /eng/pipelines/build.yml parameters: jobTemplate: ${{ variables.jobTemplate }} - osGroup: MacOS + osGroup: Linux + container: linux_arm64 + crossBuild: true buildOnly: true - publishTestArtifacts: true + publishTestArtifacts: ${{ eq(variables['System.TeamProject'], 'public') }} + publishTestRuntimeArtifacts: ${{ eq(variables['System.TeamProject'], 'public') }} buildConfigs: - configuration: Release - architecture: x64 - artifactUploadPath: bin/osx.x64.Release + architecture: arm64 + artifactUploadPath: bin/linux.arm64.Release - ${{ if in(variables['Build.Reason'], 'PullRequest') }}: - configuration: Debug - architecture: x64 - artifactUploadPath: bin/osx.x64.Debug + architecture: arm64 + artifactUploadPath: bin/linux.arm64.Debug + # Reuse one macOS agent per configuration: build/test x64 first, then cross-build arm64. + # PRs submit only arm64 SOS.Tests; non-PR builds submit both architectures. - template: /eng/pipelines/build.yml parameters: jobTemplate: ${{ variables.jobTemplate }} osGroup: MacOS - crossBuild: true - buildOnly: true + timeoutInMinutes: 360 + buildOnly: ${{ parameters.buildOnly }} + runSOSHelix: ${{ and(eq(parameters.buildOnly, false), eq(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }} + sosHelixTargetOS: osx + sosHelixRidPrefix: osx + sosHelixQueue: OSX.15.Amd64.Open + additionalArchitecture: arm64 + additionalCrossBuild: true + runAdditionalSOSHelix: ${{ and(eq(parameters.buildOnly, false), eq(variables['System.TeamProject'], 'public')) }} + additionalSOSHelixQueue: OSX.15.Arm64.Open buildConfigs: - configuration: Release - architecture: arm64 - artifactUploadPath: bin/osx.arm64.Release + architecture: x64 + artifactUploadPath: bin/osx.x64.Release + additionalArtifactUploadPath: bin/osx.arm64.Release - ${{ if in(variables['Build.Reason'], 'PullRequest') }}: - configuration: Debug - architecture: arm64 + architecture: x64 + artifactUploadPath: bin/osx.x64.Debug - ${{ if ne(variables['System.TeamProject'], 'public') }}: - template: /eng/pipelines/build.yml @@ -176,18 +189,6 @@ extends: architecture: arm artifactUploadPath: bin/linux.arm.Release - - template: /eng/pipelines/build.yml - parameters: - jobTemplate: ${{ variables.jobTemplate }} - osGroup: Linux - container: linux_arm64 - crossBuild: true - buildOnly: true - buildConfigs: - - configuration: Release - architecture: arm64 - artifactUploadPath: bin/linux.arm64.Release - - template: /eng/pipelines/build.yml parameters: jobTemplate: ${{ variables.jobTemplate }} @@ -218,18 +219,13 @@ extends: artifactUploadPath: bin/linux.arm64.Release artifactTargetPath: bin/linux-musl.arm64.Release - ############################ - # # - # Test stage # - # # - ############################ - - - ${{ if ne(parameters.buildOnly, true) }}: - - stage: test - displayName: Test Diagnostics - dependsOn: build - jobs: + ############################ + # # + # Test legs # + # # + ############################ + - ${{ if ne(parameters.buildOnly, true) }}: - template: /eng/pipelines/build.yml parameters: jobTemplate: ${{ variables.jobTemplate }} @@ -237,6 +233,10 @@ extends: osGroup: Windows_NT dependsOn: Windows testOnly: true + runSOSHelix: ${{ eq(variables['System.TeamProject'], 'public') }} + sosHelixTargetOS: Windows_NT + sosHelixRidPrefix: win + sosHelixQueue: Windows.Amd64.Server2022.Open buildConfigs: - configuration: Debug architecture: x64 @@ -245,20 +245,6 @@ extends: - configuration: Release architecture: x86 - - template: /eng/pipelines/build.yml - parameters: - jobTemplate: ${{ variables.jobTemplate }} - name: MacOS_Test - osGroup: MacOS - dependsOn: MacOS - testOnly: true - buildConfigs: - - configuration: Release - architecture: x64 - - ${{ if in(variables['Build.Reason'], 'PullRequest') }}: - - configuration: Debug - architecture: x64 - - template: /eng/pipelines/build.yml parameters: jobTemplate: ${{ variables.jobTemplate }} @@ -267,6 +253,10 @@ extends: container: test_ubuntu_22_04 dependsOn: Linux testOnly: true + runSOSHelix: ${{ eq(variables['System.TeamProject'], 'public') }} + sosHelixTargetOS: linux + sosHelixRidPrefix: linux + sosHelixQueue: Ubuntu.2204.Amd64.Open buildConfigs: - configuration: Release architecture: x64 @@ -274,6 +264,28 @@ extends: - configuration: Debug architecture: x64 + # Cross-built arm64 artifacts are submitted from an x64 Linux agent and execute only on Helix. + - ${{ if eq(variables['System.TeamProject'], 'public') }}: + - template: /eng/pipelines/build.yml + parameters: + jobTemplate: ${{ variables.jobTemplate }} + name: Ubuntu_22_04 + osGroup: Linux + container: test_ubuntu_22_04 + dependsOn: Linux + testOnly: true + helixOnly: true + runSOSHelix: true + sosHelixTargetOS: linux + sosHelixRidPrefix: linux + sosHelixQueue: Ubuntu.2204.ArmArch.Open@mcr.microsoft.com/dotnet-buildtools/prereqs:ubuntu-22.04-helix-arm64v8 + buildConfigs: + - configuration: Release + architecture: arm64 + - ${{ if in(variables['Build.Reason'], 'PullRequest') }}: + - configuration: Debug + architecture: arm64 + - template: /eng/pipelines/build.yml parameters: jobTemplate: ${{ variables.jobTemplate }} @@ -284,6 +296,10 @@ extends: dependsOn: Linux_musl testOnly: true disableComponentGovernance: true + runSOSHelix: ${{ eq(variables['System.TeamProject'], 'public') }} + sosHelixTargetOS: linux + sosHelixRidPrefix: linux-musl + sosHelixQueue: '(Alpine.323.Amd64.Open)azurelinux.3.Amd64.Open@mcr.microsoft.com/dotnet-buildtools/prereqs:alpine-3.23-helix-amd64' buildConfigs: - configuration: Release architecture: x64 diff --git a/eng/helix/SOS.Tests.Helix.proj b/eng/helix/SOS.Tests.Helix.proj new file mode 100644 index 0000000000..e2114f04de --- /dev/null +++ b/eng/helix/SOS.Tests.Helix.proj @@ -0,0 +1,232 @@ + + + + + msbuild + $(NetCoreAppTestTargetFramework) + PackageReference + true + false + true + true + true + 02:00:00 + $(ArtifactsDir)helix/SOS.Tests/$(TargetRid)/$(Configuration)/payload + dotnet.exe + dotnet + $(ArtifactsBinNativeDir) + SOSHARNESS_TEST_RUNTIME_MAJOR=11 SOSHARNESS_ONLY_COREVERSIONS=Net11 + + SOSHARNESS_ONLY_DUMPKIND=Heap,Full + + 1 + 34 + 10 + + + + + + <_SOSHelixCorrelationPayload Include="$(CorrelationPayloadDirectory)"> + %(Identity) + + + + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_0_of_8" ShardIndex="0" ShardCount="8" Liveness="Dump" MaxParallelThreads="$(SOSHelixDumpMaxParallelThreads)" Condition="'$(TargetOS)' != 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_1_of_8" ShardIndex="1" ShardCount="8" Liveness="Dump" MaxParallelThreads="$(SOSHelixDumpMaxParallelThreads)" Condition="'$(TargetOS)' != 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_2_of_8" ShardIndex="2" ShardCount="8" Liveness="Dump" MaxParallelThreads="$(SOSHelixDumpMaxParallelThreads)" Condition="'$(TargetOS)' != 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_3_of_8" ShardIndex="3" ShardCount="8" Liveness="Dump" MaxParallelThreads="$(SOSHelixDumpMaxParallelThreads)" Condition="'$(TargetOS)' != 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_4_of_8" ShardIndex="4" ShardCount="8" Liveness="Dump" MaxParallelThreads="$(SOSHelixDumpMaxParallelThreads)" Condition="'$(TargetOS)' != 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_5_of_8" ShardIndex="5" ShardCount="8" Liveness="Dump" MaxParallelThreads="$(SOSHelixDumpMaxParallelThreads)" Condition="'$(TargetOS)' != 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_6_of_8" ShardIndex="6" ShardCount="8" Liveness="Dump" MaxParallelThreads="$(SOSHelixDumpMaxParallelThreads)" Condition="'$(TargetOS)' != 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_7_of_8" ShardIndex="7" ShardCount="8" Liveness="Dump" MaxParallelThreads="$(SOSHelixDumpMaxParallelThreads)" Condition="'$(TargetOS)' != 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_0_of_32" ShardIndex="0" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_1_of_32" ShardIndex="1" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_2_of_32" ShardIndex="2" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_3_of_32" ShardIndex="3" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_4_of_32" ShardIndex="4" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_5_of_32" ShardIndex="5" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_6_of_32" ShardIndex="6" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_7_of_32" ShardIndex="7" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_8_of_32" ShardIndex="8" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_9_of_32" ShardIndex="9" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_10_of_32" ShardIndex="10" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_11_of_32" ShardIndex="11" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_12_of_32" ShardIndex="12" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_13_of_32" ShardIndex="13" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_14_of_32" ShardIndex="14" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_15_of_32" ShardIndex="15" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_16_of_32" ShardIndex="16" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_17_of_32" ShardIndex="17" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_18_of_32" ShardIndex="18" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_19_of_32" ShardIndex="19" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_20_of_32" ShardIndex="20" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_21_of_32" ShardIndex="21" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_22_of_32" ShardIndex="22" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_23_of_32" ShardIndex="23" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_24_of_32" ShardIndex="24" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_25_of_32" ShardIndex="25" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_26_of_32" ShardIndex="26" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_27_of_32" ShardIndex="27" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_28_of_32" ShardIndex="28" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_29_of_32" ShardIndex="29" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_30_of_32" ShardIndex="30" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_dump_31_of_32" ShardIndex="31" ShardCount="32" Liveness="Dump" MaxParallelThreads="4" Condition="'$(TargetOS)' == 'osx'" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_live_0_of_2" ShardIndex="0" ShardCount="2" Liveness="Live" /> + <_SOSShard Include="SOS_$(TargetRid)_$(Configuration)_live_1_of_2" ShardIndex="1" ShardCount="2" Liveness="Live" /> + + + + + + + + + + + + + + + + + + + %25HELIX_CORRELATION_PAYLOAD%25\eng\helix\sos\run-sos-tests.cmd $(Configuration) $(TargetRid) %(_SOSShard.ShardIndex) %(_SOSShard.ShardCount) %(_SOSShard.Liveness) $(NetCoreAppTestTargetFramework) + $(SOSHelixPlatformTestEnvironment)$(SOSHelixTestEnvironment)bash $HELIX_CORRELATION_PAYLOAD/eng/helix/sos/run-sos-tests.sh $(Configuration) $(TargetRid) %(_SOSShard.ShardIndex) %(_SOSShard.ShardCount) %(_SOSShard.Liveness) $(NetCoreAppTestTargetFramework) + $(SOSHelixPlatformTestEnvironment)$(SOSHelixTestEnvironment)bash $HELIX_CORRELATION_PAYLOAD/eng/helix/sos/run-sos-tests.sh $(Configuration) $(TargetRid) %(_SOSShard.ShardIndex) %(_SOSShard.ShardCount) %(_SOSShard.Liveness) $(NetCoreAppTestTargetFramework) %(_SOSShard.MaxParallelThreads) + $(WorkItemTimeout) + + <_AzurePipelinesReporterPayload Include="@(HelixCorrelationPayload)" + Condition="'$(HelixTargetQueue)' != '' and '%(HelixCorrelationPayload.IncludeDirectoryName)' == 'true'" /> + + + + + + + + + + <_PayloadRootFile Include="$(RepoRoot)global.json;$(RepoRoot)Build.cmd" /> + <_PayloadEngineeringFile Include="$(RepoRoot)eng/DisableSignatureCheck.ps1" /> + <_PayloadLauncherFile Include="$(RepoRoot)eng/helix/sos/**/*" /> + + <_PayloadBinFile Include="$(ArtifactsBinDir)SOS.Tests/$(Configuration)/**/*; + $(ArtifactsBinDir)SOS.TestHarness.EngineHost/$(Configuration)/**/*; + $(ArtifactsBinDir)SOS.TestHarness.Capturer/$(Configuration)/**/*; + $(ArtifactsBinDir)dotnet-dump/$(Configuration)/**/*; + $(ArtifactsBinDir)NestedExceptionTest/$(Configuration)/*/*; + $(ArtifactsBinDir)NestedExceptionTest/$(Configuration)/*/$(TargetRid)/publish/*; + $(ArtifactsBinDir)DivZero/$(Configuration)/*/*; + $(ArtifactsBinDir)DivZero/$(Configuration)/*/$(TargetRid)/publish/*; + $(ArtifactsBinDir)AsyncMain/$(Configuration)/*/*; + $(ArtifactsBinDir)AsyncMain/$(Configuration)/*/$(TargetRid)/publish/*; + $(ArtifactsBinDir)DynamicMethod/$(Configuration)/*/*; + $(ArtifactsBinDir)DynamicMethod/$(Configuration)/*/$(TargetRid)/publish/*; + $(ArtifactsBinDir)Overflow/$(Configuration)/*/*; + $(ArtifactsBinDir)Overflow/$(Configuration)/*/$(TargetRid)/publish/*; + $(ArtifactsBinDir)LineNums/$(Configuration)/*/*; + $(ArtifactsBinDir)LineNums/$(Configuration)/*/$(TargetRid)/publish/*; + $(ArtifactsBinDir)SimpleThrow/$(Configuration)/*/*; + $(ArtifactsBinDir)SimpleThrow/$(Configuration)/*/$(TargetRid)/publish/*; + $(ArtifactsBinDir)ReflectionTest/$(Configuration)/*/*; + $(ArtifactsBinDir)ReflectionTest/$(Configuration)/*/$(TargetRid)/publish/*; + $(ArtifactsBinDir)SosHarnessScenarios/$(Configuration)/*/*; + $(ArtifactsBinDir)SosHarnessScenarios/$(Configuration)/*/$(TargetRid)/publish/*" /> + <_PayloadNativeFile Include="$(PayloadNativeSourceDir)**/*" /> + <_PayloadDotNetTestFile Include="$(ArtifactsDotnetTestDir)$(DotNetHostName); + $(ArtifactsDotnetTestDir)LICENSE.txt; + $(ArtifactsDotnetTestDir)ThirdPartyNotices.txt; + $(ArtifactsDotnetTestDir)Debugger.Tests.Versions.txt; + $(ArtifactsDotnetTestDir)host/fxr/**/*; + $(ArtifactsDotnetTestDir)shared/Microsoft.NETCore.App/**/*" /> + <_PayloadCDacFile Include="$(ArtifactsDir)cdac-override/$(Configuration)/**/*" + Condition="Exists('$(ArtifactsDir)cdac-override/$(Configuration)')" /> + + <_PayloadCdbFile Include="$(NuGetPackageRoot)cdb-sos/$(cdbsosversion)/runtimes/$(TargetRid)/native/*" + Condition="'$(TargetOS)' == 'Windows_NT'" /> + + <_PayloadFile Include="@(_PayloadRootFile)"> + %(_PayloadRootFile.Filename)%(_PayloadRootFile.Extension) + + <_PayloadFile Include="@(_PayloadEngineeringFile)"> + eng/%(_PayloadEngineeringFile.Filename)%(_PayloadEngineeringFile.Extension) + + <_PayloadFile Include="@(_PayloadLauncherFile)"> + eng/helix/sos/%(_PayloadLauncherFile.RecursiveDir)%(_PayloadLauncherFile.Filename)%(_PayloadLauncherFile.Extension) + + <_PayloadFile Include="@(_PayloadBinFile)"> + artifacts/bin/$([MSBuild]::MakeRelative('$(ArtifactsBinDir)', '%(_PayloadBinFile.Identity)')) + + <_PayloadFile Include="@(_PayloadNativeFile)"> + artifacts/bin/$(TargetOS).$(TargetArch).$(Configuration)/%(_PayloadNativeFile.RecursiveDir)%(_PayloadNativeFile.Filename)%(_PayloadNativeFile.Extension) + + <_PayloadFile Include="@(_PayloadDotNetTestFile)"> + artifacts/dotnet-test/$([MSBuild]::MakeRelative('$(ArtifactsDotnetTestDir)', '%(_PayloadDotNetTestFile.Identity)')) + + <_PayloadFile Include="@(_PayloadCDacFile)"> + artifacts/cdac-override/$(Configuration)/%(_PayloadCDacFile.RecursiveDir)%(_PayloadCDacFile.Filename)%(_PayloadCDacFile.Extension) + + <_PayloadFile Include="@(_PayloadCdbFile)"> + artifacts/cdb-sos/%(_PayloadCdbFile.Filename)%(_PayloadCdbFile.Extension) + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eng/helix/sos/debuggee-entitlements.plist b/eng/helix/sos/debuggee-entitlements.plist new file mode 100644 index 0000000000..e73e28253b --- /dev/null +++ b/eng/helix/sos/debuggee-entitlements.plist @@ -0,0 +1,9 @@ + + + + + + com.apple.security.get-task-allow + + + diff --git a/eng/helix/sos/run-sos-tests.cmd b/eng/helix/sos/run-sos-tests.cmd new file mode 100644 index 0000000000..86bf160b2d --- /dev/null +++ b/eng/helix/sos/run-sos-tests.cmd @@ -0,0 +1,104 @@ +@echo off +setlocal EnableExtensions + +if "%~6"=="" ( + echo usage: %~nx0 ^ ^ ^ ^ ^ ^ + exit /b 2 +) + +if "%HELIX_CORRELATION_PAYLOAD%"=="" ( + echo HELIX_CORRELATION_PAYLOAD is required. + exit /b 2 +) + +if "%HELIX_WORKITEM_UPLOAD_ROOT%"=="" ( + echo HELIX_WORKITEM_UPLOAD_ROOT is required. + exit /b 2 +) + +if "%HELIX_WORKITEM_ROOT%"=="" ( + echo HELIX_WORKITEM_ROOT is required. + exit /b 2 +) + +set "CONFIGURATION=%~1" +set "RID=%~2" +set "SHARD_INDEX=%~3" +set "SHARD_COUNT=%~4" +set "LIVENESS=%~5" +set "TEST_TFM=%~6" +set "ROOT=%HELIX_CORRELATION_PAYLOAD%" +set "UPLOAD=%HELIX_WORKITEM_UPLOAD_ROOT%" +set "TARGET_ARCH=%RID:win-=%" +set "PAYLOAD_DOTNET_ROOT=%ROOT%\artifacts\dotnet-test" +set "DOTNET_MULTILEVEL_LOOKUP=0" +set "NUGET_PACKAGES=%ROOT%\.packages" +set "SOSHARNESS_REPO_ROOT=%ROOT%" +set "SOSHARNESS_ARTIFACTS_CONFIG=%CONFIGURATION%" +set "SOSHARNESS_DBGENG_ROOT=%ROOT%\artifacts\cdb-sos" +set "SOSHARNESS_USE_PREBUILT_TARGETS=1" +set "SOSHARNESS_SHARD_INDEX=%SHARD_INDEX%" +set "SOSHARNESS_SHARD_COUNT=%SHARD_COUNT%" +set "SOSHARNESS_ONLY_LIVENESS=%LIVENESS%" +set "SOSHARNESS_UPLOAD_ROOT=%UPLOAD%" + +if not exist "%UPLOAD%" mkdir "%UPLOAD%" + +set "TEST_DLL=%ROOT%\artifacts\bin\SOS.Tests\%CONFIGURATION%\%TEST_TFM%\SOS.Tests.dll" +set "SIGNATURE_SCRIPT=%ROOT%\eng\DisableSignatureCheck.ps1" +set "SIGNATURE_REPO=%HELIX_WORKITEM_ROOT%\sos-signature-repo" +set "SIGNATURE_RUNTIME=%SIGNATURE_REPO%\artifacts\dotnet-test" +set "POWERSHELL_EXE=powershell.exe" +if /I "%TARGET_ARCH%"=="x86" set "POWERSHELL_EXE=%SystemRoot%\SysWOW64\WindowsPowerShell\v1.0\powershell.exe" + +if not exist "%TEST_DLL%" ( + echo SOS.Tests.dll was not found at "%TEST_DLL%". + exit /b 3 +) + +if not exist "%SIGNATURE_SCRIPT%" ( + echo DisableSignatureCheck.ps1 was not found at "%SIGNATURE_SCRIPT%". + exit /b 3 +) + +if not exist "%SIGNATURE_REPO%\artifacts" mkdir "%SIGNATURE_REPO%\artifacts" +if not exist "%SIGNATURE_RUNTIME%\." ( + mklink /J "%SIGNATURE_RUNTIME%" "%PAYLOAD_DOTNET_ROOT%" + if errorlevel 1 exit /b 3 +) + +set "DOTNET_ROOT=%SIGNATURE_RUNTIME%" +set "DOTNET_ROOT_X86=%DOTNET_ROOT%" +set "SOSHARNESS_DOTNET_ROOT=%DOTNET_ROOT%" +set "SOSHARNESS_DOTNET_TEST_ROOT=%DOTNET_ROOT%" + +set "IDENTITY=%LIVENESS%-%SHARD_INDEX%-of-%SHARD_COUNT%" +set "LOG=%UPLOAD%\SOS.Tests-%RID%-%CONFIGURATION%-%IDENTITY%.log" + +"%POWERSHELL_EXE%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ^ + -File "%SIGNATURE_SCRIPT%" -RepoRoot "%SIGNATURE_REPO%" +set "SIGNATURE_EXIT_CODE=%ERRORLEVEL%" +if not "%SIGNATURE_EXIT_CODE%"=="0" ( + "%POWERSHELL_EXE%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ^ + -File "%SIGNATURE_SCRIPT%" -Restore -RepoRoot "%SIGNATURE_REPO%" + exit /b %SIGNATURE_EXIT_CODE% +) + +"%DOTNET_ROOT%\dotnet.exe" "%TEST_DLL%" ^ + --results-directory "%UPLOAD%" ^ + --report-xunit ^ + --report-xunit-filename "SOS.Tests-%RID%-%CONFIGURATION%-%IDENTITY%.xml" ^ + --report-xunit-html ^ + --report-xunit-html-filename "SOS.Tests-%RID%-%CONFIGURATION%-%IDENTITY%.html" ^ + --report-trx ^ + --report-trx-filename "SOS.Tests-%RID%-%CONFIGURATION%-%IDENTITY%.trx" ^ + --auto-reporters off > "%LOG%" 2>&1 +set "EXIT_CODE=%ERRORLEVEL%" + +"%POWERSHELL_EXE%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ^ + -File "%SIGNATURE_SCRIPT%" -Restore -RepoRoot "%SIGNATURE_REPO%" +set "RESTORE_EXIT_CODE=%ERRORLEVEL%" + +type "%LOG%" +if not "%RESTORE_EXIT_CODE%"=="0" exit /b %RESTORE_EXIT_CODE% +exit /b %EXIT_CODE% diff --git a/eng/helix/sos/run-sos-tests.sh b/eng/helix/sos/run-sos-tests.sh new file mode 100755 index 0000000000..d5f27e5673 --- /dev/null +++ b/eng/helix/sos/run-sos-tests.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -lt 6 || $# -gt 7 ]]; then + echo "usage: $0 [max-parallel-threads]" >&2 + exit 2 +fi + +configuration="$1" +rid="$2" +shard_index="$3" +shard_count="$4" +liveness="$5" +test_tfm="$6" +max_parallel_threads="${7:-}" +test_runtime_major="${SOSHARNESS_TEST_RUNTIME_MAJOR:-}" +liveness_name="$(printf '%s' "$liveness" | tr '[:upper:]' '[:lower:]')" + +if [[ -n "$max_parallel_threads" && (! "$max_parallel_threads" =~ ^[1-9][0-9]*$) ]]; then + echo "max-parallel-threads must be a positive integer; got '$max_parallel_threads'." >&2 + exit 2 +fi + +if [[ -n "$test_runtime_major" && (! "$test_runtime_major" =~ ^[1-9][0-9]*$) ]]; then + echo "SOSHARNESS_TEST_RUNTIME_MAJOR must be a positive integer; got '$test_runtime_major'." >&2 + exit 2 +fi + +: "${HELIX_CORRELATION_PAYLOAD:?HELIX_CORRELATION_PAYLOAD is required}" +: "${HELIX_WORKITEM_UPLOAD_ROOT:?HELIX_WORKITEM_UPLOAD_ROOT is required}" + +root="$HELIX_CORRELATION_PAYLOAD" +upload="$HELIX_WORKITEM_UPLOAD_ROOT" +test_dll="$root/artifacts/bin/SOS.Tests/$configuration/$test_tfm/SOS.Tests.dll" +identity="${liveness_name}-${shard_index}-of-${shard_count}" +work="$PWD/.sos-harness" + +mkdir -p "$upload" "$work" + +if [[ ! -f "$test_dll" ]]; then + echo "SOS.Tests.dll was not found at '$test_dll'." >&2 + exit 3 +fi + +mirror_tree() +{ + source_root="$1" + destination_root="$2" + + rm -rf "$destination_root" + mkdir -p "$destination_root" + + while IFS= read -r source_dir; do + relative_dir="${source_dir#"$source_root"}" + mkdir -p "$destination_root$relative_dir" + done < <(find "$source_root" -type d) + + while IFS= read -r source_file; do + relative_file="${source_file#"$source_root"/}" + ln -s "$source_file" "$destination_root/$relative_file" + done < <(find "$source_root" ! -type d) +} + +prepare_dotnet_root() +{ + source_root="$root/artifacts/dotnet-test" + needs_overlay=0 + + if [[ ! -x "$source_root/dotnet" ]]; then + needs_overlay=1 + fi + + while IFS= read -r createdump; do + if [[ ! -x "$createdump" ]]; then + needs_overlay=1 + break + fi + done < <(find "$source_root" -type f -name createdump) + + if [[ "$needs_overlay" == "0" ]]; then + printf '%s\n' "$source_root" + return + fi + + destination_root="$work/dotnet-test" + echo "Creating writable executable overlay for dotnet-test." >&2 + mirror_tree "$source_root" "$destination_root" + + rm "$destination_root/dotnet" + cp "$source_root/dotnet" "$destination_root/dotnet" + chmod +x "$destination_root/dotnet" + + while IFS= read -r createdump; do + relative_createdump="${createdump#"$source_root"/}" + rm "$destination_root/$relative_createdump" + cp "$createdump" "$destination_root/$relative_createdump" + chmod +x "$destination_root/$relative_createdump" + done < <(find "$source_root" -type f -name createdump) + + printf '%s\n' "$destination_root" +} + +configure_lldb() +{ + if [[ "$(uname -s)" == "Darwin" ]]; then + target_arch="${rid##*-}" + driver_source="$root/artifacts/bin/osx.$target_arch.$configuration/sos-lldb" + if [[ ! -f "$driver_source" ]]; then + echo "The SOS LLDB driver was not found at '$driver_source'." >&2 + exit 4 + fi + + driver="$driver_source" + if [[ ! -x "$driver" ]]; then + driver="$work/sos-lldb" + cp "$driver_source" "$driver" + chmod +x "$driver" + fi + + developer_dir="${DEVELOPER_DIR:-$(xcode-select -p)}" + shared_frameworks="$(cd "$developer_dir/../SharedFrameworks" && pwd)" + if [[ ! -d "$shared_frameworks/LLDB.framework" ]]; then + echo "LLDB.framework was not found under the selected Xcode at '$shared_frameworks'." >&2 + exit 4 + fi + export DYLD_FRAMEWORK_PATH="$shared_frameworks${DYLD_FRAMEWORK_PATH:+:$DYLD_FRAMEWORK_PATH}" + + lldb_check="$("$driver" --no-lldbinit --batch \ + -o 'script print("__SOSHARNESS_LLDB_READY__")' 2>&1 || true)" + if [[ "$lldb_check" != *"__SOSHARNESS_LLDB_READY__"* ]]; then + echo "The SOS LLDB driver failed its Python interpreter preflight at '$driver'." >&2 + echo "$lldb_check" >&2 + exit 4 + fi + + echo "Using SOS LLDB driver at '$driver'." + export SOSHARNESS_LLDB_PATH="$driver" + return + fi + + if [[ "$(uname -s)" != "Linux" ]]; then + return + fi + + if [[ -z "${LLDB_PATH:-}" ]]; then + for candidate in lldb-16 lldb16 lldb-15 lldb15 lldb-14 lldb14 lldb-13 lldb13 lldb-12 lldb12 lldb; do + if command -v "$candidate" > /dev/null 2>&1; then + LLDB_PATH="$(command -v "$candidate")" + break + fi + done + fi + + if [[ -z "${LLDB_PATH:-}" || ! -x "$LLDB_PATH" ]]; then + echo "Could not locate an executable LLDB. Set LLDB_PATH or install LLDB on the Helix image." >&2 + exit 4 + fi + + lldb_python_module="" + resolved_lldb="$(readlink -f "$LLDB_PATH" 2>/dev/null || printf '%s' "$LLDB_PATH")" + lldb_version="${resolved_lldb##*-}" + for llvm_root in "/usr/lib/llvm-$lldb_version" "/usr/lib/llvm$lldb_version" /usr/lib/llvm-* /usr/lib/llvm*; do + if [[ ! -d "$llvm_root" ]]; then + continue + fi + + lldb_python_module="$(find "$llvm_root" -type f -path '*/lldb/embedded_interpreter.py' -print 2>/dev/null | head -n 1 || true)" + if [[ -n "$lldb_python_module" ]]; then + break + fi + done + + if [[ -n "$lldb_python_module" ]]; then + lldb_python_root="$(dirname "$(dirname "$lldb_python_module")")" + export PYTHONPATH="$lldb_python_root${PYTHONPATH:+:$PYTHONPATH}" + fi + + lldb_check="$("$LLDB_PATH" --no-lldbinit --batch \ + -o 'script print("__SOSHARNESS_LLDB_READY__")' \ + -o quit 2>&1 || true)" + if [[ "$lldb_check" != *"__SOSHARNESS_LLDB_READY__"* ]]; then + echo "LLDB failed its Python interpreter preflight at '$LLDB_PATH'." >&2 + echo "$lldb_check" >&2 + exit 4 + fi + + echo "Using LLDB at '$LLDB_PATH'." + export LLDB_PATH +} + +dotnet_root="$(prepare_dotnet_root)" +dotnet="$dotnet_root/dotnet" +dotnet_arguments=("$test_dll") + +if [[ -n "$test_runtime_major" ]]; then + test_runtime_version="$("$dotnet" --list-runtimes | awk -v prefix="$test_runtime_major." \ + '$1 == "Microsoft.NETCore.App" && index($2, prefix) == 1 { version = $2 } END { print version }')" + if [[ -z "$test_runtime_version" ]]; then + echo "Microsoft.NETCore.App $test_runtime_major.x was not found under '$dotnet_root'." >&2 + exit 3 + fi + + echo "Running SOS.Tests on Microsoft.NETCore.App $test_runtime_version." + dotnet_arguments=(--fx-version "$test_runtime_version" "$test_dll") +fi + +if [[ "$(uname -s)" == "Darwin" ]]; then + entitlements="$root/eng/helix/sos/debuggee-entitlements.plist" + for debuggee in NestedExceptionTest DivZero AsyncMain DynamicMethod Overflow LineNums SimpleThrow ReflectionTest SosHarnessScenarios; do + while IFS= read -r source_executable; do + relative_executable="${source_executable#"$root"/}" + overlay_executable="$work/executables/$relative_executable" + mkdir -p "$(dirname "$overlay_executable")" + cp "$source_executable" "$overlay_executable" + chmod +x "$overlay_executable" + codesign --force --sign - --entitlements "$entitlements" "$overlay_executable" + done < <(find "$root/artifacts/bin/$debuggee/$configuration" -type f -name "$debuggee") + done + export SOSHARNESS_EXCLUDE_SINGLEFILE_SNAPSHOTS=1 +fi + +if [[ "$rid" == linux-musl-* ]]; then + target_arch="${rid##*-}" + native_source="$root/artifacts/bin/linux.$target_arch.$configuration" + native_overlay="$work/native" + mirror_tree "$native_source" "$native_overlay" + if [[ -e "$native_source/libmscordaccore_universal.so" ]]; then + rm "$native_overlay/libmscordaccore_universal.so" + cp "$native_source/libmscordaccore_universal.so" "$native_overlay/libmscordaccore_universal.so" + fi + export SOSHARNESS_NATIVE_ROOT="$native_overlay" +fi + +configure_lldb + +export DOTNET_ROOT="$dotnet_root" +export DOTNET_ROOT_X64="$DOTNET_ROOT" +export DOTNET_MULTILEVEL_LOOKUP=0 +export NUGET_PACKAGES="$root/.packages" +export SOSHARNESS_REPO_ROOT="$root" +export SOSHARNESS_DOTNET_ROOT="$DOTNET_ROOT" +export SOSHARNESS_DOTNET_TEST_ROOT="$DOTNET_ROOT" +export SOSHARNESS_EXECUTABLE_ROOT="$work/executables" +export SOSHARNESS_SCRATCH_ROOT="$work/scratch" +export SOSHARNESS_ARTIFACTS_CONFIG="$configuration" +export SOSHARNESS_USE_PREBUILT_TARGETS=1 +export SOSHARNESS_SHARD_INDEX="$shard_index" +export SOSHARNESS_SHARD_COUNT="$shard_count" +export SOSHARNESS_ONLY_LIVENESS="$liveness" +export SOSHARNESS_UPLOAD_ROOT="$upload" +export SOSHARNESS_LLDB_TRACE="$upload/SOS.Tests-${rid}-${configuration}-${identity}.lldb.log" + +log="$upload/SOS.Tests-${rid}-${configuration}-${identity}.log" +run_tests() +{ + "$dotnet" "${dotnet_arguments[@]}" "$@" \ + --results-directory "$upload" \ + --report-xunit \ + --report-xunit-filename "SOS.Tests-${rid}-${configuration}-${identity}.xml" \ + --report-xunit-html \ + --report-xunit-html-filename "SOS.Tests-${rid}-${configuration}-${identity}.html" \ + --report-trx \ + --report-trx-filename "SOS.Tests-${rid}-${configuration}-${identity}.trx" \ + --auto-reporters off +} + +set +e +if [[ -n "$max_parallel_threads" ]]; then + run_tests --max-threads "$max_parallel_threads" 2>&1 | tee "$log" + exit_code=${PIPESTATUS[0]} +else + run_tests 2>&1 | tee "$log" + exit_code=${PIPESTATUS[0]} +fi +set -e + +exit "$exit_code" diff --git a/eng/pipelines/build.yml b/eng/pipelines/build.yml index d6b95a98ad..28cb816abb 100644 --- a/eng/pipelines/build.yml +++ b/eng/pipelines/build.yml @@ -40,7 +40,7 @@ parameters: default: {} # Build configs. An object with the following properties: configuration, architecture. -# Optionally it can also contain artifactUploadPath, artifactTargetPath +# Optionally it can also contain primary and additional architecture artifact upload/target paths. - name: buildConfigs type: object default: {} @@ -66,6 +66,52 @@ parameters: type: boolean default: false +# Include the target-architecture test runtime in TestArtifacts for a Helix-only +# test leg that cannot execute target binaries on its submission host. +- name: publishTestRuntimeArtifacts + type: boolean + default: false + +# Optional: move SOS.Tests from this leg to a sharded Helix submission. +- name: runSOSHelix + type: boolean + default: false + +# Submit SOS.Tests to Helix without running the repository's local test suite. +# Used when a cross-built target architecture cannot run on the pipeline agent. +- name: helixOnly + type: boolean + default: false + +- name: sosHelixTargetOS + type: string + default: '' + +- name: sosHelixRidPrefix + type: string + default: '' + +- name: sosHelixQueue + type: string + default: '' + +# Optional: build a second architecture in the same job after the primary build/test. +- name: additionalArchitecture + type: string + default: '' + +- name: additionalCrossBuild + type: boolean + default: false + +- name: runAdditionalSOSHelix + type: boolean + default: false + +- name: additionalSOSHelixQueue + type: string + default: '' + # Optional: architecture cross build if true - name: crossBuild type: boolean @@ -119,7 +165,8 @@ jobs: ${{ if ne(parameters.strategy, '') }}: 'error, we can no longer support the strategy feature in the new pipeline system. Please remove the strategy from the job template.': error - ${{ if and(ne(parameters.dependsOn, ''), ne(parameters.testOnly, true)) }}: + # Within a stage, test-only jobs can start as soon as their matching build job publishes its artifacts. + ${{ if ne(parameters.dependsOn, '') }}: dependsOn: ${{ parameters.dependsOn }}_${{ config.architecture }}_${{ config.configuration }} workspace: @@ -129,13 +176,26 @@ jobs: - ${{ insert }}: ${{ parameters.variables }} - _PhaseName: ${{ coalesce(parameters.name, parameters.osGroup) }}_${{ config.architecture }}_${{ config.configuration }} + - _BuildConfig: ${{ config.configuration }} - _Pipeline_StreamDumpDir: $(Build.SourcesDirectory)/artifacts/tmp/${{ config.configuration }}/streams - _TestArgs: '-test' - _Cross: '' + - _AdditionalCross: '' + - _SkipLocalSOSTests: 'false' - ${{ if and(eq(parameters.testOnly, 'true'), eq(parameters.buildOnly, 'true')) }}: 'error, testOnly and buildOnly cannot be true at the same time': error + - ${{ if and(eq(parameters.helixOnly, true), or(ne(parameters.testOnly, true), ne(parameters.runSOSHelix, true))) }}: + 'error, helixOnly requires testOnly and runSOSHelix': error + - ${{ if and(eq(parameters.publishTestRuntimeArtifacts, true), ne(parameters.publishTestArtifacts, true)) }}: + 'error, publishTestRuntimeArtifacts requires publishTestArtifacts': error + - ${{ if and(ne(parameters.additionalArchitecture, ''), or(eq(parameters.testOnly, true), ne(parameters.osGroup, 'MacOS'))) }}: + 'error, additionalArchitecture is only supported by macOS build jobs': error + - ${{ if and(eq(parameters.additionalCrossBuild, true), eq(parameters.additionalArchitecture, '')) }}: + 'error, additionalCrossBuild requires additionalArchitecture': error + - ${{ if and(eq(parameters.runAdditionalSOSHelix, true), or(eq(parameters.additionalArchitecture, ''), eq(parameters.additionalSOSHelixQueue, ''))) }}: + 'error, runAdditionalSOSHelix requires additionalArchitecture and additionalSOSHelixQueue': error # Test-only legs invoke test.cmd/test.sh, which already encode # '-test -skipmanaged -skipnative' and do not prepend '-restore -build'. All other legs @@ -156,6 +216,9 @@ jobs: - ${{ if eq(parameters.buildOnly, 'true') }}: - _TestArgs: '' + - ${{ if or(eq(parameters.runSOSHelix, true), eq(parameters.runAdditionalSOSHelix, true)) }}: + - _SkipLocalSOSTests: 'true' + # For testing msrc's and service releases. The RuntimeSourceVersion is either "default" or the service release version to test - _InternalInstallArgs: '' - ${{ if and(ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: @@ -169,6 +232,9 @@ jobs: - ${{ if eq(parameters.crossBuild, true) }}: - _Cross: -cross + - ${{ if eq(parameters.additionalCrossBuild, true) }}: + - _AdditionalCross: -cross + steps: - ${{ if eq(parameters.testOnly, true) }}: - ${{ if notIn(parameters.osGroup, 'Windows_NT', 'MacOS', 'Linux') }}: @@ -184,22 +250,95 @@ jobs: targetPath: '$(Build.SourcesDirectory)/artifacts' checkDownloadedFiles: true - - script: $(_buildScript) - -ci - -binaryLog - -configuration ${{ config.configuration }} - -architecture ${{ config.architecture }} - $(_TestArgs) - $(_Cross) - $(_InternalInstallArgs) - /p:OfficialBuildId=$(BUILD.BUILDNUMBER) - ${{ if eq(parameters.testOnly, 'true') }}: - displayName: Test - ${{ elseif eq(parameters.buildOnly, 'true') }}: - displayName: Build - ${{ else }}: - displayName: Build / Test - condition: succeeded() + - ${{ if eq(parameters.helixOnly, false) }}: + - script: $(_buildScript) + -ci + -binaryLog + -configuration ${{ config.configuration }} + -architecture ${{ config.architecture }} + $(_TestArgs) + $(_Cross) + $(_InternalInstallArgs) + /p:OfficialBuildId=$(BUILD.BUILDNUMBER) + ${{ if eq(parameters.testOnly, 'true') }}: + displayName: Test + ${{ elseif eq(parameters.buildOnly, 'true') }}: + displayName: Build + ${{ else }}: + displayName: Build / Test + condition: succeeded() + env: + SOSHARNESS_SKIP_LOCAL_TESTS: $(_SkipLocalSOSTests) + - ${{ if eq(parameters.runSOSHelix, true) }}: + - template: /eng/common/templates/steps/send-to-helix.yml@self + parameters: + DisplayNamePrefix: Run SOS.Tests on Helix + HelixProjectPath: eng/helix/SOS.Tests.Helix.proj + HelixProjectArguments: >- + /p:Configuration=${{ config.configuration }} + /p:TargetArch=${{ config.architecture }} + /p:TargetOS=${{ parameters.sosHelixTargetOS }} + /p:TargetRid=${{ parameters.sosHelixRidPrefix }}-${{ config.architecture }} + HelixSource: pr/dotnet/diagnostics/sos-tests + HelixType: tests/sos/harness/ + HelixConfiguration: ${{ parameters.sosHelixRidPrefix }}-${{ config.architecture }}_${{ config.configuration }} + HelixTargetQueues: ${{ parameters.sosHelixQueue }} + ${{ if eq(parameters.sosHelixRidPrefix, 'linux-musl') }}: + HelixPreCommands: sudo apk add --no-cache py3-lldb + ${{ if eq(variables['System.TeamProject'], 'public') }}: + Creator: dotnet-bot + ${{ else }}: + HelixAccessToken: $(HelixApiAccessToken) + condition: succeeded() + + - ${{ if ne(parameters.additionalArchitecture, '') }}: + # InstallRuntimes.proj uses one shared output directory whose incremental marker is not + # architecture-specific. Reset it before switching targets so the second build installs + # the correct runtime and apphost closure. + - pwsh: | + $runtimePaths = @( + "$(Build.SourcesDirectory)/artifacts/dotnet-test", + "$(Build.SourcesDirectory)/artifacts/dotnet-build" + ) + foreach ($runtimePath in $runtimePaths) { + if (Test-Path $runtimePath) { + Remove-Item -Recurse -Force $runtimePath + } + } + displayName: Prepare ${{ parameters.additionalArchitecture }} runtime + condition: succeeded() + + - script: $(_buildScript) + -ci + -binaryLog + -configuration ${{ config.configuration }} + -architecture ${{ parameters.additionalArchitecture }} + $(_AdditionalCross) + $(_InternalInstallArgs) + /p:OfficialBuildId=$(BUILD.BUILDNUMBER) + displayName: Build ${{ parameters.additionalArchitecture }} + condition: succeeded() + + - ${{ if eq(parameters.runAdditionalSOSHelix, true) }}: + - template: /eng/common/templates/steps/send-to-helix.yml@self + parameters: + DisplayNamePrefix: Run ${{ parameters.additionalArchitecture }} SOS.Tests on Helix + HelixProjectPath: eng/helix/SOS.Tests.Helix.proj + HelixProjectArguments: >- + /p:Configuration=${{ config.configuration }} + /p:TargetArch=${{ parameters.additionalArchitecture }} + /p:TargetOS=${{ parameters.sosHelixTargetOS }} + /p:TargetRid=${{ parameters.sosHelixRidPrefix }}-${{ parameters.additionalArchitecture }} + HelixSource: pr/dotnet/diagnostics/sos-tests + HelixType: tests/sos/harness/ + HelixConfiguration: ${{ parameters.sosHelixRidPrefix }}-${{ parameters.additionalArchitecture }}_${{ config.configuration }} + HelixTargetQueues: ${{ parameters.additionalSOSHelixQueue }} + ${{ if eq(variables['System.TeamProject'], 'public') }}: + Creator: dotnet-bot + ${{ else }}: + HelixAccessToken: $(HelixApiAccessToken) + condition: succeeded() + - ${{ if ne(config.artifactUploadPath, '') }}: - task: CopyFiles@2 displayName: Gather binaries for publish @@ -215,6 +354,21 @@ jobs: targetPath: '$(Build.ArtifactStagingDirectory)/artifacts' artifactName: Build_$(_PhaseName) + - ${{ if ne(config.additionalArtifactUploadPath, '') }}: + - task: CopyFiles@2 + displayName: Gather ${{ parameters.additionalArchitecture }} binaries for publish + inputs: + SourceFolder: '$(Build.SourcesDirectory)/artifacts/${{ config.additionalArtifactUploadPath }}' + Contents: '**' + TargetFolder: $(Build.ArtifactStagingDirectory)/additional-artifacts/${{ coalesce(config.additionalArtifactTargetPath, config.additionalArtifactUploadPath) }} + + - template: /eng/pipelines/publish-pipeline-artifact-shim.yml@self + parameters: + displayName: Publish ${{ parameters.additionalArchitecture }} Build Artifacts + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/additional-artifacts' + artifactName: Build_${{ coalesce(parameters.name, parameters.osGroup) }}_${{ parameters.additionalArchitecture }}_${{ config.configuration }} + # Publish the full build outputs for the matching test-only legs to consume. This lets the # test legs run with -skipnative -skipmanaged instead of rebuilding the product themselves. # Only valid on build-only legs (publishTestArtifacts without buildOnly is a configuration error). @@ -229,6 +383,20 @@ jobs: test/** TargetFolder: $(Build.ArtifactStagingDirectory)/testartifacts + - ${{ if eq(parameters.publishTestRuntimeArtifacts, true) }}: + - task: CopyFiles@2 + displayName: Gather target test runtime for publish + inputs: + SourceFolder: '$(Build.SourcesDirectory)/artifacts/dotnet-test' + Contents: | + dotnet + LICENSE.txt + ThirdPartyNotices.txt + Debugger.Tests.Versions.txt + host/fxr/** + shared/Microsoft.NETCore.App/** + TargetFolder: $(Build.ArtifactStagingDirectory)/testartifacts/dotnet-test + - template: /eng/pipelines/publish-pipeline-artifact-shim.yml@self parameters: displayName: Publish Test Artifacts @@ -236,7 +404,7 @@ jobs: targetPath: '$(Build.ArtifactStagingDirectory)/testartifacts' artifactName: TestArtifacts_$(_PhaseName) - - ${{ if ne(parameters.buildOnly, 'true') }}: + - ${{ if and(ne(parameters.buildOnly, 'true'), eq(parameters.helixOnly, false)) }}: # Publish test results to Azure Pipelines - task: PublishTestResults@2 inputs: diff --git a/global.json b/global.json index 34ccec2c24..2a20d1e049 100644 --- a/global.json +++ b/global.json @@ -15,6 +15,7 @@ "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.0", "Microsoft.Build.Traversal": "3.4.0", - "Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.26431.5" + "Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.26431.5", + "Microsoft.DotNet.Helix.Sdk": "10.0.0-beta.26431.5" } } diff --git a/src/SOS/Strike/clrma/managedanalysis.cpp b/src/SOS/Strike/clrma/managedanalysis.cpp index 9e0b86ffc0..649ad7ca3b 100644 --- a/src/SOS/Strike/clrma/managedanalysis.cpp +++ b/src/SOS/Strike/clrma/managedanalysis.cpp @@ -413,6 +413,12 @@ ClrmaManagedAnalysis::GetThread( #endif } + if (osThreadId == 0 || osThreadId == (ULONG)-1) + { + TraceError("GetThread resolved an invalid OS thread ID %08x\n", osThreadId); + return E_UNEXPECTED; + } + if (m_clrmaService != nullptr) { if (FAILED(hr = m_clrmaService->GetThread(osThreadId, ppClrThread))) 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/SOS/lldbplugin/CMakeLists.txt b/src/SOS/lldbplugin/CMakeLists.txt index 5ed1f71759..79cfda5493 100644 --- a/src/SOS/lldbplugin/CMakeLists.txt +++ b/src/SOS/lldbplugin/CMakeLists.txt @@ -113,8 +113,6 @@ add_compile_options(-Wno-delete-non-virtual-dtor) include_directories(${ROOT_DIR}/src/SOS/inc) include_directories(${ROOT_DIR}/src/SOS/extensions) -include_directories("${LLDB_H}") - set(SOURCES sosplugin.cpp soscommand.cpp @@ -125,10 +123,14 @@ set(SOURCES set(LIBRARIES extensions + sos_lldb_dependencies ) +add_library(sos_lldb_dependencies INTERFACE) +target_include_directories(sos_lldb_dependencies INTERFACE "${LLDB_H}") + if(NOT ${LLDB_LIB} STREQUAL "") - list(APPEND LIBRARIES ${LLDB_LIB}) + target_link_libraries(sos_lldb_dependencies INTERFACE ${LLDB_LIB}) endif() add_library_clr(sosplugin SHARED ${SOURCES}) diff --git a/src/SOS/lldbplugin/services.cpp b/src/SOS/lldbplugin/services.cpp index 66054f4e72..0373a0860e 100644 --- a/src/SOS/lldbplugin/services.cpp +++ b/src/SOS/lldbplugin/services.cpp @@ -535,7 +535,11 @@ LLDBServices::GetLastEventInformation( InitializeThreadInfo(process); *processId = GetProcessId(process); - *threadId = GetThreadId(thread); + HRESULT hr = GetThreadId(thread, threadId); + if (FAILED(hr)) + { + return hr; + } SpecialDiagInfoHeader header; size_t read = process.ReadMemory(SpecialDiagInfoAddress, &header, sizeof(header), error); @@ -1656,8 +1660,7 @@ LLDBServices::GetCurrentThreadSystemId( return E_FAIL; } - *sysId = GetThreadId(thread); - return S_OK; + return GetThreadId(thread, sysId); } HRESULT @@ -2293,6 +2296,7 @@ class ExtensionCommand : public lldb::SBCommandPluginInterface char** arguments, lldb::SBCommandReturnObject &result) { + LLDBServices::CurrentResultScope resultScope(g_services, &result); IHostServices* hostservices = GetHostServices(); if (hostservices == nullptr) { @@ -2442,7 +2446,11 @@ LLDBServices::GetThreadIdsByIndex( } if (sysIds != nullptr) { - sysIds[index] = GetThreadId(thread); + HRESULT hr = GetThreadId(thread, &sysIds[index]); + if (FAILED(hr)) + { + return hr; + } } } return S_OK; @@ -2878,18 +2886,37 @@ LLDBServices::GetProcessId(lldb::SBProcess process) return m_processId != 0 ? m_processId : process.GetProcessID(); } -uint32_t -LLDBServices::GetThreadId(lldb::SBThread thread) +HRESULT +LLDBServices::GetThreadId(lldb::SBThread thread, PULONG threadId) { + if (threadId == nullptr) + { + return E_INVALIDARG; + } + uint32_t index = thread.GetIndexID() - 1; + ULONG id; if (m_threadInfos.size() > index && m_threadInfos[index].tid != 0) { - return m_threadInfos[index].tid; + id = m_threadInfos[index].tid; } else { - return thread.GetThreadID(); + lldb::tid_t lldbId = thread.GetThreadID(); + if (lldbId == LLDB_INVALID_THREAD_ID || lldbId > UINT32_MAX) + { + *threadId = 0; + return E_UNEXPECTED; + } + id = static_cast(lldbId); + } + if (id == 0 || id == UINT32_MAX) + { + *threadId = 0; + return E_UNEXPECTED; } + *threadId = id; + return S_OK; } lldb::SBProcess @@ -3154,6 +3181,8 @@ LLDBServices::ExecuteCommand( char** arguments, lldb::SBCommandReturnObject &result) { + CurrentResultScope resultScope(this, &result); + // Build all the possible arguments into a string std::string commandArguments; for (const char* arg = *arguments; arg != nullptr; arg = *(++arguments)) diff --git a/src/SOS/lldbplugin/services.h b/src/SOS/lldbplugin/services.h index d9b10ebcb6..4c2ba788b7 100644 --- a/src/SOS/lldbplugin/services.h +++ b/src/SOS/lldbplugin/services.h @@ -65,13 +65,35 @@ class LLDBServices : public ILLDBServices, public ILLDBServices2, public IDebugg void InitializeThreadInfo(lldb::SBProcess process); uint32_t GetProcessId(lldb::SBProcess process); - uint32_t GetThreadId(lldb::SBThread thread); + HRESULT GetThreadId(lldb::SBThread thread, PULONG threadId); lldb::SBThread GetThreadBySystemId(ULONG sysId); lldb::SBProcess GetCurrentProcess(); lldb::SBThread GetCurrentThread(); lldb::SBFrame GetCurrentFrame(); public: + class CurrentResultScope + { + LLDBServices* m_services; + lldb::SBCommandReturnObject* m_previousResult; + + public: + CurrentResultScope(LLDBServices* services, lldb::SBCommandReturnObject* result) : + m_services(services), + m_previousResult(services->m_currentResult) + { + m_services->m_currentResult = result; + } + + ~CurrentResultScope() + { + m_services->m_currentResult = m_previousResult; + } + + CurrentResultScope(const CurrentResultScope&) = delete; + CurrentResultScope& operator=(const CurrentResultScope&) = delete; + }; + LLDBServices(lldb::SBDebugger debugger); ~LLDBServices(); @@ -455,8 +477,5 @@ class LLDBServices : public ILLDBServices, public ILLDBServices2, public IDebugg bool ExecuteCommand( const char* commandName, char** arguments, lldb::SBCommandReturnObject &result); - void SetCurrentResult(lldb::SBCommandReturnObject *result) { m_currentResult = result; } - void ClearCurrentResult() { m_currentResult = nullptr; } - HRESULT InternalOutputVaList(ULONG mask, PCSTR format, va_list args); }; diff --git a/src/SOS/lldbplugin/soscommand.cpp b/src/SOS/lldbplugin/soscommand.cpp index c64d11ba64..3ce7565f6b 100644 --- a/src/SOS/lldbplugin/soscommand.cpp +++ b/src/SOS/lldbplugin/soscommand.cpp @@ -35,6 +35,7 @@ class sosCommand : public lldb::SBCommandPluginInterface char** arguments, lldb::SBCommandReturnObject &result) { + LLDBServices::CurrentResultScope resultScope(g_services, &result); result.SetStatus(lldb::eReturnStatusSuccessFinishResult); const char* sosCommand = m_command; @@ -76,10 +77,8 @@ class sosCommand : public lldb::SBCommandPluginInterface } } g_services->FlushCheck(); - g_services->SetCurrentResult(&result); const char* sosArgs = str.c_str(); HRESULT hr = commandFunc(g_services, sosArgs); - g_services->ClearCurrentResult(); if (hr != S_OK) { result.SetStatus(lldb::eReturnStatusFailed); diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index bf31588276..cc186c8606 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -7,3 +7,7 @@ if(CLR_CMAKE_HOST_WIN32) add_subdirectory(DesktopClrHost) endif(NOT CLR_CMAKE_TARGET_ARCH_ARM) endif(CLR_CMAKE_HOST_WIN32) + +if(CLR_CMAKE_HOST_OSX AND TARGET sos_lldb_dependencies) + add_subdirectory(sos-lldb) +endif() 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/HostDiagnostics.cs b/src/tests/SOS.TestHarness/HostDiagnostics.cs index 12fe81156c..af635b98f8 100644 --- a/src/tests/SOS.TestHarness/HostDiagnostics.cs +++ b/src/tests/SOS.TestHarness/HostDiagnostics.cs @@ -30,8 +30,11 @@ public sealed class HostDiagnostics // on a long-lived shared host would grow without limit across the many tests that reuse it. private const int MaxStreamChars = 128 * 1024; - private static readonly string s_crashRoot = - Path.Combine(RepoLayout.Root, "artifacts", "replays", "crashdumps"); + internal const string UploadRootVariable = "SOSHARNESS_UPLOAD_ROOT"; + + private static readonly string s_crashRoot = ResolveCrashDumpDirectory( + Environment.GetEnvironmentVariable(UploadRootVariable), + RepoLayout.Root); private readonly object _gate = new(); private readonly StringBuilder _stdout = new(); @@ -49,6 +52,11 @@ public sealed class HostDiagnostics /// The shared directory crash dumps are written to (created on demand). public static string CrashDumpDirectory => s_crashRoot; + internal static string ResolveCrashDumpDirectory(string? uploadRoot, string repoRoot) => + string.IsNullOrEmpty(uploadRoot) + ? Path.Combine(repoRoot, "artifacts", "replays", "crashdumps") + : Path.Combine(uploadRoot, "failure-diagnostics", "crashdumps"); + /// The launched command line (exe + args), captured for the replay. public string CommandLine { 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..4415f76915 100644 --- a/src/tests/SOS.TestHarness/RepoLayout.cs +++ b/src/tests/SOS.TestHarness/RepoLayout.cs @@ -33,14 +33,20 @@ public static class RepoLayout .Value!; /// The repo root (the directory containing global.json and Build.cmd). - public static string Root { get; } = FindRoot(); + public static string Root { get; } = + Environment.GetEnvironmentVariable("SOSHARNESS_REPO_ROOT") is { Length: > 0 } root + ? Path.GetFullPath(root) + : FindRoot(); /// artifacts/bin under the repo root. public static string ArtifactsBin => Path.Combine(Root, "artifacts", "bin"); - /// The native build output directory, e.g. artifacts/bin/Windows_NT.x64.Debug. + /// The native build output directory, e.g. artifacts/bin/Windows_NT.x64.Debug. + /// SOSHARNESS_NATIVE_ROOT redirects it to a writable Helix overlay when necessary. public static string ArtifactsBinNative => - Path.Combine(ArtifactsBin, $"{TargetOS}.{TargetArch}.{ArtifactsConfiguration}"); + ResolveDirectory( + Environment.GetEnvironmentVariable("SOSHARNESS_NATIVE_ROOT"), + Path.Combine(ArtifactsBin, $"{TargetOS}.{TargetArch}.{ArtifactsConfiguration}")); /// The processor architecture token used in repo artifact paths (x64/x86/arm64). public static string TargetArch { get; } = RuntimeInformation.ProcessArchitecture switch @@ -69,7 +75,12 @@ public static class RepoLayout .Value!; /// The repo's locally-acquired .NET host (.dotnet/dotnet.exe) used to shell out builds. - public static string DotNetExe => Path.Combine(Root, ".dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); + public static string DotNetRoot { get; } = + Environment.GetEnvironmentVariable("SOSHARNESS_DOTNET_ROOT") is { Length: > 0 } root + ? Path.GetFullPath(root) + : Path.Combine(Root, ".dotnet"); + + public static string DotNetExe => Path.Combine(DotNetRoot, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); /// The platform suffix for an apphost executable: .exe on Windows, none elsewhere /// (Linux/macOS apphosts have no extension). @@ -88,13 +99,22 @@ 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). /// Used as DOTNET_ROOT when launching a debuggee so its apphost resolves the matching runtime /// version (the repo's .dotnet only carries the build SDK's runtime). /// - public static string DotnetTestRoot { get; } = Path.Combine(Root, "artifacts", "dotnet-test"); + /// The multi-version test runtime root. SOSHARNESS_DOTNET_TEST_ROOT redirects it to + /// a writable executable overlay for read-only Helix payloads. + public static string DotnetTestRoot => + ResolveDirectory( + Environment.GetEnvironmentVariable("SOSHARNESS_DOTNET_TEST_ROOT"), + Path.Combine(Root, "artifacts", "dotnet-test")); /// The multi-version test .NET host (artifacts/dotnet-test/dotnet[.exe]). This is the /// net11-capable SDK that Debuggees.proj uses to pre-build the debuggees, so local Core fallback @@ -102,9 +122,12 @@ public static string SingleFileDebuggeeDir(string name, string tfm) => /// frameworks (NETSDK1045). public static string DotnetTestExe => Path.Combine(DotnetTestRoot, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); - /// Scratch directory for harness-produced artifacts (on-the-fly builds, captured dumps). - public static string Scratch { get; } = - Path.Combine(Root, "artifacts", "tmp", "sos-harness", ArtifactsConfiguration); + /// Scratch directory for harness-produced artifacts (on-the-fly builds, captured dumps). + /// SOSHARNESS_SCRATCH_ROOT redirects writes outside a read-only Helix payload. + public static string Scratch => + ResolveDirectory( + Environment.GetEnvironmentVariable("SOSHARNESS_SCRATCH_ROOT"), + Path.Combine(Root, "artifacts", "tmp", "sos-harness", ArtifactsConfiguration)); /// /// A hermetic, local-only symbol path for the SOS host child processes. The dev machine's @@ -115,6 +138,9 @@ public static string SingleFileDebuggeeDir(string name, string tfm) => /// public static string SymbolCache { get; } = Path.Combine(Scratch, "symcache"); + internal static string ResolveDirectory(string? overridePath, string defaultPath) => + Path.GetFullPath(string.IsNullOrEmpty(overridePath) ? defaultPath : overridePath); + private static string FindRoot() { string? dir = AppContext.BaseDirectory; 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..35d9a6e270 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(); @@ -83,23 +86,71 @@ public static string TargetExe(Flavor flavor, string targetName, CoreVersion cor string exe = s_targetExe .GetOrAdd((flavor, targetName, coreVersion), k => new Lazy(() => AcquireTarget(k.Flavor, TargetCatalog.Get(k.Target), k.CoreVersion))) .Value; - EnsureExecutable(exe); - return exe; + return EnsureExecutable( + exe, + Environment.GetEnvironmentVariable("SOSHARNESS_EXECUTABLE_ROOT"), + RepoLayout.Root); } - private static void EnsureExecutable(string path) + internal static string EnsureExecutable(string path, string? executableRoot, string repoRoot) { if (OperatingSystem.IsWindows()) { - return; + return path; } UnixFileMode mode = File.GetUnixFileMode(path); UnixFileMode execute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; - if ((mode & execute) != execute) + if (string.IsNullOrEmpty(executableRoot)) { - File.SetUnixFileMode(path, mode | execute); + if ((mode & execute) != execute) + { + File.SetUnixFileMode(path, mode | execute); + } + + return path; + } + + string relative = Path.GetRelativePath(repoRoot, path); + if (Path.IsPathRooted(relative) || + relative.Equals("..", StringComparison.Ordinal) || + relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Executable '{path}' is outside the repo root '{repoRoot}' and cannot be copied to the writable executable overlay."); + } + + string destination = Path.Combine(executableRoot, relative); + string sourceDirectory = Path.GetDirectoryName(path)!; + string destinationDirectory = Path.GetDirectoryName(destination)!; + + lock (BuildLockFor(destination)) + { + Directory.CreateDirectory(destinationDirectory); + foreach (string sibling in Directory.EnumerateFiles(sourceDirectory)) + { + string siblingDestination = Path.Combine(destinationDirectory, Path.GetFileName(sibling)); + if (string.Equals(sibling, path, StringComparison.Ordinal)) + { + if (!File.Exists(siblingDestination)) + { + File.Copy(sibling, siblingDestination); + } + } + else if (!File.Exists(siblingDestination)) + { + File.CreateSymbolicLink(siblingDestination, sibling); + } + } + + UnixFileMode destinationMode = File.GetUnixFileMode(destination); + if ((destinationMode & execute) != execute) + { + File.SetUnixFileMode(destination, destinationMode | execute); + } } + + return destination; } private static string DumpDir(Flavor flavor, string target, GcType gcType, DumpKind dumpKind, CoreVersion coreVersion) => @@ -215,18 +266,51 @@ 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. /// /// macOS-only dump configuration applied to every debuggee we capture a dump from (createdump on crash, @@ -323,19 +407,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 { @@ -351,6 +441,18 @@ private static string AcquireCore(TargetDefinition target, CoreVersion coreVersi { string tfm = CoreVersions.Tfm(coreVersion); string exe = Path.Combine(RepoLayout.CoreDebuggeeDir(target.Project, tfm), target.Project + RepoLayout.ExeSuffix); + if (UsePrebuiltTargets) + { + if (File.Exists(exe)) + { + return exe; + } + + throw new FileNotFoundException( + $"Pre-built Core debuggee '{target.Project}' ({tfm}) was not found at '{exe}'.", + exe); + } + string project = RepoLayout.DebuggeeProject(target.Project); if (IsUpToDate(exe, NewestSourceWriteTime(project))) { @@ -365,7 +467,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 +516,19 @@ 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; + } + + if (UsePrebuiltTargets) + { + throw new FileNotFoundException( + $"Pre-built Framework debuggee '{target.Project}' was not found at '{prebuilt}'.", + 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 +540,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. @@ -448,6 +567,12 @@ private static string BuildFramework(TargetDefinition target) return exe; } + private static bool UsePrebuiltTargets => + string.Equals( + Environment.GetEnvironmentVariable("SOSHARNESS_USE_PREBUILT_TARGETS"), + "1", + StringComparison.Ordinal); + private static readonly ConcurrentDictionary s_projectBuildLocks = new(StringComparer.OrdinalIgnoreCase); private static object BuildLockFor(string projectPath) => @@ -485,6 +610,11 @@ private static string SubprocessDll(string name) string dll = Path.Combine(RepoLayout.ArtifactsBin, name, RepoLayout.ArtifactsConfiguration, RepoLayout.TestTargetFramework, RepoLayout.Rid, name + ".dll"); string project = Path.Combine(RepoLayout.Root, "src", "tests", name, name + ".csproj"); + if (UsePrebuiltTargets && !File.Exists(dll)) + { + throw new FileNotFoundException($"Pre-built subprocess '{name}' was not found at '{dll}'.", dll); + } + if (!File.Exists(dll)) { // Both helper projects reference SOS.TestHarness and can be initialized concurrently by 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..2fd9eee995 100644 --- a/src/tests/SOS.TestHarness/TestConfig.cs +++ b/src/tests/SOS.TestHarness/TestConfig.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; +using System.Text; using Xunit; using Xunit.Sdk; @@ -105,7 +107,8 @@ public static TheoryData BuildMatrix( Dac dac = Dac.All) { TheoryData data = new(); - foreach (TestConfig cfg in Permutations(targets, flavor, host, liveness, gcType, dumpKind, coreVersion, dac)) + foreach (TestConfig cfg in ApplyShardFilter( + UnshardedPermutations(targets, flavor, host, liveness, gcType, dumpKind, coreVersion, dac))) { data.Add(cfg); } @@ -119,6 +122,17 @@ public static TheoryData BuildMatrix( /// e.g. a stop-point name — into their own TheoryData<TestConfig, ...>. /// public static IEnumerable Permutations( + string[] targets, + Flavor flavor = Flavor.AllValid, + Host host = Host.AllValid, + Liveness liveness = Liveness.Dump, + GcType gcType = GcType.Workstation, + DumpKind dumpKind = DumpKind.Heap, + CoreVersion coreVersion = CoreVersion.All, + Dac dac = Dac.All) => + ApplyShardFilter(UnshardedPermutations(targets, flavor, host, liveness, gcType, dumpKind, coreVersion, dac)); + + internal static IEnumerable UnshardedPermutations( string[] targets, Flavor flavor = Flavor.AllValid, Host host = Host.AllValid, @@ -183,11 +197,54 @@ public static IEnumerable Permutations( } } + internal static IEnumerable ApplyShardFilter(IEnumerable configs) + => ApplyShardFilter( + configs, + ShardSelection.FromEnvironment(Environment.GetEnvironmentVariable)); + + internal static IEnumerable ApplyShardFilter( + IEnumerable configs, + ShardSelection? shard) + { + foreach (TestConfig config in configs) + { + if (shard is null || config.GetCaptureShard(shard.Value.Count) == shard.Value.Index) + { + yield return config; + } + } + } + + /// + /// The immutable capture-family key used for sharding. Host and DAC are deliberately absent because + /// they replay the same captured dump; keeping them together preserves reuse. + /// + internal string CaptureFamilyKey => + $"{Target}|{Flavor}|{CoreVersion}|{GcType}|{DumpKind}|{Liveness}"; + + internal int GetCaptureShard(int shardCount) => + (int)(StableHash(CaptureFamilyKey) % (ulong)shardCount); + + internal static ulong StableHash(string value) + { + const ulong offsetBasis = 14695981039346656037; + const ulong prime = 1099511628211; + + ulong hash = offsetBasis; + foreach (byte b in Encoding.UTF8.GetBytes(value)) + { + hash ^= b; + hash = unchecked(hash * prime); + } + + return hash; + } + /// /// 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 +263,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 +286,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. Helix launchers 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,33 +325,47 @@ 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) + if (!IsDacSupported(c)) { 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; - } + return true; + } + + /// + /// The cDAC is available only for .NET Core 11+; desktop Framework and earlier Core versions use the + /// legacy DAC. This is independent of host-platform constraints such as musl SingleFile support. + /// + internal static bool IsDacSupported(TestConfig config) => + config.Dac != Dac.CDac || + (config.Flavor != Flavor.Framework && (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."), + }; - // 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) + internal static bool AllowEmptyMatrix(Func getEnvironmentVariable) + { + if (ShardSelection.FromEnvironment(getEnvironmentVariable) is not null) { - return false; + return true; } - return true; + return !string.IsNullOrEmpty(getEnvironmentVariable("SOSHARNESS_ONLY_HOSTS")) || + !string.IsNullOrEmpty(getEnvironmentVariable("SOSHARNESS_ONLY_FLAVORS")) || + !string.IsNullOrEmpty(getEnvironmentVariable("SOSHARNESS_ONLY_LIVENESS")) || + !string.IsNullOrEmpty(getEnvironmentVariable("SOSHARNESS_ONLY_GCTYPE")) || + !string.IsNullOrEmpty(getEnvironmentVariable("SOSHARNESS_ONLY_DUMPKIND")) || + !string.IsNullOrEmpty(getEnvironmentVariable("SOSHARNESS_ONLY_COREVERSIONS")) || + !string.IsNullOrEmpty(getEnvironmentVariable("SOSHARNESS_ONLY_DAC")); } private static IEnumerable SingleFlags(T value) where T : struct, Enum @@ -298,7 +388,11 @@ private static IEnumerable SingleFlags(T value) where T : struct, Enum /// private static IEnumerable SingleFlags(T value, string envVar) where T : struct, Enum { - string? only = Environment.GetEnvironmentVariable(envVar); + return ApplyAllowList(value, Environment.GetEnvironmentVariable(envVar)); + } + + internal static IEnumerable ApplyAllowList(T value, string? only) where T : struct, Enum + { HashSet? allowed = string.IsNullOrEmpty(only) ? null : new HashSet(only.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase); @@ -351,3 +445,42 @@ public override string ToString() } } + +internal readonly record struct ShardSelection(int Index, int Count) +{ + private const string IndexVariable = "SOSHARNESS_SHARD_INDEX"; + private const string CountVariable = "SOSHARNESS_SHARD_COUNT"; + + public static ShardSelection? FromEnvironment(Func getEnvironmentVariable) + { + string? indexValue = getEnvironmentVariable(IndexVariable); + string? countValue = getEnvironmentVariable(CountVariable); + + if (indexValue is null && countValue is null) + { + return null; + } + + if (indexValue is null || countValue is null) + { + throw new InvalidOperationException( + $"{IndexVariable} and {CountVariable} must either both be set or both be unset."); + } + + if (!int.TryParse(countValue, NumberStyles.None, CultureInfo.InvariantCulture, out int count) || count <= 0) + { + throw new InvalidOperationException( + $"{CountVariable} must be a positive base-10 integer; received '{countValue}'."); + } + + if (!int.TryParse(indexValue, NumberStyles.None, CultureInfo.InvariantCulture, out int index) || + index < 0 || + index >= count) + { + throw new InvalidOperationException( + $"{IndexVariable} must be a base-10 integer in [0, {count}); received '{indexValue}'."); + } + + return new ShardSelection(index, count); + } +} diff --git a/src/tests/SOS.TestHarness/ToolPaths.cs b/src/tests/SOS.TestHarness/ToolPaths.cs index 300a12d53a..1f33c29003 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; @@ -119,6 +120,21 @@ public static void EnsureLldbPluginCDacOverride() private static string ResolveDbgEngDirectory() { + string? configuredDirectory = Environment.GetEnvironmentVariable("SOSHARNESS_DBGENG_ROOT"); + if (!string.IsNullOrEmpty(configuredDirectory)) + { + string directory = Path.GetFullPath(configuredDirectory); + string dbgEngPath = Path.Combine(directory, "dbgeng.dll"); + if (File.Exists(dbgEngPath)) + { + return directory; + } + + throw new FileNotFoundException( + $"Could not locate dbgeng.dll in the configured SOS harness DbgEng directory '{directory}'.", + dbgEngPath); + } + string relativeNative = Path.Combine("runtimes", $"win-{RepoLayout.TargetArch}", "native"); foreach (string root in NuGetPackageRoots()) @@ -174,15 +190,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) 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; } - // 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. + // 4) Xcode's LLDB. if (OperatingSystem.IsMacOS()) { string? developerDir = TryRun("xcode-select", "-p"); @@ -196,7 +229,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,16 +237,52 @@ 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. - string sharedRoot = Path.Combine(RepoLayout.Root, ".dotnet", "shared", "Microsoft.NETCore.App"); + string sharedRoot = Path.Combine(RepoLayout.DotNetRoot, "shared", "Microsoft.NETCore.App"); if (Directory.Exists(sharedRoot)) { string? best = Directory.GetDirectories(sharedRoot) 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/COVERAGE.md b/src/tests/SOS.Tests/COVERAGE.md index ca77b10204..2bb7848d3c 100644 --- a/src/tests/SOS.Tests/COVERAGE.md +++ b/src/tests/SOS.Tests/COVERAGE.md @@ -2,8 +2,9 @@ This audit compares PR #5979 at `40ccc0262a199a1576ed3bec25d82152a32c1bc4` with the legacy `SOS.UnitTests` entry points and 29 scripts present at the same -commit. The new project contains 89 command-focused test methods after the -focused `dumpgen` addition described below. +commit. At that baseline, the new project contained 89 command-focused test +methods after the focused `dumpgen` addition described below; this layer adds +the command and parser coverage used for the retirements recorded here. ## Result @@ -19,15 +20,19 @@ Status meanings: - **Covered**: the same observable command behavior has a direct new test. - **Improved**: the new test adds a stronger oracle, more options, or broader host/flavor/runtime coverage. +- **Retired**: every material legacy assertion and debugger path is covered by + focused tests, so the legacy script and registration have been removed. - **Retained**: the specialized scenario remains intentionally owned by the legacy suite; the command may also have generic new coverage. - **Gap**: `SOS.Tests` has no equivalent for a material legacy behavior. The legacy test is still active, so this is a migration gap rather than a regression in the PR baseline. -No legacy script is retired by #5979. Rows that are fully covered or improved -are candidates for later retirement only after CI proves the intended matrix -replacement. +No legacy script was retired by #5979. This layer retires `DivZero.script`, +`NestedExceptionTest.script`, and `SimpleThrow.script` after moving their exact +exception, source-line, stack, thread, live/dump, and CLRMA assertions into the +command-focused suite. `Reflection.script` remains active because its reflected +target-invocation boundary is still specialized legacy behavior. ## Legacy-to-new map @@ -36,7 +41,7 @@ replacement. | `AsyncMain.script` | Gap | General stack shape is covered by `ClrStackLinesTests` and `ClrStackAllThreadsTests`; the async-`Main` frame identity has no new oracle. | | `ClrStackWithNumberOfFrames.script` | Improved | `ClrStackFrameCountTests.ClrStack_FrameCount` compares each `-c N` result with the exact prefix of an unlimited walk and checks an over-limit request across four crash targets. | | `ConcurrentDictionaries.script` | Improved | `SpecializedInspectionTests.Dcd_DumpsConcurrentDictionary`, `DumpArrayTests`, and `ObjectFieldsTests` provide typed data assertions. Legacy still covers dcd argument errors and its wider generic key/value set. | -| `DivZero.script` | Improved | `PrintExceptionTests`, `ClrThreadsTests`, `ClrStackLinesTests`, `ClrStackICorDebugTests`, and frame-count tests split the monolithic script into data and structure oracles. | +| `DivZero.script` | Retired | `PrintExceptionTests.PrintException_NoInnerException` verifies the exact exception type, message, HResult, no-inner state, generated frame, `-nested`, and `-lines` output across live and dump rows. `ClrThreadsTests`, `ClrStackLinesTests`, and `ClrStackICorDebugTests` cover the remaining thread and stack behavior. | | `DualRuntimes.script` | Retained | Generic stacks, threads, heaps, and runtime listing are covered; loading and switching between two runtimes in one process remains a specialized legacy scenario. | | `DumpGCData.script` | Covered | `DiagnosticCommandTests.DumpGcData_ReportsGcStatistics` directly exercises `dumpgcdata`. | | `DumpGen.script` | Improved | `GcInspectionTests.DumpGen_ListsGenerationObjects` asserts a known gen0 object. `DumpGen_ArgumentsAndFilters` adds missing/invalid generation and valid `-type`/`-mt` coverage. Exact legacy gen1/gen2/LOH/POH populations remain retained. | @@ -48,11 +53,11 @@ replacement. | `InterpreterStackTest.script` | Retained | Interpreter-frame ordering remains specialized legacy coverage. | | `LineNums.script` | Improved | `ClrStackLinesTests` checks source file/line behavior and `PrintExceptionTests` checks exception structure/data across the matrix. | | `MiniDumpLocalVarLookup.script` | Gap | `ClrStackArgsLocalsTests` and `ClrStackICorDebugTests` improve variable data checks, but they use full/heap dumps rather than proving local recovery from a Mini dump. | -| `NestedExceptionTest.script` | Improved | `PrintExceptionTests` verifies exact outer/inner types, messages, HResult, frame data, and inner-address round-trip; stack variants are independently covered. | +| `NestedExceptionTest.script` | Retired | `PrintExceptionTests` verifies exact outer/inner types, messages, HResults, `-nested`, `-lines`, frame data, and inner-address round-trip across live and dump rows. `LiveBpmdTests`, `ClrThreadsTests`, the stack tests, and `DiagnosticCommandTests.Clrma_ReportsCurrentExceptionChain` cover the breakpoint, thread, stack, and CLRMA paths. | | `OtherCommands.script` | Improved | Its broad command set is split across object, module, domain, heap, runtime, memory, code-info, and diagnostic test classes with structured round-trips. | | `Overflow.script` | Retained | Stack-overflow exception behavior remains a specialized crash/live legacy target; ordinary exception output is covered by `PrintExceptionTests`. | | `Reflection.script` | Retained | Target-invocation exception and reflection boundary behavior remains legacy; generic nested exception and stack behavior is improved in the new project. | -| `SimpleThrow.script` | Covered | Exception fields, HResult, source lines, threads, and managed stack shape are directly covered by the new exception and stack classes on deterministic crash targets. | +| `SimpleThrow.script` | Retired | `PrintExceptionTests.PrintException_NoInnerException` verifies the exact exception type, message, HResult, no-inner state, generated frame, `-nested`, and `-lines` output across live and dump rows. `ClrThreadsTests` and the stack tests cover the remaining thread and managed-stack behavior. | | `StackAndOtherTests.script` | Improved | `RuntimeInfoTests` and the `ClrStack*Tests` classes separately cover runtime selection, plain/line/full/all/register/args/locals stack modes with stronger comparisons. | | `StackTests.script` | Improved | `ClrStackTests`, `ClrStackFullTests`, `ClrStackAllThreadsTests`, `ClrStackArgsLocalsTests`, `StackInspectionTests`, and `RuntimeInfoTests` replace shape-only checks with tables and address round-trips. | | `StackTraceFaultingExceptionFrame.script` | Gap | Exception and ordinary stack data are covered; no new assertion requires the synthetic `[FaultingExceptionFrame: ...]` row. | @@ -65,7 +70,8 @@ replacement. The focused gap fixed in this review is `dumpgen` argument and filtering behavior. The new test uses the source-generated scenario marker, resolves its -real MethodTable through `dumpobj`, and proves both `-type` and `-mt` select it. +address and real MethodTable from `dumpheap`, and proves both `-type` and `-mt` +select it. The remaining gaps require purpose-built targets, Mini-dump policy, or platform-specific debugger sequencing and are not appropriate baseline-harness refactors. diff --git a/src/tests/SOS.Tests/ClrStackICorDebugTests.cs b/src/tests/SOS.Tests/ClrStackICorDebugTests.cs index 1dc207890a..042f91774a 100644 --- a/src/tests/SOS.Tests/ClrStackICorDebugTests.cs +++ b/src/tests/SOS.Tests/ClrStackICorDebugTests.cs @@ -20,7 +20,10 @@ namespace SOS.Tests; public sealed class ClrStackICorDebugTests { public static TheoryData Matrix { get; } - = TestConfig.BuildMatrix([TargetCatalog.DivZero, TargetCatalog.Scenarios], Flavor.Core | Flavor.Framework); + = TestMatrices.StackWalk( + [TargetCatalog.DivZero, TargetCatalog.Scenarios], + Flavor.Core | Flavor.Framework, + filter: TestMatrices.SupportsICorDebugStackWalk); [SosTheory] [MemberData(nameof(Matrix))] diff --git a/src/tests/SOS.Tests/ClrStackLinesTests.cs b/src/tests/SOS.Tests/ClrStackLinesTests.cs index 7f008ab96b..c947820946 100644 --- a/src/tests/SOS.Tests/ClrStackLinesTests.cs +++ b/src/tests/SOS.Tests/ClrStackLinesTests.cs @@ -20,23 +20,28 @@ namespace SOS.Tests; /// public sealed class ClrStackLinesTests { - private sealed record Frame(string Function, string SourceFile); + private sealed record Frame(string Function, string SourceFile, params int[] SourceLines); // Hardcoded like the legacy scripts: distinctive method substrings expected on each target's stack // (in caller order) and the source file each resolves to. - private static IReadOnlyList ExpectedFrames(string target) => target switch + private static IReadOnlyList ExpectedFrames(TestConfig config) => config.Target switch { TargetCatalog.SimpleThrow => - [new("UseObject", "UserObject.cs"), new("Simple.Main", "SimpleThrow.cs")], + [new("UseObject", "UserObject.cs", 19), new("Simple.Main", "SimpleThrow.cs", 12)], TargetCatalog.LineNums => [new(".Bar(", "Program.cs"), new(".Foo(", "Program.cs"), new(".Main(", "Program.cs")], TargetCatalog.DivZero => - [new(".DivideByZero(", "DivZero.cs"), new(".F3(", "DivZero.cs"), new(".F2(", "DivZero.cs")], + [ + new(".DivideByZero(", "DivZero.cs", 15), + new(".F3(", "DivZero.cs", 24), + new(".F2(", "DivZero.cs", 36), + new(".Main(", "DivZero.cs", config.Flavor == Flavor.Framework ? 56 : 57), + ], TargetCatalog.NestedException => - [new(".Main(", "NestedExceptionTest.cs")], + [new(".Main(", "NestedExceptionTest.cs", config.Flavor == Flavor.Framework ? [11, 20] : [20])], TargetCatalog.Scenarios => [new(".ArgsLocalsMethod(", "SosHarnessScenarios.cs"), new(".Main(", "SosHarnessScenarios.cs")], - _ => throw new ArgumentOutOfRangeException(nameof(target), target, "no expected frames"), + _ => throw new ArgumentOutOfRangeException(nameof(config), config.Target, "no expected frames"), }; public static TheoryData Matrix { get; } @@ -47,7 +52,8 @@ private sealed record Frame(string Function, string SourceFile); TargetCatalog.DivZero, TargetCatalog.NestedException, TargetCatalog.Scenarios, - ]); + ], + liveness: Liveness.AllValid); [SosTheory] [MemberData(nameof(Matrix))] @@ -68,14 +74,22 @@ public async Task ClrStack_SourceLines(TestConfig config) // The expected managed methods appear, in caller order, each resolving to its source file with // a real (positive) line number. int searchFrom = 0; - foreach (Frame expected in ExpectedFrames(config.Target)) + foreach (Frame expected in ExpectedFrames(config)) { int at = IndexOfFrame(plain, expected.Function, searchFrom); Assert.True(at >= 0, $"Expected frame '{expected.Function}' at/after row {searchFrom} in:\n{Dump(plain)}"); SosRow row = plain.Row(at); Assert.Equal(expected.SourceFile, Path.GetFileName(row["SourceFile"].Value)); - Assert.True(row["LineNumber"].AsInt32(Sos.Integer) > 0, $"Expected a positive line number for '{expected.Function}'."); + int lineNumber = row["LineNumber"].AsInt32(Sos.Integer); + if (expected.SourceLines.Length == 0) + { + Assert.True(lineNumber > 0, $"Expected a positive line number for '{expected.Function}'."); + } + else + { + Assert.Contains(lineNumber, expected.SourceLines); + } searchFrom = at + 1; } diff --git a/src/tests/SOS.Tests/ClrStackTests.cs b/src/tests/SOS.Tests/ClrStackTests.cs index b9da87f04c..6938ea323e 100644 --- a/src/tests/SOS.Tests/ClrStackTests.cs +++ b/src/tests/SOS.Tests/ClrStackTests.cs @@ -108,7 +108,10 @@ private static TheoryData BuildGcRootsMatrix() TheoryData data = new(); // Live opt-in: !clrstack -gcroots is fundamentally different from !clrstack (it scans the live // stack and registers for GC-reported roots), so it runs dump AND live. - foreach (TestConfig config in TestMatrices.StackWalkConfigs([TargetCatalog.Scenarios], liveness: Liveness.AllValid)) + foreach (TestConfig config in TestMatrices.StackWalkConfigs( + [TargetCatalog.Scenarios], + liveness: Liveness.AllValid, + filter: TestMatrices.SupportsGcRootEnumeration)) { data.Add(config, TargetCatalog.StopRoots); data.Add(config, TargetCatalog.StopArgsLocals); @@ -158,7 +161,10 @@ public async Task ClrStack_GcRoots(TestConfig config, string stopName) // the marker, so the parser's handling of the optional (pinned)/(interior) flags and the // sometimes-absent type is actually exercised — and so we can assert the SosDataRow always // carries Pinned/Interior (defaulting to False when the flag isn't printed). - public static TheoryData GcRootsFlagMatrix { get; } = TestMatrices.StackWalkFullDumpOnCoreVersions([TargetCatalog.Scenarios], CoreVersion.Net8 | CoreVersion.Net9 | CoreVersion.Net10); + public static TheoryData GcRootsFlagMatrix { get; } = TestMatrices.StackWalkFullDumpOnCoreVersions( + [TargetCatalog.Scenarios], + CoreVersion.Net8 | CoreVersion.Net9 | CoreVersion.Net10, + filter: TestMatrices.SupportsGcRootEnumeration); [SosTheory] [MemberData(nameof(GcRootsFlagMatrix))] diff --git a/src/tests/SOS.Tests/ClrUTests.cs b/src/tests/SOS.Tests/ClrUTests.cs index 32a8acc11d..4bdd060414 100644 --- a/src/tests/SOS.Tests/ClrUTests.cs +++ b/src/tests/SOS.Tests/ClrUTests.cs @@ -38,7 +38,11 @@ public async Task ClrU_StructureLinesOffsets(TestConfig config) Assert.NotEqual(0ul, plain.Begin); Assert.True(plain.Size > 0); Assert.NotEmpty(plain.Instructions); - Assert.True(plain.SourceLineCount > 0, "expected source-line annotations by default"); + // The x86 desktop DAC does not surface managed source annotations through clru even with full PDBs. + if (IntPtr.Size != 4 || config.Flavor != Flavor.Framework) + { + Assert.True(plain.SourceLineCount > 0, "expected source-line annotations by default"); + } Assert.False(plain.HasOffsets); // -n suppresses the source annotations but still disassembles. @@ -66,7 +70,8 @@ public async Task ClrU_InterleavesGcInfoEhInfoIl(TestConfig config) // -gcinfo interleaves the same interruptibility info that standalone gcinfo prints. ClrUResult withGc = target.ClrU(atHeap.MethodDesc.Value, gcInfo: true); - Assert.Contains("interruptible", withGc.Output.Text, StringComparison.Ordinal); + string transitionMarker = IntPtr.Size == 4 ? "becoming live" : "interruptible"; + Assert.Contains(transitionMarker, withGc.Output.Text, StringComparison.OrdinalIgnoreCase); Assert.NotEmpty(withGc.Instructions); // still a real disassembly, not just the gc dump Assert.NotEmpty(target.GcInfo(atHeap.MethodDesc.Value).Transitions); diff --git a/src/tests/SOS.Tests/CodeCommandParsing.cs b/src/tests/SOS.Tests/CodeCommandParsing.cs index b3c69066d1..25dbbbb7a5 100644 --- a/src/tests/SOS.Tests/CodeCommandParsing.cs +++ b/src/tests/SOS.Tests/CodeCommandParsing.cs @@ -25,7 +25,7 @@ public static ClrUResult ClrU( string flags = string.Concat( noLines ? "-n " : "", offsets ? "-o " : "", gcInfo ? "-gcinfo " : "", ehInfo ? "-ehinfo " : "", il ? "-il " : "", map ? "-map " : ""); - return new ClrUResult(target.Sos($"clru {flags}{address:x}")); + return new ClrUResult(target.Sos($"clru {flags}{address:x}"), offsets); } public static EhInfoResult EhInfo(this Target target, ulong address) => @@ -52,12 +52,17 @@ public sealed class ClrUResult new(@"^Begin\s+([0-9a-fA-F`]+),\s+size\s+([0-9a-fA-F]+)\s*$", RegexOptions.Compiled); private static readonly Regex s_source = new(@"@\s+(\d+):\s*$", RegexOptions.Compiled); private static readonly Regex s_instruction = new( - @"^(?:(?[0-9a-fA-F]{4,8})\s+)?(?[0-9a-fA-F]+`[0-9a-fA-F]+)\s+(?[0-9a-fA-F]+)\s+(?\S+)(?:\s+(?.*\S))?\s*$", + @"^(?[0-9a-fA-F]+(?:`[0-9a-fA-F]+)?)\s+(?[0-9a-fA-F]+)\s+(?\S+)(?:\s+(?.*\S))?\s*$", RegexOptions.Compiled); + private static readonly Regex s_instructionWithOffset = new( + @"^(?[0-9a-fA-F]{4,8})\s+(?[0-9a-fA-F]+(?:`[0-9a-fA-F]+)?)\s+(?[0-9a-fA-F]+)\s+(?\S+)(?:\s+(?.*\S))?\s*$", + RegexOptions.Compiled); + private readonly bool _hasOffsets; - public ClrUResult(SosOutput output) + public ClrUResult(SosOutput output, bool hasOffsets = false) { Output = output; + _hasOffsets = hasOffsets; HasNormalJitBanner = output.Contains("Normal JIT generated code"); List instructions = new(); @@ -80,7 +85,7 @@ public ClrUResult(SosOutput output) continue; } - Match m = s_instruction.Match(line); + Match m = (hasOffsets ? s_instructionWithOffset : s_instruction).Match(line); if (m.Success) { int? off = m.Groups["off"].Success ? (int)CodeCommandParsing.Hex(m.Groups["off"].Value) : null; @@ -115,7 +120,7 @@ public ClrUResult(SosOutput output) public int SourceLineCount { get; } /// True if the instruction lines carry the -o offset prefix. - public bool HasOffsets => Instructions.Count > 0 && Instructions.All(i => i.Offset is not null); + public bool HasOffsets => _hasOffsets && Instructions.Count > 0 && Instructions.All(i => i.Offset is not null); } /// One exception-handling clause from !ehinfo. @@ -198,9 +203,11 @@ public sealed class GcInfoResult { private static readonly Regex s_entry = new(@"^entry point\s+([0-9a-fA-F`]+)", RegexOptions.Compiled); private static readonly Regex s_gcinfo = new(@"^GC info\s+([0-9a-fA-F`]+)", RegexOptions.Compiled); - private static readonly Regex s_codeSize = new(@"^Code size:\s+(\d+)", RegexOptions.Compiled); + private static readonly Regex s_codeSize = new( + @"^(?:Code size:\s+(?\d+)|method\s+size\s*=\s*(?[0-9a-fA-F]+))", + RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex s_transition = - new(@"^[0-9a-fA-F]{4,}\s+(interruptible|not interruptible|[+\-].+)$", RegexOptions.Compiled); + new(@"^[0-9a-fA-F]{4,}\s+(interruptible|not interruptible|[+\-].+|reg .+ becoming (?:live|dead))$", RegexOptions.Compiled | RegexOptions.IgnoreCase); public GcInfoResult(SosOutput output) { @@ -208,7 +215,7 @@ public GcInfoResult(SosOutput output) List transitions = new(); foreach (string raw in output.Lines) { - string line = raw.TrimEnd(); + string line = raw.Trim(); Match e = s_entry.Match(line); if (e.Success) { @@ -226,13 +233,17 @@ public GcInfoResult(SosOutput output) Match c = s_codeSize.Match(line); if (c.Success) { - CodeSize = int.Parse(c.Groups[1].Value, CultureInfo.InvariantCulture); + CodeSize = c.Groups["decimal"].Success + ? int.Parse(c.Groups["decimal"].Value, CultureInfo.InvariantCulture) + : int.Parse(c.Groups["hex"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture); continue; } - if (s_transition.IsMatch(line)) + int encodedPrefix = line.LastIndexOf('|'); + string transition = encodedPrefix >= 0 ? line[(encodedPrefix + 1)..].Trim() : line; + if (s_transition.IsMatch(transition)) { - transitions.Add(line.Trim()); + transitions.Add(transition); } } diff --git a/src/tests/SOS.Tests/CodeCommandParsingTests.cs b/src/tests/SOS.Tests/CodeCommandParsingTests.cs new file mode 100644 index 0000000000..87a7c2b45a --- /dev/null +++ b/src/tests/SOS.Tests/CodeCommandParsingTests.cs @@ -0,0 +1,68 @@ +// 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 CodeCommandParsingTests +{ + [Fact] + public void ClrUParsesX86InstructionWithoutOffset() + { + ClrUResult result = new(new SosOutput("cdb", "clru", """ + Normal JIT generated code + Type.Method() + Begin 088837b0, size 10 + 088837b0 03c1 add eax,ecx + """)); + + DisasmLine instruction = Assert.Single(result.Instructions); + Assert.Null(instruction.Offset); + Assert.Equal(0x088837b0ul, instruction.Address); + Assert.Equal("03c1", instruction.Bytes); + Assert.Equal("add", instruction.Mnemonic); + Assert.Equal("eax,ecx", instruction.Operands); + Assert.False(result.HasOffsets); + } + + [Fact] + public void ClrUParsesX86InstructionWithOffset() + { + ClrUResult result = new(new SosOutput("cdb", "clru -o", """ + Normal JIT generated code + Type.Method() + Begin 088837b0, size 10 + 0000 088837b0 03c1 add eax,ecx + """), hasOffsets: true); + + DisasmLine instruction = Assert.Single(result.Instructions); + Assert.Equal(0, instruction.Offset); + Assert.Equal(0x088837b0ul, instruction.Address); + Assert.Equal("03c1", instruction.Bytes); + Assert.Equal("add", instruction.Mnemonic); + Assert.True(result.HasOffsets); + } + + [Fact] + public void GcInfoParsesX86LegacyEncoding() + { + GcInfoResult result = new(new SosOutput("cdb", "gcinfo", """ + entry point 08755230 + GC info 0A7596E4 + Method info block: + method size = 0022 + Pointer table: + F1 4F FF ...| 0017 reg ECX becoming live + 0E 00 39 ...| 001D reg ECX becoming dead + """)); + + Assert.Equal(0x08755230ul, result.EntryPoint); + Assert.Equal(0x0A7596E4ul, result.GcInfoAddress); + Assert.Equal(0x22, result.CodeSize); + Assert.Equal(2, result.Transitions.Count); + Assert.Contains("becoming live", result.Transitions[0], StringComparison.OrdinalIgnoreCase); + Assert.Contains("becoming dead", result.Transitions[1], StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/tests/SOS.Tests/CodeInfoTests.cs b/src/tests/SOS.Tests/CodeInfoTests.cs index 942b8066e4..728701cbd7 100644 --- a/src/tests/SOS.Tests/CodeInfoTests.cs +++ b/src/tests/SOS.Tests/CodeInfoTests.cs @@ -64,7 +64,8 @@ public async Task GcInfo_ReportsEncoding(TestConfig config) Assert.NotEqual(0ul, gc.GcInfoAddress); Assert.True(gc.CodeSize > 0, "expected a positive code size"); Assert.NotEmpty(gc.Transitions); - Assert.Contains(gc.Transitions, t => t.Contains("interruptible", StringComparison.Ordinal)); + string transitionMarker = IntPtr.Size == 4 ? "becoming live" : "interruptible"; + Assert.Contains(gc.Transitions, t => t.Contains(transitionMarker, StringComparison.OrdinalIgnoreCase)); // gcinfo accepts an IP and reports the same entry point. GcInfoResult byIp = target.GcInfo(atHeap.JittedCodeAddress!.Value); diff --git a/src/tests/SOS.Tests/DiagnosticCommandTests.cs b/src/tests/SOS.Tests/DiagnosticCommandTests.cs index 6fa7e59f83..f32de37d31 100644 --- a/src/tests/SOS.Tests/DiagnosticCommandTests.cs +++ b/src/tests/SOS.Tests/DiagnosticCommandTests.cs @@ -21,6 +21,8 @@ public sealed class DiagnosticCommandTests // (dotnet-dump) hosts. The lldb SOS plugin never registered it (true of the legacy suite too — clrma // ran only under the dotnet-dump host there), so lldb is excluded from the matrix rather than skipped. public static TheoryData ClrmaMatrix => TestConfig.BuildMatrix([TargetCatalog.Scenarios], Flavor.AllValid, Host.Cdb | Host.DotnetDump); + public static TheoryData ClrmaExceptionMatrix => + TestConfig.BuildMatrix([TargetCatalog.NestedException], Flavor.AllValid, Host.DotnetDump, dumpKind: DumpKind.All); [SosTheory] [MemberData(nameof(Matrix))] @@ -87,4 +89,20 @@ public async Task Clrma_DrivesManagedAnalysis(TestConfig config) clrma.AssertContains("Managed analysis provider"); clrma.AssertContains("OSThreadId:"); } + + [SosTheory] + [MemberData(nameof(ClrmaExceptionMatrix))] + public async Task Clrma_ReportsCurrentExceptionChain(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToFirstStop(); + + SosOutput clrma = target.Sos("clrma"); + clrma.AssertContains("Current exception:"); + Assert.Matches(@"Exception type:\s+System\.InvalidOperationException", clrma.Text); + Assert.Matches(@"HResult:\s+80131509", clrma.Text); + clrma.AssertContains("InnerException:"); + Assert.Matches(@"Exception type:\s+System\.FormatException", clrma.Text); + Assert.Matches(@"HResult:\s+80131537", clrma.Text); + } } diff --git a/src/tests/SOS.Tests/DumpHeapStringsTests.cs b/src/tests/SOS.Tests/DumpHeapStringsTests.cs index 2af1516388..a5008a57aa 100644 --- a/src/tests/SOS.Tests/DumpHeapStringsTests.cs +++ b/src/tests/SOS.Tests/DumpHeapStringsTests.cs @@ -15,7 +15,7 @@ namespace SOS.Tests; /// public sealed class DumpHeapStringsTests { - public static TheoryData Matrix => TestConfig.BuildMatrix([TargetCatalog.NestedException]); + public static TheoryData Matrix => TestMatrices.HeapEnumeration([TargetCatalog.NestedException]); [SosTheory] [MemberData(nameof(Matrix))] diff --git a/src/tests/SOS.Tests/DumpHeapThinLockTests.cs b/src/tests/SOS.Tests/DumpHeapThinLockTests.cs index 529bd9a0f4..89b4585ac0 100644 --- a/src/tests/SOS.Tests/DumpHeapThinLockTests.cs +++ b/src/tests/SOS.Tests/DumpHeapThinLockTests.cs @@ -22,6 +22,12 @@ public sealed class DumpHeapThinLockTests [MemberData(nameof(Matrix))] public async Task DumpHeap_ThinLock(TestConfig config) { + if (RepoLayout.Rid.StartsWith("linux-musl-", StringComparison.Ordinal)) + { + HarnessSkipException.Now( + "Thin-lock data is unavailable from the DAC on Linux musl, so dumpheap -thinlock omits the table."); + } + using Target target = await Targets.GetTargetAsync(config); target.GoToStopPoint(TargetCatalog.StopThinLock); diff --git a/src/tests/SOS.Tests/EeHeapParsing.cs b/src/tests/SOS.Tests/EeHeapParsing.cs index 804ed8cfe3..0130285111 100644 --- a/src/tests/SOS.Tests/EeHeapParsing.cs +++ b/src/tests/SOS.Tests/EeHeapParsing.cs @@ -159,7 +159,7 @@ public EeHeap(SosOutput output) /// The raw command output, for custom assertions / failure messages. public SosOutput Output => _output; - /// True for the modern regions/DATAS layout; false for the segment/ephemeral layout. + /// True when generation-specific region headers are present; false for the segment/ephemeral layout. public bool IsRegions { get; private set; } /// The DATAS mode value (e.g. "0"/""), or null when there is no DATAS banner (segment layout). @@ -198,7 +198,6 @@ private List Parse() if (line.StartsWith("DATAS", StringComparison.Ordinal)) { - IsRegions = true; int eq = line.IndexOf('='); Datas = eq >= 0 ? line[(eq + 1)..].Trim() : string.Empty; continue; @@ -250,6 +249,7 @@ private List Parse() Match genHeader = s_genHeader.Match(line); if (genHeader.Success) { + IsRegions = true; target = genHeader.Groups[1].Value switch { "0" => heap.Gen0, diff --git a/src/tests/SOS.Tests/EeHeapTests.cs b/src/tests/SOS.Tests/EeHeapTests.cs index c1b9895242..ab41ceac64 100644 --- a/src/tests/SOS.Tests/EeHeapTests.cs +++ b/src/tests/SOS.Tests/EeHeapTests.cs @@ -47,7 +47,9 @@ public async Task EeHeap_ServerGc_IsMultiHeap(TestConfig config) // Each heap is independently well-formed with its own gen0/1/2 segments. Assert.All(ee.Heaps, h => { - Assert.NotEmpty(h.Gen0.Concat(h.Gen1).Concat(h.Gen2)); + Assert.NotEmpty(h.Ranges(GcGeneration.Gen0)); + Assert.NotEmpty(h.Ranges(GcGeneration.Gen1)); + Assert.NotEmpty(h.Ranges(GcGeneration.Gen2)); Assert.NotNull(h.Address); }); } diff --git a/src/tests/SOS.Tests/GcInspectionTests.cs b/src/tests/SOS.Tests/GcInspectionTests.cs index e20eee5939..37d8e14243 100644 --- a/src/tests/SOS.Tests/GcInspectionTests.cs +++ b/src/tests/SOS.Tests/GcInspectionTests.cs @@ -99,8 +99,24 @@ public async Task GcHeapStat_ReportsGenerationSizes(TestConfig config) [MemberData(nameof(Matrix))] public async Task VerifyHeap_ReportsNoCorruption(TestConfig config) { + if (RepoLayout.Rid.StartsWith("linux-musl-", StringComparison.Ordinal)) + { + HarnessSkipException.Now( + "The DAC reports incorrect sync-block-related verifyheap failures on Linux musl."); + } + + if (OperatingSystem.IsMacOS() && + config.Host == Host.DotnetDump && + config.Dac == Dac.Legacy && + config.Flavor == Flavor.Core && + config.CoreVersion == CoreVersion.Net11) + { + HarnessSkipException.Now( + "https://github.com/dotnet/diagnostics/issues/5985: legacy DAC SyncBlock data is unavailable for macOS .NET 11 dumps."); + } + using Target target = await Targets.GetTargetAsync(config); - target.GoToStopPoint(TargetCatalog.StopHeap); + target.GoToStopPoint(TargetCatalog.StopGen1); SosOutput verify = target.Sos("verifyheap"); Assert.Matches(@"\b0 errors\b", verify.Text); @@ -129,8 +145,14 @@ public async Task DumpGen_ArgumentsAndFilters(TestConfig config) target.Sos("dumpgen").AssertContains("Generation argument is missing"); target.Sos("dumpgen invalid").AssertContains("invalid is not a supported generation"); - ulong marker = target.FindUniqueObject("ThinLockMarker"); - ulong methodTable = target.DumpObj(marker).MethodTable; + DumpHeapResult markerDump = target.DumpHeap("-type ThinLockMarker"); + SosRow markerStat = markerDump.Statistics + .SingleRow(r => r["Class Name"].Value == "ThinLockMarker", "the ThinLockMarker method table"); + Assert.Equal(1, markerStat["Count"].AsInt32(Sos.Integer)); + ulong methodTable = markerStat["MT"].AsUInt64(Sos.Addr); + SosRow markerRow = markerDump.Objects + .SingleRow(r => r["MT"].AsUInt64(Sos.Addr) == methodTable, "the ThinLockMarker object"); + ulong marker = markerRow["Address"].AsUInt64(Sos.Addr); target.Sos("dumpgen gen0 -type ThinLockMarker") .AssertContains("ThinLockMarker"); diff --git a/src/tests/SOS.Tests/GcWhereTests.cs b/src/tests/SOS.Tests/GcWhereTests.cs index 69b62d9570..7a11702f5c 100644 --- a/src/tests/SOS.Tests/GcWhereTests.cs +++ b/src/tests/SOS.Tests/GcWhereTests.cs @@ -17,8 +17,9 @@ public sealed class GcWhereTests // gcwhere's structure check is dump-only (the generation layout is identical in a dump). The // generation-promotion check (GcWhere_Moves) is the live-worthy one: it drives bpmd through the // gen0->gen1->gen2 promotion (GC.Collect(2) between markers) on a live process, so it opts into live. - public static TheoryData Matrix => TestConfig.BuildMatrix([TargetCatalog.Scenarios]); - public static TheoryData LiveMatrix => TestConfig.BuildMatrix([TargetCatalog.Scenarios], liveness: Liveness.AllValid); + public static TheoryData Matrix => TestMatrices.CurrentThreadCommands([TargetCatalog.Scenarios]); + public static TheoryData LiveMatrix => + TestMatrices.CurrentThreadCommands([TargetCatalog.Scenarios], Liveness.AllValid); [SosTheory] [MemberData(nameof(Matrix))] 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/MemoryAndDecodeTests.cs b/src/tests/SOS.Tests/MemoryAndDecodeTests.cs index 3fc9c5f387..d644e77eb0 100644 --- a/src/tests/SOS.Tests/MemoryAndDecodeTests.cs +++ b/src/tests/SOS.Tests/MemoryAndDecodeTests.cs @@ -16,7 +16,7 @@ namespace SOS.Tests; public sealed class MemoryAndDecodeTests { public static TheoryData ScenariosMatrix => TestConfig.BuildMatrix([TargetCatalog.Scenarios]); - public static TheoryData NestedExceptionMatrix => TestConfig.BuildMatrix([TargetCatalog.NestedException]); + public static TheoryData NestedExceptionMatrix => TestMatrices.HeapEnumeration([TargetCatalog.NestedException]); public static TheoryData DotnetDumpMatrix => TestConfig.BuildMatrix([TargetCatalog.Scenarios], Flavor.AllValid, Host.DotnetDump); [SosTheory] @@ -29,10 +29,31 @@ public async Task MemoryDumpers_ShowKnownFieldBytes(TestConfig config) // The memory dumpers are dotnet-dump REPL commands (cdb uses the native d*/dp/db). FieldMarker's // LongField is a known 64-bit value, so it appears verbatim in the pointer/qword dumps. ulong marker = target.FindUniqueObject("FieldMarker"); - string longHex = ((ulong)TestTargets.SosHarnessScenarios.FieldMarkerLong).ToString("x"); + ulong longValue = (ulong)TestTargets.SosHarnessScenarios.FieldMarkerLong; + string longHex = longValue.ToString("x"); + + string pointerDump = target.Sos($"dp {marker:x}").Text; + if (IntPtr.Size == 4) + { + Assert.Contains(unchecked((uint)longValue).ToString("x8"), pointerDump, StringComparison.OrdinalIgnoreCase); + Assert.Contains(unchecked((uint)(longValue >> 32)).ToString("x8"), pointerDump, StringComparison.OrdinalIgnoreCase); + } + else + { + Assert.Contains(longHex, pointerDump, StringComparison.OrdinalIgnoreCase); + } + + string qwordDump = target.Sos($"dq {marker:x}").Text; + if (IntPtr.Size == 4) + { + Assert.Contains(unchecked((uint)longValue).ToString("x8"), qwordDump, StringComparison.OrdinalIgnoreCase); + Assert.Contains(unchecked((uint)(longValue >> 32)).ToString("x8"), qwordDump, StringComparison.OrdinalIgnoreCase); + } + else + { + Assert.Contains(longHex, qwordDump, StringComparison.OrdinalIgnoreCase); + } - Assert.Contains(longHex, target.Sos($"dp {marker:x}").Text, StringComparison.OrdinalIgnoreCase); - Assert.Contains(longHex, target.Sos($"dq {marker:x}").Text, StringComparison.OrdinalIgnoreCase); target.Sos($"db {marker:x}").AssertContains(":"); // byte dump prints ": " } diff --git a/src/tests/SOS.Tests/MiscCommandTests.cs b/src/tests/SOS.Tests/MiscCommandTests.cs index c111375e35..78a25d6aae 100644 --- a/src/tests/SOS.Tests/MiscCommandTests.cs +++ b/src/tests/SOS.Tests/MiscCommandTests.cs @@ -26,30 +26,37 @@ public async Task SessionCommands_Execute(TestConfig config) // dbgout toggles internal debug logging and reports the new state. target.Sos("dbgout").AssertContains("Debug output logging"); - // sosflush resets SOS's cached state; it produces no output but must run cleanly. The cDAC - // implements IXCLRDataProcess::Flush (dotnet/runtime Legacy/SOSDacImpl.IXCLRDataProcess.cs), so this - // works on every host/DAC including net11 + cDAC. - AssertRuns(target.Sos("sosflush")); - - // enummem is not surfaced by the lldb SOS plugin; return early rather - // than skipping — sosflush above has already been verified on this config. - if (config.Host == Host.Lldb) + try { - return; - } + // sosflush resets SOS's cached state; it produces no output but must run cleanly. The cDAC + // implements IXCLRDataProcess::Flush (dotnet/runtime Legacy/SOSDacImpl.IXCLRDataProcess.cs), so this + // works on every host/DAC including net11 + cDAC. + AssertRuns(target.Sos("sosflush")); + + // enummem is not surfaced by the lldb SOS plugin; return early rather + // than skipping — sosflush above has already been verified on this config. + if (config.Host == Host.Lldb) + { + return; + } + + // enummem (EnumMemoryRegions) is E_NOTIMPL under the cDAC on the dotnet-dump host — by design, the + // cDAC doesn't implement the memory-region enumeration contract, surfaced as "Unrecognized SOS + // command". The native (cdb) host services enummem itself, so + // it's only unavailable on dotnet-dump. Return early rather than skipping — sosflush above has + // already been verified on this config. cDAC only exists on net11+, so no version check is needed. + if (config.Dac == Dac.CDac && config.Host == Host.DotnetDump) + { + return; + } - // enummem (EnumMemoryRegions) is E_NOTIMPL under the cDAC on the dotnet-dump host — by design, the - // cDAC doesn't implement the memory-region enumeration contract, surfaced as "Unrecognized SOS - // command". The native (cdb) host services enummem itself, so - // it's only unavailable on dotnet-dump. Return early rather than skipping — sosflush above has - // already been verified on this config. cDAC only exists on net11+, so no version check is needed. - if (config.Dac == Dac.CDac && config.Host == Host.DotnetDump) + // enummem produces no output but must be a recognised command that runs cleanly. + AssertRuns(target.Sos("enummem")); + } + finally { - return; + target.Sos("dbgout -off").AssertContains("Debug output logging"); } - - // enummem produces no output but must be a recognised command that runs cleanly. - AssertRuns(target.Sos("enummem")); } private static void AssertRuns(SosOutput output) diff --git a/src/tests/SOS.Tests/NativeAddressSpaceTests.cs b/src/tests/SOS.Tests/NativeAddressSpaceTests.cs index f58b47273c..7c7f28b089 100644 --- a/src/tests/SOS.Tests/NativeAddressSpaceTests.cs +++ b/src/tests/SOS.Tests/NativeAddressSpaceTests.cs @@ -21,12 +21,22 @@ public async Task NotReachableInRange_ScansPointerRange(TestConfig config) using Target target = await Targets.GetTargetAsync(config); target.GoToStopPoint(TargetCatalog.StopHeap); - // notreachableinrange treats [start,end) as an array of object pointers (it backs !finalizerqueue) - // and reports the dead ones. Point it at a known live object's span: the command computes the live - // set and emits the dumpheap-style listing, proving the scan path works. - ulong marker = target.FindUniqueObject("FieldMarker"); - SosOutput scan = target.Sos($"notreachableinrange {marker:x} {marker + 0x200:x}"); - scan.AssertContains("Calculating live objects"); + // notreachableinrange treats [start,end) as an array of object pointers (it backs !finalizerqueue). + // ObjectReference[] contains one-field value types, so dumparray reports the address of an + // actual object-reference slot rather than the referenced object or an object header. + ulong references = target.FindUniqueObject("ObjectReference[]"); + ulong slot = target.DumpArray(references).Elements[0].Address; + + SosOutput scan = target.Sos($"notreachableinrange {slot:x} {slot + (ulong)IntPtr.Size:x}"); + string[] results = scan.Lines + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Where(line => !IsLivenessProgress(line)) + .ToArray(); + Assert.Empty(results); } + internal static bool IsLivenessProgress(string line) => + line.StartsWith("Calculating live objects", StringComparison.Ordinal) || + line is "Caching GC roots, this may take a while." or + "Subsequent runs of this command will be faster."; } diff --git a/src/tests/SOS.Tests/ObjectInspectionTests.cs b/src/tests/SOS.Tests/ObjectInspectionTests.cs index 05200a5de8..9d8f70ffd4 100644 --- a/src/tests/SOS.Tests/ObjectInspectionTests.cs +++ b/src/tests/SOS.Tests/ObjectInspectionTests.cs @@ -58,7 +58,7 @@ public async Task DumpObj_Mt_Class_Md_Chain(TestConfig config) // dumpobj: identity of the object. DumpObjResult obj = target.DumpObj(marker); Assert.Equal("ThinLockMarker", obj.Name); - Assert.Equal(24, obj.Size); + Assert.Equal(3 * IntPtr.Size, obj.Size); ulong mt = obj.MethodTable; Assert.NotEqual(0ul, mt); diff --git a/src/tests/SOS.Tests/PrintExceptionTests.cs b/src/tests/SOS.Tests/PrintExceptionTests.cs index e88ae0b448..50edffba14 100644 --- a/src/tests/SOS.Tests/PrintExceptionTests.cs +++ b/src/tests/SOS.Tests/PrintExceptionTests.cs @@ -13,17 +13,26 @@ namespace SOS.Tests; /// public sealed class PrintExceptionTests { - /// - /// A matrix of all combinations of hosts, targets, and flavors. - /// Hosts.DumpHosts = [cdb, dotnet-dump] || [lldb, dotnet-dump] - /// Flavors = e.g. [Flavor.Core, Flavor.SingleFile, Flavor.Framework] - /// - public static TheoryData Matrix { get; } = - TestConfig.BuildMatrix([TargetCatalog.NestedException], dumpKind: DumpKind.All); + public static TheoryData NestedMatrix { get; } = + TestMatrices.CurrentThreadCommands( + [TargetCatalog.NestedException], + Liveness.AllValid, + DumpKind.All); + public static TheoryData NoInnerMatrix { get; } = + TestMatrices.CurrentThreadCommands( + [TargetCatalog.DivZero, TargetCatalog.SimpleThrow], + Liveness.AllValid, + DumpKind.All); + + public static TheoryData ReflectionMatrix { get; } = + TestMatrices.CurrentThreadCommands( + [TargetCatalog.Reflection], + Liveness.AllValid, + DumpKind.All); [SosTheory] - [MemberData(nameof(Matrix))] + [MemberData(nameof(NestedMatrix))] public async Task PrintException_Structure(TestConfig config) { using Target target = await Targets.GetTargetAsync(config); @@ -35,16 +44,42 @@ public async Task PrintException_Structure(TestConfig config) Assert.NotEmpty(pe["Message"].Value); Assert.NotEmpty(pe["InnerException"].Value); // this is present because we are looking at exception with a nested exception pe.AssertContains("StackTrace (generated):"); - - SosTable table = pe.Table(("SP", Sos.Addr), ("IP", Sos.Addr), ("Function", Sos.ModuleFunctionWithOffset)); - Assert.NotEmpty(table); + AssertFrameSequence(pe, ["NestedExceptionTest.Program.Main"]); pe["HResult"].AssertValid(Sos.Hex); pe.AssertContains("There are nested exceptions on this thread"); + + SosOutput nested = target.Sos("printexception -nested"); + nested.AssertContains("Nested exception"); + nested.AssertContains("System.InvalidOperationException"); + nested.AssertContains("Invalid operation exception, outer"); + nested.AssertContains("System.FormatException"); + nested.AssertContains("Bad format exception, inner"); + Assert.Matches(@"HResult:\s+80131509", nested.Text); + Assert.Matches(@"HResult:\s+80131537", nested.Text); + if (nested.Text.Split("NestedExceptionTest.Program.Main").Length - 1 < 2) + { + throw nested.Fail("outer and inner NestedExceptionTest.Program.Main frames"); + } + + SosOutput lines = target.Sos("printexception -lines"); + lines.AssertContains("NestedExceptionTest.Program.Main"); + AssertFrameSequence( + lines, + ["NestedExceptionTest.Program.Main"], + ["NestedExceptionTest.cs @ "]); + if (config.Flavor == Flavor.Framework) + { + Assert.Matches(@"NestedExceptionTest\.cs @ (11|20)", lines.Text); + } + else + { + lines.AssertContains("NestedExceptionTest.cs @ 20"); + } } [SosTheory] - [MemberData(nameof(Matrix))] + [MemberData(nameof(NestedMatrix))] public async Task PrintException_Data(TestConfig config) { using Target target = await Targets.GetTargetAsync(config); @@ -55,8 +90,7 @@ public async Task PrintException_Data(TestConfig config) Assert.Equal("Invalid operation exception, outer", pe["Message"]); pe["InnerException"].AssertContains("System.FormatException"); - SosTable table = pe.Table(("SP", Sos.Addr), ("IP", Sos.Addr), ("Function", Sos.ModuleFunctionWithOffset)); - table.AssertContainsRow(row => row["Function"].Contains("NestedExceptionTest.Program.Main"), "Function contains NestedExceptionTest.Program.Main"); + AssertFrameSequence(pe, ["NestedExceptionTest.Program.Main"]); Assert.Equal(0x80131509u, pe.Field("HResult").AsUInt32(Sos.Hex)); @@ -64,5 +98,143 @@ public async Task PrintException_Data(TestConfig config) SosOutput inner = target.Sos($"printexception {innerExceptionAddr:x}"); Assert.Equal("System.FormatException", inner["Exception type"]); Assert.Equal("Bad format exception, inner", inner["Message"]); + Assert.Equal("", inner["InnerException"]); + Assert.Equal(0x80131537u, inner.Field("HResult").AsUInt32(Sos.Hex)); + AssertFrameSequence(inner, ["NestedExceptionTest.Program.Main"]); + } + + [SosTheory] + [MemberData(nameof(NoInnerMatrix))] + public async Task PrintException_NoInnerException(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToFirstStop(); + + (string exceptionType, string message, uint hresult, string[] functions, string[] sourceLines) = config.Target switch + { + TargetCatalog.DivZero => ( + "System.DivideByZeroException", + "Attempted to divide by zero.", + 0x80020012, + new[] { "C.DivideByZero", "C.F3", "C.F2", "C.Main" }, + new[] + { + "DivZero.cs @ 15", + "DivZero.cs @ 24", + "DivZero.cs @ 36", + config.Flavor == Flavor.Framework ? "DivZero.cs @ 56" : "DivZero.cs @ 57", + }), + TargetCatalog.SimpleThrow => ( + "System.InvalidOperationException", + "Throwing an invalid operation....", + 0x80131509, + new[] { "UserObject.UseObject", "Simple.Main" }, + new[] { "UserObject.cs @ 19", "SimpleThrow.cs @ 12" }), + _ => throw new ArgumentOutOfRangeException(nameof(config), config.Target, "unexpected target"), + }; + + SosOutput pe = target.Sos("printexception"); + Assert.Equal(exceptionType, pe["Exception type"]); + Assert.Equal(message, pe["Message"]); + Assert.Equal("", pe["InnerException"]); + Assert.Equal(hresult, pe.Field("HResult").AsUInt32(Sos.Hex)); + AssertFrameSequence(pe, functions); + + SosOutput nested = target.Sos("printexception -nested"); + Assert.Equal(exceptionType, nested["Exception type"]); + Assert.Equal(message, nested["Message"]); + Assert.Equal("", nested["InnerException"]); + Assert.Equal(hresult, nested.Field("HResult").AsUInt32(Sos.Hex)); + AssertFrameSequence(nested, functions); + + SosOutput lines = target.Sos("printexception -lines"); + Assert.Equal(exceptionType, lines["Exception type"]); + Assert.Equal(message, lines["Message"]); + Assert.Equal("", lines["InnerException"]); + Assert.Equal(hresult, lines.Field("HResult").AsUInt32(Sos.Hex)); + AssertFrameSequence(lines, functions, sourceLines); + } + + [SosTheory] + [MemberData(nameof(ReflectionMatrix))] + public async Task PrintException_ReflectionInnerException(TestConfig config) + { + using Target target = await Targets.GetTargetAsync(config); + target.GoToFirstStop(); + + SosOutput pe = target.Sos("printexception"); + Assert.Equal("System.Reflection.TargetInvocationException", pe["Exception type"]); + Assert.Equal("Exception has been thrown by the target of an invocation.", pe["Message"]); + pe["InnerException"].AssertContains("System.Exception"); + Assert.Equal(0x80131604u, pe.Field("HResult").AsUInt32(Sos.Hex)); + pe.Table(("SP", Sos.Addr), ("IP", Sos.Addr), ("Function", Sos.ModuleFunctionWithOffset)) + .AssertContainsRow(row => row["Function"].Contains("RefLoader.Loader.Main"), "Function contains RefLoader.Loader.Main"); + + ulong innerExceptionAddr = pe["InnerException"].Extract(Sos.Addr); + SosOutput inner = target.Sos($"printexception {innerExceptionAddr:x}"); + Assert.Equal("System.Exception", inner["Exception type"]); + Assert.Equal("Exception from InvokedCode.Invoked.ExceptionNoHandler()", inner["Message"]); + Assert.Equal(0x80131500u, inner.Field("HResult").AsUInt32(Sos.Hex)); + + SosOutput nested = target.Sos("printexception -nested"); + nested.AssertContains("System.Reflection.TargetInvocationException"); + nested.AssertContains("System.Exception"); + + SosOutput lines = target.Sos("printexception -lines"); + AssertFrameSequence(lines, ["RefLoader.Loader.Main"], ["ReflectionTest.cs @ "]); + } + + private static void AssertFrameSequence( + SosOutput output, + IReadOnlyList expectedFunctions, + IReadOnlyList? expectedSourceLines = null) + { + if (expectedSourceLines is not null && expectedSourceLines.Count != expectedFunctions.Count) + { + throw new ArgumentException("Source-line expectations must match the function count.", nameof(expectedSourceLines)); + } + + SosTable table = output.Table("SP", "IP", "Function"); + int searchFrom = 0; + for (int expectedIndex = 0; expectedIndex < expectedFunctions.Count; expectedIndex++) + { + string expectedFunction = expectedFunctions[expectedIndex]; + int row = -1; + for (int i = searchFrom; i < table.Length; i++) + { + if (table.Row(i)["Function"].Value.Contains(expectedFunction, StringComparison.Ordinal)) + { + row = i; + break; + } + } + + if (row < 0) + { + throw output.Fail($"ordered stack frame '{expectedFunction}' at or after row {searchFrom}"); + } + + SosRow frame = table.Row(row); + if (!Sos.Addr.Matches(frame["SP"].Value) || !Sos.Addr.Matches(frame["IP"].Value)) + { + throw output.Fail($"frame '{expectedFunction}' to contain valid SP and IP addresses"); + } + + string functionCell = frame["Function"].Value; + int sourceStart = functionCell.LastIndexOf(" [", StringComparison.Ordinal); + string function = sourceStart >= 0 ? functionCell[..sourceStart] : functionCell; + if (!Sos.ModuleFunctionWithOffset.Matches(function)) + { + throw output.Fail($"frame '{expectedFunction}' to contain a module, function, and offset"); + } + if (expectedSourceLines is not null && + !functionCell.Contains(expectedSourceLines[expectedIndex], StringComparison.Ordinal)) + { + throw output.Fail( + $"frame '{expectedFunction}' to contain source annotation '{expectedSourceLines[expectedIndex]}'"); + } + + searchFrom = row + 1; + } } } diff --git a/src/tests/SOS.Tests/README.md b/src/tests/SOS.Tests/README.md index 555a5a8a4b..e8a2ed5f9c 100644 --- a/src/tests/SOS.Tests/README.md +++ b/src/tests/SOS.Tests/README.md @@ -72,7 +72,7 @@ Each theory receives one `TestConfig`, whose axes are: Notably, dotnet-dump is dump-only; cdb is Windows-only and LLDB is non-Windows; Framework is Windows-only; Server GC is Core/SingleFile dump-only; single-file Mini dumps and live LLDB navigation through stripped single-file images are -excluded; cDAC is Core-only and requires .NET 11 or later. +excluded; cDAC supports Core and SingleFile on .NET 11 or later. Tests opt into expensive axes. The default is dump, workstation GC, and Heap. Live, Server, Mini, and Full rows appear only where they exercise distinct @@ -91,6 +91,12 @@ The DAC is deliberately not a capture dimension: legacy DAC and cDAC analyze the same dump, with DAC selection happening when the host opens it. Cached dumps are reused only while newer than their debuggee. +Helix splits the matrix by the same capture-family key, including target, +flavor, runtime version, GC type, dump kind, and liveness. Host and DAC are +excluded from the key so every analysis of one dump stays in the same work +item. `SOSHARNESS_SHARD_INDEX` and `SOSHARNESS_SHARD_COUNT` must be set +together; hashing is stable across processes and runtimes. + `Targets.GetTargetAsync` returns a cheap cursor over shared, read-only dump sessions. A session is memoized by host, target, stop, flavor, GC type, dump kind, runtime, and DAC. Live targets are never shared because command execution @@ -164,6 +170,16 @@ A canonical test defines the smallest valid matrix, acquires and disposes a structure plus product data. Keep host conditionals in the matrix or host abstraction rather than duplicating the assertion body. +## Legacy coverage migration + +Legacy retirement requires assertion and matrix equivalence, not command-name +overlap. This layer retires `DivZero.script`, `NestedExceptionTest.script`, and +`SimpleThrow.script` after moving their exact exception, source-line, stack, +thread, live/dump, and CLRMA behavior into focused tests. `Reflection.script` +remains active because its reflected target-invocation boundary is still a +specialized legacy scenario. [COVERAGE.md](COVERAGE.md) records the evidence and +all remaining retained scenarios and gaps. + ## Controls Comma-separated matrix allow-lists are case-insensitive enum names: @@ -178,17 +194,39 @@ Comma-separated matrix allow-lists are case-insensitive enum names: | `SOSHARNESS_ONLY_COREVERSIONS` | Select versions such as `Net8,Net11`; explicit selection also permits an out-of-support version. | | `SOSHARNESS_ONLY_DAC` | Select `Legacy` and/or `CDac`. | | `SOSHARNESS_TEST_OUT_OF_SUPPORT_CORE` | Set to `1` to include every installed out-of-support runtime. | +| `SOSHARNESS_EXCLUDE_SINGLEFILE_SNAPSHOTS` | Set to `1` to omit constrained single-file snapshot rows while retaining single-file crash coverage. | | `SOSHARNESS_ARTIFACTS_CONFIG` | Override the build configuration embedded in the harness assembly. | +| `SOSHARNESS_REPO_ROOT` | Override repository-root discovery, primarily for a staged correlation payload. | +| `SOSHARNESS_DOTNET_ROOT` | Override the repository-local .NET root used for harness subprocesses. | +| `SOSHARNESS_DOTNET_TEST_ROOT` | Override the multi-runtime test installation, including a writable executable overlay. | +| `SOSHARNESS_NATIVE_ROOT` | Override the native SOS output directory, including a writable musl overlay. | +| `SOSHARNESS_EXECUTABLE_ROOT` | Copy non-executable debuggee apphosts and symlink their sidecars into this writable overlay. | +| `SOSHARNESS_SCRATCH_ROOT` | Redirect dump, target, and symbol-cache writes outside a staged payload. | +| `SOSHARNESS_DBGENG_ROOT` | Override the directory containing the Helix payload's `dbgeng.dll`. | +| `SOSHARNESS_HOST_RUNTIME_DIR` | Override the complete runtime layout used to host SOS's managed extension. | +| `SOSHARNESS_USE_PREBUILT_TARGETS` | Set to `1` to require prebuilt debuggees and subprocess hosts instead of building in place. | +| `SOSHARNESS_SHARD_INDEX` | Zero-based capture-family shard index; must be paired with `SOSHARNESS_SHARD_COUNT`. | +| `SOSHARNESS_SHARD_COUNT` | Positive capture-family shard count; must be paired with `SOSHARNESS_SHARD_INDEX`. | +| `SOSHARNESS_UPLOAD_ROOT` | Route replay files and debugger-host crash diagnostics to an upload directory. Helix launchers translate `HELIX_WORKITEM_UPLOAD_ROOT` to this harness-owned control. | +| `SOSHARNESS_TEST_RUNTIME_MAJOR` | Unix launcher override selecting the installed runtime major used to run `SOS.Tests.dll`. | | `SOSHARNESS_MAX_LIVE` | Set the positive maximum number of concurrent live sessions. | | `SOSHARNESS_LIVE_TIMEOUT` | Set the positive live LLDB command timeout in seconds. | | `SOSHARNESS_LLDB_LOAD_TIMEOUT` | Set the positive LLDB target-load timeout in seconds. | | `SOSHARNESS_LLDB_TRACE` | Enable LLDB protocol tracing and record its value in replay files. | +| `SOSHARNESS_LLDB_PATH` | Override the LLDB executable or the macOS `sos-lldb` driver. | | `SOSHARNESS_DAC_DIR` | Override the legacy DAC directory used by the dbgeng engine host. | | `SOSHARNESS_CDAC_DIR` | Override cDAC discovery with a directory containing the cDAC. | | `SOSHARNESS_USECDAC` | Local global DAC clamp; overrides the matrix DAC selection and is not set in CI. | | `LLDB_PATH` | Override LLDB discovery. Otherwise Xcode and then `PATH` are searched. | | `NUGET_PACKAGES` | Override the NuGet package root used to locate runtime packs and cDAC assets. | +The unprivileged Azure Linux Helix Alpine container sets +`SOSHARNESS_ONLY_DUMPKIND=Heap,Full` and runs dump work items one test at a time +to avoid an intermittent .NET 8 createdump `PR_SET_PTRACER` race during +concurrent snapshot capture. This retains Heap, Full, and live coverage; live +work items remain parallel, and local Alpine test containers continue to run +Mini rows. + The harness sets the following implementation-owned values for child processes; they are not supported user controls: @@ -219,3 +257,19 @@ original test failure even if replay writing also fails. Reusable targets, dumps, symbols, and host crash artifacts live under `artifacts/tmp/sos-harness/`. Dumps can be large; remove that scratch subtree when a clean recapture is required. + +## Helix execution + +`eng/helix/SOS.Tests.Helix.proj` stages one immutable correlation payload per +OS, RID, and configuration, then points every named shard work item at it. The +payload contains `SOS.Tests`, its harness subprocesses, native SOS, the +repository-built dotnet-dump, the test runtime and DAC closure, DbgEng on +Windows, and all prebuilt Core, SingleFile, and Framework debuggees needed by +that platform. Per-work-item writable overlays hold executable copies, dumps, +logs, replays, and host crash diagnostics. + +The standard layout is eight dump shards and two live shards. macOS uses 32 +dump shards and two live shards, with bounded in-process parallelism, to stay +within observed runtime and machine limits. Work-item names and commands carry +the RID, configuration, liveness, shard index, and shard count for direct +diagnosis and replay. diff --git a/src/tests/SOS.Tests/RuntimeInfoTests.cs b/src/tests/SOS.Tests/RuntimeInfoTests.cs index 22987f34ba..5806b68959 100644 --- a/src/tests/SOS.Tests/RuntimeInfoTests.cs +++ b/src/tests/SOS.Tests/RuntimeInfoTests.cs @@ -1,6 +1,7 @@ // 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.Text.RegularExpressions; using SOS.TestHarness; using Xunit; @@ -53,7 +54,14 @@ public async Task Modules_Registers_Threads_DotnetDumpOnly(TestConfig config) target.GoToStopPoint(TargetCatalog.StopHeap); target.Sos("modules").AssertContains("SosHarnessScenarios"); - target.Sos("registers").AssertContains("rsp"); + string stackPointerRegister = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X86 => "esp", + Architecture.X64 => "rsp", + Architecture.Arm or Architecture.Arm64 => "sp", + _ => throw new PlatformNotSupportedException(), + }; + target.Sos("registers").AssertContains(stackPointerRegister); // The debuggee parks several worker threads, so the thread list has multiple entries. SosOutput threads = target.Sos("threads"); diff --git a/src/tests/SOS.Tests/SOS.Tests.csproj b/src/tests/SOS.Tests/SOS.Tests.csproj index 06d3ae8d3e..5bf88850b0 100644 --- a/src/tests/SOS.Tests/SOS.Tests.csproj +++ b/src/tests/SOS.Tests/SOS.Tests.csproj @@ -12,11 +12,7 @@ implicitly references xunit.v3 and the test runner; no explicit PackageReference needed. xunit v3 runs as an MTP executable, so OutputType must be Exe. --> XUnitV3 - - - - - true + true diff --git a/src/tests/SOS.Tests/SosReplayAttribute.cs b/src/tests/SOS.Tests/SosReplayAttribute.cs index 0f0c47aa70..fcec053db6 100644 --- a/src/tests/SOS.Tests/SosReplayAttribute.cs +++ b/src/tests/SOS.Tests/SosReplayAttribute.cs @@ -27,12 +27,16 @@ namespace SOS.Tests; public sealed class SosReplayAttribute : BeforeAfterTestAttribute { private static readonly string s_runDirectory = Path.Combine( - RepoLayout.Root, - "artifacts", - "TestResults", - "SOS.Tests", + ResolveReplayDirectory( + Environment.GetEnvironmentVariable(HostDiagnostics.UploadRootVariable), + RepoLayout.Root), $"{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Environment.ProcessId}"); + internal static string ResolveReplayDirectory(string? uploadRoot, string repoRoot) => + string.IsNullOrEmpty(uploadRoot) + ? Path.Combine(repoRoot, "artifacts", "TestResults", "SOS.Tests") + : Path.Combine(uploadRoot, "SOS-replays"); + public override void After(MethodInfo methodUnderTest, IXunitTest test) { // Always take (and remove) this test's capture, pass or fail, so the table stays bounded. diff --git a/src/tests/SOS.Tests/SosTheoryAttribute.cs b/src/tests/SOS.Tests/SosTheoryAttribute.cs index b01f03c257..e8a83e5713 100644 --- a/src/tests/SOS.Tests/SosTheoryAttribute.cs +++ b/src/tests/SOS.Tests/SosTheoryAttribute.cs @@ -1,6 +1,7 @@ // 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; @@ -22,5 +23,6 @@ public SosTheoryAttribute( [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1) : base(sourceFilePath, sourceLineNumber) { + SkipTestWithoutData = TestConfig.AllowEmptyMatrix(Environment.GetEnvironmentVariable); } } diff --git a/src/tests/SOS.Tests/SpecializedInspectionTests.cs b/src/tests/SOS.Tests/SpecializedInspectionTests.cs index a05982488f..2736b0f7a7 100644 --- a/src/tests/SOS.Tests/SpecializedInspectionTests.cs +++ b/src/tests/SOS.Tests/SpecializedInspectionTests.cs @@ -53,6 +53,22 @@ public async Task ThreadPool_ReportsWorkerStats(TestConfig config) [MemberData(nameof(Matrix))] public async Task SyncBlk_ReportsInflatedMonitor(TestConfig config) { + if (RepoLayout.Rid.StartsWith("linux-musl-", StringComparison.Ordinal)) + { + HarnessSkipException.Now( + "SyncBlock data is unavailable from the DAC on Linux musl, so syncblk cannot report inflated monitors."); + } + + if (OperatingSystem.IsMacOS() && + config.Host == Host.DotnetDump && + config.Dac == Dac.Legacy && + config.Flavor == Flavor.Core && + config.CoreVersion == CoreVersion.Net11) + { + HarnessSkipException.Now( + "https://github.com/dotnet/diagnostics/issues/5985: legacy DAC SyncBlock data is unavailable for macOS .NET 11 dumps."); + } + using Target target = await Targets.GetTargetAsync(config); target.GoToStopPoint(TargetCatalog.StopHeap); diff --git a/src/tests/SOS.Tests/StackInspectionTests.cs b/src/tests/SOS.Tests/StackInspectionTests.cs index 1144457add..8f254ac318 100644 --- a/src/tests/SOS.Tests/StackInspectionTests.cs +++ b/src/tests/SOS.Tests/StackInspectionTests.cs @@ -17,7 +17,8 @@ public sealed class StackInspectionTests { // Live opt-in: !dso (dumpstackobjects) scans a live thread's stack memory for object references, so // DumpStackObjects_ListsStackRoots runs dump AND live; the other stack commands here stay dump-only. - public static TheoryData Matrix => TestConfig.BuildMatrix([TargetCatalog.Scenarios], liveness: Liveness.AllValid); + public static TheoryData Matrix => + TestMatrices.CurrentThreadCommands([TargetCatalog.Scenarios], Liveness.AllValid); public static TheoryData CdbMatrix => TestMatrices.FullDumpOnCoreVersions([TargetCatalog.Scenarios], CoreVersion.Net8 | CoreVersion.Net9 | CoreVersion.Net10, Flavor.AllValid, Host.Cdb); public static TheoryData DotnetDumpMatrix => TestConfig.BuildMatrix([TargetCatalog.Scenarios], Flavor.AllValid, Host.DotnetDump); diff --git a/src/tests/SOS.Tests/TestConfigShardTests.cs b/src/tests/SOS.Tests/TestConfigShardTests.cs new file mode 100644 index 0000000000..982a11a3ce --- /dev/null +++ b/src/tests/SOS.Tests/TestConfigShardTests.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +public sealed class TestConfigShardTests +{ + [Fact] + public void ShardControlsCanBeUnset() + { + Assert.Null(ShardSelection.FromEnvironment(_ => null)); + } + + [Theory] + [InlineData("0", null)] + [InlineData(null, "8")] + public void ShardControlsMustBeSpecifiedTogether(string? index, string? count) + { + InvalidOperationException error = Assert.Throws( + () => Parse(index, count)); + + Assert.Contains("must either both be set or both be unset", error.Message); + } + + [Theory] + [InlineData("0", "0")] + [InlineData("0", "-1")] + [InlineData("0", " 8")] + [InlineData("0", "eight")] + public void ShardCountMustBeStrictlyPositive(string index, string count) + { + InvalidOperationException error = Assert.Throws( + () => Parse(index, count)); + + Assert.Contains("SOSHARNESS_SHARD_COUNT", error.Message); + } + + [Theory] + [InlineData("-1", "8")] + [InlineData("8", "8")] + [InlineData(" 0", "8")] + [InlineData("zero", "8")] + public void ShardIndexMustBeInRange(string index, string count) + { + InvalidOperationException error = Assert.Throws( + () => Parse(index, count)); + + Assert.Contains("SOSHARNESS_SHARD_INDEX", error.Message); + } + + [Fact] + public void CaptureFamilyExcludesReplayOnlyAxes() + { + TestConfig first = Config(Host.Cdb, Dac.Legacy); + TestConfig second = Config(Host.DotnetDump, Dac.CDac); + + Assert.Equal(first.CaptureFamilyKey, second.CaptureFamilyKey); + Assert.Equal(first.GetCaptureShard(8), second.GetCaptureShard(8)); + } + + [Fact] + public void CaptureFamilyIncludesEveryCaptureAxis() + { + TestConfig baseline = Config(Host.Cdb, Dac.Legacy); + + Assert.NotEqual(baseline.CaptureFamilyKey, (baseline with { Target = TargetCatalog.DivZero }).CaptureFamilyKey); + Assert.NotEqual(baseline.CaptureFamilyKey, (baseline with { Flavor = Flavor.SingleFile }).CaptureFamilyKey); + Assert.NotEqual(baseline.CaptureFamilyKey, (baseline with { CoreVersion = CoreVersion.Net11 }).CaptureFamilyKey); + Assert.NotEqual(baseline.CaptureFamilyKey, (baseline with { GcType = GcType.Server }).CaptureFamilyKey); + Assert.NotEqual(baseline.CaptureFamilyKey, (baseline with { DumpKind = DumpKind.Full }).CaptureFamilyKey); + Assert.NotEqual(baseline.CaptureFamilyKey, (baseline with { Liveness = Liveness.Live }).CaptureFamilyKey); + } + + [Fact] + public void StableHashHasFixedValue() + { + Assert.Equal( + 0x1C7CF9C1B972727AUL, + TestConfig.StableHash("scenarios|Core|Net10|Workstation|Heap|Dump")); + Assert.Equal(2, Config(Host.Cdb, Dac.Legacy).GetCaptureShard(8)); + } + + [Fact] + public void ShardingUsesTransformedCaptureFamily() + { + TestConfig transformed = Config(Host.Cdb, Dac.Legacy) with { DumpKind = DumpKind.Full }; + ShardSelection shard = new(transformed.GetCaptureShard(8), 8); + + Assert.Equal( + [transformed], + TestConfig.ApplyShardFilter([transformed], shard).ToArray()); + } + + [Fact] + public void MatrixPartitionControlsAllowEmptyTheories() + { + Assert.False(TestConfig.AllowEmptyMatrix(_ => null)); + Assert.True(TestConfig.AllowEmptyMatrix(name => + name == "SOSHARNESS_ONLY_LIVENESS" ? "Live" : null)); + Assert.True(TestConfig.AllowEmptyMatrix(name => name switch + { + "SOSHARNESS_SHARD_INDEX" => "31", + "SOSHARNESS_SHARD_COUNT" => "32", + _ => null, + })); + } + + private static ShardSelection? Parse(string? index, string? count) => + ShardSelection.FromEnvironment(name => name switch + { + "SOSHARNESS_SHARD_INDEX" => index, + "SOSHARNESS_SHARD_COUNT" => count, + _ => null, + }); + + private static TestConfig Config(Host host, Dac dac) => + new( + TargetCatalog.Scenarios, + host, + Flavor.Core, + Liveness.Dump, + GcType.Workstation, + DumpKind.Heap, + CoreVersion.Net10, + dac); +} diff --git a/src/tests/SOS.Tests/TestConfigValidityTests.cs b/src/tests/SOS.Tests/TestConfigValidityTests.cs new file mode 100644 index 0000000000..97fd7559b5 --- /dev/null +++ b/src/tests/SOS.Tests/TestConfigValidityTests.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.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 CDacRequiresSupportedNet11Configuration() + { + 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.True(TestConfig.IsDacSupported(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 MuslHelixDumpKindFilterPreservesHeapAndFull() + { + DumpKind[] dumpKinds = TestConfig.ApplyAllowList( + DumpKind.Heap | DumpKind.Mini | DumpKind.Full, + "Heap,Full").ToArray(); + + Assert.Equal([DumpKind.Heap, DumpKind.Full], dumpKinds); + } + + [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.Tests/TestMatrices.cs b/src/tests/SOS.Tests/TestMatrices.cs index bd8600a3c5..ae659e3705 100644 --- a/src/tests/SOS.Tests/TestMatrices.cs +++ b/src/tests/SOS.Tests/TestMatrices.cs @@ -8,11 +8,6 @@ namespace SOS.Tests; internal static class TestMatrices { - /// - /// Matrix for commands that use the managed stack walker. The universal cDAC can inspect a - /// single-file runtime, but it cannot currently start a stack walk for one (SOS reports - /// COR_E_INVALIDOPERATION), so that combination is not a supported stack-walk configuration. - /// public static TheoryData StackWalk( string[] targets, Flavor flavor = Flavor.AllValid, @@ -21,10 +16,11 @@ public static TheoryData StackWalk( GcType gcType = GcType.Workstation, DumpKind dumpKind = DumpKind.Heap, CoreVersion coreVersion = CoreVersion.All, - Dac dac = Dac.All) + Dac dac = Dac.All, + Func? filter = null) { TheoryData data = new(); - foreach (TestConfig config in StackWalkConfigs(targets, flavor, host, liveness, gcType, dumpKind, coreVersion, dac)) + foreach (TestConfig config in StackWalkConfigs(targets, flavor, host, liveness, gcType, dumpKind, coreVersion, dac, filter)) { data.Add(config); } @@ -40,12 +36,53 @@ public static IEnumerable StackWalkConfigs( GcType gcType = GcType.Workstation, DumpKind dumpKind = DumpKind.Heap, CoreVersion coreVersion = CoreVersion.All, - Dac dac = Dac.All) => - TestConfig.Permutations(targets, flavor, host, liveness, gcType, dumpKind, coreVersion: coreVersion, dac: dac) - .Where(SupportsStackWalk); + Dac dac = Dac.All, + Func? filter = null) => + TestConfig.ApplyShardFilter( + UnshardedStackWalkConfigs(targets, flavor, host, liveness, gcType, dumpKind, coreVersion, dac, filter)); + + private static IEnumerable UnshardedStackWalkConfigs( + string[] targets, + Flavor flavor, + Host host, + Liveness liveness, + GcType gcType, + DumpKind dumpKind, + CoreVersion coreVersion, + Dac dac, + Func? filter) => + // .NET 11 cDAC supports SingleFile stack walks; TestConfig rejects cDAC on earlier runtimes. + TestConfig.UnshardedPermutations(targets, flavor, host, liveness, gcType, dumpKind, coreVersion: coreVersion, dac: dac) + .Where(SupportsCurrentThread) + .Where(config => filter is null || filter(config)); + + public static TheoryData HeapEnumeration(string[] targets) + { + TheoryData data = new(); + foreach (TestConfig config in TestConfig.Permutations(targets).Where(SupportsHeapEnumeration)) + { + data.Add(config); + } - public static bool SupportsStackWalk(TestConfig config) => - config.Flavor != Flavor.SingleFile || config.Dac != Dac.CDac; + return data; + } + + public static TheoryData CurrentThreadCommands( + string[] targets, + Liveness liveness = Liveness.Dump, + DumpKind dumpKind = DumpKind.Heap) + { + TheoryData data = new(); + foreach (TestConfig config in TestConfig.Permutations( + targets, + liveness: liveness, + dumpKind: dumpKind).Where(SupportsCurrentThread)) + { + data.Add(config); + } + + return data; + } /// /// Wraps for commands whose data is absent from a reduced Heap dump on @@ -75,16 +112,16 @@ public static TheoryData FullDumpOnCoreVersions( Dac dac = Dac.All) { TheoryData data = new(); - foreach (TestConfig config in TestConfig.Permutations(targets, flavor, host, liveness, gcType, dumpKind, coreVersion, dac)) + IEnumerable configs = + TestConfig.UnshardedPermutations(targets, flavor, host, liveness, gcType, dumpKind, coreVersion, dac) + .Select(config => + OperatingSystem.IsWindows() && (config.CoreVersion & fullDumpVersions) != 0 + ? config with { DumpKind = DumpKind.Full } + : config); + + foreach (TestConfig config in TestConfig.ApplyShardFilter(configs)) { - if (OperatingSystem.IsWindows() && (config.CoreVersion & fullDumpVersions) != 0) - { - data.Add(config with { DumpKind = DumpKind.Full }); - } - else - { - data.Add(config); - } + data.Add(config); } return data; @@ -97,19 +134,63 @@ public static TheoryData StackWalkFullDumpOnCoreVersions( Host host = Host.AllValid, Liveness liveness = Liveness.Dump, CoreVersion coreVersion = CoreVersion.All, - Dac dac = Dac.All) + Dac dac = Dac.All, + Func? filter = null) { TheoryData data = new(); - foreach (TestConfig config in StackWalkConfigs(targets, flavor, host, liveness, coreVersion: coreVersion, dac: dac)) + IEnumerable configs = UnshardedStackWalkConfigs( + targets, + flavor, + host, + liveness, + GcType.Workstation, + DumpKind.Heap, + coreVersion, + dac, + filter) + .Select(config => + OperatingSystem.IsWindows() && (config.CoreVersion & fullDumpVersions) != 0 + ? config with { DumpKind = DumpKind.Full } + : config); + + foreach (TestConfig config in TestConfig.ApplyShardFilter(configs)) { - data.Add(OperatingSystem.IsWindows() && (config.CoreVersion & fullDumpVersions) != 0 - ? config with { DumpKind = DumpKind.Full } - : config); + data.Add(config); } return data; } + internal static bool SupportsHeapEnumeration(TestConfig config) => + SupportsHeapEnumeration(config, OperatingSystem.IsWindows()); + + internal static bool SupportsHeapEnumeration(TestConfig config, bool isWindows) => + // https://github.com/dotnet/runtime/pull/132938: dbgeng /mw dumps omit the WKS card-table + // pointer slot, so cDAC cannot construct a heap until the runtime fix flows into this repo. + // Windows SingleFile crash dumps use dbgeng capture regardless of the later analysis host. + !isWindows + || config.Flavor != Flavor.SingleFile + || config.Dac != Dac.CDac; + + internal static bool SupportsGcRootEnumeration(TestConfig config) => + // Desktop SOS can fail GC-reference enumeration or corrupt the debugger host in clrstack -gc. + config.Flavor != Flavor.Framework; + + internal static bool SupportsCurrentThread(TestConfig config) => + // createdump ELF cores expose synthetic runtime thread IDs that LLDB cannot select, so + // current-thread commands report "The current thread is unmanaged" even on the crash thread. + config.Host != Host.Lldb || config.Liveness != Liveness.Dump; + + internal static bool SupportsICorDebugStackWalk(TestConfig config) => + SupportsICorDebugStackWalk(config, Environment.Is64BitProcess); + + internal static bool SupportsICorDebugStackWalk(TestConfig config, bool is64BitProcess) => + // Desktop x64 ICorDebug returns no frames for a dump captured at DivZero's second-chance crash. + !is64BitProcess + || config.Host != Host.Cdb + || config.Target != TargetCatalog.DivZero + || config.Flavor != Flavor.Framework; + public static TheoryData CoreFrameworkConditional(string[] targets) { TheoryData data = new(); @@ -121,9 +202,15 @@ public static TheoryData CoreFrameworkConditional(string[] targets) return data; } - public static IEnumerable CoreFrameworkConditionalFullDumpConfigs(string[] targets) + public static IEnumerable CoreFrameworkConditionalFullDumpConfigs(string[] targets) => + TestConfig.ApplyShardFilter(UnshardedCoreFrameworkConditionalFullDumpConfigs(targets)); + + private static IEnumerable UnshardedCoreFrameworkConditionalFullDumpConfigs(string[] targets) { - foreach (TestConfig config in TestConfig.Permutations(targets, flavor: Flavor.Core | Flavor.Framework, dumpKind: DumpKind.Heap)) + foreach (TestConfig config in TestConfig.UnshardedPermutations( + targets, + flavor: Flavor.Core | Flavor.Framework, + dumpKind: DumpKind.Heap)) { if (!OperatingSystem.IsWindows() && config.CoreVersion == CoreVersion.Net10) { diff --git a/src/tests/SOS.Tests/TestMatrixCapabilityTests.cs b/src/tests/SOS.Tests/TestMatrixCapabilityTests.cs new file mode 100644 index 0000000000..515d7655f4 --- /dev/null +++ b/src/tests/SOS.Tests/TestMatrixCapabilityTests.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SOS.TestHarness; +using Xunit; + +namespace SOS.Tests; + +public sealed class TestMatrixCapabilityTests +{ + [Fact] + public void HeapEnumerationExcludesWindowsSingleFileCDacDumps() + { + TestConfig config = Config(Host.Cdb, Dac.CDac) with + { + Flavor = Flavor.SingleFile, + CoreVersion = CoreVersion.Net11, + }; + + Assert.False(TestMatrices.SupportsHeapEnumeration(config, isWindows: true)); + Assert.True(TestMatrices.SupportsHeapEnumeration(config, isWindows: false)); + Assert.False(TestMatrices.SupportsHeapEnumeration(config with { Host = Host.DotnetDump }, isWindows: true)); + Assert.True(TestMatrices.SupportsHeapEnumeration(config with { Dac = Dac.Legacy }, isWindows: true)); + Assert.True(TestMatrices.SupportsHeapEnumeration(config with { Flavor = Flavor.Core }, isWindows: true)); + } + + [Fact] + public void GcRootEnumerationExcludesOnlyFramework() + { + TestConfig config = Config(Host.Cdb, Dac.Legacy); + + Assert.True(TestMatrices.SupportsGcRootEnumeration(config)); + Assert.False(TestMatrices.SupportsGcRootEnumeration(config with { Flavor = Flavor.Framework })); + } + + [Fact] + public void CurrentThreadCommandsExcludeOnlyLldbDumps() + { + TestConfig config = Config(Host.Lldb, Dac.Legacy); + + Assert.False(TestMatrices.SupportsCurrentThread(config)); + Assert.True(TestMatrices.SupportsCurrentThread(config with { Liveness = Liveness.Live })); + Assert.True(TestMatrices.SupportsCurrentThread(config with { Host = Host.DotnetDump })); + Assert.True(TestMatrices.SupportsCurrentThread(config with { Host = Host.Cdb })); + } + + [Fact] + public void ICorDebugStackWalkExcludesOnlyX64CdbFrameworkDivZeroDumps() + { + TestConfig config = Config(Host.Cdb, Dac.Legacy) with + { + Target = TargetCatalog.DivZero, + Flavor = Flavor.Framework, + }; + + Assert.False(TestMatrices.SupportsICorDebugStackWalk(config, is64BitProcess: true)); + Assert.True(TestMatrices.SupportsICorDebugStackWalk(config, is64BitProcess: false)); + Assert.True(TestMatrices.SupportsICorDebugStackWalk(config with { Host = Host.DotnetDump }, is64BitProcess: true)); + Assert.True(TestMatrices.SupportsICorDebugStackWalk(config with { Target = TargetCatalog.Scenarios }, is64BitProcess: true)); + Assert.True(TestMatrices.SupportsICorDebugStackWalk(config with { Flavor = Flavor.Core }, is64BitProcess: true)); + } + + [Theory] + [InlineData("Calculating live objects, this may take a while...", true)] + [InlineData("Calculating live objects complete: 42 objects from 3 roots", true)] + [InlineData("Caching GC roots, this may take a while.", true)] + [InlineData("Subsequent runs of this command will be faster.", true)] + [InlineData("0000000123456780", false)] + public void NotReachableInRangeIgnoresOnlyLivenessProgress(string line, bool expected) + { + Assert.Equal(expected, NativeAddressSpaceTests.IsLivenessProgress(line)); + } + + private static TestConfig Config(Host host, Dac dac) => + new( + TargetCatalog.Scenarios, + host, + Flavor.Core, + Liveness.Dump, + GcType.Workstation, + DumpKind.Heap, + CoreVersion.Net10, + dac); +} diff --git a/src/tests/SOS.Tests/UnixPayloadTests.cs b/src/tests/SOS.Tests/UnixPayloadTests.cs new file mode 100644 index 0000000000..a8c363763a --- /dev/null +++ b/src/tests/SOS.Tests/UnixPayloadTests.cs @@ -0,0 +1,91 @@ +// 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 UnixPayloadTests +{ + [Fact] + public void DirectoryOverrideUsesConfiguredPath() + { + string expected = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "sos-harness-scratch")); + string defaultPath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "default-scratch")); + + Assert.Equal(expected, RepoLayout.ResolveDirectory(expected, "/unused")); + Assert.Equal(defaultPath, RepoLayout.ResolveDirectory(null, defaultPath)); + } + + [Fact] + public void UploadRootRoutesHarnessArtifacts() + { + string repoRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "sos-repo")); + string uploadRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "sos-upload")); + + Assert.Equal( + Path.Combine(uploadRoot, "failure-diagnostics", "crashdumps"), + HostDiagnostics.ResolveCrashDumpDirectory(uploadRoot, repoRoot)); + Assert.Equal( + Path.Combine(uploadRoot, "SOS-replays"), + SosReplayAttribute.ResolveReplayDirectory(uploadRoot, repoRoot)); + Assert.Equal( + Path.Combine(repoRoot, "artifacts", "replays", "crashdumps"), + HostDiagnostics.ResolveCrashDumpDirectory(null, repoRoot)); + Assert.Equal( + Path.Combine(repoRoot, "artifacts", "TestResults", "SOS.Tests"), + SosReplayAttribute.ResolveReplayDirectory(null, repoRoot)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TargetUsesWritableOverlay(bool sourceIsExecutable) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + string testRoot = Path.Combine(Path.GetTempPath(), $"sos-payload-{Guid.NewGuid():N}"); + string sourceRoot = Path.Combine(testRoot, "payload"); + string sourceDirectory = Path.Combine(sourceRoot, "artifacts", "bin", "Debuggee"); + string overlayRoot = Path.Combine(testRoot, "overlay"); + string sourceExecutable = Path.Combine(sourceDirectory, "Debuggee"); + string sourceSidecar = Path.Combine(sourceDirectory, "Debuggee.dll"); + + try + { + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText(sourceExecutable, "executable"); + File.WriteAllText(sourceSidecar, "sidecar"); + UnixFileMode sourceMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + if (sourceIsExecutable) + { + sourceMode |= UnixFileMode.UserExecute; + } + File.SetUnixFileMode(sourceExecutable, sourceMode); + + string executable = SnapshotStore.EnsureExecutable(sourceExecutable, overlayRoot, sourceRoot); + + Assert.NotEqual(sourceExecutable, executable); + Assert.Equal("executable", File.ReadAllText(executable)); + Assert.Equal("sidecar", File.ReadAllText(Path.Combine(Path.GetDirectoryName(executable)!, "Debuggee.dll"))); + Assert.True((File.GetUnixFileMode(executable) & UnixFileMode.UserExecute) != 0); + Assert.Equal( + sourceIsExecutable, + (File.GetUnixFileMode(sourceExecutable) & UnixFileMode.UserExecute) != 0); + + File.SetLastWriteTimeUtc(executable, DateTime.UtcNow.AddDays(-1)); + DateTime overlayWriteTime = File.GetLastWriteTimeUtc(executable); + + Assert.Equal(executable, SnapshotStore.EnsureExecutable(sourceExecutable, overlayRoot, sourceRoot)); + Assert.Equal(overlayWriteTime, File.GetLastWriteTimeUtc(executable)); + } + finally + { + Directory.Delete(testRoot, recursive: true); + } + } +} diff --git a/src/tests/SOS.Tests/WindowsTheoryAttribute.cs b/src/tests/SOS.Tests/WindowsTheoryAttribute.cs index 4e80548c5e..027c657a65 100644 --- a/src/tests/SOS.Tests/WindowsTheoryAttribute.cs +++ b/src/tests/SOS.Tests/WindowsTheoryAttribute.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using SOS.TestHarness; using Xunit.Sdk; using Xunit.v3; using Xunit; @@ -24,6 +25,7 @@ public WindowsTheoryAttribute( [CallerLineNumber] int sourceLineNumber = -1) : base(sourceFilePath, sourceLineNumber) { + SkipTestWithoutData = TestConfig.AllowEmptyMatrix(Environment.GetEnvironmentVariable); } } 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.cs b/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/SosHarnessScenarios.cs index d3a294b466..eea6557523 100644 --- a/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/SosHarnessScenarios.cs +++ b/src/tests/SOS.UnitTests/Debuggees/SosHarnessScenarios/SosHarnessScenarios.cs @@ -125,6 +125,7 @@ private static void Main() TextField = FieldMarkerText, Value = new ValueMarker { First = ValueMarkerFirst, Second = ValueMarkerSecond }, Numbers = known, + ObjectReferences = new[] { new ObjectReference { Value = live } }, MethodSignature = new byte[] { 0x00, 0x00, 0x01 }, // [DEFAULT] Void () SignatureElement = new byte[] { 0x08 }, // ELEMENT_TYPE_I4 }; @@ -373,6 +374,13 @@ public struct ValueMarker public long Second; } +// A value-type array element containing exactly one object-reference slot. !dumparray reports the element's +// address, giving notreachableinrange a real pointer range to scan without relying on object layout. +public struct ObjectReference +{ + public object? Value; +} + // A reference type with known instance fields of several shapes (primitive, wide primitive, reference, // and an embedded value type), so dumpobj prints a non-trivial Fields table to assert against. public sealed class FieldMarker @@ -382,6 +390,7 @@ public sealed class FieldMarker public string? TextField; public ValueMarker Value; public int[]? Numbers; + public ObjectReference[]? ObjectReferences; // Raw COR_SIGNATURE blobs so dumpsig/dumpsigelem can decode a real signature: a method signature // [DEFAULT] Void () = { CALLCONV_DEFAULT(0x00), argCount 0, ELEMENT_TYPE_VOID(0x01) }, and a single 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/SOS.UnitTests/SOS.cs b/src/tests/SOS.UnitTests/SOS.cs index 309a8f1b1e..6051bf33a6 100644 --- a/src/tests/SOS.UnitTests/SOS.cs +++ b/src/tests/SOS.UnitTests/SOS.cs @@ -365,34 +365,6 @@ public SOSExceptionTests(ITestOutputHelper output) private ITestOutputHelper Output { get; set; } - [SkippableTheory, MemberData(nameof(SOSTestHelpers.Configurations), MemberType = typeof(SOSTestHelpers))] - public async Task DivZero(TestConfiguration config) - { - await SOSTestHelpers.RunTest( - config, - debuggeeName: "DivZero", - scriptName: "DivZero.script", - Output, - testTriage: true); - } - - [SkippableTheory, MemberData(nameof(SOSTestHelpers.Configurations), MemberType = typeof(SOSTestHelpers))] - public async Task SimpleThrow(TestConfiguration config) - { - await SOSTestHelpers.RunTest(config, debuggeeName: "SimpleThrow", scriptName: "SimpleThrow.script", Output, testTriage: true); - } - - [SkippableTheory, MemberData(nameof(SOSTestHelpers.Configurations), MemberType = typeof(SOSTestHelpers))] - public async Task NestedExceptionTest(TestConfiguration config) - { - await SOSTestHelpers.RunTest( - config, - debuggeeName: "NestedExceptionTest", - scriptName: "NestedExceptionTest.script", - Output, - testTriage: true); - } - [SkippableTheory, MemberData(nameof(SOSTestHelpers.Configurations), MemberType = typeof(SOSTestHelpers))] public async Task TaskNestedException(TestConfiguration config) { diff --git a/src/tests/SOS.UnitTests/Scripts/DivZero.script b/src/tests/SOS.UnitTests/Scripts/DivZero.script deleted file mode 100644 index 1bf4e66633..0000000000 --- a/src/tests/SOS.UnitTests/Scripts/DivZero.script +++ /dev/null @@ -1,70 +0,0 @@ -# Divide By Zero debugging scenario -# 1) Load the executable -# 2) Run the executable and wait for it to crash -# 3) Take a dump of the executable. -# 4) Open the dump and compare the output - -CONTINUE - -LOADSOS - -# Verifying that PrintException gives us the right exception in the format above. -SOSCOMMAND:PrintException -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.DivideByZeroException\s+ -VERIFY:Message:\s+(|Attempted to divide by zero\.)\s+ -VERIFY:InnerException:\s+\s+ -VERIFY:StackTrace \(generated\):\s+ -VERIFY:\s+SP\s+IP\s+\s+Function\s+ -VERIFY:\s+\s+\s+[Dd]iv[Zz]ero.*!C\.DivideByZero(\(.*\))?\+0x\s+ -VERIFY:\s+\s+\s+[Dd]iv[Zz]ero.*!C\.F3(\(.*\))?\+0x\s+ -VERIFY:\s+\s+\s+[Dd]iv[Zz]ero.*!C\.F2(\(.*\))?\+0x\s+ -VERIFY:\s+\s+\s+[Dd]iv[Zz]ero.*!C\.Main(\(.*\))?\+0x\s+ -VERIFY:(StackTraceString: \s+)?\s+ -VERIFY:HResult:\s+80020012\s+ - -SOSCOMMAND:PrintException -lines -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.DivideByZeroException\s+ -VERIFY:Message:\s+(|Attempted to divide by zero\.)\s+ -VERIFY:InnerException:\s+\s+ -VERIFY:StackTrace \(generated\):\s+ -VERIFY:\s+SP\s+IP\s+\s+Function\s+ -VERIFY:\s+\s+\s+[Dd]iv[Zz]ero.*!C\.DivideByZero(\(.*\))?\+0x\s+ -VERIFY:\[.*[\\|/]Debuggees[\\|/].*DivZero[\\|/]DivZero\.cs @ 15\s*\]\s* -VERIFY:\s+\s+\s+[Dd]iv[Zz]ero.*!C\.F3(\(.*\))?\+0x\s+ -VERIFY:\[.*[\\|/]Debuggees[\\|/].*DivZero[\\|/]DivZero\.cs @ 24\s*\]\s* -VERIFY:\s+\s+\s+[Dd]iv[Zz]ero.*!C\.F2(\(.*\))?\+0x\s+ -VERIFY:\[.*[\\|/]Debuggees[\\|/].*DivZero[\\|/]DivZero\.cs @ 36\s*\]\s* -VERIFY:\s+\s+\s+[Dd]iv[Zz]ero.*!C\.Main(\(.*\))?\+0x\s+ -IFDEF:DESKTOP -VERIFY:\[.*[\\|/]Debuggees[\\|/].*DivZero[\\|/]DivZero\.cs @ 56\s*\]\s* -ENDIF:DESKTOP -!IFDEF:DESKTOP -VERIFY:\[.*[\\|/]Debuggees[\\|/].*DivZero[\\|/]DivZero\.cs @ 57\s*\]\s* -ENDIF:DESKTOP - -# Verify that Threads (clrthreads) works -SOSCOMMAND:clrthreads -VERIFY:\s*ThreadCount:\s+\s+ -VERIFY:\s+UnstartedThread:\s+\s+ -VERIFY:\s+BackgroundThread:\s+\s+ -VERIFY:\s+PendingThread:\s+\s+ -VERIFY:\s+DeadThread:\s+\s+ -VERIFY:\s+Hosted Runtime:\s+no\s+ -VERIFY:\s+ID\s+OSID\s+ThreadOBJ\s+State.*\s+ -VERIFY:\s+\s+\s+\s+.*\s+ - -# Verify that ClrStack with no options works -SOSCOMMAND:ClrStack -VERIFY:.*OS Thread Id:\s+0x\s+.* -VERIFY:\s+Child\s+SP\s+IP\s+Call Site\s+ -VERIFY:\s+\s+\s+(\*\*\* WARNING: Unable to verify checksum for DivZero.exe\s*)?C\.DivideByZero(\(.*\))?\s+\[(?i:.*[\\|/]DivZero\.cs) @ 15\s*\]\s+ -VERIFY:\s+\s+\s+C\.F3(\(.*\))?\s+\[(?i:.*[\\|/]DivZero\.cs) @ 24\s*\]\s+ -VERIFY:\s+\s+\s+C\.F2(\(.*\))?\s+\[(?i:.*[\\|/]DivZero\.cs) @ 36\s*\]\s+ -IFDEF:DESKTOP -VERIFY:\s+\s+\s+C\.Main(\(.*\))?\s+\[(?i:.*[\\|/]DivZero\.cs) @ 56\s*\]\s+ -ENDIF:DESKTOP -!IFDEF:DESKTOP -VERIFY:\s+\s+\s+C\.Main(\(.*\))?\s+\[(?i:.*[\\|/]DivZero\.cs) @ 57\s*\]\s+ -ENDIF:DESKTOP diff --git a/src/tests/SOS.UnitTests/Scripts/NestedExceptionTest.script b/src/tests/SOS.UnitTests/Scripts/NestedExceptionTest.script deleted file mode 100644 index 833ba12d24..0000000000 --- a/src/tests/SOS.UnitTests/Scripts/NestedExceptionTest.script +++ /dev/null @@ -1,144 +0,0 @@ -# Nested exception debugging scenario -# 1) load the executable -# 2) run to the exception -# 3) load sos -# We are only verifying the main PrintException fields and for the stacktrace, source lines in the program. - -LOADSOS - -# Verify that bpmd works -IFDEF:LIVE -# Issue: https://github.com/dotnet/diagnostics/issues/2459 -!IFDEF:ALPINE -# Issue: https://github.com/dotnet/diagnostics/issues/2673 -!IFDEF:UNIX_SINGLE_FILE_APP - -IFDEF:DESKTOP -SOSCOMMAND:bpmd NestedExceptionTest.exe NestedExceptionTest.Program.Main -ENDIF:DESKTOP -!IFDEF:DESKTOP -SOSCOMMAND:bpmd NestedExceptionTest.dll NestedExceptionTest.Program.Main -ENDIF:DESKTOP -CONTINUE - -# Unloads desktop sos and reloads new SOS -IFDEF:DESKTOP -LOADSOS -ENDIF:DESKTOP - -SOSCOMMAND:ClrStack -VERIFY:\s+\s+\s+NestedExceptionTest\.Program\.Main(\(.*\))?\s* -VERIFY:\[.*[\\|/]Debuggees[\\|/].*[Nn]ested[Ee]xception[Tt]est[\\|/][Nn]ested[Ee]xception[Tt]est\.cs @ (11|16)\s*\]\s* - -ENDIF:UNIX_SINGLE_FILE_APP -ENDIF:ALPINE -ENDIF:LIVE - -CONTINUE - -# 4) Verifying that !pe gives us the right exception in the format above. -SOSCOMMAND:PrintException -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.InvalidOperationException\s+ -VERIFY:Message:\s+(|Invalid operation exception, outer)\s+ -VERIFY:InnerException:\s+System\.FormatException, Use !?printexception to see more.\s+ -VERIFY:StackTrace \(generated\):\s+ -VERIFY:\s+SP\s+IP\s+\s+Function\s+ -VERIFY:\s+\s+\s+[Nn]ested[Ee]xception[Tt]est.*!NestedExceptionTest\.Program\.Main(\(.*\))?\+0x\s+ -VERIFY:(StackTraceString: \s+)?\s+ -VERIFY:HResult:\s+80131509\s+ -VERIFY:There are nested exceptions on this thread. Run with -nested for details\s+ - -# 5) Verifying that -nested gives us more info. -SOSCOMMAND:PrintException -nested -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.InvalidOperationException\s+ -VERIFY:Message:\s+(|Invalid operation exception, outer)\s+ -VERIFY:InnerException:\s+System\.FormatException, Use !?printexception to see more.\s+ -VERIFY:StackTrace \(generated\):\s+ -VERIFY:\s+SP\s+IP\s+Function\s+ -VERIFY:\s+\s+\s+[Nn]ested[Ee]xception[Tt]est.*!NestedExceptionTest\.Program\.Main(\(.*\))?\+0x\s+ -VERIFY:(StackTraceString: \s+)?\s+ -VERIFY:HResult:\s+80131509\s+ -VERIFY:Nested exception\s+[\-]+\s+ -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.FormatException\s+ -VERIFY:Message:\s+(|Bad format exception, inner)\s+ -VERIFY:InnerException:\s+\s+ -VERIFY:StackTrace \(generated\):\s+ -VERIFY:\s+SP\s+IP\s+Function\s+ -VERIFY:\s+\s+\s+[Nn]ested[Ee]xception[Tt]est.*!NestedExceptionTest\.Program\.Main(\(.*\))?\+0x\s+ -VERIFY:(StackTraceString: \s+)? -VERIFY:HResult:\s+80131537 - -# 6) Verifying that the Exception Object/ID gives us the same output about the reflected exception. -SOSCOMMAND:PrintException InnerException:\s+System\.FormatException, Use !?printexception () to see more -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.FormatException\s+ -VERIFY:Message:\s+(|Bad format exception, inner)\s+ -VERIFY:InnerException:\s+\s+ -VERIFY:StackTrace \(generated\): -VERIFY:\s+SP\s+IP\s+Function\s+ -VERIFY:\s+\s+\s+[Nn]ested[Ee]xception[Tt]est.*!NestedExceptionTest\.Program\.Main(\(.*\))?\+0x\s+ -VERIFY:(StackTraceString: \s+)? -VERIFY:HResult:\s+80131537\s+ -VERIFY:There are nested exceptions on this thread. Run with -nested for details - -# 7) Verifying that there is source file and line number info -SOSCOMMAND:PrintException -lines -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.InvalidOperationException\s+ -VERIFY:Message:\s+(|Invalid operation exception, outer)\s+ -VERIFY:InnerException:\s+System\.FormatException, Use !?printexception to see more.\s+ -VERIFY:StackTrace \(generated\): -VERIFY:\s+SP\s+IP\s+Function\s+ -VERIFY:\s+\s+\s+[Nn]ested[Ee]xception[Tt]est.*!NestedExceptionTest\.Program\.Main(\(.*\))?\+0x\s* -IFDEF:DESKTOP -VERIFY:\[.*[\\|/]Debuggees[\\|/].*[Nn]ested[Ee]xception[Tt]est[\\|/][Nn]ested[Ee]xception[Tt]est\.cs @ (11|20)\s*\]\s* -ENDIF:DESKTOP -!IFDEF:DESKTOP -VERIFY:\[.*[\\|/]Debuggees[\\|/].*[Nn]ested[Ee]xception[Tt]est[\\|/][Nn]ested[Ee]xception[Tt]est\.cs @ 20\s*\]\s* -ENDIF:DESKTOP -VERIFY:(StackTraceString: \s+)? -VERIFY:HResult:\s+80131509\s+ -VERIFY:There are nested exceptions on this thread. Run with -nested for details - -# Verify that Threads (clrthreads) works -SOSCOMMAND:clrthreads -VERIFY:\s*ThreadCount:\s+\s+ -VERIFY:\s+UnstartedThread:\s+\s+ -VERIFY:\s+BackgroundThread:\s+\s+ -VERIFY:\s+PendingThread:\s+\s+ -VERIFY:\s+DeadThread:\s+\s+ -VERIFY:\s+Hosted Runtime:\s+no\s+ -VERIFY:\s+ID\s+OSID\s+ThreadOBJ\s+State.*\s+ -VERIFY:\s+\s+\s+\s+.*\s+ - -# Verify that ClrStack with no options works -SOSCOMMAND:ClrStack -VERIFY:.*OS Thread Id:\s+0x\s+.* -VERIFY:\s+Child\s+SP\s+IP\s+Call Site\s+ -VERIFY:\s+\s+\s+NestedExceptionTest\.Program\.Main(\(.*\))?\s* -IFDEF:DESKTOP -VERIFY:\[.*[\\|/]Debuggees[\\|/].*[Nn]ested[Ee]xception[Tt]est[\\|/][Nn]ested[Ee]xception[Tt]est\.cs @ (11|20)\s*\]\s* -ENDIF:DESKTOP -!IFDEF:DESKTOP -VERIFY:\[.*[\\|/]Debuggees[\\|/].*[Nn]ested[Ee]xception[Tt]est[\\|/][Nn]ested[Ee]xception[Tt]est\.cs @ 20\s*\]\s* -ENDIF:DESKTOP - -# Verify the SOS clrma command (CLRMA managed analysis) under dotnet-dump. This exercises the -# CLRMACreateInstance/ICLRManagedAnalysis path used by Watson/!analyze, but in the dotnet-dump -# host so it can be validated locally without windbg/lldb. We only check the structural fields -# (provider, current exception type/HResult and the inner exception type/HResult) that !pe above -# already proved are available on these dumps. -IFDEF:DOTNETDUMP -SOSCOMMAND:clrma -VERIFY:Managed analysis provider:\s+SOSCLRMA\s+ -VERIFY:OSThreadId:\s+\s+ -VERIFY:Current exception:\s+ -VERIFY:\s+Exception type:\s+System\.InvalidOperationException\s+ -VERIFY:\s+HResult:\s+80131509\s+ -VERIFY:\s+InnerException:\s+ -VERIFY:\s+Exception type:\s+System\.FormatException\s+ -VERIFY:\s+HResult:\s+80131537\s+ -ENDIF:DOTNETDUMP diff --git a/src/tests/SOS.UnitTests/Scripts/SimpleThrow.script b/src/tests/SOS.UnitTests/Scripts/SimpleThrow.script deleted file mode 100644 index 8ae602b04b..0000000000 --- a/src/tests/SOS.UnitTests/Scripts/SimpleThrow.script +++ /dev/null @@ -1,66 +0,0 @@ -# PrintException debugging scenario -# 1) Load the executable -# 2) Run the executable and wait for it to crash -# 3) Take a dump of the executable. (MUST BE AT SECOND CHANCE EXCEPTION!) -# 4) Open the dump and compare the output - -CONTINUE - -LOADSOS - -# B) Verifying that !pe gives us the right exception in the format above. -# For desktop, it has an SP, so we conditionally will match it. (ProjectN does not though.) -SOSCOMMAND:PrintException -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.InvalidOperationException\s+ -VERIFY:Message:\s+(|Throwing an invalid operation\.\.\.\.)\s+ -VERIFY:InnerException:\s+\s+ -VERIFY:StackTrace\s+\(generated\):\s+ -VERIFY:\s+SP\s+IP\s+Function\s+ -VERIFY:\s+\s+\s+[Ss]imple[Tt]hrow.*!(\$0_)?UserObject\.UseObject.*\+0x\s+ -VERIFY:\s+\s+\s+[Ss]imple[Tt]hrow.*!(\$0_)?Simple\.Main.*\+0x\s+ -VERIFY:HResult:\s+80131509 - -# C) Verifying that -nested gives us the same output -SOSCOMMAND:PrintException -nested -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.InvalidOperationException\s+ -VERIFY:Message:\s+(|Throwing an invalid operation\.\.\.\.)\s+ -VERIFY:InnerException:\s+\s+ -VERIFY:StackTrace\s+\(generated\):\s+ -VERIFY:\s+SP\s+IP\s+Function\s+ -VERIFY:\s+\s+\s+[Ss]imple[Tt]hrow.*!(\$0_)?UserObject\.UseObject.*\+0x\s+ -VERIFY:\s+\s+\s+[Ss]imple[Tt]hrow.*!(\$0_)?Simple\.Main.*\+0x\s+ -VERIFY:HResult:\s+80131509 - -SOSCOMMAND:PrintException -lines -VERIFY:Exception object:\s+\s+ -VERIFY:Exception type:\s+System\.InvalidOperationException\s+ -VERIFY:Message:\s+(|Throwing an invalid operation\.\.\.\.)\s+ -VERIFY:InnerException:\s+\s+ -VERIFY:StackTrace\s+\(generated\):\s+ -VERIFY:\s+SP\s+IP\s+Function\s+ -VERIFY:\s+\s+\s+[Ss]imple[Tt]hrow.*!(\$0_)?UserObject\.UseObject.*\+0x\s* -VERIFY:\[.*[\\|/]Debuggees[\\|/].*SimpleThrow[\\|/]UserObject\.cs @ 19\s*\]\s* -VERIFY:\s+\s+\s+[Ss]imple[Tt]hrow.*!(\$0_)?Simple\.Main.*\+0x\s+ -VERIFY:\[.*[\\|/]Debuggees[\\|/].*SimpleThrow[\\|/]SimpleThrow\.cs @ 12\s*\]\s* - -# Verify that Threads (clrthreads) works -SOSCOMMAND:clrthreads -VERIFY:\s*ThreadCount:\s+\s+ -VERIFY:\s+UnstartedThread:\s+\s+ -VERIFY:\s+BackgroundThread:\s+\s+ -VERIFY:\s+PendingThread:\s+\s+ -VERIFY:\s+DeadThread:\s+\s+ -VERIFY:\s+Hosted Runtime:\s+no\s+ -VERIFY:\s+ID\s+OSID\s+ThreadOBJ\s+State.*\s+ -VERIFY:\s+\s+\s+\s+.*\s+ - -# Verify that ClrStack with no options works -SOSCOMMAND:ClrStack -VERIFY:.*OS Thread Id:\s+0x\s+.* -VERIFY:\s+Child\s+SP\s+IP\s+Call Site\s+ -VERIFY:\s+\s+\s+(\*\*\* WARNING: Unable to verify checksum for SimpleThrow.exe\s*)?UserObject\.UseObject.*\s+ -VERIFY:[.*[\\|/]Debuggees[\\|/].*SimpleThrow[\\|/]UserObject\.cs @ 19\s*\] -VERIFY:\s+\s+\s+Simple\.Main\(\)\s+ -VERIFY:[.*[\\|/]Debuggees[\\|/].*SimpleThrow[\\|/]SimpleThrow\.cs @ 12\s*\] 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 @@ - diff --git a/src/tests/sos-lldb/CMakeLists.txt b/src/tests/sos-lldb/CMakeLists.txt new file mode 100644 index 0000000000..20be1a9b4e --- /dev/null +++ b/src/tests/sos-lldb/CMakeLists.txt @@ -0,0 +1,6 @@ +project(sos-lldb) + +add_executable_clr(sos-lldb driver.cpp) +target_link_libraries(sos-lldb sos_lldb_dependencies) + +install_clr(TARGETS sos-lldb DESTINATIONS .) diff --git a/src/tests/sos-lldb/driver.cpp b/src/tests/sos-lldb/driver.cpp new file mode 100644 index 0000000000..586c334769 --- /dev/null +++ b/src/tests/sos-lldb/driver.cpp @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#include +#include +#include +#include +#include +#include + +namespace +{ + struct CommandOutcome + { + bool shouldContinue; + bool succeeded; + }; + + void PrintUsage(const char* program) + { + std::fprintf(stderr, "Usage: %s [--no-lldbinit] [--batch] [-o command]...\n", program); + } + + CommandOutcome ExecuteCommand(lldb::SBCommandInterpreter& interpreter, const std::string& command) + { + lldb::SBCommandReturnObject result; + result.SetImmediateOutputFile(stdout, false); + result.SetImmediateErrorFile(stderr, false); + interpreter.HandleCommand(command.c_str(), result, false); + std::fflush(stdout); + std::fflush(stderr); + + return { result.GetStatus() != lldb::eReturnStatusQuit, result.Succeeded() }; + } +} + +int main(int argc, char** argv) +{ + bool batch = false; + bool sourceInitFiles = true; + std::vector startupCommands; + + for (int index = 1; index < argc; index++) + { + if (std::strcmp(argv[index], "--no-lldbinit") == 0) + { + sourceInitFiles = false; + } + else if (std::strcmp(argv[index], "--batch") == 0) + { + batch = true; + } + else if (std::strcmp(argv[index], "-o") == 0) + { + if (++index == argc) + { + std::fprintf(stderr, "Missing command after -o.\n"); + PrintUsage(argv[0]); + return 2; + } + startupCommands.emplace_back(argv[index]); + } + else + { + std::fprintf(stderr, "Unsupported argument: %s\n", argv[index]); + PrintUsage(argv[0]); + return 2; + } + } + + lldb::SBDebugger::Initialize(); + lldb::SBDebugger debugger = lldb::SBDebugger::Create(sourceInitFiles); + if (!debugger.IsValid()) + { + std::fprintf(stderr, "Failed to initialize LLDB.\n"); + lldb::SBDebugger::Terminate(); + return 1; + } + + debugger.SetAsync(false); + debugger.SetInputFileHandle(stdin, false); + debugger.SetOutputFileHandle(stdout, false); + debugger.SetErrorFileHandle(stderr, false); + + lldb::SBCommandInterpreter interpreter = debugger.GetCommandInterpreter(); + bool keepRunning = true; + int exitCode = 0; + for (const std::string& command : startupCommands) + { + CommandOutcome outcome = ExecuteCommand(interpreter, command); + if (!outcome.shouldContinue) + { + keepRunning = false; + break; + } + if (batch && !outcome.succeeded) + { + exitCode = 1; + keepRunning = false; + break; + } + } + + if (!batch) + { + std::string command; + while (keepRunning && std::getline(std::cin, command)) + { + keepRunning = ExecuteCommand(interpreter, command).shouldContinue; + } + } + + lldb::SBDebugger::Destroy(debugger); + lldb::SBDebugger::Terminate(); + return exitCode; +}