diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 4cf7688a6..f636c14e7 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -67,6 +67,13 @@ jobs: ulimit -c unlimited xvfb-run ./UnitTests + - name: Module Load Test + if: ${{ !inputs.enable-sanitizers }} + 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..f06d64d7f 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,13 @@ jobs: ulimit -c unlimited ./UnitTests + - name: Run ModuleLoadTest macOS + if: ${{ !inputs.enable-sanitizers }} + run: | + cd build/macOS/Apps/ModuleLoadTest/RelWithDebInfo + ulimit -c unlimited + ./ModuleLoadTest + - name: Upload UnitTests Core Dumps if: failure() uses: actions/upload-artifact@v6 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..1ce26bd8c --- /dev/null +++ b/Apps/ModuleLoadTest/CMakeLists.txt @@ -0,0 +1,47 @@ +set(SOURCES + "Source/App.h" + "Source/App.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}) + +target_link_libraries(ModuleLoadTest + PRIVATE AppRuntime + PRIVATE Blob + PRIVATE Canvas + PRIVATE Console + PRIVATE GraphicsDevice + PRIVATE NativeEngine + PRIVATE NativeEncoding + PRIVATE Window + PRIVATE XMLHttpRequest + ${ADDITIONAL_LIBRARIES}) + +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.Apple.mm b/Apps/ModuleLoadTest/Source/App.Apple.mm new file mode 100644 index 000000000..18dcdb577 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.Apple.mm @@ -0,0 +1,91 @@ +#include "App.h" +#include "ModuleSnapshot.h" + +#include +#include + +#import + +#include +#include +#include + +#include +#include + +#import + +namespace ModuleLoadTest +{ + // 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; + } + + const ModuleSnapshot& GetExpectedBootModules() + { + // 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; + } + + // 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; + } + std::optional CreateGraphicsConfig() + { + // 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::Graphics::Configuration config{}; + config.Device = device.get(); + config.Width = 600; + config.Height = 400; + return config; + } +} diff --git a/Apps/ModuleLoadTest/Source/App.Win32.cpp b/Apps/ModuleLoadTest/Source/App.Win32.cpp new file mode 100644 index 000000000..f6afbc887 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.Win32.cpp @@ -0,0 +1,179 @@ +#include "App.h" +#include "ModuleSnapshot.h" + +#include +#include + +#include + +#include +#include + +namespace ModuleLoadTest +{ + namespace + { + LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) + { + return ::DefWindowProc(hWnd, msg, wParam, lParam); + } + } + + 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. + // + // 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 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 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", + // 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", + "directxdatabasehelper.dll", + "drvstore.dll", + "dwmapi.dll", + "dxcore.dll", + "dxgi.dll", + "dxgidebug.dll", + "dxilconv.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", + "userenv.dll", + "uxtheme.dll", + "version.dll", + "windows.storage.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; + } + std::optional CreateGraphicsConfig() + { + ::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); + + 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; + return config; + } +} diff --git a/Apps/ModuleLoadTest/Source/App.X11.cpp b/Apps/ModuleLoadTest/Source/App.X11.cpp new file mode 100644 index 000000000..92193cec2 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.X11.cpp @@ -0,0 +1,192 @@ +// 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 +{ + // 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; + } + + // 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). + // 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; + } + + // 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", + // 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", + "libegl.", + "libgles", + "libopengl", + }; + for (const auto& prefix : kPrefixes) + { + if (name.size() >= prefix.size() && name.compare(0, prefix.size(), prefix) == 0) + { + return true; + } + } + return false; + } + std::optional CreateGraphicsConfig() + { + constexpr const char* kApplicationName = "Babylon Native ModuleLoadTest"; + constexpr int kWidth = 640; + constexpr int kHeight = 480; + + XInitThreads(); + + // 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; + } + + 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; + } +} diff --git a/Apps/ModuleLoadTest/Source/App.cpp b/Apps/ModuleLoadTest/Source/App.cpp new file mode 100644 index 000000000..2a2e30304 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.cpp @@ -0,0 +1,181 @@ +#include "App.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +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 + // (D3D11/D3D12/Metal/OpenGL) and GPU ICD DLLs/dylibs/sos 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(); + } + + 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(); + + 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); + 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; + } +} + +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 new file mode 100644 index 000000000..127cda582 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/App.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include +#include + +#include "ModuleSnapshot.h" + +namespace ModuleLoadTest +{ + // Drive BabylonNative to a stable boot state, then return the snapshot of + // 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); + + // 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. + 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. + int CompareAndReport(const ModuleSnapshot& postBoot); +} 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.Win32.cpp b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp new file mode 100644 index 000000000..1616f5027 --- /dev/null +++ b/Apps/ModuleLoadTest/Source/ModuleSnapshot.Win32.cpp @@ -0,0 +1,175 @@ +#include "ModuleSnapshot.h" + +#include +#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), '\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; + } + } + + 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(); + + // 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, modules.data(), + static_cast(modules.size() * sizeof(HMODULE)), &requiredBytes)) + { + std::fprintf(stderr, + "ModuleLoadTest FAIL: EnumProcessModules failed (error %lu).\n", + ::GetLastError()); + ::ExitProcess(1); + } + + const size_t count = requiredBytes / sizeof(HMODULE); + if (count > modules.size()) + { + // 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); + } + + 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(); +} 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; + } +}