From cdb9bb57aeb3c4280ce110b5950c350168211421 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Mon, 19 Oct 2026 12:13:28 -0500 Subject: [PATCH] fix: Mesh Boolean crash on Windows builds made with clang-cl (#16651) Build without precompiled headers when the compiler is clang-cl, in both the slicer and the wxWidgets dependency, and add a CI check that fails if broken exception throw info comes back. With a precompiled header, clang-cl can leave the copy constructor out of the throw info it emits for an exception type (llvm/llvm-project#53486, open since 2022 and still present in clang 22.1.3). The linker keeps one throw info per type for the whole binary. When it keeps one from a precompiled header file, a copy of that exception is bitwise, and the copy and the original free the same data. Mesh Boolean hits this on every operation. mcut destroys a std::promise that was never set, which stores a broken_promise future_error in an exception_ptr. Freeing it corrupts the heap, and Windows ends the process with 0xC0000374. The 2.5.0-alpha release has broken throw info for 64 exception classes, including std::runtime_error, std::bad_alloc and boost exceptions. scripts/check_exception_throw_info.ps1 runs after the Windows clang build and its uploads, before any deploy. It fails when an exception class has throw info both with and without a copy constructor, when OrcaSlicer.pdb is missing, or when it finds no exception classes at all. scripts/test_check_exception_throw_info.ps1 tests it on canned dumps. build_win.bat now treats -l as --no-pch. The Windows ccache key drops the cl.exe version, which only mattered for precompiled headers. The bug came in with #15428, which moved Windows CI to clang-cl. 2.4.2 was built with MSVC. Fixes #16581 --- .github/workflows/build_all.yml | 8 ++ .github/workflows/build_orca.yml | 16 ++- CMakeLists.txt | 8 ++ build_win.bat | 11 +- deps/wxWidgets/wxWidgets.cmake | 10 +- scripts/check_exception_throw_info.ps1 | 126 ++++++++++++++++++++ scripts/test_build_win.ps1 | 4 + scripts/test_check_exception_throw_info.ps1 | 93 +++++++++++++++ 8 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 scripts/check_exception_throw_info.ps1 create mode 100644 scripts/test_check_exception_throw_info.ps1 diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index ff07d1efb7..1918c009db 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -19,6 +19,8 @@ on: - 'build_linux.sh' - 'build_win.bat' - 'scripts/test_build_win.ps1' + - 'scripts/check_exception_throw_info.ps1' + - 'scripts/test_check_exception_throw_info.ps1' - 'build_release_macos.sh' - 'scripts/build_preset_cache.*' - 'scripts/flatpak/**' @@ -41,6 +43,8 @@ on: - 'build_linux.sh' - 'build_win.bat' - 'scripts/test_build_win.ps1' + - 'scripts/check_exception_throw_info.ps1' + - 'scripts/test_check_exception_throw_info.ps1' - 'build_release_macos.sh' - 'scripts/build_preset_cache.*' - 'scripts/flatpak/**' @@ -84,6 +88,10 @@ jobs: shell: powershell run: .\scripts\test_build_win.ps1 + - name: Run the exception throw info check tests + shell: powershell + run: .\scripts\test_check_exception_throw_info.ps1 + build_linux: strategy: fail-fast: false diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index 60456ad171..e9c4603bc8 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -65,15 +65,6 @@ jobs: shell: bash run: | leg="${{ runner.os }}-${{ inputs.arch || 'amd64' }}${{ runner.os == 'Windows' && format('-{0}', inputs.compiler) || '' }}" - # clang-cl refuses a precompiled header from another cl.exe build and ccache - # does not hash that build, so each one gets its own cache. The build number - # is read from cl.exe itself; the toolset directory keeps its name across patches. - if [ "${{ runner.os }}" = Windows ]; then - vswhere='/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe' - toolset=$(tr -d '\r\n' < "$("$vswhere" -latest -products '*' -find 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt' | tr -d '\r')") - cl=$("$vswhere" -latest -products '*' -find 'VC\Tools\MSVC\'"$toolset"'\**\cl.exe' | tr -d '\r' | head -1) - leg="$leg-vc$("$cl" 2>&1 | grep -o -E 'Version [0-9.]+' | cut -d' ' -f2)" - fi echo "CCACHE_LEG=$leg" >> "$GITHUB_ENV" echo "CCACHE_ENTRY=ccache-$leg-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV" @@ -552,6 +543,13 @@ jobs: name: OrcaSlicer_profile_validator_Windows_${{ env.ver }} path: ${{ github.workspace }}/build/src/Release/OrcaSlicer_profile_validator.exe + - name: Check exception throw info Win + if: runner.os == 'Windows' && inputs.compiler == 'clang' + working-directory: ${{ github.workspace }} + # After the uploads, so a failure still leaves the build and its PDB to inspect, and before any deploy. + shell: pwsh + run: scripts\check_exception_throw_info.ps1 -BuildDir $env:BUILD_DIR + - name: Deploy Windows release portable if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && !vars.SELF_HOSTED uses: ./.github/actions/deploy-nightly diff --git a/CMakeLists.txt b/CMakeLists.txt index 06ea0dbd6f..62798ebfc4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -321,6 +321,14 @@ if (IS_CROSS_COMPILE) set(BUILD_TESTS OFF CACHE BOOL "" FORCE) endif () +# With a precompiled header, clang-cl can omit an exception type's copy constructor from its throw info, +# so copies of the exception share and double-free their data: https://github.com/llvm/llvm-project/issues/53486 +if (MSVC AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + message(STATUS "clang-cl: building without the precompiled header (llvm/llvm-project#53486)") + set_property(CACHE SLIC3R_PCH PROPERTY HELPSTRING "Use precompiled headers (ignored with clang-cl, llvm/llvm-project#53486)") + set(SLIC3R_PCH OFF) +endif () + # Print out the SLIC3R_* cache options get_cmake_property(_cache_vars CACHE_VARIABLES) list (SORT _cache_vars) diff --git a/build_win.bat b/build_win.bat index dc19df06bc..8af3581abf 100644 --- a/build_win.bat +++ b/build_win.bat @@ -53,7 +53,7 @@ call :add_section "Build configuration" call :add_arg config string "" config "release, debug, relwithdebinfo or minsizerel (default: release)" call :add_arg target_arch string "" arch "x64 or arm64 (default: the host architecture)" call :add_arg slicer_asan bool a asan "Build the slicer with ASAN enabled" -call :add_arg no_pch bool "" no-pch "Build without the precompiled header, implied by --cache" +call :add_arg no_pch bool "" no-pch "Build without the precompiled header, implied by -l and --cache" call :add_section "Toolchain" call :add_arg use_clang_cl bool l clang-cl "Use clang-cl as the compiler" @@ -388,6 +388,9 @@ if "%use_ninja%" == "ON" if "%use_msbuild%" == "ON" ( exit /b 1 ) +REM CMakeLists turns the precompiled header off for clang-cl (llvm/llvm-project#53486). +if "%use_clang_cl%" == "ON" set "no_pch=ON" + set "generator=!vs_gen_%vs_version%!" if "%use_ninja%" == "ON" ( set "generator=Ninja Multi-Config" @@ -543,7 +546,7 @@ REM real path rather than one glued onto the repository root. for %%p in ("!build_dir!") do set "build_full=%%~fp" echo Configuration: %build_type%, %arch% if not "%clang_exe%" == "" echo Compiler: %clang_exe% -if "%no_pch%" == "ON" echo Precompiled header: off +if "%build_slicer%" == "ON" if "%no_pch%" == "ON" echo Precompiled header: off if not "%cache_args%" == "" echo Compiler cache: %cache_exe% set "SIG_FLAG=" @@ -716,8 +719,8 @@ if "%build_slicer%" == "ON" ( set "slicer_args=!slicer_args! -DSLIC3R_ASAN=ON" ) - REM A later -DSLIC3R_PCH=ON in ORCA_SLICER_CMAKE_ARGS still wins, because - REM CMake takes the last definition on the command line. + REM Under cl, a later -DSLIC3R_PCH=ON in ORCA_SLICER_CMAKE_ARGS still wins, + REM because CMake takes the last definition on the command line. if "%no_pch%" == "ON" ( set "slicer_args=!slicer_args! -DSLIC3R_PCH=OFF" ) diff --git a/deps/wxWidgets/wxWidgets.cmake b/deps/wxWidgets/wxWidgets.cmake index e57e82f3e9..605fad2f0b 100644 --- a/deps/wxWidgets/wxWidgets.cmake +++ b/deps/wxWidgets/wxWidgets.cmake @@ -21,6 +21,14 @@ else () set(_wx_edge "-DwxUSE_WEBVIEW_EDGE=OFF") endif () +# With a precompiled header, clang-cl can omit an exception type's copy constructor from its throw info, +# so copies of the exception share and double-free their data: https://github.com/llvm/llvm-project/issues/53486 +if (MSVC AND CMAKE_C_COMPILER_ID STREQUAL "Clang") + set(_wx_precomp OFF) +else () + set(_wx_precomp ON) +endif () + set(_wx_patch_command "") if (APPLE) set(_wx_patch_command @@ -39,7 +47,7 @@ orcaslicer_add_cmake_project( PATCH_COMMAND ${_wx_patch_command} DEPENDS ${PNG_PKG} ${ZLIB_PKG} ${EXPAT_PKG} ${JPEG_PKG} CMAKE_ARGS - -DwxBUILD_PRECOMP=ON + -DwxBUILD_PRECOMP=${_wx_precomp} ${_wx_toolkit} "-DCMAKE_DEBUG_POSTFIX:STRING=${_wx_debug_postfix}" -DwxBUILD_DEBUG_LEVEL=0 diff --git a/scripts/check_exception_throw_info.ps1 b/scripts/check_exception_throw_info.ps1 new file mode 100644 index 0000000000..5fd76599b8 --- /dev/null +++ b/scripts/check_exception_throw_info.ps1 @@ -0,0 +1,126 @@ +<# +.SYNOPSIS + Fails when a Windows build has an exception class with broken throw info. + +.DESCRIPTION + Clang on the MSVC ABI can leave the copy constructor out of the CatchableType it emits for + an exception class that comes through a precompiled header + (https://github.com/llvm/llvm-project/issues/53486). The linker keeps one throw info per + class, so when it keeps such a record, a copy of that exception (std::exception_ptr, a catch + by value) shares its data with the original and the data is freed twice. + + A class is either trivially copyable or not, so a binary should never have both a CatchableType + without a copy constructor (_CT??_R0?AV@@@8) and one with it + (_CT??_R0?AV@@@8??0...). The script lists each PDB's public symbols with the + llvm-pdbutil next to the build's compiler and reports every class that has both. + + scripts\test_check_exception_throw_info.ps1 tests it. + +.PARAMETER BuildDir + The slicer build directory. Its CMakeCache.txt names the compiler, and its src\ + holds the PDBs to check, which must include OrcaSlicer.pdb. + +.PARAMETER Config + The configuration whose PDBs to check (default: Release). + +.PARAMETER Pdb + PDBs to check instead of the ones in \src\, as a list or comma-separated. + +.PARAMETER Dump + Saved `llvm-pdbutil dump --publics` output to check instead of PDBs. Needs no build. + +.EXAMPLE + scripts\check_exception_throw_info.ps1 -BuildDir build +#> +param( + [string] $BuildDir, + [string] $Config = 'Release', + [string[]] $Pdb, + [string[]] $Dump +) +$ErrorActionPreference = 'Stop' + +# powershell -File passes "a.pdb,b.pdb" as one string. A path that exists as given stays whole. +function Split-PathList([string[]] $values) { + @($values | Where-Object { $_ } | ForEach-Object { + if ((Test-Path -LiteralPath $_) -or $_ -notmatch ',') { $_ } else { $_ -split ',' | Where-Object { $_ } } + }) +} + +$Dump = Split-PathList $Dump +if (-not $Dump) { + if (-not $BuildDir) { + throw 'Name the slicer build directory with -BuildDir.' + } + $cache = Join-Path $BuildDir 'CMakeCache.txt' + if (-not (Test-Path $cache)) { + throw "No CMakeCache.txt in $BuildDir. Name the slicer build directory." + } + $entry = Select-String -Path $cache -Pattern '^CMAKE_CXX_COMPILER:\w+=(.+)$' | Select-Object -First 1 + if (-not $entry) { + throw "No CMAKE_CXX_COMPILER in $cache." + } + $compiler = $entry.Matches[0].Groups[1].Value + # A tree configured with a bare compiler name found it on PATH. + if (-not (Split-Path $compiler)) { + $found = Get-Command $compiler -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $found) { + throw "$compiler, the compiler in $cache, is not on PATH." + } + $compiler = $found.Source + } + $pdbutil = Join-Path (Split-Path $compiler) 'llvm-pdbutil.exe' + if (-not (Test-Path $pdbutil)) { + throw "No llvm-pdbutil.exe next to $compiler. This check needs a build made with Clang." + } + + $Pdb = Split-PathList $Pdb + if (-not $Pdb) { + # OrcaSlicer.dll holds the slicer itself, so a check without it proves nothing. + $pdbDir = Join-Path $BuildDir "src\$Config" + if (-not (Test-Path (Join-Path $pdbDir 'OrcaSlicer.pdb'))) { + throw "No OrcaSlicer.pdb in $pdbDir." + } + $Pdb = @(Get-ChildItem $pdbDir -Filter *.pdb | ForEach-Object FullName) + } +} + +$catchable = [regex]'`_CT\?\?_R0\?A[UV](.+?)@@@8([^`]*)`' +$tmp = [IO.Path]::GetTempFileName() +$failed = $false +$classes = 0 +try { + foreach ($file in $(if ($Dump) { $Dump } else { $Pdb })) { + $text = $file + if (-not $Dump) { + $run = Start-Process -FilePath $pdbutil -ArgumentList 'dump', '--publics', "`"$file`"" -NoNewWindow -Wait -PassThru -RedirectStandardOutput $tmp + if ($run.ExitCode -ne 0) { + throw "llvm-pdbutil failed on $file." + } + $text = $tmp + } + + $kinds = @{} + foreach ($match in $catchable.Matches([IO.File]::ReadAllText((Resolve-Path -LiteralPath $text).Path))) { + $class = $match.Groups[1].Value + if (-not $kinds.ContainsKey($class)) { $kinds[$class] = @{} } + $kinds[$class][$(if ($match.Groups[2].Value.StartsWith('??0')) { 'copy' } else { 'bitwise' })] = $true + } + + $broken = @($kinds.Keys | Where-Object { $kinds[$_].Count -eq 2 } | Sort-Object) + "$(Split-Path -Leaf $file): $($kinds.Count) exception classes, $($broken.Count) with broken throw info" + foreach ($class in $broken) { " $class" } + if ($broken.Count -gt 0) { $failed = $true } + $classes += $kinds.Count + } +} finally { + Remove-Item $tmp -ErrorAction SilentlyContinue +} + +# The slicer's PDBs always contain exception throw info, so finding none means the dump format or the PDBs changed. +if ($classes -eq 0) { + 'No exception classes found. Check that llvm-pdbutil still prints public symbols in backticks.' + $failed = $true +} + +exit [int]$failed diff --git a/scripts/test_build_win.ps1 b/scripts/test_build_win.ps1 index 5f36fb7ac9..0c2a26c952 100644 --- a/scripts/test_build_win.ps1 +++ b/scripts/test_build_win.ps1 @@ -301,6 +301,10 @@ $cases = @( Contains = @('Precompiled header: off') } @{ Name = 'the precompiled header is on unless asked'; Args = @('-s') NotContains = @('SLIC3R_PCH') } + @{ Name = '-l turns the precompiled header off'; Args = @('-s', '-l') + Contains = @('-DSLIC3R_PCH=OFF', 'Precompiled header: off') } + @{ Name = 'a deps-only build says nothing about the precompiled header'; Args = @('-d', '-l') + NotContains = @('Precompiled header') } @{ Name = '--no-pch works without a cache'; Args = @('-s', '--no-pch') NotContains = @('COMPILER_LAUNCHER') } @{ Name = 'the slicer build runs gettext'; Args = @('-s') diff --git a/scripts/test_check_exception_throw_info.ps1 b/scripts/test_check_exception_throw_info.ps1 new file mode 100644 index 0000000000..0c2ef2edfb --- /dev/null +++ b/scripts/test_check_exception_throw_info.ps1 @@ -0,0 +1,93 @@ +<# +.SYNOPSIS + Tests scripts/check_exception_throw_info.ps1 on canned llvm-pdbutil dumps. + +.DESCRIPTION + Each case runs the check in its own powershell -File process, as CI does, and asserts on the + exit code and output. Dumps are written to a temporary folder, so no build is needed. + +.EXAMPLE + powershell -File scripts/test_check_exception_throw_info.ps1 +#> +$ErrorActionPreference = 'Stop' + +$check = Join-Path $PSScriptRoot 'check_exception_throw_info.ps1' +$work = Join-Path ([IO.Path]::GetTempPath()) 'check_exception_throw_info_tests' +Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $work | Out-Null + +function Write-Dump([string] $path, [string[]] $symbols) { + New-Item -ItemType Directory -Force -Path (Split-Path $path) | Out-Null + $i = 0 + $lines = @(' Public Symbols', '============================================================') + foreach ($symbol in $symbols) { + $i++ + $lines += " $i | S_PUB32 [size = 48] ``$symbol``", ' flags = none, addr = 0007:0000' + } + Set-Content -Path $path -Encoding ascii -Value $lines +} + +$bitwise = '_CT??_R0?AVfuture_error@std@@@840' +$copy = '_CT??_R0?AVfuture_error@std@@@8??0future_error@std@@QEAA@AEBV01@@Z40' +$trivial = '_CT??_R0?AUtag@@@81' +Write-Dump "$work\broken.txt" @($bitwise, $copy, $trivial) +Write-Dump "$work\clean.txt" @($copy, $trivial) +Write-Dump "$work\empty.txt" @() +Write-Dump "$work\a dir, with a comma\clean.txt" @($copy) + +# A build directory whose compiler has an llvm-pdbutil beside it but no OrcaSlicer.pdb. +# Nothing runs the stand-in; the check stops before it gets there. +$build = "$work\build" +New-Item -ItemType Directory -Force -Path "$build\bin", "$build\src\Release" | Out-Null +Copy-Item "$env:SystemRoot\System32\where.exe" "$build\bin\llvm-pdbutil.exe" +Set-Content -Path "$build\CMakeCache.txt" -Encoding ascii -Value "CMAKE_CXX_COMPILER:FILEPATH=$build\bin\clang-cl.exe" +Set-Content -Path "$build\src\Release\orca-slicer.pdb" -Value '' +$bare = "$work\bare" +New-Item -ItemType Directory -Force -Path $bare | Out-Null +Set-Content -Path "$bare\CMakeCache.txt" -Encoding ascii -Value 'CMAKE_CXX_COMPILER:STRING=no-such-clang-cl.exe' + +$cases = @( + @{ Name = 'a class with and without a copy constructor fails'; Args = @('-Dump', "$work\broken.txt") + Exit = 1; Contains = @('1 with broken throw info', 'future_error@std') } + @{ Name = 'copy constructors and trivially copyable classes pass'; Args = @('-Dump', "$work\clean.txt") + Exit = 0; Contains = @('2 exception classes, 0 with broken throw info') } + @{ Name = 'no exception classes at all fails'; Args = @('-Dump', "$work\empty.txt") + Exit = 1; Contains = @('No exception classes found') } + @{ Name = 'a comma-separated list checks each dump'; Args = @('-Dump', "$work\broken.txt,$work\clean.txt") + Exit = 1; Contains = @('broken.txt: 2 exception classes', 'clean.txt: 2 exception classes') } + @{ Name = 'a path with a comma stays whole'; Args = @('-Dump', "$work\a dir, with a comma\clean.txt") + Exit = 0; Contains = @('clean.txt: 1 exception classes') } + @{ Name = 'a build without OrcaSlicer.pdb fails'; Args = @('-BuildDir', $build) + Exit = 1; Contains = @('No OrcaSlicer.pdb in') } + @{ Name = 'a compiler that is not on PATH is named'; Args = @('-BuildDir', $bare) + Exit = 1; Contains = @('no-such-clang-cl.exe, the compiler in') } + @{ Name = 'a missing build directory is named'; Args = @('-BuildDir', "$work\nowhere") + Exit = 1; Contains = @('No CMakeCache.txt in') } + @{ Name = 'no build directory and no dump fails'; Args = @() + Exit = 1; Contains = @('Name the slicer build directory') } +) + +$failed = 0 +foreach ($case in $cases) { + $ErrorActionPreference = 'Continue' + $output = (& powershell -NoProfile -File $check @($case.Args) 2>&1 | Out-String) + $code = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + $problems = @() + if ($code -ne $case.Exit) { $problems += "exit $code, expected $($case.Exit)" } + foreach ($text in $case.Contains) { + if (-not $output.Contains($text)) { $problems += "no '$text' in the output" } + } + if ($problems) { + $failed++ + " FAIL $($case.Name)" + $problems | ForEach-Object { " $_" } + $output.TrimEnd() -split "`r?`n" | ForEach-Object { " | $_" } + } else { + " ok $($case.Name)" + } +} + +Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue +"$($cases.Count - $failed) passed, $failed failed" +exit [int]($failed -gt 0)