From 63053c523fe302f19699842e9cb1ee897b2fa2cc Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Mon, 20 Apr 2026 14:13:01 -0700 Subject: [PATCH 01/16] Apps/ModuleLoadTest: add boot-time module load regression test Adds a dedicated test harness under Apps/ModuleLoadTest that snapshots modules loaded before C++ static init (via a TLS callback) and after BabylonNative reaches a stable boot state (graphics device up, all polyfills + plugins initialized, one frame rendered). The delta is compared against a golden list to catch new native dependencies being pulled in on boot. Motivating case: dbghelp.dll being introduced via bx's DbgHelpSymbolResolve static initializer. A main()-entry baseline would miss this because the static fires before main runs; the TLS callback fires before any C++ static init in this binary. Design notes: - Pre-static-init baseline captured in the .CRT$XLB TLS callback. - Asymmetric assertion: fail only on unexpected new modules (missing entries are environmental variance, not regressions). - Debug config and debugger-attached runs SKIP explicitly. - Launch-env noise (VS Ctrl-F5 injections, GPU driver ICDs) is filtered via IsAllowedOptionalModule. - Windows-only for this commit; macOS and Linux support will follow in this PR. CI: invoked from build-win32.yml after UnitTests, RelWithDebInfo only, non-sanitizers configs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-win32.yml | 7 + Apps/CMakeLists.txt | 1 + Apps/ModuleLoadTest/CMakeLists.txt | 34 +++ Apps/ModuleLoadTest/Source/App.Win32.cpp | 249 ++++++++++++++++++ Apps/ModuleLoadTest/Source/App.cpp | 63 +++++ Apps/ModuleLoadTest/Source/App.h | 17 ++ .../Source/ModuleSnapshot.Win32.cpp | 152 +++++++++++ Apps/ModuleLoadTest/Source/ModuleSnapshot.h | 21 ++ 8 files changed, 544 insertions(+) create mode 100644 Apps/ModuleLoadTest/CMakeLists.txt create mode 100644 Apps/ModuleLoadTest/Source/App.Win32.cpp create mode 100644 Apps/ModuleLoadTest/Source/App.cpp create mode 100644 Apps/ModuleLoadTest/Source/App.h create mode 100644 Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp create mode 100644 Apps/ModuleLoadTest/Source/ModuleSnapshot.h diff --git a/.github/workflows/build-win32.yml b/.github/workflows/build-win32.yml index 3baadc0c6..7375a5030 100644 --- a/.github/workflows/build-win32.yml +++ b/.github/workflows/build-win32.yml @@ -136,6 +136,13 @@ jobs: cd build\${{ steps.vars.outputs.solution_name }}\Apps\UnitTests\RelWithDebInfo UnitTests + - name: Module Load Test + if: ${{ !inputs.enable-sanitizers }} + shell: cmd + run: | + cd build\${{ steps.vars.outputs.solution_name }}\Apps\ModuleLoadTest\RelWithDebInfo + ModuleLoadTest + - name: Stage UnitTests Crash Dump if: failure() shell: powershell diff --git a/Apps/CMakeLists.txt b/Apps/CMakeLists.txt index 532c7981a..51b2e2ec7 100644 --- a/Apps/CMakeLists.txt +++ b/Apps/CMakeLists.txt @@ -9,6 +9,7 @@ endif() if((WIN32 AND NOT WINDOWS_STORE) OR (APPLE AND NOT IOS AND NOT VISIONOS) OR (UNIX AND NOT ANDROID AND NOT APPLE)) add_subdirectory(UnitTests) + add_subdirectory(ModuleLoadTest) endif() if((WIN32 AND NOT WINDOWS_STORE AND GRAPHICS_API STREQUAL D3D11) OR (APPLE AND NOT IOS AND NOT VISIONOS)) diff --git a/Apps/ModuleLoadTest/CMakeLists.txt b/Apps/ModuleLoadTest/CMakeLists.txt new file mode 100644 index 000000000..3c51aca31 --- /dev/null +++ b/Apps/ModuleLoadTest/CMakeLists.txt @@ -0,0 +1,34 @@ +if(NOT(WIN32 AND NOT WINDOWS_STORE)) + # Windows-only for the initial implementation. + # macOS and Linux support will be added in follow-up commits on this PR. + return() +endif() + +set(SOURCES + "Source/App.h" + "Source/App.cpp" + "Source/App.Win32.cpp" + "Source/ModuleSnapshot.h" + "Source/ModuleSnapshot.Win32.cpp") + +add_executable(ModuleLoadTest ${SOURCES}) + +target_link_libraries(ModuleLoadTest + PRIVATE AppRuntime + PRIVATE Blob + PRIVATE Canvas + PRIVATE Console + PRIVATE GraphicsDevice + PRIVATE NativeEngine + PRIVATE NativeEncoding + PRIVATE Window + PRIVATE XMLHttpRequest) + +add_test(NAME ModuleLoadTest COMMAND ModuleLoadTest CONFIGURATIONS Release RelWithDebInfo) + +# See https://gitlab.kitware.com/cmake/cmake/-/issues/23543 +add_custom_command(TARGET ModuleLoadTest POST_BUILD + COMMAND ${CMAKE_COMMAND} -E $>,copy,true> $ $ COMMAND_EXPAND_LISTS) + +set_property(TARGET ModuleLoadTest PROPERTY FOLDER Apps) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Apps/ModuleLoadTest/Source/App.Win32.cpp b/Apps/ModuleLoadTest/Source/App.Win32.cpp new file mode 100644 index 000000000..737ea1236 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.Win32.cpp @@ -0,0 +1,249 @@ +#include "App.h" +#include "ModuleSnapshot.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ + LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) + { + return ::DefWindowProc(hWnd, msg, wParam, lParam); + } + + ModuleLoadTest::ModuleSnapshot Subtract(const ModuleLoadTest::ModuleSnapshot& lhs, const ModuleLoadTest::ModuleSnapshot& rhs) + { + ModuleLoadTest::ModuleSnapshot result; + std::set_difference(lhs.begin(), lhs.end(), rhs.begin(), rhs.end(), + std::inserter(result, result.end())); + return result; + } + + void PrintList(const char* label, const ModuleLoadTest::ModuleSnapshot& items) + { + std::cout << label << " (" << items.size() << "):" << std::endl; + for (const auto& item : items) + { + std::cout << " " << item << std::endl; + } + } + + // Expected set of modules loaded during BabylonNative boot, as a delta + // from the baseline snapshot captured by the TLS callback before any C++ + // static initializer in this binary has run. Base names only, lower case. + // + // This list targets optimized builds (Release and RelWithDebInfo, which + // produce identical module sets). Debug builds load additional debug CRT + // and diagnostic DLLs and are not supported — main() returns a SKIP in + // that config. CI runs only in RelWithDebInfo (see CMakeLists.txt's + // `add_test ... CONFIGURATIONS Release RelWithDebInfo`). + // + // Launch-environment noise (e.g. VS's Start Without Debugging injecting + // kernel.appcore.dll) is filtered via IsAllowedOptionalModule so devs + // see the same verdict from Ctrl-F5 as CI does from a plain cmd launch. + // + // The assertion is asymmetric: we FAIL on modules not in this list (a new + // module was pulled in — the regression signal we want), and we IGNORE + // modules in this list that did not load (environmental variance across + // GPU SKUs, Windows patch levels, VS vs cmd launch, etc.). This lets the + // list be a permissive superset that works on both dev machines and CI. + // + // Motivating example: a dependency quietly pulling in dbghelp.dll on boot. + // dbghelp.dll lives in System32, so a path-based OS filter would miss it; + // we therefore golden-list the full delta (including OS modules) and only + // allow a narrow name-pattern carve-out for GPU driver ICDs whose exact + // names differ per runner SKU (see IsAllowedOptionalModule). + const ModuleLoadTest::ModuleSnapshot& GetExpectedBootModules() + { + // Seeded from a local RelWithDebInfo run on Windows 11 x64 with D3D11 + // and Chakra. CI may add more entries for other Win32 configs + // (V8/JSI/D3D12) — those should be appended as the draft PR runs. + static const ModuleLoadTest::ModuleSnapshot kModules{ + "cfgmgr32.dll", + "crypt32.dll", + "cryptbase.dll", + "cryptnet.dll", + "d3d11.dll", + "d3d11_3sdklayers.dll", + "dbghelp.dll", + "dcomp.dll", + "devobj.dll", + "directxdatabasehelper.dll", + "drvstore.dll", + "dwmapi.dll", + "dxcore.dll", + "dxgi.dll", + "dxgidebug.dll", + "iertutil.dll", + "imagehlp.dll", + "msasn1.dll", + "msctf.dll", + "netutils.dll", + "ntmarta.dll", + "powrprof.dll", + "profapi.dll", + "rsaenh.dll", + "setupapi.dll", + "shcore.dll", + "shell32.dll", + "srvcli.dll", + "umpdc.dll", + "uxtheme.dll", + "version.dll", + "winmm.dll", + "wintrust.dll", + "wintypes.dll", + "wldp.dll", + }; + return kModules; + } + + // Name patterns for modules whose presence in the delta is allowed but + // not required. Covers two classes: + // + // * GPU driver ICDs, whose exact base name depends on which GPU the + // runner has (NVIDIA/Intel/AMD/ATI). + // * Environmental/ambient DLLs that some launchers (e.g. Visual Studio's + // Start Without Debugging) inject into the process but which a plain + // cmd.exe launch does not. These are not introduced by BabylonNative. + // + // Prefixes match at the start of the base name; exacts match the whole + // name. + bool IsAllowedOptionalModule(std::string_view name) + { + static constexpr std::string_view kPrefixes[] = { + // GPU driver ICDs + "nv", // NVIDIA (nvoglv64.dll, nvapi64.dll, nvd3dum.dll, ...) + "ig", // Intel (igdumd64.dll, igxelpicd64.dll, ...) + "amd", // AMD (amdvlk64.dll, amdxc64.dll, ...) + "atio", // AMD/ATI (atio6axx.dll, ...) + // Ambient WARP variants + "microsoft.internal.warppal", + }; + static constexpr std::string_view kExact[] = { + // GPU driver ICD + "dxil.dll", + // VS Start-Without-Debugging launch environment + "kernel.appcore.dll", + }; + for (const auto& prefix : kPrefixes) + { + if (name.size() >= prefix.size() && name.compare(0, prefix.size(), prefix) == 0) + { + return true; + } + } + for (const auto& exact : kExact) + { + if (name == exact) + { + return true; + } + } + return false; + } +} + +int main(int /*argc*/, char* /*argv*/[]) +{ +#if defined(_DEBUG) + // Debug builds load a different set of modules than RelWithDebInfo (debug + // CRT, heavier diagnostic DLLs, etc). The golden list in this file + // targets RelWithDebInfo only. Rather than produce a confusing FAIL, make + // the skip explicit. + std::cout << "ModuleLoadTest: SKIP - Debug config is not supported. " + "Build with Release or RelWithDebInfo." << std::endl; + return 0; +#else + // Running under a debugger injects additional modules (and changes some + // timing) that would cause spurious FAIL diagnostics. CI runs headless + // from the CLI, so this only affects local debugging. Non-debugger + // ambient modules (e.g. VS Ctrl-F5) are handled by IsAllowedOptionalModule. + if (::IsDebuggerPresent()) + { + std::cout << "ModuleLoadTest: SKIP - running under a debugger. " + "Launch ModuleLoadTest.exe directly (no debugger attached) " + "to exercise the full assertion." << std::endl; + return 0; + } + + // Baseline was captured by a TLS callback before any C++ static + // initializer in this binary ran, so modules loaded by bx-style static + // objects (e.g. dbghelp.dll from bx's DbgHelpSymbolResolve) appear in the + // delta below rather than in the baseline. + const ModuleLoadTest::ModuleSnapshot& baseline = ModuleLoadTest::GetPreInitBaseline(); + + ::SetConsoleOutputCP(CP_UTF8); + + // bgfx D3D12 implementation requires an HWND to avoid a device refcount + // leak on shutdown. Create a hidden window to satisfy that requirement. + WNDCLASSEX wc = { sizeof(WNDCLASSEX), CS_CLASSDC, WndProc, 0L, 0L, + ::GetModuleHandle(nullptr), nullptr, nullptr, nullptr, nullptr, + "BabylonNativeModuleLoadTest", nullptr }; + ::RegisterClassEx(&wc); + HWND hWnd = ::CreateWindow(wc.lpszClassName, "BabylonNativeModuleLoadTest", + WS_OVERLAPPEDWINDOW, -1, -1, -1, -1, nullptr, nullptr, wc.hInstance, nullptr); + + Babylon::Graphics::Configuration config{}; + config.Window = hWnd; + config.Width = 600; + config.Height = 400; + + Babylon::DebugTrace::EnableDebugTrace(true); + Babylon::DebugTrace::SetTraceOutput([](const char* trace) { + ::OutputDebugStringA(trace); + ::OutputDebugStringA("\n"); + }); + + const ModuleLoadTest::ModuleSnapshot postBoot = ModuleLoadTest::RunBoot(config); + + // Delta = what boot caused to load. Filter out GPU ICD and + // launch-environment noise (see IsAllowedOptionalModule). + ModuleLoadTest::ModuleSnapshot delta = Subtract(postBoot, baseline); + for (auto it = delta.begin(); it != delta.end();) + { + if (IsAllowedOptionalModule(*it)) + { + it = delta.erase(it); + } + else + { + ++it; + } + } + + const ModuleLoadTest::ModuleSnapshot& expected = GetExpectedBootModules(); + + // Only fail on NEW modules. Missing-from-delta is environmental variance + // (GPU SKU, Windows patch, launch env, config) and is not a regression. + const ModuleLoadTest::ModuleSnapshot unexpected = Subtract(delta, expected); + + // Always print both snapshots so CI logs can be used to extend the golden + // list. The actual pass/fail is decided by the `unexpected` diff below. + PrintList("Baseline (modules loaded before C++ static init)", baseline); + PrintList("Delta (modules loaded during BN boot)", delta); + + if (!unexpected.empty()) + { + std::cout << std::endl; + std::cout << "FAIL: ModuleLoadTest detected unexpected modules loaded on boot." << std::endl; + PrintList("Unexpected new modules", unexpected); + std::cout << std::endl; + std::cout << "If these are intentional, add them to GetExpectedBootModules() " + "in Apps/ModuleLoadTest/Source/App.Win32.cpp." << std::endl; + return 1; + } + + std::cout << "ModuleLoadTest: PASS" << std::endl; + return 0; +#endif +} diff --git a/Apps/ModuleLoadTest/Source/App.cpp b/Apps/ModuleLoadTest/Source/App.cpp new file mode 100644 index 000000000..8d11324d3 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.cpp @@ -0,0 +1,63 @@ +#include "App.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace ModuleLoadTest +{ + ModuleSnapshot RunBoot(const Babylon::Graphics::Configuration& config) + { + // Bring up the real graphics device. This triggers the backend + // (D3D11/D3D12/Metal/OpenGL) and GPU ICD DLLs to load. + Babylon::Graphics::Device device{config}; + device.StartRenderingCurrentFrame(); + + std::optional nativeCanvas; + + Babylon::AppRuntime::Options options{}; + options.UnhandledExceptionHandler = [](const Napi::Error& error) { + std::cerr << "[Uncaught Error] " << Napi::GetErrorString(error) << std::endl; + std::quick_exit(1); + }; + + Babylon::AppRuntime runtime{options}; + + std::promise initDone; + + runtime.Dispatch([&device, &nativeCanvas, &initDone](Napi::Env env) { + device.AddToJavaScript(env); + + Babylon::Polyfills::XMLHttpRequest::Initialize(env); + Babylon::Polyfills::Console::Initialize(env, [](const char* message, Babylon::Polyfills::Console::LogLevel) { + std::cout << message << std::endl; + }); + Babylon::Polyfills::Window::Initialize(env); + Babylon::Polyfills::Blob::Initialize(env); + nativeCanvas.emplace(Babylon::Polyfills::Canvas::Initialize(env)); + Babylon::Plugins::NativeEngine::Initialize(env); + Babylon::Plugins::NativeEncoding::Initialize(env); + + initDone.set_value(); + }); + + initDone.get_future().get(); + + device.FinishRenderingCurrentFrame(); + + // Snapshot immediately after the first frame completes. At this point + // the graphics backend and every polyfill/plugin has been initialized, + // but no user JS has executed. + return CaptureSnapshot(); + } +} diff --git a/Apps/ModuleLoadTest/Source/App.h b/Apps/ModuleLoadTest/Source/App.h new file mode 100644 index 000000000..a7abbd5d6 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "ModuleSnapshot.h" + +namespace ModuleLoadTest +{ + // Drive BabylonNative to a stable boot state, then return the snapshot of + // loaded modules at that point. Platform-specific main() is responsible + // for: + // 1. Creating the platform window and Graphics::Configuration. + // 2. Invoking RunBoot() with the config. + // 3. Comparing the returned post-boot snapshot against GetPreInitBaseline() + // and the expected golden list. + ModuleSnapshot RunBoot(const Babylon::Graphics::Configuration& config); +} diff --git a/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp new file mode 100644 index 000000000..557932da7 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp @@ -0,0 +1,152 @@ +#include "ModuleSnapshot.h" + +#include +#include +#include + +#include +#include +#include + +#pragma comment(lib, "Psapi.lib") +#pragma comment(lib, "Shlwapi.lib") + +// Register a TLS callback so we capture the module list BEFORE any C++ static +// initializer in this binary runs. The linker would normally drop unreferenced +// TLS directory entries, so we force the symbols in. +#ifdef _WIN64 + #pragma comment(linker, "/INCLUDE:_tls_used") + #pragma comment(linker, "/INCLUDE:p_module_load_test_tls_cb") +#else + #pragma comment(linker, "/INCLUDE:__tls_used") + #pragma comment(linker, "/INCLUDE:_p_module_load_test_tls_cb") +#endif + +extern "C" void NTAPI ModuleLoadTest_OnTlsCallback(PVOID dllHandle, DWORD reason, PVOID reserved); + +extern "C" +{ + #pragma const_seg(push) + #pragma const_seg(".CRT$XLB") + extern const PIMAGE_TLS_CALLBACK p_module_load_test_tls_cb; + const PIMAGE_TLS_CALLBACK p_module_load_test_tls_cb = ModuleLoadTest_OnTlsCallback; + #pragma const_seg(pop) +} + +namespace ModuleLoadTest +{ + namespace + { + std::string ToLower(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; + } + + std::string WideToUtf8(const wchar_t* wide) + { + if (wide == nullptr || *wide == L'\0') + { + return {}; + } + + const int size = ::WideCharToMultiByte(CP_UTF8, 0, wide, -1, nullptr, 0, nullptr, nullptr); + if (size <= 0) + { + return {}; + } + + std::string result(static_cast(size - 1), '\0'); + ::WideCharToMultiByte(CP_UTF8, 0, wide, -1, result.data(), size, nullptr, nullptr); + return result; + } + } + + namespace + { + // Zero-initialized in the PE .bss — valid before any code in this + // binary runs. The TLS callback below allocates the snapshot before + // any C++ static initializer fires. + ModuleSnapshot* g_preInitSnapshot = nullptr; + } + + const ModuleSnapshot& GetPreInitBaseline() + { + static const ModuleSnapshot empty{}; + return g_preInitSnapshot != nullptr ? *g_preInitSnapshot : empty; + } + + // Called from the TLS callback. Defined in this TU so the anonymous + // g_preInitSnapshot is reachable. + void CapturePreInitSnapshot() + { + if (g_preInitSnapshot == nullptr) + { + // At this point the exe's import DLLs (kernel32, Psapi, Shlwapi, + // ucrtbase) are mapped and the process heap is available, so + // new/std::set/std::string are safe to use even though the rest of + // the CRT has not fully initialized. + g_preInitSnapshot = new ModuleSnapshot(CaptureSnapshot()); + } + } + + void DisposePreInitSnapshot() + { + delete g_preInitSnapshot; + g_preInitSnapshot = nullptr; + } + + ModuleSnapshot CaptureSnapshot() + { + ModuleSnapshot snapshot{}; + + const HANDLE process = ::GetCurrentProcess(); + + DWORD requiredBytes = 0; + if (!::EnumProcessModules(process, nullptr, 0, &requiredBytes) || requiredBytes == 0) + { + return snapshot; + } + + std::vector modules(requiredBytes / sizeof(HMODULE)); + if (!::EnumProcessModules(process, modules.data(), static_cast(modules.size() * sizeof(HMODULE)), &requiredBytes)) + { + return snapshot; + } + + const size_t count = requiredBytes / sizeof(HMODULE); + for (size_t i = 0; i < count; ++i) + { + wchar_t path[MAX_PATH]{}; + const DWORD length = ::GetModuleFileNameW(modules[i], path, static_cast(std::size(path))); + if (length == 0 || length >= std::size(path)) + { + continue; + } + + const wchar_t* baseName = ::PathFindFileNameW(path); + if (baseName == nullptr || *baseName == L'\0') + { + continue; + } + + snapshot.insert(ToLower(WideToUtf8(baseName))); + } + + return snapshot; + } +} + +extern "C" void NTAPI ModuleLoadTest_OnTlsCallback(PVOID /*dllHandle*/, DWORD reason, PVOID /*reserved*/) +{ + if (reason == DLL_PROCESS_ATTACH) + { + ModuleLoadTest::CapturePreInitSnapshot(); + } + else if (reason == DLL_PROCESS_DETACH) + { + ModuleLoadTest::DisposePreInitSnapshot(); + } +} diff --git a/Apps/ModuleLoadTest/Source/ModuleSnapshot.h b/Apps/ModuleLoadTest/Source/ModuleSnapshot.h new file mode 100644 index 000000000..b143d96c5 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/ModuleSnapshot.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +namespace ModuleLoadTest +{ + // Loaded-module snapshot. Base names only (no paths, lower-cased on + // case-insensitive platforms) for stable comparison across OS patch + // versions and symlink differences. + using ModuleSnapshot = std::set; + + // Enumerate modules currently loaded into the process. + ModuleSnapshot CaptureSnapshot(); + + // Return the baseline captured BEFORE any C++ static initializer in this + // binary has run. This is what lets the delta catch modules loaded by + // bx-style `static` objects (e.g. dbghelp.dll via bx's debug.cpp), which + // would otherwise already be in a main()-entry baseline. + const ModuleSnapshot& GetPreInitBaseline(); +} From 901a593f13be38d16d18df23792ae7a14b155605 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Mon, 20 Apr 2026 14:20:21 -0700 Subject: [PATCH 02/16] TEMP: gate non-Win32 CI jobs with if: false for iteration Speeds up ModuleLoadTest golden-list iteration. Revert before ready-for-review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a466d0c0a..4f6c0cab0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,39 +7,50 @@ on: branches: [master] jobs: + # TODO(module-load-test): REVERT BEFORE READY-FOR-REVIEW — non-Win32 jobs gated with `if: false` + # to speed up CI iteration while seeding the ModuleLoadTest golden list. Restore by removing + # every `if: false` in this file. + # ── Apple: macOS ────────────────────────────────────────────── MacOS: + if: false uses: ./.github/workflows/build-macos.yml MacOS_Ninja: + if: false uses: ./.github/workflows/build-macos.yml with: generator: 'Ninja Multi-Config' MacOS_Sanitizers: + if: false uses: ./.github/workflows/build-macos.yml with: enable-sanitizers: true # ── Apple: iOS ──────────────────────────────────────────────── iOS_iOS180: + if: false uses: ./.github/workflows/build-ios.yml with: deployment-target: '18.0' iOS_iOS175: + if: false uses: ./.github/workflows/build-ios.yml with: deployment-target: '17.5' # ── Apple: Xcode 26 ────────────────────────────────────────── MacOS_Xcode26: + if: false uses: ./.github/workflows/build-macos.yml with: xcode-version: '26.4' runs-on: macos-26 iOS_Xcode26: + if: false uses: ./.github/workflows/build-ios.yml with: deployment-target: '26.0' @@ -81,16 +92,19 @@ jobs: # ── UWP ─────────────────────────────────────────────────────── UWP_x64: + if: false uses: ./.github/workflows/build-uwp.yml with: platform: x64 UWP_arm64: + if: false uses: ./.github/workflows/build-uwp.yml with: platform: arm64 UWP_arm64_JSI: + if: false uses: ./.github/workflows/build-uwp.yml with: platform: arm64 @@ -98,6 +112,7 @@ jobs: # ── Ubuntu / Linux ──────────────────────────────────────────── Ubuntu_Clang_JSC: + if: false uses: ./.github/workflows/build-linux.yml with: cc: clang @@ -105,6 +120,7 @@ jobs: js-engine: JavaScriptCore Ubuntu_GCC_JSC: + if: false uses: ./.github/workflows/build-linux.yml with: cc: gcc @@ -113,24 +129,28 @@ jobs: # ── Android ─────────────────────────────────────────────────── Android_Ubuntu_JSC: + if: false uses: ./.github/workflows/build-android.yml with: runs-on: ubuntu-latest js-engine: JavaScriptCore Android_Ubuntu_V8: + if: false uses: ./.github/workflows/build-android.yml with: runs-on: ubuntu-latest js-engine: V8 Android_MacOS_JSC: + if: false uses: ./.github/workflows/build-android.yml with: runs-on: macos-latest js-engine: JavaScriptCore Android_MacOS_V8: + if: false uses: ./.github/workflows/build-android.yml with: runs-on: macos-latest @@ -138,14 +158,17 @@ jobs: # ── Installation Tests ──────────────────────────────────────── iOS_Installation: + if: false uses: ./.github/workflows/test-install-ios.yml with: deployment-target: '17.2' Linux_Installation: + if: false uses: ./.github/workflows/test-install-linux.yml MacOS_Installation: + if: false uses: ./.github/workflows/test-install-macos.yml Win32_Installation: From d087f0bbf1740d4a4b2d70b1ce53ffc933d064d9 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Mon, 20 Apr 2026 14:37:41 -0700 Subject: [PATCH 03/16] ModuleLoadTest: union CI-observed modules; scope CI to D3D11; run step after UnitTests failure - App.Win32.cpp: add bcryptprimitives, d3d10warp, d3d12/core/sdklayers, d3dscache, dxilconv, userenv, windows.storage from V8 + D3D12 CI runs - ci.yml: disable all non-Win32_x64_D3D11 jobs for fast iteration (TEMP, revert) - build-win32.yml: add always() so Module Load Test runs despite pre-existing light-projection UnitTests failure (TEMP, revert after BJS bump) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-win32.yml | 5 ++++- .github/workflows/ci.yml | 6 ++++++ Apps/ModuleLoadTest/Source/App.Win32.cpp | 9 +++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-win32.yml b/.github/workflows/build-win32.yml index 7375a5030..485cb8947 100644 --- a/.github/workflows/build-win32.yml +++ b/.github/workflows/build-win32.yml @@ -137,7 +137,10 @@ jobs: UnitTests - name: Module Load Test - if: ${{ !inputs.enable-sanitizers }} + # TODO(module-load-test): REVERT BEFORE READY-FOR-REVIEW — use `always()` to run even + # when UnitTests fails (pre-existing light-projection regression blocks D3D11). Revert + # to `!inputs.enable-sanitizers` once upstream Babylon.js fix is picked up. + if: ${{ always() && !inputs.enable-sanitizers }} shell: cmd run: | cd build\${{ steps.vars.outputs.solution_name }}\Apps\ModuleLoadTest\RelWithDebInfo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f6c0cab0..e5078681b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,30 +64,35 @@ jobs: platform: x64 Win32_x64_JSI_D3D11: + if: false uses: ./.github/workflows/build-win32.yml with: platform: x64 napi-type: jsi Win32_x64_V8_D3D11: + if: false uses: ./.github/workflows/build-win32.yml with: platform: x64 napi-type: V8 Win32_x64_D3D11_Sanitizers: + if: false uses: ./.github/workflows/build-win32.yml with: platform: x64 enable-sanitizers: true Win32_x64_D3D12: + if: false uses: ./.github/workflows/build-win32.yml with: platform: x64 graphics-api: D3D12 Win32_x64_D3D11_PrecompiledShaderTest: + if: false uses: ./.github/workflows/build-win32-shader.yml # ── UWP ─────────────────────────────────────────────────────── @@ -172,4 +177,5 @@ jobs: uses: ./.github/workflows/test-install-macos.yml Win32_Installation: + if: false uses: ./.github/workflows/test-install-win32.yml diff --git a/Apps/ModuleLoadTest/Source/App.Win32.cpp b/Apps/ModuleLoadTest/Source/App.Win32.cpp index 737ea1236..65aa145c3 100644 --- a/Apps/ModuleLoadTest/Source/App.Win32.cpp +++ b/Apps/ModuleLoadTest/Source/App.Win32.cpp @@ -68,12 +68,18 @@ namespace // and Chakra. CI may add more entries for other Win32 configs // (V8/JSI/D3D12) — those should be appended as the draft PR runs. static const ModuleLoadTest::ModuleSnapshot kModules{ + "bcryptprimitives.dll", "cfgmgr32.dll", "crypt32.dll", "cryptbase.dll", "cryptnet.dll", + "d3d10warp.dll", "d3d11.dll", "d3d11_3sdklayers.dll", + "d3d12.dll", + "d3d12core.dll", + "d3d12sdklayers.dll", + "d3dscache.dll", "dbghelp.dll", "dcomp.dll", "devobj.dll", @@ -83,6 +89,7 @@ namespace "dxcore.dll", "dxgi.dll", "dxgidebug.dll", + "dxilconv.dll", "iertutil.dll", "imagehlp.dll", "msasn1.dll", @@ -97,8 +104,10 @@ namespace "shell32.dll", "srvcli.dll", "umpdc.dll", + "userenv.dll", "uxtheme.dll", "version.dll", + "windows.storage.dll", "winmm.dll", "wintrust.dll", "wintypes.dll", From 44594cacc937a85661b4e8c75f26397070a51fd3 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Mon, 20 Apr 2026 14:39:37 -0700 Subject: [PATCH 04/16] ModuleLoadTest: re-enable all Win32 build configs for module-delta coverage Different configs (D3D11/D3D12/V8/JSI) load different modules. Need the full Win32 matrix to collect the complete union. Sanitizers stays off (step is gated on !enable-sanitizers); PrecompiledShaderTest uses a different workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5078681b..2be056f5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,14 +64,12 @@ jobs: platform: x64 Win32_x64_JSI_D3D11: - if: false uses: ./.github/workflows/build-win32.yml with: platform: x64 napi-type: jsi Win32_x64_V8_D3D11: - if: false uses: ./.github/workflows/build-win32.yml with: platform: x64 @@ -85,7 +83,6 @@ jobs: enable-sanitizers: true Win32_x64_D3D12: - if: false uses: ./.github/workflows/build-win32.yml with: platform: x64 From 6f70b36bef4233e5804953c1fa88a580c91d9c56 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Mon, 20 Apr 2026 14:54:30 -0700 Subject: [PATCH 05/16] ModuleLoadTest: revert TEMP CI iteration hacks - build-win32.yml: remove always() gate on Module Load Test step - ci.yml: restore full job matrix (non-Win32 jobs were gated off during iteration) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-win32.yml | 5 +---- .github/workflows/ci.yml | 26 -------------------------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/.github/workflows/build-win32.yml b/.github/workflows/build-win32.yml index 485cb8947..7375a5030 100644 --- a/.github/workflows/build-win32.yml +++ b/.github/workflows/build-win32.yml @@ -137,10 +137,7 @@ jobs: UnitTests - name: Module Load Test - # TODO(module-load-test): REVERT BEFORE READY-FOR-REVIEW — use `always()` to run even - # when UnitTests fails (pre-existing light-projection regression blocks D3D11). Revert - # to `!inputs.enable-sanitizers` once upstream Babylon.js fix is picked up. - if: ${{ always() && !inputs.enable-sanitizers }} + if: ${{ !inputs.enable-sanitizers }} shell: cmd run: | cd build\${{ steps.vars.outputs.solution_name }}\Apps\ModuleLoadTest\RelWithDebInfo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2be056f5f..a466d0c0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,50 +7,39 @@ on: branches: [master] jobs: - # TODO(module-load-test): REVERT BEFORE READY-FOR-REVIEW — non-Win32 jobs gated with `if: false` - # to speed up CI iteration while seeding the ModuleLoadTest golden list. Restore by removing - # every `if: false` in this file. - # ── Apple: macOS ────────────────────────────────────────────── MacOS: - if: false uses: ./.github/workflows/build-macos.yml MacOS_Ninja: - if: false uses: ./.github/workflows/build-macos.yml with: generator: 'Ninja Multi-Config' MacOS_Sanitizers: - if: false uses: ./.github/workflows/build-macos.yml with: enable-sanitizers: true # ── Apple: iOS ──────────────────────────────────────────────── iOS_iOS180: - if: false uses: ./.github/workflows/build-ios.yml with: deployment-target: '18.0' iOS_iOS175: - if: false uses: ./.github/workflows/build-ios.yml with: deployment-target: '17.5' # ── Apple: Xcode 26 ────────────────────────────────────────── MacOS_Xcode26: - if: false uses: ./.github/workflows/build-macos.yml with: xcode-version: '26.4' runs-on: macos-26 iOS_Xcode26: - if: false uses: ./.github/workflows/build-ios.yml with: deployment-target: '26.0' @@ -76,7 +65,6 @@ jobs: napi-type: V8 Win32_x64_D3D11_Sanitizers: - if: false uses: ./.github/workflows/build-win32.yml with: platform: x64 @@ -89,24 +77,20 @@ jobs: graphics-api: D3D12 Win32_x64_D3D11_PrecompiledShaderTest: - if: false uses: ./.github/workflows/build-win32-shader.yml # ── UWP ─────────────────────────────────────────────────────── UWP_x64: - if: false uses: ./.github/workflows/build-uwp.yml with: platform: x64 UWP_arm64: - if: false uses: ./.github/workflows/build-uwp.yml with: platform: arm64 UWP_arm64_JSI: - if: false uses: ./.github/workflows/build-uwp.yml with: platform: arm64 @@ -114,7 +98,6 @@ jobs: # ── Ubuntu / Linux ──────────────────────────────────────────── Ubuntu_Clang_JSC: - if: false uses: ./.github/workflows/build-linux.yml with: cc: clang @@ -122,7 +105,6 @@ jobs: js-engine: JavaScriptCore Ubuntu_GCC_JSC: - if: false uses: ./.github/workflows/build-linux.yml with: cc: gcc @@ -131,28 +113,24 @@ jobs: # ── Android ─────────────────────────────────────────────────── Android_Ubuntu_JSC: - if: false uses: ./.github/workflows/build-android.yml with: runs-on: ubuntu-latest js-engine: JavaScriptCore Android_Ubuntu_V8: - if: false uses: ./.github/workflows/build-android.yml with: runs-on: ubuntu-latest js-engine: V8 Android_MacOS_JSC: - if: false uses: ./.github/workflows/build-android.yml with: runs-on: macos-latest js-engine: JavaScriptCore Android_MacOS_V8: - if: false uses: ./.github/workflows/build-android.yml with: runs-on: macos-latest @@ -160,19 +138,15 @@ jobs: # ── Installation Tests ──────────────────────────────────────── iOS_Installation: - if: false uses: ./.github/workflows/test-install-ios.yml with: deployment-target: '17.2' Linux_Installation: - if: false uses: ./.github/workflows/test-install-linux.yml MacOS_Installation: - if: false uses: ./.github/workflows/test-install-macos.yml Win32_Installation: - if: false uses: ./.github/workflows/test-install-win32.yml From 9776057719fe2140be07e5ee9772eaee8c36f497 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 09:58:02 -0700 Subject: [PATCH 06/16] ModuleLoadTest: extract shared Subtract/PrintList/CompareAndReport into App.cpp --- Apps/ModuleLoadTest/Source/App.Win32.cpp | 82 +++--------------------- Apps/ModuleLoadTest/Source/App.cpp | 67 ++++++++++++++++++- Apps/ModuleLoadTest/Source/App.h | 28 +++++++- 3 files changed, 101 insertions(+), 76 deletions(-) diff --git a/Apps/ModuleLoadTest/Source/App.Win32.cpp b/Apps/ModuleLoadTest/Source/App.Win32.cpp index 65aa145c3..5ea40066c 100644 --- a/Apps/ModuleLoadTest/Source/App.Win32.cpp +++ b/Apps/ModuleLoadTest/Source/App.Win32.cpp @@ -6,34 +6,16 @@ #include -#include #include -#include -#include -#include #include -namespace +namespace ModuleLoadTest { - LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) + namespace { - return ::DefWindowProc(hWnd, msg, wParam, lParam); - } - - ModuleLoadTest::ModuleSnapshot Subtract(const ModuleLoadTest::ModuleSnapshot& lhs, const ModuleLoadTest::ModuleSnapshot& rhs) - { - ModuleLoadTest::ModuleSnapshot result; - std::set_difference(lhs.begin(), lhs.end(), rhs.begin(), rhs.end(), - std::inserter(result, result.end())); - return result; - } - - void PrintList(const char* label, const ModuleLoadTest::ModuleSnapshot& items) - { - std::cout << label << " (" << items.size() << "):" << std::endl; - for (const auto& item : items) + LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { - std::cout << " " << item << std::endl; + return ::DefWindowProc(hWnd, msg, wParam, lParam); } } @@ -62,12 +44,12 @@ namespace // we therefore golden-list the full delta (including OS modules) and only // allow a narrow name-pattern carve-out for GPU driver ICDs whose exact // names differ per runner SKU (see IsAllowedOptionalModule). - const ModuleLoadTest::ModuleSnapshot& GetExpectedBootModules() + const ModuleSnapshot& GetExpectedBootModules() { // Seeded from a local RelWithDebInfo run on Windows 11 x64 with D3D11 // and Chakra. CI may add more entries for other Win32 configs // (V8/JSI/D3D12) — those should be appended as the draft PR runs. - static const ModuleLoadTest::ModuleSnapshot kModules{ + static const ModuleSnapshot kModules{ "bcryptprimitives.dll", "cfgmgr32.dll", "crypt32.dll", @@ -164,7 +146,7 @@ namespace int main(int /*argc*/, char* /*argv*/[]) { -#if defined(_DEBUG) +#if !defined(NDEBUG) // Debug builds load a different set of modules than RelWithDebInfo (debug // CRT, heavier diagnostic DLLs, etc). The golden list in this file // targets RelWithDebInfo only. Rather than produce a confusing FAIL, make @@ -185,17 +167,11 @@ int main(int /*argc*/, char* /*argv*/[]) return 0; } - // Baseline was captured by a TLS callback before any C++ static - // initializer in this binary ran, so modules loaded by bx-style static - // objects (e.g. dbghelp.dll from bx's DbgHelpSymbolResolve) appear in the - // delta below rather than in the baseline. - const ModuleLoadTest::ModuleSnapshot& baseline = ModuleLoadTest::GetPreInitBaseline(); - ::SetConsoleOutputCP(CP_UTF8); // bgfx D3D12 implementation requires an HWND to avoid a device refcount // leak on shutdown. Create a hidden window to satisfy that requirement. - WNDCLASSEX wc = { sizeof(WNDCLASSEX), CS_CLASSDC, WndProc, 0L, 0L, + WNDCLASSEX wc = { sizeof(WNDCLASSEX), CS_CLASSDC, ModuleLoadTest::WndProc, 0L, 0L, ::GetModuleHandle(nullptr), nullptr, nullptr, nullptr, nullptr, "BabylonNativeModuleLoadTest", nullptr }; ::RegisterClassEx(&wc); @@ -213,46 +189,6 @@ int main(int /*argc*/, char* /*argv*/[]) ::OutputDebugStringA("\n"); }); - const ModuleLoadTest::ModuleSnapshot postBoot = ModuleLoadTest::RunBoot(config); - - // Delta = what boot caused to load. Filter out GPU ICD and - // launch-environment noise (see IsAllowedOptionalModule). - ModuleLoadTest::ModuleSnapshot delta = Subtract(postBoot, baseline); - for (auto it = delta.begin(); it != delta.end();) - { - if (IsAllowedOptionalModule(*it)) - { - it = delta.erase(it); - } - else - { - ++it; - } - } - - const ModuleLoadTest::ModuleSnapshot& expected = GetExpectedBootModules(); - - // Only fail on NEW modules. Missing-from-delta is environmental variance - // (GPU SKU, Windows patch, launch env, config) and is not a regression. - const ModuleLoadTest::ModuleSnapshot unexpected = Subtract(delta, expected); - - // Always print both snapshots so CI logs can be used to extend the golden - // list. The actual pass/fail is decided by the `unexpected` diff below. - PrintList("Baseline (modules loaded before C++ static init)", baseline); - PrintList("Delta (modules loaded during BN boot)", delta); - - if (!unexpected.empty()) - { - std::cout << std::endl; - std::cout << "FAIL: ModuleLoadTest detected unexpected modules loaded on boot." << std::endl; - PrintList("Unexpected new modules", unexpected); - std::cout << std::endl; - std::cout << "If these are intentional, add them to GetExpectedBootModules() " - "in Apps/ModuleLoadTest/Source/App.Win32.cpp." << std::endl; - return 1; - } - - std::cout << "ModuleLoadTest: PASS" << std::endl; - return 0; + return ModuleLoadTest::CompareAndReport(ModuleLoadTest::RunBoot(config)); #endif } diff --git a/Apps/ModuleLoadTest/Source/App.cpp b/Apps/ModuleLoadTest/Source/App.cpp index 8d11324d3..521b9ee6e 100644 --- a/Apps/ModuleLoadTest/Source/App.cpp +++ b/Apps/ModuleLoadTest/Source/App.cpp @@ -10,8 +10,10 @@ #include #include +#include #include #include +#include #include namespace ModuleLoadTest @@ -19,7 +21,7 @@ namespace ModuleLoadTest ModuleSnapshot RunBoot(const Babylon::Graphics::Configuration& config) { // Bring up the real graphics device. This triggers the backend - // (D3D11/D3D12/Metal/OpenGL) and GPU ICD DLLs to load. + // (D3D11/D3D12/Metal/OpenGL) and GPU ICD DLLs/dylibs/sos to load. Babylon::Graphics::Device device{config}; device.StartRenderingCurrentFrame(); @@ -60,4 +62,67 @@ namespace ModuleLoadTest // but no user JS has executed. return CaptureSnapshot(); } + + ModuleSnapshot Subtract(const ModuleSnapshot& lhs, const ModuleSnapshot& rhs) + { + ModuleSnapshot result; + std::set_difference(lhs.begin(), lhs.end(), rhs.begin(), rhs.end(), + std::inserter(result, result.end())); + return result; + } + + void PrintList(const char* label, const ModuleSnapshot& items) + { + std::cout << label << " (" << items.size() << "):" << std::endl; + for (const auto& item : items) + { + std::cout << " " << item << std::endl; + } + } + + int CompareAndReport(const ModuleSnapshot& postBoot) + { + const ModuleSnapshot& baseline = GetPreInitBaseline(); + + // Delta = what boot caused to load. Filter out GPU ICD and + // launch-environment noise (see platform IsAllowedOptionalModule). + ModuleSnapshot delta = Subtract(postBoot, baseline); + for (auto it = delta.begin(); it != delta.end();) + { + if (IsAllowedOptionalModule(*it)) + { + it = delta.erase(it); + } + else + { + ++it; + } + } + + const ModuleSnapshot& expected = GetExpectedBootModules(); + + // Only fail on NEW modules. Missing-from-delta is environmental variance + // (GPU SKU, OS patch, launch env, config) and is not a regression. + const ModuleSnapshot unexpected = Subtract(delta, expected); + + // Always print both snapshots so CI logs can be used to extend the + // golden list. The actual pass/fail is decided by the `unexpected` + // diff below. + PrintList("Baseline (modules loaded before C++ static init)", baseline); + PrintList("Delta (modules loaded during BN boot)", delta); + + if (!unexpected.empty()) + { + std::cout << std::endl; + std::cout << "FAIL: ModuleLoadTest detected unexpected modules loaded on boot." << std::endl; + PrintList("Unexpected new modules", unexpected); + std::cout << std::endl; + std::cout << "If these are intentional, add them to GetExpectedBootModules() " + "in the platform-specific App source." << std::endl; + return 1; + } + + std::cout << "ModuleLoadTest: PASS" << std::endl; + return 0; + } } diff --git a/Apps/ModuleLoadTest/Source/App.h b/Apps/ModuleLoadTest/Source/App.h index a7abbd5d6..3419a77b1 100644 --- a/Apps/ModuleLoadTest/Source/App.h +++ b/Apps/ModuleLoadTest/Source/App.h @@ -2,6 +2,8 @@ #include +#include + #include "ModuleSnapshot.h" namespace ModuleLoadTest @@ -11,7 +13,29 @@ namespace ModuleLoadTest // for: // 1. Creating the platform window and Graphics::Configuration. // 2. Invoking RunBoot() with the config. - // 3. Comparing the returned post-boot snapshot against GetPreInitBaseline() - // and the expected golden list. + // 3. Calling CompareAndReport() with the returned snapshot. ModuleSnapshot RunBoot(const Babylon::Graphics::Configuration& config); + + // Set-difference helper: elements in lhs that are not in rhs. + ModuleSnapshot Subtract(const ModuleSnapshot& lhs, const ModuleSnapshot& rhs); + + // Print a labeled module list to stdout. + void PrintList(const char* label, const ModuleSnapshot& items); + + // Provided by each platform's App..{cpp,mm}: the golden list of + // modules expected to be loaded during boot, as a delta from the baseline + // returned by GetPreInitBaseline(). Base names only, lower case. + const ModuleSnapshot& GetExpectedBootModules(); + + // Provided by each platform's App..{cpp,mm}: returns true for + // modules whose presence in the delta is allowed but not required (e.g. + // GPU-driver ICDs whose names vary by runner SKU, launch-environment + // noise). See platform-specific comments for scope. + bool IsAllowedOptionalModule(std::string_view name); + + // Compare a post-boot snapshot against the baseline + platform-specific + // expected list and allowed-optional filter. Prints baseline+delta and + // returns 0 on pass, 1 on fail. Called by each platform's main() after + // RunBoot(). + int CompareAndReport(const ModuleSnapshot& postBoot); } From a36b8939a93ad95ecdcaf9d6aadc88b4c88d5795 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 09:58:02 -0700 Subject: [PATCH 07/16] Apps/ModuleLoadTest: add Linux (X11) and macOS (Metal) platform support --- Apps/ModuleLoadTest/CMakeLists.txt | 33 ++-- Apps/ModuleLoadTest/Source/App.Apple.mm | 101 +++++++++++ Apps/ModuleLoadTest/Source/App.X11.cpp | 161 ++++++++++++++++++ .../Source/ModuleSnapshot.Linux.cpp | 97 +++++++++++ .../Source/ModuleSnapshot.macOS.mm | 83 +++++++++ 5 files changed, 465 insertions(+), 10 deletions(-) create mode 100644 Apps/ModuleLoadTest/Source/App.Apple.mm create mode 100644 Apps/ModuleLoadTest/Source/App.X11.cpp create mode 100644 Apps/ModuleLoadTest/Source/ModuleSnapshot.Linux.cpp create mode 100644 Apps/ModuleLoadTest/Source/ModuleSnapshot.macOS.mm diff --git a/Apps/ModuleLoadTest/CMakeLists.txt b/Apps/ModuleLoadTest/CMakeLists.txt index 3c51aca31..1ce26bd8c 100644 --- a/Apps/ModuleLoadTest/CMakeLists.txt +++ b/Apps/ModuleLoadTest/CMakeLists.txt @@ -1,15 +1,27 @@ -if(NOT(WIN32 AND NOT WINDOWS_STORE)) - # Windows-only for the initial implementation. - # macOS and Linux support will be added in follow-up commits on this PR. - return() -endif() - set(SOURCES "Source/App.h" "Source/App.cpp" - "Source/App.Win32.cpp" - "Source/ModuleSnapshot.h" - "Source/ModuleSnapshot.Win32.cpp") + "Source/ModuleSnapshot.h") + +if(WIN32 AND NOT WINDOWS_STORE) + list(APPEND SOURCES + "Source/App.Win32.cpp" + "Source/ModuleSnapshot.Win32.cpp") +elseif(APPLE AND NOT IOS) + list(APPEND SOURCES + "Source/App.Apple.mm" + "Source/ModuleSnapshot.macOS.mm") + find_library(JAVASCRIPTCORE_LIBRARY JavaScriptCore) + set(ADDITIONAL_LIBRARIES PRIVATE ${JAVASCRIPTCORE_LIBRARY}) +elseif(UNIX AND NOT ANDROID) + list(APPEND SOURCES + "Source/App.X11.cpp" + "Source/ModuleSnapshot.Linux.cpp") +else() + # Unsupported platform (UWP, Android, iOS) — mobile/store bundling + # changes module-load semantics. Silently skip. + return() +endif() add_executable(ModuleLoadTest ${SOURCES}) @@ -22,7 +34,8 @@ target_link_libraries(ModuleLoadTest PRIVATE NativeEngine PRIVATE NativeEncoding PRIVATE Window - PRIVATE XMLHttpRequest) + PRIVATE XMLHttpRequest + ${ADDITIONAL_LIBRARIES}) add_test(NAME ModuleLoadTest COMMAND ModuleLoadTest CONFIGURATIONS Release RelWithDebInfo) diff --git a/Apps/ModuleLoadTest/Source/App.Apple.mm b/Apps/ModuleLoadTest/Source/App.Apple.mm new file mode 100644 index 000000000..e9dc39e42 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.Apple.mm @@ -0,0 +1,101 @@ +#include "App.h" +#include "ModuleSnapshot.h" + +#include +#include + +#import + +#include +#include +#include + +#include +#include + +#import + +namespace ModuleLoadTest +{ + namespace + { + // Apple equivalent of IsDebuggerPresent() — non-invasive. + // https://developer.apple.com/library/archive/qa/qa1361/_index.html + bool IsBeingTraced() + { + int name[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid() }; + struct kinfo_proc info{}; + size_t size = sizeof(info); + if (sysctl(name, 4, &info, &size, nullptr, 0) != 0) + { + return false; + } + return (info.kp_proc.p_flag & P_TRACED) != 0; + } + } + + // Empty initial seed — the CI run of this PR will print the observed + // delta and we'll append entries here in follow-up commits. + const ModuleSnapshot& GetExpectedBootModules() + { + static const ModuleSnapshot kModules{}; + return kModules; + } + + // On macOS the interesting per-SKU variation is the Metal/GPU driver + // bundles (AMDMTLBronzeDriver, AppleIntelKBLGraphicsMTLDriver, ...), which + // dyld loads by path under /System/Library/Extensions/. Base names still + // differ between Apple Silicon and Intel. Seed a broad carve-out. + bool IsAllowedOptionalModule(std::string_view name) + { + static constexpr std::string_view kPrefixes[] = { + // Metal GPU driver bundles + "amdmtl", + "appleintel", + "applem1", + "applem2", + "applem3", + "nvmtl", + // Ambient/IOSurface layer + "iosurface", + }; + for (const auto& prefix : kPrefixes) + { + if (name.size() >= prefix.size() && name.compare(0, prefix.size(), prefix) == 0) + { + return true; + } + } + return false; + } +} + +int main(int /*argc*/, char* /*argv*/[]) +{ +#if !defined(NDEBUG) + std::cout << "ModuleLoadTest: SKIP - Debug config is not supported. " + "Build with Release or RelWithDebInfo." << std::endl; + return 0; +#else + if (ModuleLoadTest::IsBeingTraced()) + { + std::cout << "ModuleLoadTest: SKIP - running under a debugger." << std::endl; + return 0; + } + + Babylon::DebugTrace::EnableDebugTrace(true); + Babylon::DebugTrace::SetTraceOutput([](const char* trace) { NSLog(@"%s", trace); }); + + Babylon::Graphics::Configuration config{}; + config.Device = MTL::CreateSystemDefaultDevice(); + if (config.Device == nullptr) + { + std::cout << "ModuleLoadTest: SKIP - no Metal device available." << std::endl; + return 0; + } + config.Width = 600; + config.Height = 400; + + return ModuleLoadTest::CompareAndReport(ModuleLoadTest::RunBoot(config)); +#endif +} diff --git a/Apps/ModuleLoadTest/Source/App.X11.cpp b/Apps/ModuleLoadTest/Source/App.X11.cpp new file mode 100644 index 000000000..fa6ba5e4f --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.X11.cpp @@ -0,0 +1,161 @@ +// gtest.h-style include ordering: X11 headers #define None (which collides +// with lots of C++ code) so they must come before anything that cares. +#define XK_MISCELLANY +#define XK_LATIN1 +#include +#include +#undef None + +#include "App.h" +#include "ModuleSnapshot.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ModuleLoadTest +{ + namespace + { + // Parse /proc/self/status for a non-zero TracerPid. Non-invasive + // (contrast with ptrace(PTRACE_TRACEME), which *creates* trace state + // if none exists). + bool IsBeingTraced() + { + std::ifstream status{"/proc/self/status"}; + std::string line; + while (std::getline(status, line)) + { + constexpr std::string_view prefix{"TracerPid:"}; + if (line.size() >= prefix.size() && line.compare(0, prefix.size(), prefix) == 0) + { + const char* p = line.c_str() + prefix.size(); + while (*p == ' ' || *p == '\t') ++p; + return *p != '\0' && *p != '0'; + } + } + return false; + } + } + + // Empty initial seed — the CI run of this PR will print the observed delta + // and we'll append entries here in follow-up commits. See App.Win32.cpp + // for the full rationale of the asymmetric (permissive superset) check. + const ModuleSnapshot& GetExpectedBootModules() + { + static const ModuleSnapshot kModules{}; + return kModules; + } + + // Name patterns for modules whose presence in the delta is allowed but + // not required. On Linux the graphics stack varies a lot across runners: + // Mesa DRI drivers, llvmpipe/swrast, NVIDIA/AMD userspace libs, Vulkan + // ICD loaders, GL ABI variants. Seed a broad carve-out up front so the + // golden list doesn't have to re-list every distro/GPU permutation. + bool IsAllowedOptionalModule(std::string_view name) + { + static constexpr std::string_view kPrefixes[] = { + // Mesa and DRI driver shims (swrast, llvmpipe, iris, radeonsi, ...) + "libglapi", + "libdri", + "swrast", + "llvmpipe", + "iris", + "radeonsi", + "i965", + "i915", + "nouveau", + "virtio", + "vmwgfx", + "kms", + // NVIDIA proprietary + "libnvidia", + "libcuda", + "libgl_nvidia", + "libegl_nvidia", + // AMD proprietary + "libamdgpu", + "libdrm_amdgpu", + // Vulkan ICD loaders + "libvulkan", + // GLVND variants + "libgl.", + "libglx", + "libegl.", + "libgles", + "libopengl", + }; + for (const auto& prefix : kPrefixes) + { + if (name.size() >= prefix.size() && name.compare(0, prefix.size(), prefix) == 0) + { + return true; + } + } + return false; + } +} + +namespace +{ + constexpr const char* kApplicationName = "Babylon Native ModuleLoadTest"; + constexpr int kWidth = 640; + constexpr int kHeight = 480; +} + +int main(int /*argc*/, char* /*argv*/[]) +{ +#if !defined(NDEBUG) + std::cout << "ModuleLoadTest: SKIP - Debug config is not supported. " + "Build with Release or RelWithDebInfo." << std::endl; + return 0; +#else + if (ModuleLoadTest::IsBeingTraced()) + { + std::cout << "ModuleLoadTest: SKIP - running under a debugger." << std::endl; + return 0; + } + + XInitThreads(); + Display* display = XOpenDisplay(nullptr); + if (display == nullptr) + { + // Headless environments without an X display (e.g. local runs outside + // xvfb-run) cannot bring up bgfx's GL backend. CI wraps this binary + // in xvfb-run so the path below is the normal case there. + std::cout << "ModuleLoadTest: SKIP - no X display (set DISPLAY or run " + "under xvfb-run)." << std::endl; + return 0; + } + + const int screen = DefaultScreen(display); + const int depth = DefaultDepth(display, screen); + Visual* visual = DefaultVisual(display, screen); + const Window root = RootWindow(display, screen); + + XSetWindowAttributes windowAttrs{}; + const Window window = XCreateWindow(display, root, 0, 0, kWidth, kHeight, 0, depth, + InputOutput, visual, CWBorderPixel | CWEventMask, &windowAttrs); + XStoreName(display, window, kApplicationName); + XMapWindow(display, window); + + Babylon::Graphics::Configuration config{}; + config.Window = window; + config.Width = static_cast(kWidth); + config.Height = static_cast(kHeight); + + Babylon::DebugTrace::EnableDebugTrace(true); + Babylon::DebugTrace::SetTraceOutput([](const char* trace) { std::printf("%s\n", trace); std::fflush(stdout); }); + + const int rc = ModuleLoadTest::CompareAndReport(ModuleLoadTest::RunBoot(config)); + + XCloseDisplay(display); + return rc; +#endif +} diff --git a/Apps/ModuleLoadTest/Source/ModuleSnapshot.Linux.cpp b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Linux.cpp new file mode 100644 index 000000000..919a9ef42 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Linux.cpp @@ -0,0 +1,97 @@ +#include "ModuleSnapshot.h" + +#include +#include +#include +#include + +#include + +namespace ModuleLoadTest +{ + namespace + { + std::string ToLower(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; + } + + // The baseline is captured by a constructor with priority 101 + // (priorities 0-100 are reserved for the implementation). This runs + // before user-default-priority constructors (e.g. bx's) in *this* + // image and in images whose constructors have default priority. + // + // Caveat (unlike Win32 TLS callbacks): constructors in a *separate* + // shared object may still run before this one if the dynamic linker + // orders that object's init ahead of ours. As a result the baseline + // may include modules loaded by those early initializers. For this + // test that's acceptable — anything in the baseline is NOT counted + // against BN's boot delta, and the signal we want (new modules + // pulled in by Graphics::Device + AppRuntime + polyfills during + // actual boot) still appears in the delta. + ModuleSnapshot* g_preInitSnapshot = nullptr; + + void CapturePreInitSnapshot() __attribute__((constructor(101))); + void CapturePreInitSnapshot() + { + if (g_preInitSnapshot == nullptr) + { + g_preInitSnapshot = new ModuleSnapshot(CaptureSnapshot()); + } + } + + void DisposePreInitSnapshot() __attribute__((destructor(101))); + void DisposePreInitSnapshot() + { + delete g_preInitSnapshot; + g_preInitSnapshot = nullptr; + } + + int IteratePhdrCallback(struct dl_phdr_info* info, size_t /*size*/, void* data) + { + auto& snapshot = *static_cast(data); + + const char* name = info->dlpi_name; + if (name == nullptr || name[0] == '\0') + { + // Main executable: dl_iterate_phdr reports an empty name for + // it. Skip so the test's own binary isn't counted as a "BN + // module"; the test's own binary is never a regression. + return 0; + } + + // Skip pseudo-images like "linux-vdso.so.1" that are kernel-provided. + if (std::strstr(name, "linux-vdso") != nullptr || + std::strstr(name, "linux-gate") != nullptr) + { + return 0; + } + + const char* slash = std::strrchr(name, '/'); + const char* base = slash ? slash + 1 : name; + if (*base == '\0') + { + return 0; + } + + snapshot.insert(ToLower(base)); + return 0; + } + } + + const ModuleSnapshot& GetPreInitBaseline() + { + static const ModuleSnapshot empty{}; + return g_preInitSnapshot != nullptr ? *g_preInitSnapshot : empty; + } + + ModuleSnapshot CaptureSnapshot() + { + ModuleSnapshot snapshot{}; + ::dl_iterate_phdr(&IteratePhdrCallback, &snapshot); + return snapshot; + } +} diff --git a/Apps/ModuleLoadTest/Source/ModuleSnapshot.macOS.mm b/Apps/ModuleLoadTest/Source/ModuleSnapshot.macOS.mm new file mode 100644 index 000000000..4263d0e6f --- /dev/null +++ b/Apps/ModuleLoadTest/Source/ModuleSnapshot.macOS.mm @@ -0,0 +1,83 @@ +#include "ModuleSnapshot.h" + +#include +#include +#include +#include + +#include + +namespace ModuleLoadTest +{ + namespace + { + std::string ToLower(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; + } + + // The baseline is captured by a constructor with priority 101 + // (priorities 0-100 are reserved for the implementation). On Darwin + // the constructor-priority attribute is permitted by the compiler + // but dyld's image-init ordering does not honor it strictly across + // images: dylibs loaded before this one (including bx-style static + // initializers in linked dylibs) may have already run by the time + // this fires. As a result the baseline may include modules loaded + // by those early initializers. For this test that's acceptable — + // anything in the baseline is NOT counted against BN's boot delta, + // and the signal we want (new images loaded by Graphics::Device + + // AppRuntime + polyfills during actual boot) still appears. + ModuleSnapshot* g_preInitSnapshot = nullptr; + + void CapturePreInitSnapshot() __attribute__((constructor(101))); + void CapturePreInitSnapshot() + { + if (g_preInitSnapshot == nullptr) + { + g_preInitSnapshot = new ModuleSnapshot(CaptureSnapshot()); + } + } + + void DisposePreInitSnapshot() __attribute__((destructor(101))); + void DisposePreInitSnapshot() + { + delete g_preInitSnapshot; + g_preInitSnapshot = nullptr; + } + } + + const ModuleSnapshot& GetPreInitBaseline() + { + static const ModuleSnapshot empty{}; + return g_preInitSnapshot != nullptr ? *g_preInitSnapshot : empty; + } + + ModuleSnapshot CaptureSnapshot() + { + ModuleSnapshot snapshot{}; + + const uint32_t count = ::_dyld_image_count(); + for (uint32_t i = 0; i < count; ++i) + { + const char* path = ::_dyld_get_image_name(i); + if (path == nullptr || *path == '\0') + { + continue; + } + + const char* slash = std::strrchr(path, '/'); + const char* base = slash ? slash + 1 : path; + if (*base == '\0') + { + continue; + } + + snapshot.insert(ToLower(base)); + } + + return snapshot; + } +} From 84cd83993d9860e827a78f7affe6efc3fff5b4b3 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 09:58:02 -0700 Subject: [PATCH 08/16] CI: build and run ModuleLoadTest on Linux (xvfb) and macOS --- .github/workflows/build-linux.yml | 6 ++++++ .github/workflows/build-macos.yml | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 4cf7688a6..54bd72662 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -67,6 +67,12 @@ jobs: ulimit -c unlimited xvfb-run ./UnitTests + - name: Module Load Test + run: | + cd build/Linux/Apps/ModuleLoadTest + ulimit -c unlimited + xvfb-run ./ModuleLoadTest + - name: Upload Rendered Pictures if: always() uses: actions/upload-artifact@v6 diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index a54999698..ec34cf330 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -51,6 +51,9 @@ jobs: - name: Build UnitTests macOS run: cmake --build build/macOS --target UnitTests --config RelWithDebInfo + - name: Build ModuleLoadTest macOS + run: cmake --build build/macOS --target ModuleLoadTest --config RelWithDebInfo + - name: Enable Core Dump run: sudo sysctl -w kern.corefile=%N.core.%P @@ -60,6 +63,12 @@ jobs: ulimit -c unlimited ./UnitTests + - name: Run ModuleLoadTest macOS + run: | + cd build/macOS/Apps/ModuleLoadTest/RelWithDebInfo + ulimit -c unlimited + ./ModuleLoadTest + - name: Upload UnitTests Core Dumps if: failure() uses: actions/upload-artifact@v6 From e6c5c0bbb8cd9169f3abf3b921b94003926cf6f6 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 10:08:21 -0700 Subject: [PATCH 09/16] ModuleLoadTest: seed macOS expected-module list from CI macos-latest (ARM64 paravirtualized GPU runner) surfaces two system modules on first boot: appleparavirtgpumetaliogpufamily and iogpu. Add them to GetExpectedBootModules so the test passes on that runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Apps/ModuleLoadTest/Source/App.Apple.mm | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Apps/ModuleLoadTest/Source/App.Apple.mm b/Apps/ModuleLoadTest/Source/App.Apple.mm index e9dc39e42..f3e286229 100644 --- a/Apps/ModuleLoadTest/Source/App.Apple.mm +++ b/Apps/ModuleLoadTest/Source/App.Apple.mm @@ -34,11 +34,14 @@ bool IsBeingTraced() } } - // Empty initial seed — the CI run of this PR will print the observed - // delta and we'll append entries here in follow-up commits. const ModuleSnapshot& GetExpectedBootModules() { - static const ModuleSnapshot kModules{}; + // Seeded from CI on macos-latest (ARM64 paravirtualized GPU runner). + // Append entries here as new configs surface deltas. + static const ModuleSnapshot kModules{ + "appleparavirtgpumetaliogpufamily", + "iogpu", + }; return kModules; } From b0f94f81fc9c4091fbcb8d4930e0f0acb50f2090 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 10:08:31 -0700 Subject: [PATCH 10/16] ModuleLoadTest: mirror UnitTests X11 setup to fix xvfb EGL surface failure Linux CI aborted in bgfx at glcontext_egl.cpp:551 (Failed to create surface). Apps/UnitTests runs under the same xvfb-run wrapper without issue, so match its X11 initialization sequence exactly: explicit field-by-field zero of XSetWindowAttributes, a clear-to-black XChangeWindowAttributes, WM_DELETE_WINDOW protocol setup, and the XMapWindow -> XStoreName ordering. bgfx's GL/EGL path is sensitive to this sequencing under Xvfb. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Apps/ModuleLoadTest/Source/App.X11.cpp | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/Apps/ModuleLoadTest/Source/App.X11.cpp b/Apps/ModuleLoadTest/Source/App.X11.cpp index fa6ba5e4f..a8876f021 100644 --- a/Apps/ModuleLoadTest/Source/App.X11.cpp +++ b/Apps/ModuleLoadTest/Source/App.X11.cpp @@ -139,11 +139,32 @@ int main(int /*argc*/, char* /*argv*/[]) Visual* visual = DefaultVisual(display, screen); const Window root = RootWindow(display, screen); - XSetWindowAttributes windowAttrs{}; + // Mirror Apps/UnitTests/Source/App.X11.cpp exactly: explicit zero-init of + // window attributes, explicit clear-to-black via XChangeWindowAttributes, + // and the same XMapWindow -> XStoreName order. bgfx's GL/EGL backend is + // sensitive to this sequencing under xvfb-run; deviations have been + // observed to cause "Failed to create surface" aborts. + XSetWindowAttributes windowAttrs; + windowAttrs.background_pixel = 0; + windowAttrs.background_pixmap = 0; + windowAttrs.border_pixel = 0; + windowAttrs.event_mask = 0; + const Window window = XCreateWindow(display, root, 0, 0, kWidth, kHeight, 0, depth, InputOutput, visual, CWBorderPixel | CWEventMask, &windowAttrs); - XStoreName(display, window, kApplicationName); + + // Clear window to black. + XSetWindowAttributes attr; + std::memset(&attr, 0, sizeof(attr)); + XChangeWindowAttributes(display, window, CWBackPixel, &attr); + + constexpr const char* wmDeleteWindowName = "WM_DELETE_WINDOW"; + Atom wmDeleteWindow; + XInternAtoms(display, (char**)&wmDeleteWindowName, 1, False, &wmDeleteWindow); + XSetWMProtocols(display, window, &wmDeleteWindow, 1); + XMapWindow(display, window); + XStoreName(display, window, kApplicationName); Babylon::Graphics::Configuration config{}; config.Window = window; From e2bb676b3a7cc204d83343e76f0c7c0d0c61292a Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 10:21:20 -0700 Subject: [PATCH 11/16] ModuleLoadTest: seed Linux expected-module list from CI ubuntu-latest with Mesa software renderer under xvfb-run loads a predictable set of X/GL/DRI userspace libs during bgfx init. Add the stable-named ones to GetExpectedBootModules and extend the IsAllowedOptionalModule prefix list with libgallium-* and libllvm.so.* to tolerate Mesa/LLVM version bumps in the runner image. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Apps/ModuleLoadTest/Source/App.X11.cpp | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/Apps/ModuleLoadTest/Source/App.X11.cpp b/Apps/ModuleLoadTest/Source/App.X11.cpp index a8876f021..d2d4757b7 100644 --- a/Apps/ModuleLoadTest/Source/App.X11.cpp +++ b/Apps/ModuleLoadTest/Source/App.X11.cpp @@ -49,7 +49,31 @@ namespace ModuleLoadTest // for the full rationale of the asymmetric (permissive superset) check. const ModuleSnapshot& GetExpectedBootModules() { - static const ModuleSnapshot kModules{}; + // Seeded from CI on ubuntu-latest (Mesa software renderer via xvfb). + // Append entries here as new configs surface deltas. + static const ModuleSnapshot kModules{ + "libdrm.so.2", + "libdrm_intel.so.1", + "libedit.so.2", + "libegl_mesa.so.0", + "libelf.so.1", + "libexpat.so.1", + "libgbm.so.1", + "libgldispatch.so.0", + "libpciaccess.so.0", + "libsensors.so.5", + "libtinfo.so.6", + "libwayland-client.so.0", + "libx11-xcb.so.1", + "libxcb-dri3.so.0", + "libxcb-present.so.0", + "libxcb-randr.so.0", + "libxcb-shm.so.0", + "libxcb-sync.so.1", + "libxcb-xfixes.so.0", + "libxml2.so.2", + "libxshmfence.so.1", + }; return kModules; } @@ -84,6 +108,11 @@ namespace ModuleLoadTest "libdrm_amdgpu", // Vulkan ICD loaders "libvulkan", + // Mesa shared renderer shim (libgallium--.so) and + // the LLVM library it uses for shader compilation — both carry a + // version suffix that drifts with Ubuntu image updates. + "libgallium-", + "libllvm.so.", // GLVND variants "libgl.", "libglx", From ab61e055044aaa95f8c5fe8d517dcf14430dbb50 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 11:42:51 -0700 Subject: [PATCH 12/16] Address review feedback: robust module enumeration + lifetimes - ModuleSnapshot.Win32.cpp: use a single fixed-size (512) EnumProcessModules call. Avoids the documented race in the two-call sizing pattern (the module list can change between calls per MSDN). Fail loudly with an explicit error if the buffer is ever too small, rather than silently truncating and hiding regressions. - App.Apple.mm: wrap MTL::CreateSystemDefaultDevice() in NS::SharedPtr via NS::TransferPtr so the +1 retained device is released on scope exit. - App.cpp: in CompareAndReport, fail loudly if the pre-init baseline is empty. If the platform pre-static-init hook (TLS callback on Win32, __attribute__((constructor)) on Linux/macOS) fails to run, the baseline would be empty and the asymmetric assertion would silently report PASS despite providing no regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Apps/ModuleLoadTest/Source/App.Apple.mm | 5 ++-- Apps/ModuleLoadTest/Source/App.cpp | 13 ++++++++ .../Source/ModuleSnapshot.Win32.cpp | 30 +++++++++++++++---- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Apps/ModuleLoadTest/Source/App.Apple.mm b/Apps/ModuleLoadTest/Source/App.Apple.mm index f3e286229..cae49210c 100644 --- a/Apps/ModuleLoadTest/Source/App.Apple.mm +++ b/Apps/ModuleLoadTest/Source/App.Apple.mm @@ -90,12 +90,13 @@ int main(int /*argc*/, char* /*argv*/[]) Babylon::DebugTrace::SetTraceOutput([](const char* trace) { NSLog(@"%s", trace); }); Babylon::Graphics::Configuration config{}; - config.Device = MTL::CreateSystemDefaultDevice(); - if (config.Device == nullptr) + NS::SharedPtr device = NS::TransferPtr(MTL::CreateSystemDefaultDevice()); + if (!device) { std::cout << "ModuleLoadTest: SKIP - no Metal device available." << std::endl; return 0; } + config.Device = device.get(); config.Width = 600; config.Height = 400; diff --git a/Apps/ModuleLoadTest/Source/App.cpp b/Apps/ModuleLoadTest/Source/App.cpp index 521b9ee6e..e5e45f1dd 100644 --- a/Apps/ModuleLoadTest/Source/App.cpp +++ b/Apps/ModuleLoadTest/Source/App.cpp @@ -84,6 +84,19 @@ namespace ModuleLoadTest { const ModuleSnapshot& baseline = GetPreInitBaseline(); + if (baseline.empty()) + { + // The baseline is captured by a platform pre-static-init hook + // (TLS callback on Windows, __attribute__((constructor)) on + // Linux/macOS). If it's empty the hook did not run, which means + // the entire delta is "new" modules relative to an empty set, + // and IsAllowedOptionalModule would silently filter most of + // them and report a spurious PASS. Fail loudly instead. + std::cerr << "ModuleLoadTest FAIL: pre-init baseline was not captured. " + "The platform pre-init hook did not run." << std::endl; + return 1; + } + // Delta = what boot caused to load. Filter out GPU ICD and // launch-environment noise (see platform IsAllowedOptionalModule). ModuleSnapshot delta = Subtract(postBoot, baseline); diff --git a/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp index 557932da7..7213c504c 100644 --- a/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp +++ b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #pragma comment(lib, "Psapi.lib") @@ -104,19 +105,36 @@ namespace ModuleLoadTest const HANDLE process = ::GetCurrentProcess(); + // Large up-front buffer avoids the documented race in the + // two-call sizing pattern: EnumProcessModules "may fail or return + // incorrect information" if the module list changes during the + // call. The pre-init + post-boot module sets are on the order of + // 50-150 entries on realistic SKUs, so 512 gives ample headroom. + constexpr size_t kMaxModules = 512; + std::vector modules(kMaxModules); + DWORD requiredBytes = 0; - if (!::EnumProcessModules(process, nullptr, 0, &requiredBytes) || requiredBytes == 0) + if (!::EnumProcessModules(process, modules.data(), + static_cast(modules.size() * sizeof(HMODULE)), &requiredBytes)) { - return snapshot; + std::fprintf(stderr, + "ModuleLoadTest FAIL: EnumProcessModules failed (error %lu).\n", + ::GetLastError()); + ::ExitProcess(1); } - std::vector modules(requiredBytes / sizeof(HMODULE)); - if (!::EnumProcessModules(process, modules.data(), static_cast(modules.size() * sizeof(HMODULE)), &requiredBytes)) + const size_t count = requiredBytes / sizeof(HMODULE); + if (count > modules.size()) { - return snapshot; + // Silent truncation would hide exactly the kind of regression + // this test exists to catch. Bail loudly instead. + std::fprintf(stderr, + "ModuleLoadTest FAIL: module buffer too small (need %zu, have %zu). " + "Increase kMaxModules in ModuleSnapshot.Win32.cpp.\n", + count, modules.size()); + ::ExitProcess(1); } - const size_t count = requiredBytes / sizeof(HMODULE); for (size_t i = 0; i < count; ++i) { wchar_t path[MAX_PATH]{}; From 2951f2318a4186dfd4ae8e2593aaef49325aee76 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 11:53:41 -0700 Subject: [PATCH 13/16] Address Copilot review on #1666 - build-macos.yml / build-linux.yml: skip ModuleLoadTest when sanitizers are enabled. The ASan/UBSan runtime preloads extra dylibs/sos that would show up as unexpected new modules and cause spurious failures. Matches the existing guard on the Win32 workflow. - App.X11.cpp: replace (char**)&const-pointer cast with a proper char[]/char*[] array for XInternAtoms. The cast is formally UB and unnecessary. - ModuleSnapshot.Win32.cpp (WideToUtf8): WideCharToMultiByte includes the null terminator in its required-size result, so allocating size bytes and then resizing to converted-1 is correct. The previous code allocated size-1 bytes and let the conversion write the terminator into the std::string's implicit null slot, which was borderline-UB. Also check the conversion return value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-linux.yml | 1 + .github/workflows/build-macos.yml | 1 + Apps/ModuleLoadTest/Source/App.X11.cpp | 5 +++-- Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp | 9 +++++++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 54bd72662..f636c14e7 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -68,6 +68,7 @@ jobs: xvfb-run ./UnitTests - name: Module Load Test + if: ${{ !inputs.enable-sanitizers }} run: | cd build/Linux/Apps/ModuleLoadTest ulimit -c unlimited diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index ec34cf330..f06d64d7f 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -64,6 +64,7 @@ jobs: ./UnitTests - name: Run ModuleLoadTest macOS + if: ${{ !inputs.enable-sanitizers }} run: | cd build/macOS/Apps/ModuleLoadTest/RelWithDebInfo ulimit -c unlimited diff --git a/Apps/ModuleLoadTest/Source/App.X11.cpp b/Apps/ModuleLoadTest/Source/App.X11.cpp index d2d4757b7..cd643cb88 100644 --- a/Apps/ModuleLoadTest/Source/App.X11.cpp +++ b/Apps/ModuleLoadTest/Source/App.X11.cpp @@ -187,9 +187,10 @@ int main(int /*argc*/, char* /*argv*/[]) std::memset(&attr, 0, sizeof(attr)); XChangeWindowAttributes(display, window, CWBackPixel, &attr); - constexpr const char* wmDeleteWindowName = "WM_DELETE_WINDOW"; + char wmDeleteWindowName[] = "WM_DELETE_WINDOW"; + char* wmDeleteWindowNames[] = {wmDeleteWindowName}; Atom wmDeleteWindow; - XInternAtoms(display, (char**)&wmDeleteWindowName, 1, False, &wmDeleteWindow); + XInternAtoms(display, wmDeleteWindowNames, 1, False, &wmDeleteWindow); XSetWMProtocols(display, window, &wmDeleteWindow, 1); XMapWindow(display, window); diff --git a/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp index 7213c504c..1616f5027 100644 --- a/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp +++ b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp @@ -59,8 +59,13 @@ namespace ModuleLoadTest return {}; } - std::string result(static_cast(size - 1), '\0'); - ::WideCharToMultiByte(CP_UTF8, 0, wide, -1, result.data(), size, nullptr, nullptr); + std::string result(static_cast(size), '\0'); + const int converted = ::WideCharToMultiByte(CP_UTF8, 0, wide, -1, result.data(), size, nullptr, nullptr); + if (converted <= 0) + { + return {}; + } + result.resize(static_cast(converted - 1)); return result; } } From a42227754ce75c5b5b0490a2fa34e857dc3c4f58 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 13:55:48 -0700 Subject: [PATCH 14/16] Factor out shared main() preflight into ShouldSkipEnvironment Each platform's main() had identical boilerplate for the NDEBUG-skip and debugger-attached skip. Move both to ModuleLoadTest::ShouldSkipEnvironment() in the shared App.cpp, backed by per-platform IsBeingTraced() declared in App.h. Each platform now implements IsBeingTraced() (Win32 wraps ::IsDebuggerPresent(); Linux reads /proc/self/status TracerPid; macOS uses sysctl(KERN_PROC)). Also remove a stale 'Empty initial seed' comment in App.X11.cpp -- the Linux golden list has been populated from CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Apps/ModuleLoadTest/Source/App.Apple.mm | 30 ++++++----------- Apps/ModuleLoadTest/Source/App.Win32.cpp | 24 ++++--------- Apps/ModuleLoadTest/Source/App.X11.cpp | 43 +++++++++--------------- Apps/ModuleLoadTest/Source/App.cpp | 24 +++++++++++++ Apps/ModuleLoadTest/Source/App.h | 11 ++++++ 5 files changed, 67 insertions(+), 65 deletions(-) diff --git a/Apps/ModuleLoadTest/Source/App.Apple.mm b/Apps/ModuleLoadTest/Source/App.Apple.mm index cae49210c..0a5514904 100644 --- a/Apps/ModuleLoadTest/Source/App.Apple.mm +++ b/Apps/ModuleLoadTest/Source/App.Apple.mm @@ -17,21 +17,18 @@ namespace ModuleLoadTest { - namespace + // Apple equivalent of IsDebuggerPresent() — non-invasive. + // https://developer.apple.com/library/archive/qa/qa1361/_index.html + bool IsBeingTraced() { - // Apple equivalent of IsDebuggerPresent() — non-invasive. - // https://developer.apple.com/library/archive/qa/qa1361/_index.html - bool IsBeingTraced() + int name[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid() }; + struct kinfo_proc info{}; + size_t size = sizeof(info); + if (sysctl(name, 4, &info, &size, nullptr, 0) != 0) { - int name[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid() }; - struct kinfo_proc info{}; - size_t size = sizeof(info); - if (sysctl(name, 4, &info, &size, nullptr, 0) != 0) - { - return false; - } - return (info.kp_proc.p_flag & P_TRACED) != 0; + return false; } + return (info.kp_proc.p_flag & P_TRACED) != 0; } const ModuleSnapshot& GetExpectedBootModules() @@ -75,14 +72,8 @@ bool IsAllowedOptionalModule(std::string_view name) int main(int /*argc*/, char* /*argv*/[]) { -#if !defined(NDEBUG) - std::cout << "ModuleLoadTest: SKIP - Debug config is not supported. " - "Build with Release or RelWithDebInfo." << std::endl; - return 0; -#else - if (ModuleLoadTest::IsBeingTraced()) + if (ModuleLoadTest::ShouldSkipEnvironment()) { - std::cout << "ModuleLoadTest: SKIP - running under a debugger." << std::endl; return 0; } @@ -101,5 +92,4 @@ int main(int /*argc*/, char* /*argv*/[]) config.Height = 400; return ModuleLoadTest::CompareAndReport(ModuleLoadTest::RunBoot(config)); -#endif } diff --git a/Apps/ModuleLoadTest/Source/App.Win32.cpp b/Apps/ModuleLoadTest/Source/App.Win32.cpp index 5ea40066c..f1d7c4499 100644 --- a/Apps/ModuleLoadTest/Source/App.Win32.cpp +++ b/Apps/ModuleLoadTest/Source/App.Win32.cpp @@ -19,6 +19,11 @@ namespace ModuleLoadTest } } + bool IsBeingTraced() + { + return ::IsDebuggerPresent() != FALSE; + } + // Expected set of modules loaded during BabylonNative boot, as a delta // from the baseline snapshot captured by the TLS callback before any C++ // static initializer in this binary has run. Base names only, lower case. @@ -146,24 +151,8 @@ namespace ModuleLoadTest int main(int /*argc*/, char* /*argv*/[]) { -#if !defined(NDEBUG) - // Debug builds load a different set of modules than RelWithDebInfo (debug - // CRT, heavier diagnostic DLLs, etc). The golden list in this file - // targets RelWithDebInfo only. Rather than produce a confusing FAIL, make - // the skip explicit. - std::cout << "ModuleLoadTest: SKIP - Debug config is not supported. " - "Build with Release or RelWithDebInfo." << std::endl; - return 0; -#else - // Running under a debugger injects additional modules (and changes some - // timing) that would cause spurious FAIL diagnostics. CI runs headless - // from the CLI, so this only affects local debugging. Non-debugger - // ambient modules (e.g. VS Ctrl-F5) are handled by IsAllowedOptionalModule. - if (::IsDebuggerPresent()) + if (ModuleLoadTest::ShouldSkipEnvironment()) { - std::cout << "ModuleLoadTest: SKIP - running under a debugger. " - "Launch ModuleLoadTest.exe directly (no debugger attached) " - "to exercise the full assertion." << std::endl; return 0; } @@ -190,5 +179,4 @@ int main(int /*argc*/, char* /*argv*/[]) }); return ModuleLoadTest::CompareAndReport(ModuleLoadTest::RunBoot(config)); -#endif } diff --git a/Apps/ModuleLoadTest/Source/App.X11.cpp b/Apps/ModuleLoadTest/Source/App.X11.cpp index cd643cb88..faad7d356 100644 --- a/Apps/ModuleLoadTest/Source/App.X11.cpp +++ b/Apps/ModuleLoadTest/Source/App.X11.cpp @@ -21,32 +21,28 @@ namespace ModuleLoadTest { - namespace + // Parse /proc/self/status for a non-zero TracerPid. Non-invasive + // (contrast with ptrace(PTRACE_TRACEME), which *creates* trace state + // if none exists). + bool IsBeingTraced() { - // Parse /proc/self/status for a non-zero TracerPid. Non-invasive - // (contrast with ptrace(PTRACE_TRACEME), which *creates* trace state - // if none exists). - bool IsBeingTraced() + std::ifstream status{"/proc/self/status"}; + std::string line; + while (std::getline(status, line)) { - std::ifstream status{"/proc/self/status"}; - std::string line; - while (std::getline(status, line)) + constexpr std::string_view prefix{"TracerPid:"}; + if (line.size() >= prefix.size() && line.compare(0, prefix.size(), prefix) == 0) { - constexpr std::string_view prefix{"TracerPid:"}; - if (line.size() >= prefix.size() && line.compare(0, prefix.size(), prefix) == 0) - { - const char* p = line.c_str() + prefix.size(); - while (*p == ' ' || *p == '\t') ++p; - return *p != '\0' && *p != '0'; - } + const char* p = line.c_str() + prefix.size(); + while (*p == ' ' || *p == '\t') ++p; + return *p != '\0' && *p != '0'; } - return false; } + return false; } - // Empty initial seed — the CI run of this PR will print the observed delta - // and we'll append entries here in follow-up commits. See App.Win32.cpp - // for the full rationale of the asymmetric (permissive superset) check. + // See App.Win32.cpp for the full rationale of the asymmetric + // (permissive superset) check. const ModuleSnapshot& GetExpectedBootModules() { // Seeded from CI on ubuntu-latest (Mesa software renderer via xvfb). @@ -140,14 +136,8 @@ namespace int main(int /*argc*/, char* /*argv*/[]) { -#if !defined(NDEBUG) - std::cout << "ModuleLoadTest: SKIP - Debug config is not supported. " - "Build with Release or RelWithDebInfo." << std::endl; - return 0; -#else - if (ModuleLoadTest::IsBeingTraced()) + if (ModuleLoadTest::ShouldSkipEnvironment()) { - std::cout << "ModuleLoadTest: SKIP - running under a debugger." << std::endl; return 0; } @@ -208,5 +198,4 @@ int main(int /*argc*/, char* /*argv*/[]) XCloseDisplay(display); return rc; -#endif } diff --git a/Apps/ModuleLoadTest/Source/App.cpp b/Apps/ModuleLoadTest/Source/App.cpp index e5e45f1dd..aec2af4e7 100644 --- a/Apps/ModuleLoadTest/Source/App.cpp +++ b/Apps/ModuleLoadTest/Source/App.cpp @@ -18,6 +18,30 @@ namespace ModuleLoadTest { + bool ShouldSkipEnvironment() + { +#if !defined(NDEBUG) + // Debug builds load a different set of modules than optimized builds + // (debug CRT, heavier diagnostic DLLs, etc). The platform golden lists + // target Release/RelWithDebInfo. Rather than produce a confusing FAIL, + // make the skip explicit. CI runs only in optimized configs (see + // CMakeLists.txt's `add_test ... CONFIGURATIONS Release RelWithDebInfo`). + std::cout << "ModuleLoadTest: SKIP - Debug config is not supported. " + "Build with Release or RelWithDebInfo." << std::endl; + return true; +#else + // Running under a debugger injects additional modules (and changes + // some timing) that would cause spurious FAIL diagnostics. CI runs + // headless from the CLI, so this only affects local debugging. + if (IsBeingTraced()) + { + std::cout << "ModuleLoadTest: SKIP - running under a debugger." << std::endl; + return true; + } + return false; +#endif + } + ModuleSnapshot RunBoot(const Babylon::Graphics::Configuration& config) { // Bring up the real graphics device. This triggers the backend diff --git a/Apps/ModuleLoadTest/Source/App.h b/Apps/ModuleLoadTest/Source/App.h index 3419a77b1..8c771e35d 100644 --- a/Apps/ModuleLoadTest/Source/App.h +++ b/Apps/ModuleLoadTest/Source/App.h @@ -22,6 +22,17 @@ namespace ModuleLoadTest // Print a labeled module list to stdout. void PrintList(const char* label, const ModuleSnapshot& items); + // Provided by each platform's App..{cpp,mm}: true if the + // current process is being traced/debugged. On Windows this wraps + // ::IsDebuggerPresent(); on Linux it reads /proc/self/status; on macOS + // it uses sysctl(KERN_PROC). All three are non-invasive. + bool IsBeingTraced(); + + // Shared preflight for main(). Prints a SKIP message and returns true + // when the test should not run in the current environment (Debug config, + // debugger attached). Returns false when the test should proceed. + bool ShouldSkipEnvironment(); + // Provided by each platform's App..{cpp,mm}: the golden list of // modules expected to be loaded during boot, as a delta from the baseline // returned by GetPreInitBaseline(). Base names only, lower case. From ae7fa6842ac01249decc51aff0406cb3e2cd38e2 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 16:52:27 -0700 Subject: [PATCH 15/16] Move main() to shared App.cpp, platform files expose CreateGraphicsConfig Each platform's main() was essentially the same shape after the previous preflight refactor: skip check, platform-specific setup to populate a Graphics::Configuration, then RunBoot + CompareAndReport. Move the one main() to App.cpp and have each platform expose a single CreateGraphicsConfig() that returns an optional. Platform-owned resources (HWND, Display*, Window, MTL::Device) are parked in function-local static storage so they live for the duration of the process. XCloseDisplay is dropped -- kernel reclaims the FD on exit, which is the documented safe pattern for short-lived clients. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Apps/ModuleLoadTest/Source/App.Apple.mm | 36 +++---- Apps/ModuleLoadTest/Source/App.Win32.cpp | 51 ++++----- Apps/ModuleLoadTest/Source/App.X11.cpp | 129 +++++++++++------------ Apps/ModuleLoadTest/Source/App.cpp | 16 +++ Apps/ModuleLoadTest/Source/App.h | 20 ++-- 5 files changed, 128 insertions(+), 124 deletions(-) diff --git a/Apps/ModuleLoadTest/Source/App.Apple.mm b/Apps/ModuleLoadTest/Source/App.Apple.mm index 0a5514904..18dcdb577 100644 --- a/Apps/ModuleLoadTest/Source/App.Apple.mm +++ b/Apps/ModuleLoadTest/Source/App.Apple.mm @@ -68,28 +68,24 @@ bool IsAllowedOptionalModule(std::string_view name) } return false; } -} - -int main(int /*argc*/, char* /*argv*/[]) -{ - if (ModuleLoadTest::ShouldSkipEnvironment()) + std::optional CreateGraphicsConfig() { - return 0; - } + // MTL::Device must outlive RunBoot. Park in function-local static + // storage so it lives for the duration of the process. + static NS::SharedPtr device = NS::TransferPtr(MTL::CreateSystemDefaultDevice()); + if (!device) + { + std::cout << "ModuleLoadTest: SKIP - no Metal device available." << std::endl; + return std::nullopt; + } - Babylon::DebugTrace::EnableDebugTrace(true); - Babylon::DebugTrace::SetTraceOutput([](const char* trace) { NSLog(@"%s", trace); }); + Babylon::DebugTrace::EnableDebugTrace(true); + Babylon::DebugTrace::SetTraceOutput([](const char* trace) { NSLog(@"%s", trace); }); - Babylon::Graphics::Configuration config{}; - NS::SharedPtr device = NS::TransferPtr(MTL::CreateSystemDefaultDevice()); - if (!device) - { - std::cout << "ModuleLoadTest: SKIP - no Metal device available." << std::endl; - return 0; + Babylon::Graphics::Configuration config{}; + config.Device = device.get(); + config.Width = 600; + config.Height = 400; + return config; } - config.Device = device.get(); - config.Width = 600; - config.Height = 400; - - return ModuleLoadTest::CompareAndReport(ModuleLoadTest::RunBoot(config)); } diff --git a/Apps/ModuleLoadTest/Source/App.Win32.cpp b/Apps/ModuleLoadTest/Source/App.Win32.cpp index f1d7c4499..8dec02223 100644 --- a/Apps/ModuleLoadTest/Source/App.Win32.cpp +++ b/Apps/ModuleLoadTest/Source/App.Win32.cpp @@ -147,36 +147,31 @@ namespace ModuleLoadTest } return false; } -} - -int main(int /*argc*/, char* /*argv*/[]) -{ - if (ModuleLoadTest::ShouldSkipEnvironment()) + std::optional CreateGraphicsConfig() { - return 0; - } + ::SetConsoleOutputCP(CP_UTF8); - ::SetConsoleOutputCP(CP_UTF8); + // bgfx D3D12 implementation requires an HWND to avoid a device refcount + // leak on shutdown. Create a hidden window to satisfy that requirement. + // Parked in function-local static storage so the handle lives for the + // duration of the process. + static WNDCLASSEX wc = { sizeof(WNDCLASSEX), CS_CLASSDC, WndProc, 0L, 0L, + ::GetModuleHandle(nullptr), nullptr, nullptr, nullptr, nullptr, + "BabylonNativeModuleLoadTest", nullptr }; + ::RegisterClassEx(&wc); + static HWND hWnd = ::CreateWindow(wc.lpszClassName, "BabylonNativeModuleLoadTest", + WS_OVERLAPPEDWINDOW, -1, -1, -1, -1, nullptr, nullptr, wc.hInstance, nullptr); - // bgfx D3D12 implementation requires an HWND to avoid a device refcount - // leak on shutdown. Create a hidden window to satisfy that requirement. - WNDCLASSEX wc = { sizeof(WNDCLASSEX), CS_CLASSDC, ModuleLoadTest::WndProc, 0L, 0L, - ::GetModuleHandle(nullptr), nullptr, nullptr, nullptr, nullptr, - "BabylonNativeModuleLoadTest", nullptr }; - ::RegisterClassEx(&wc); - HWND hWnd = ::CreateWindow(wc.lpszClassName, "BabylonNativeModuleLoadTest", - WS_OVERLAPPEDWINDOW, -1, -1, -1, -1, nullptr, nullptr, wc.hInstance, nullptr); + Babylon::DebugTrace::EnableDebugTrace(true); + Babylon::DebugTrace::SetTraceOutput([](const char* trace) { + ::OutputDebugStringA(trace); + ::OutputDebugStringA("\n"); + }); - Babylon::Graphics::Configuration config{}; - config.Window = hWnd; - config.Width = 600; - config.Height = 400; - - Babylon::DebugTrace::EnableDebugTrace(true); - Babylon::DebugTrace::SetTraceOutput([](const char* trace) { - ::OutputDebugStringA(trace); - ::OutputDebugStringA("\n"); - }); - - return ModuleLoadTest::CompareAndReport(ModuleLoadTest::RunBoot(config)); + Babylon::Graphics::Configuration config{}; + config.Window = hWnd; + config.Width = 600; + config.Height = 400; + return config; + } } diff --git a/Apps/ModuleLoadTest/Source/App.X11.cpp b/Apps/ModuleLoadTest/Source/App.X11.cpp index faad7d356..92193cec2 100644 --- a/Apps/ModuleLoadTest/Source/App.X11.cpp +++ b/Apps/ModuleLoadTest/Source/App.X11.cpp @@ -125,77 +125,68 @@ namespace ModuleLoadTest } return false; } -} + std::optional CreateGraphicsConfig() + { + constexpr const char* kApplicationName = "Babylon Native ModuleLoadTest"; + constexpr int kWidth = 640; + constexpr int kHeight = 480; -namespace -{ - constexpr const char* kApplicationName = "Babylon Native ModuleLoadTest"; - constexpr int kWidth = 640; - constexpr int kHeight = 480; -} + XInitThreads(); -int main(int /*argc*/, char* /*argv*/[]) -{ - if (ModuleLoadTest::ShouldSkipEnvironment()) - { - return 0; - } + // Display and Window parked in function-local static storage so they + // live for the duration of the process; cleanup happens at process + // exit. + static Display* display = XOpenDisplay(nullptr); + if (display == nullptr) + { + // Headless environments without an X display (e.g. local runs outside + // xvfb-run) cannot bring up bgfx's GL backend. CI wraps this binary + // in xvfb-run so the path below is the normal case there. + std::cout << "ModuleLoadTest: SKIP - no X display (set DISPLAY or run " + "under xvfb-run)." << std::endl; + return std::nullopt; + } - XInitThreads(); - Display* display = XOpenDisplay(nullptr); - if (display == nullptr) - { - // Headless environments without an X display (e.g. local runs outside - // xvfb-run) cannot bring up bgfx's GL backend. CI wraps this binary - // in xvfb-run so the path below is the normal case there. - std::cout << "ModuleLoadTest: SKIP - no X display (set DISPLAY or run " - "under xvfb-run)." << std::endl; - return 0; + const int screen = DefaultScreen(display); + const int depth = DefaultDepth(display, screen); + Visual* visual = DefaultVisual(display, screen); + const ::Window root = RootWindow(display, screen); + + // Mirror Apps/UnitTests/Source/App.X11.cpp exactly: explicit zero-init of + // window attributes, explicit clear-to-black via XChangeWindowAttributes, + // and the same XMapWindow -> XStoreName order. bgfx's GL/EGL backend is + // sensitive to this sequencing under xvfb-run; deviations have been + // observed to cause "Failed to create surface" aborts. + XSetWindowAttributes windowAttrs; + windowAttrs.background_pixel = 0; + windowAttrs.background_pixmap = 0; + windowAttrs.border_pixel = 0; + windowAttrs.event_mask = 0; + + static const ::Window window = XCreateWindow(display, root, 0, 0, kWidth, kHeight, 0, depth, + InputOutput, visual, CWBorderPixel | CWEventMask, &windowAttrs); + + // Clear window to black. + XSetWindowAttributes attr; + std::memset(&attr, 0, sizeof(attr)); + XChangeWindowAttributes(display, window, CWBackPixel, &attr); + + char wmDeleteWindowName[] = "WM_DELETE_WINDOW"; + char* wmDeleteWindowNames[] = {wmDeleteWindowName}; + Atom wmDeleteWindow; + XInternAtoms(display, wmDeleteWindowNames, 1, False, &wmDeleteWindow); + XSetWMProtocols(display, window, &wmDeleteWindow, 1); + + XMapWindow(display, window); + XStoreName(display, window, kApplicationName); + + Babylon::DebugTrace::EnableDebugTrace(true); + Babylon::DebugTrace::SetTraceOutput([](const char* trace) { std::printf("%s\n", trace); std::fflush(stdout); }); + + Babylon::Graphics::Configuration config{}; + config.Window = window; + config.Width = static_cast(kWidth); + config.Height = static_cast(kHeight); + return config; } - - const int screen = DefaultScreen(display); - const int depth = DefaultDepth(display, screen); - Visual* visual = DefaultVisual(display, screen); - const Window root = RootWindow(display, screen); - - // Mirror Apps/UnitTests/Source/App.X11.cpp exactly: explicit zero-init of - // window attributes, explicit clear-to-black via XChangeWindowAttributes, - // and the same XMapWindow -> XStoreName order. bgfx's GL/EGL backend is - // sensitive to this sequencing under xvfb-run; deviations have been - // observed to cause "Failed to create surface" aborts. - XSetWindowAttributes windowAttrs; - windowAttrs.background_pixel = 0; - windowAttrs.background_pixmap = 0; - windowAttrs.border_pixel = 0; - windowAttrs.event_mask = 0; - - const Window window = XCreateWindow(display, root, 0, 0, kWidth, kHeight, 0, depth, - InputOutput, visual, CWBorderPixel | CWEventMask, &windowAttrs); - - // Clear window to black. - XSetWindowAttributes attr; - std::memset(&attr, 0, sizeof(attr)); - XChangeWindowAttributes(display, window, CWBackPixel, &attr); - - char wmDeleteWindowName[] = "WM_DELETE_WINDOW"; - char* wmDeleteWindowNames[] = {wmDeleteWindowName}; - Atom wmDeleteWindow; - XInternAtoms(display, wmDeleteWindowNames, 1, False, &wmDeleteWindow); - XSetWMProtocols(display, window, &wmDeleteWindow, 1); - - XMapWindow(display, window); - XStoreName(display, window, kApplicationName); - - Babylon::Graphics::Configuration config{}; - config.Window = window; - config.Width = static_cast(kWidth); - config.Height = static_cast(kHeight); - - Babylon::DebugTrace::EnableDebugTrace(true); - Babylon::DebugTrace::SetTraceOutput([](const char* trace) { std::printf("%s\n", trace); std::fflush(stdout); }); - - const int rc = ModuleLoadTest::CompareAndReport(ModuleLoadTest::RunBoot(config)); - - XCloseDisplay(display); - return rc; } diff --git a/Apps/ModuleLoadTest/Source/App.cpp b/Apps/ModuleLoadTest/Source/App.cpp index aec2af4e7..2a2e30304 100644 --- a/Apps/ModuleLoadTest/Source/App.cpp +++ b/Apps/ModuleLoadTest/Source/App.cpp @@ -163,3 +163,19 @@ namespace ModuleLoadTest return 0; } } + +int main(int /*argc*/, char* /*argv*/[]) +{ + if (ModuleLoadTest::ShouldSkipEnvironment()) + { + return 0; + } + + auto config = ModuleLoadTest::CreateGraphicsConfig(); + if (!config) + { + return 0; + } + + return ModuleLoadTest::CompareAndReport(ModuleLoadTest::RunBoot(*config)); +} diff --git a/Apps/ModuleLoadTest/Source/App.h b/Apps/ModuleLoadTest/Source/App.h index 8c771e35d..127cda582 100644 --- a/Apps/ModuleLoadTest/Source/App.h +++ b/Apps/ModuleLoadTest/Source/App.h @@ -2,6 +2,7 @@ #include +#include #include #include "ModuleSnapshot.h" @@ -9,13 +10,19 @@ namespace ModuleLoadTest { // Drive BabylonNative to a stable boot state, then return the snapshot of - // loaded modules at that point. Platform-specific main() is responsible - // for: - // 1. Creating the platform window and Graphics::Configuration. - // 2. Invoking RunBoot() with the config. - // 3. Calling CompareAndReport() with the returned snapshot. + // loaded modules at that point. ModuleSnapshot RunBoot(const Babylon::Graphics::Configuration& config); + // Provided by each platform's App..{cpp,mm}: set up the platform + // window / graphics device and DebugTrace output, then return a populated + // Graphics::Configuration. Platform-owned resources (HWND / Display / Metal + // device) are parked in function-local static storage so they live for the + // duration of the process; teardown happens at process exit. + // + // Returns nullopt for platform-level SKIPs (no X display, no Metal device) + // after the platform has printed an explanatory message. + std::optional CreateGraphicsConfig(); + // Set-difference helper: elements in lhs that are not in rhs. ModuleSnapshot Subtract(const ModuleSnapshot& lhs, const ModuleSnapshot& rhs); @@ -46,7 +53,6 @@ namespace ModuleLoadTest // Compare a post-boot snapshot against the baseline + platform-specific // expected list and allowed-optional filter. Prints baseline+delta and - // returns 0 on pass, 1 on fail. Called by each platform's main() after - // RunBoot(). + // returns 0 on pass, 1 on fail. int CompareAndReport(const ModuleSnapshot& postBoot); } From dfb82762d0c5bad8173139a575498b603c6c2ee9 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Tue, 21 Apr 2026 17:09:53 -0700 Subject: [PATCH 16/16] Note dbghelp.dll is in allow-list because bgfx loads it at boot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Apps/ModuleLoadTest/Source/App.Win32.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Apps/ModuleLoadTest/Source/App.Win32.cpp b/Apps/ModuleLoadTest/Source/App.Win32.cpp index 8dec02223..f6afbc887 100644 --- a/Apps/ModuleLoadTest/Source/App.Win32.cpp +++ b/Apps/ModuleLoadTest/Source/App.Win32.cpp @@ -67,6 +67,8 @@ namespace ModuleLoadTest "d3d12core.dll", "d3d12sdklayers.dll", "d3dscache.dll", + // TODO: bgfx loads dbghelp.dll at boot (callstack/crash helper). Drop this + // entry once bgfx stops pulling it in. "dbghelp.dll", "dcomp.dll", "devobj.dll",