From 4112ff0cf357ebef3df628165bd574e3f648af96 Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:03:22 +0100 Subject: [PATCH 01/10] ci: establish Linux native SDK build and source evidence --- .github/workflows/linux-native-sdk.yml | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/linux-native-sdk.yml diff --git a/.github/workflows/linux-native-sdk.yml b/.github/workflows/linux-native-sdk.yml new file mode 100644 index 000000000..9dfeab763 --- /dev/null +++ b/.github/workflows/linux-native-sdk.yml @@ -0,0 +1,49 @@ +name: Linux native SDK +on: + push: + branches: [codex/linux-headless-sdk] + pull_request: + paths: + - 'src/WebScene.NativeWeb/**' + - 'src/WebScene.Sdk/**' + - 'tests/NativeWeb/**' + - 'eng/graphics/**' + - '.github/workflows/linux-native-sdk.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: linux-native-sdk-${{ github.ref }} + cancel-in-progress: true +jobs: + native: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: Record source input + run: | + mkdir -p artifacts/linux-native + git rev-parse HEAD > artifacts/linux-native/source-revision.txt + git archive --format=tar.gz HEAD > artifacts/linux-native/source.tar.gz + - name: Install compiler and native prerequisites + run: | + sudo apt-get update + sudo apt-get install -y clang-18 clang-tools-18 ninja-build libfontconfig1-dev libfreetype-dev libvulkan-dev mesa-vulkan-drivers vulkan-tools + python3 -m pip install --break-system-packages cmake==3.31.6 + - name: Build native document, shared CSS and UI compiler + run: | + cmake -S src/WebScene.NativeWeb -B artifacts/linux-native/build -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++-18 + cmake --build artifacts/linux-native/build --parallel 2 + ctest --test-dir artifacts/linux-native/build --output-on-failure + - uses: actions/upload-artifact@v4 + if: always() + with: + name: linux-native-evidence + path: | + artifacts/linux-native/source.tar.gz + artifacts/linux-native/source-revision.txt + artifacts/linux-native/build/Testing/Temporary/*.log + if-no-files-found: error + retention-days: 7 From 11235dfebf44894fe74a51a3ef109b95671e439e Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:14:04 +0100 Subject: [PATCH 02/10] sdk: add a native-only Linux producer and pinned LLVM toolchain --- .github/workflows/linux-native-sdk.yml | 22 ++++++- eng/sdk/install-linux-llvm.py | 52 +++++++++++++++++ src/WebScene.Sdk/CMakeLists.txt | 7 ++- src/WebScene.Sdk/cmake/WebSceneConfig.cmake | 4 ++ .../cmake/WebSceneLinuxConfig.cmake | 57 +++++++++++++++++++ src/WebScene.Sdk/cmake/WebSceneLinuxSDK.cmake | 56 ++++++++++++++++++ .../cmake/WebSceneToolchain.cmake | 23 ++++++++ 7 files changed, 219 insertions(+), 2 deletions(-) create mode 100755 eng/sdk/install-linux-llvm.py create mode 100644 src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake create mode 100644 src/WebScene.Sdk/cmake/WebSceneLinuxSDK.cmake diff --git a/.github/workflows/linux-native-sdk.yml b/.github/workflows/linux-native-sdk.yml index 9dfeab763..591868cbe 100644 --- a/.github/workflows/linux-native-sdk.yml +++ b/.github/workflows/linux-native-sdk.yml @@ -31,12 +31,31 @@ jobs: sudo apt-get update sudo apt-get install -y clang-18 clang-tools-18 ninja-build libfontconfig1-dev libfreetype-dev libvulkan-dev mesa-vulkan-drivers vulkan-tools python3 -m pip install --break-system-packages cmake==3.31.6 + - uses: actions/cache@v4 + with: + path: ~/.cache/webscene/llvm-22.1.1 + key: linux-llvm-22.1.1-efc4d945744f951d + - name: Install checksum-pinned LLVM + run: | + python3 eng/sdk/install-linux-llvm.py "$HOME/.cache/webscene/llvm-22.1.1" + echo "WEBSCENE_LLVM_ROOT=$HOME/.cache/webscene/llvm-22.1.1" >> "$GITHUB_ENV" + echo "$HOME/.cache/webscene/llvm-22.1.1/bin" >> "$GITHUB_PATH" - name: Build native document, shared CSS and UI compiler run: | cmake -S src/WebScene.NativeWeb -B artifacts/linux-native/build -G Ninja \ - -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++-18 + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_TOOLCHAIN_FILE="$PWD/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake" cmake --build artifacts/linux-native/build --parallel 2 ctest --test-dir artifacts/linux-native/build --output-on-failure + - name: Install native-only SDK without V8 + run: | + cmake -S src/WebScene.Sdk -B artifacts/linux-native/sdk-build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DWEBSCENE_SDK_WEBGPU=OFF \ + -DCMAKE_TOOLCHAIN_FILE="$PWD/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake" \ + -DCMAKE_INSTALL_PREFIX="$PWD/artifacts/linux-native/sdk" + cmake --build artifacts/linux-native/sdk-build --parallel 2 + cmake --install artifacts/linux-native/sdk-build + tar -czf artifacts/linux-native/sdk.tar.gz -C artifacts/linux-native sdk - uses: actions/upload-artifact@v4 if: always() with: @@ -44,6 +63,7 @@ jobs: path: | artifacts/linux-native/source.tar.gz artifacts/linux-native/source-revision.txt + artifacts/linux-native/sdk.tar.gz artifacts/linux-native/build/Testing/Temporary/*.log if-no-files-found: error retention-days: 7 diff --git a/eng/sdk/install-linux-llvm.py b/eng/sdk/install-linux-llvm.py new file mode 100755 index 000000000..1c0a27758 --- /dev/null +++ b/eng/sdk/install-linux-llvm.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Install the checksum-pinned Linux compiler used by the native SDK profile.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import urllib.request + +VERSION = "22.1.1" +URL = "https://github.com/llvm/llvm-project/releases/download/llvmorg-22.1.1/LLVM-22.1.1-Linux-X64.tar.xz" +SHA256 = "efc4d945744f951df00ec72c5b31da5d5a2eaf1d53cc7c9d0644f93f0f9e817d" + +def install(destination): + destination = destination.resolve() + stamp = destination / "webscene-toolchain.json" + if stamp.is_file(): + if json.loads(stamp.read_text()).get("archiveSha256") != SHA256: + raise RuntimeError("Refusing a different compiler cache") + subprocess.run([destination / "bin/clang++", "--version"], check=True) + return + if destination.exists(): + raise RuntimeError("Refusing an incomplete or unrelated installation: " + str(destination)) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="llvm-install-", dir=destination.parent) as work: + root = Path(work) + archive = root / "llvm.tar.xz" + digest = hashlib.sha256() + with urllib.request.urlopen(URL, timeout=120) as response, archive.open("wb") as output: + while chunk := response.read(1024 * 1024): + digest.update(chunk) + output.write(chunk) + if digest.hexdigest() != SHA256: + raise RuntimeError("LLVM archive checksum mismatch") + extracted = root / "extracted" + extracted.mkdir() + with tarfile.open(archive) as source: + source.extractall(extracted, filter="data") + children = list(extracted.iterdir()) + if len(children) != 1 or not (children[0] / "bin/clang++").exists(): + raise RuntimeError("Unexpected LLVM archive layout") + subprocess.run([children[0] / "bin/clang++", "--version"], check=True) + shutil.move(str(children[0]), destination) + stamp.write_text(json.dumps({"version": VERSION, "archiveSha256": SHA256, "url": URL}, indent=2) + "\n") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("destination", type=Path) + install(parser.parse_args().destination) diff --git a/src/WebScene.Sdk/CMakeLists.txt b/src/WebScene.Sdk/CMakeLists.txt index 89a8ba38f..e6f00b1c9 100644 --- a/src/WebScene.Sdk/CMakeLists.txt +++ b/src/WebScene.Sdk/CMakeLists.txt @@ -1,5 +1,10 @@ cmake_minimum_required(VERSION 3.28) -project(WebSceneSDK VERSION 0.1.0 LANGUAGES C CXX OBJCXX) +project(WebSceneSDK VERSION 0.1.0 LANGUAGES C CXX) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/WebSceneLinuxSDK.cmake") + return() +endif() +enable_language(OBJCXX) if(NOT APPLE OR NOT CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64") message(FATAL_ERROR "The SDK preview qualifies macOS ARM64") endif() diff --git a/src/WebScene.Sdk/cmake/WebSceneConfig.cmake b/src/WebScene.Sdk/cmake/WebSceneConfig.cmake index 82403f252..6af3a637f 100644 --- a/src/WebScene.Sdk/cmake/WebSceneConfig.cmake +++ b/src/WebScene.Sdk/cmake/WebSceneConfig.cmake @@ -1,4 +1,8 @@ include_guard(GLOBAL) +if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/WebSceneLinuxProfile.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/WebSceneLinuxConfig.cmake") + return() +endif() get_filename_component(_ws_prefix "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE) if(NOT APPLE OR NOT CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64") message(FATAL_ERROR "WebScene SDK preview requires macOS ARM64") diff --git a/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake b/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake new file mode 100644 index 000000000..01b8d25d5 --- /dev/null +++ b/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake @@ -0,0 +1,57 @@ +include(CMakeFindDependencyMacro) +include("${CMAKE_CURRENT_LIST_DIR}/WebSceneLinuxProfile.cmake") +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux" OR NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") + set(WebScene_FOUND FALSE) + set(WebScene_NOT_FOUND_MESSAGE "This SDK contains Linux x86_64 binaries") + return() +endif() +if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR NOT CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL "22.1.1") + message(FATAL_ERROR "This SDK requires LLVM 22.1.1/libc++; use WebSceneToolchain.cmake") +endif() +find_dependency(Threads) +get_filename_component(WebScene_SDK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE) +function(_ws_linux_static name file) + if(NOT EXISTS "${WebScene_SDK_ROOT}/lib/${file}") + message(FATAL_ERROR "Incomplete Linux SDK: missing ${file}") + endif() + add_library(WebScene::${name} STATIC IMPORTED GLOBAL) + set_target_properties(WebScene::${name} PROPERTIES + IMPORTED_LOCATION "${WebScene_SDK_ROOT}/lib/${file}" + INTERFACE_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include" + INTERFACE_COMPILE_FEATURES cxx_std_20 + INTERFACE_COMPILE_OPTIONS "-stdlib=libc++" + INTERFACE_LINK_OPTIONS "-stdlib=libc++") +endfunction() +_ws_linux_static(Core libwebscene_core.a) +_ws_linux_static(NativeWeb libwebscene_native_web.a) +_ws_linux_static(_Parser libwebscene_html_parser.a) +_ws_linux_static(SharedCSS libwebscene_native_web_shared_css.a) +set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::Core;Threads::Threads;${CMAKE_DL_LIBS}") +set_property(TARGET WebScene::SharedCSS PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::NativeWeb;WebScene::_Parser;Threads::Threads;${CMAKE_DL_LIBS}") +if(WebScene_SDK_WEBGPU) + if(NOT EXISTS "${WebScene_SDK_ROOT}/lib/libwebgpu_dawn.so") + message(FATAL_ERROR "Incomplete Linux SDK: missing pinned Dawn library") + endif() + add_library(WebScene::_Dawn SHARED IMPORTED GLOBAL) + set_target_properties(WebScene::_Dawn PROPERTIES IMPORTED_LOCATION "${WebScene_SDK_ROOT}/lib/libwebgpu_dawn.so" + INTERFACE_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include") + add_library(WebScene::WebGPU INTERFACE IMPORTED GLOBAL) + set_target_properties(WebScene::WebGPU PROPERTIES + INTERFACE_LINK_LIBRARIES "WebScene::Core;WebScene::_Dawn;Threads::Threads;${CMAKE_DL_LIBS}" + INTERFACE_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include/graphics") +endif() +add_executable(WebScene::Compiler IMPORTED GLOBAL) +set_property(TARGET WebScene::Compiler PROPERTY IMPORTED_LOCATION "${WebScene_SDK_ROOT}/bin/webscene-uic") +foreach(component IN LISTS WebScene_FIND_COMPONENTS) + if(TARGET WebScene::${component}) + set(WebScene_${component}_FOUND TRUE) + else() + set(WebScene_${component}_FOUND FALSE) + if(WebScene_FIND_REQUIRED_${component}) + set(WebScene_FOUND FALSE) + set(WebScene_NOT_FOUND_MESSAGE "Component ${component} is not installed in this native-only Linux SDK") + return() + endif() + endif() +endforeach() +include("${CMAKE_CURRENT_LIST_DIR}/WebSceneApplication.cmake") diff --git a/src/WebScene.Sdk/cmake/WebSceneLinuxSDK.cmake b/src/WebScene.Sdk/cmake/WebSceneLinuxSDK.cmake new file mode 100644 index 000000000..4a5a4d763 --- /dev/null +++ b/src/WebScene.Sdk/cmake/WebSceneLinuxSDK.cmake @@ -0,0 +1,56 @@ +# Native-only Linux producer. The existing macOS Runtime profile is unchanged. +if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") + message(FATAL_ERROR "The Linux SDK profile currently targets x86_64") +endif() +if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR NOT CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL "22.1.1") + message(FATAL_ERROR "Linux SDK production requires the pinned LLVM 22.1.1 toolchain") +endif() +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +option(WEBSCENE_SDK_WEBGPU "Include pinned Dawn/Vulkan native authoring support" ON) +option(WEBSCENE_SDK_RUNTIME "Include the optional JavaScript Runtime component" OFF) +if(WEBSCENE_SDK_RUNTIME) + message(FATAL_ERROR "This Linux SDK profile is Native-only; Runtime/hybrid is not qualified") +endif() +set(BUILD_TESTING OFF CACHE BOOL "Validate through installed SDK consumers" FORCE) +set(WEBSCENE_NATIVE_WEB_SDK_BUILD ON) +get_filename_component(WEBSCENE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +find_package(Threads REQUIRED) +add_subdirectory("${WEBSCENE_ROOT}/src/WebScene.NativeWeb" native-web) +target_link_libraries(webscene_native_web PUBLIC Threads::Threads ${CMAKE_DL_LIBS}) +target_link_libraries(webscene_native_web_shared_css PUBLIC Threads::Threads ${CMAKE_DL_LIBS}) +add_library(WebScene::Core ALIAS webscene_core) +add_library(WebScene::NativeWeb ALIAS webscene_native_web) +add_library(WebScene::SharedCSS ALIAS webscene_native_web_shared_css) +add_executable(WebScene::Compiler ALIAS webscene-uic) +set_target_properties(webscene-uic PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") +install(TARGETS webscene_core webscene_native_web webscene_native_web_shared_css ARCHIVE DESTINATION lib) +install(TARGETS webscene-uic RUNTIME DESTINATION bin) +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/native-web/parser/release/libwebscene_html_parser.a" DESTINATION lib) +find_package(Python3 REQUIRED COMPONENTS Interpreter) +execute_process(COMMAND "${Python3_EXECUTABLE}" "${WEBSCENE_ROOT}/eng/sdk/header-closure.py" "${WEBSCENE_ROOT}" + OUTPUT_FILE "${CMAKE_CURRENT_BINARY_DIR}/sdk-headers.cmake" COMMAND_ERROR_IS_FATAL ANY) +include("${CMAKE_CURRENT_BINARY_DIR}/sdk-headers.cmake") +if(WEBSCENE_SDK_WEBGPU) + set(WEBSCENE_GRAPHICS_COMPONENTS dawn) + include("${WEBSCENE_ROOT}/eng/graphics/GraphicsDependencies.cmake") + add_library(webscene_sdk_webgpu INTERFACE) + target_include_directories(webscene_sdk_webgpu INTERFACE "${WEBSCENE_ROOT}/experiments/WebScene.NativeEngine.Probe/native/graphics") + target_link_libraries(webscene_sdk_webgpu INTERFACE webscene_core dawn::webgpu_dawn Threads::Threads) + add_library(WebScene::WebGPU ALIAS webscene_sdk_webgpu) + install(DIRECTORY "${WEBSCENE_GRAPHICS_SDK_ROOT}/dawn/include/" DESTINATION include) + install(FILES "${WEBSCENE_GRAPHICS_SDK_ROOT}/dawn/lib/libwebgpu_dawn.so" DESTINATION lib) + install(DIRECTORY "${WEBSCENE_GRAPHICS_SDK_ROOT}/dawn/licenses/" DESTINATION share/licenses/WebScene/Dawn) + install(FILES "${WEBSCENE_GRAPHICS_SDK_ROOT}/dawn/webscene-graphics-package.json" + "${WEBSCENE_ROOT}/eng/graphics/dependencies.lock.json" DESTINATION share/webscene/dependencies) +endif() +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/WebSceneLinuxProfile.cmake" + "set(WebScene_SDK_PLATFORM Linux)\nset(WebScene_SDK_PROCESSOR x86_64)\nset(WebScene_SDK_WEBGPU ${WEBSCENE_SDK_WEBGPU})\nset(WebScene_SDK_RUNTIME OFF)\n") +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/WebSceneLinuxProfile.cmake" DESTINATION lib/cmake/WebScene) +install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/cmake/" DESTINATION lib/cmake/WebScene) +include(CMakePackageConfigHelpers) +write_basic_package_version_file("${CMAKE_CURRENT_BINARY_DIR}/WebSceneConfigVersion.cmake" + VERSION ${PROJECT_VERSION} COMPATIBILITY SameMinorVersion) +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/WebSceneConfigVersion.cmake" DESTINATION lib/cmake/WebScene) +install(FILES "${WEBSCENE_ROOT}/LICENSE" DESTINATION share/licenses/WebScene) diff --git a/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake b/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake index 530758c4e..d51beb760 100644 --- a/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake +++ b/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake @@ -1,3 +1,26 @@ +# Linux and macOS use the same pinned compiler, but distinct system/ABI profiles. +if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + if(NOT WEBSCENE_LLVM_ROOT) + set(WEBSCENE_LLVM_ROOT "$ENV{WEBSCENE_LLVM_ROOT}" CACHE PATH "Pinned LLVM 22.1.1 installation") + endif() + if(NOT EXISTS "${WEBSCENE_LLVM_ROOT}/bin/clang++") + message(FATAL_ERROR "Set WEBSCENE_LLVM_ROOT to the verified LLVM 22.1.1 Linux installation") + endif() + set(CMAKE_C_COMPILER "${WEBSCENE_LLVM_ROOT}/bin/clang" CACHE FILEPATH "") + set(CMAKE_CXX_COMPILER "${WEBSCENE_LLVM_ROOT}/bin/clang++" CACHE FILEPATH "") + set(CMAKE_CXX_FLAGS_INIT "-stdlib=libc++") + set(CMAKE_CXX_STANDARD 20 CACHE STRING "") + list(APPEND CMAKE_TRY_COMPILE_PLATFORM_VARIABLES WEBSCENE_LLVM_ROOT) + file(GLOB _ws_cxx_dirs "${WEBSCENE_LLVM_ROOT}/lib/*-linux-gnu" "${WEBSCENE_LLVM_ROOT}/lib/*-unknown-linux-gnu") + list(APPEND _ws_cxx_dirs "${WEBSCENE_LLVM_ROOT}/lib") + foreach(_ws_dir IN LISTS _ws_cxx_dirs) + if(EXISTS "${_ws_dir}/libc++.so.1") + set(CMAKE_BUILD_RPATH "${_ws_dir}" CACHE STRING "Pinned C++ ABI runtime") + break() + endif() + endforeach() + return() +endif() # Pass with -DCMAKE_TOOLCHAIN_FILE=/lib/cmake/WebScene/WebSceneToolchain.cmake. # Override WEBSCENE_LLVM_ROOT when LLVM is installed outside Homebrew. set(WEBSCENE_LLVM_ROOT "/opt/homebrew/opt/llvm" CACHE PATH "LLVM 22.1.1 installation") From 3c885a7728900b0e7bc900c874d26bc1a8b18b2c Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:37:15 +0100 Subject: [PATCH 03/10] sdk: stage relocatable C++ runtime and a bounded offline compiler profile --- .github/workflows/linux-native-sdk.yml | 14 ++++++-- eng/sdk/archive-linux-llvm.py | 19 ++++++++++ eng/sdk/install-linux-llvm.py | 23 +++++++++--- eng/sdk/stage-linux-runtime.py | 49 ++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 6 deletions(-) create mode 100755 eng/sdk/archive-linux-llvm.py create mode 100755 eng/sdk/stage-linux-runtime.py diff --git a/.github/workflows/linux-native-sdk.yml b/.github/workflows/linux-native-sdk.yml index 591868cbe..9082bd4e1 100644 --- a/.github/workflows/linux-native-sdk.yml +++ b/.github/workflows/linux-native-sdk.yml @@ -8,6 +8,7 @@ on: - 'src/WebScene.Sdk/**' - 'tests/NativeWeb/**' - 'eng/graphics/**' + - 'eng/sdk/**' - '.github/workflows/linux-native-sdk.yml' workflow_dispatch: permissions: @@ -29,17 +30,25 @@ jobs: - name: Install compiler and native prerequisites run: | sudo apt-get update - sudo apt-get install -y clang-18 clang-tools-18 ninja-build libfontconfig1-dev libfreetype-dev libvulkan-dev mesa-vulkan-drivers vulkan-tools + sudo apt-get install -y patchelf ninja-build libfontconfig1-dev libfreetype-dev libvulkan-dev mesa-vulkan-drivers vulkan-tools python3 -m pip install --break-system-packages cmake==3.31.6 - uses: actions/cache@v4 with: path: ~/.cache/webscene/llvm-22.1.1 - key: linux-llvm-22.1.1-efc4d945744f951d + key: linux-cxx-llvm-22.1.1-efc4d945744f951d-v2 - name: Install checksum-pinned LLVM run: | python3 eng/sdk/install-linux-llvm.py "$HOME/.cache/webscene/llvm-22.1.1" echo "WEBSCENE_LLVM_ROOT=$HOME/.cache/webscene/llvm-22.1.1" >> "$GITHUB_ENV" echo "$HOME/.cache/webscene/llvm-22.1.1/bin" >> "$GITHUB_PATH" + - name: Archive offline compiler profile + run: python3 eng/sdk/archive-linux-llvm.py "$WEBSCENE_LLVM_ROOT" artifacts/linux-native/llvm.tar.gz + - uses: actions/upload-artifact@v4 + with: + name: linux-native-compiler + path: artifacts/linux-native/llvm.tar.gz + compression-level: 0 + retention-days: 7 - name: Build native document, shared CSS and UI compiler run: | cmake -S src/WebScene.NativeWeb -B artifacts/linux-native/build -G Ninja \ @@ -55,6 +64,7 @@ jobs: -DCMAKE_INSTALL_PREFIX="$PWD/artifacts/linux-native/sdk" cmake --build artifacts/linux-native/sdk-build --parallel 2 cmake --install artifacts/linux-native/sdk-build + python3 eng/sdk/stage-linux-runtime.py --llvm "$WEBSCENE_LLVM_ROOT" --sdk artifacts/linux-native/sdk tar -czf artifacts/linux-native/sdk.tar.gz -C artifacts/linux-native sdk - uses: actions/upload-artifact@v4 if: always() diff --git a/eng/sdk/archive-linux-llvm.py b/eng/sdk/archive-linux-llvm.py new file mode 100755 index 000000000..4e62abfb7 --- /dev/null +++ b/eng/sdk/archive-linux-llvm.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Archive only the verified native C++ compiler profile for offline consumers.""" +import argparse +import importlib.util +from pathlib import Path +import tarfile + +p = argparse.ArgumentParser(description=__doc__) +p.add_argument("source", type=Path) +p.add_argument("output", type=Path) +a = p.parse_args() +spec = importlib.util.spec_from_file_location("installer", Path(__file__).with_name("install-linux-llvm.py")) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +if not (a.source / "webscene-toolchain.json").exists(): + raise RuntimeError("Compiler provenance marker missing") +a.output.parent.mkdir(parents=True, exist_ok=True) +with tarfile.open(a.output, "w:gz", compresslevel=3) as archive: + archive.add(a.source, arcname="llvm-22.1.1", filter=lambda item: item if module.keep(item) or item.name.endswith("webscene-toolchain.json") else None) diff --git a/eng/sdk/install-linux-llvm.py b/eng/sdk/install-linux-llvm.py index 1c0a27758..9e4d7b966 100755 --- a/eng/sdk/install-linux-llvm.py +++ b/eng/sdk/install-linux-llvm.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Install the checksum-pinned Linux compiler used by the native SDK profile.""" +"""Install the checksum-pinned Linux C++ compiler without unrelated LLVM SDKs.""" import argparse import hashlib import json -from pathlib import Path +from pathlib import Path, PurePosixPath import shutil import subprocess import tarfile @@ -13,6 +13,20 @@ VERSION = "22.1.1" URL = "https://github.com/llvm/llvm-project/releases/download/llvmorg-22.1.1/LLVM-22.1.1-Linux-X64.tar.xz" SHA256 = "efc4d945744f951df00ec72c5b31da5d5a2eaf1d53cc7c9d0644f93f0f9e817d" +BINARIES = {"clang", "clang++", "clang-22", "clang-scan-deps", "llvm-ar", "llvm-ranlib", + "lld", "ld.lld", "llvm-strip", "llvm-nm", "llvm-readobj", "llvm-objdump", "llvm-objcopy"} + +def keep(member): + parts = PurePosixPath(member.name).parts[1:] + if not parts: + return True + if parts[0] == "bin": + return len(parts) == 1 or parts[1] in BINARIES + if parts[0] == "include": + return True + if parts[0] == "lib": + return member.isdir() or "clang" in parts or ".so" in parts[-1] or parts[-1].endswith(".ld") + return parts[-1].startswith(("LICENSE", "NOTICE")) or parts[:2] == ("share", "licenses") def install(destination): destination = destination.resolve() @@ -38,13 +52,14 @@ def install(destination): extracted = root / "extracted" extracted.mkdir() with tarfile.open(archive) as source: - source.extractall(extracted, filter="data") + source.extractall(extracted, members=(m for m in source if keep(m)), filter="data") children = list(extracted.iterdir()) if len(children) != 1 or not (children[0] / "bin/clang++").exists(): raise RuntimeError("Unexpected LLVM archive layout") subprocess.run([children[0] / "bin/clang++", "--version"], check=True) shutil.move(str(children[0]), destination) - stamp.write_text(json.dumps({"version": VERSION, "archiveSha256": SHA256, "url": URL}, indent=2) + "\n") + stamp.write_text(json.dumps({"version": VERSION, "archiveSha256": SHA256, "url": URL, + "profile": "native-cxx"}, indent=2) + "\n") if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) diff --git a/eng/sdk/stage-linux-runtime.py b/eng/sdk/stage-linux-runtime.py new file mode 100755 index 000000000..4c99e2279 --- /dev/null +++ b/eng/sdk/stage-linux-runtime.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Copy the pinned C++ shared ABI runtime and its licenses into an installed SDK.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess + + +def stage(llvm, sdk): + llvm, sdk = llvm.resolve(), sdk.resolve() + destination = sdk / "lib" + destination.mkdir(parents=True, exist_ok=True) + selected = {} + for pattern in ("libc++.so*", "libc++abi.so*", "libunwind.so*"): + candidates = sorted((llvm / "lib").rglob(pattern)) + if not candidates: + raise RuntimeError("Missing pinned C++ runtime: " + pattern) + for source in candidates: + # Copy symlink targets too, preserving the SONAME used by ELF consumers. + if not source.is_file(): + continue + raw = source.read_bytes() + digest = hashlib.sha256(raw).hexdigest() + if source.name in selected and selected[source.name] != digest: + raise RuntimeError("Ambiguous C++ runtime architecture: " + source.name) + selected[source.name] = digest + target = destination / source.name + shutil.copyfile(source, target, follow_symlinks=True) + if raw.startswith(b"\x7fELF"): + subprocess.run(["patchelf", "--set-rpath", "$ORIGIN", target], check=True) + licenses = sdk / "share/licenses/WebScene/LLVM" + licenses.mkdir(parents=True, exist_ok=True) + for source in llvm.rglob("LICENSE*.TXT"): + shutil.copyfile(source, licenses / ("-".join(source.relative_to(llvm).parts))) + metadata = sdk / "share/webscene" + metadata.mkdir(parents=True, exist_ok=True) + (metadata / "linux-cxx-runtime.json").write_text(json.dumps({ + "toolchain": json.loads((llvm / "webscene-toolchain.json").read_text()), + "files": {name: hashlib.sha256((destination / name).read_bytes()).hexdigest() for name in selected}, + }, indent=2) + "\n") + +if __name__ == "__main__": + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--llvm", type=Path, required=True) + p.add_argument("--sdk", type=Path, required=True) + a = p.parse_args() + stage(a.llvm, a.sdk) From 060e4b565d92746e3ac52e9ffcc1d3e2043c468a Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:08:42 +0100 Subject: [PATCH 04/10] graphics: add bounded native Vulkan headless surfaces and explicit image capture --- .../graphics/native_headless_webgpu_surface.h | 247 ++++++++++++++++++ .../native/graphics/native_image_capture.h | 39 +++ .../native/graphics/native_webgpu_device.h | 4 +- .../native/graphics/native_webgpu_surface.h | 5 + tests/Headless/CMakeLists.txt | 17 ++ tests/Headless/headless_gpu.cpp | 72 +++++ 6 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 experiments/WebScene.NativeEngine.Probe/native/graphics/native_headless_webgpu_surface.h create mode 100644 experiments/WebScene.NativeEngine.Probe/native/graphics/native_image_capture.h create mode 100644 tests/Headless/CMakeLists.txt create mode 100644 tests/Headless/headless_gpu.cpp diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_headless_webgpu_surface.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_headless_webgpu_surface.h new file mode 100644 index 000000000..8ad0ac732 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_headless_webgpu_surface.h @@ -0,0 +1,247 @@ +#pragma once +#include "native_webgpu_device.h" +#include "native_image_capture.h" +#include +#include +#include +#include +#include +#include + +namespace webscene::graphics { +struct headless_webgpu_options { + wgpu::BackendType backend = wgpu::BackendType::Vulkan; + bool allow_software = false; + bool force_software = false; + std::shared_ptr wake; + static headless_webgpu_options environment() { + auto enabled = [](const char* name) { const auto* value = std::getenv(name); return value && std::string_view(value) == "1"; }; + headless_webgpu_options result; + result.allow_software = enabled("WEBSCENE_HEADLESS_ALLOW_SOFTWARE"); + result.force_software = enabled("WEBSCENE_HEADLESS_FORCE_SOFTWARE"); + return result; + } +}; +struct headless_adapter_info { + std::string vendor, architecture, device, description; + wgpu::BackendType backend{}; + bool software{}; +}; +// Offscreen native WebGPU, not a Linux external-memory desktop presenter. +// Uses the shared immutable lease contract; completion progresses while idle. +class native_headless_webgpu_surface final { + struct storage final : native_image_capture_provider { + struct slot { wgpu::Texture texture; image_metadata metadata; uint64_t bytes{}; }; + std::shared_ptr gpu; + const std::thread::id owner = std::this_thread::get_id(); + std::array slots; + std::mutex mutex; + std::atomic captures{}, captured_bytes{}; + uint64_t allocated_bytes{}; + explicit storage(std::shared_ptr device) : gpu(std::move(device)) {} + void check_thread() const { + if (owner != std::this_thread::get_id()) throw std::logic_error("Headless WebGPU is owner-thread-only"); + } + captured_native_image capture(std::shared_ptr consumer, + image_capture_options options) override { + check_thread(); + const auto metadata = consumer->describe(); + wgpu::Texture texture; + { + std::lock_guard lock(mutex); + for (const auto& item : slots) + if (item.metadata.allocation == metadata.allocation && + item.metadata.allocation_generation == metadata.allocation_generation && + item.metadata.content_serial == metadata.content_serial) texture = item.texture; + } + if (!texture || gpu->failed->load()) throw std::runtime_error("Stale or lost native capture image"); + const uint64_t row = (uint64_t(metadata.width) * 4 + 255) & ~uint64_t(255); + const uint64_t packed = uint64_t(metadata.width) * metadata.height * 4; + const uint64_t bytes = row * metadata.height; + if (row > UINT32_MAX || bytes > options.byte_budget || packed > options.byte_budget - bytes) + throw std::length_error("Diagnostic GPU capture exceeds byte budget"); + wgpu::BufferDescriptor bd{}; + bd.size = bytes; bd.usage = wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::MapRead; + auto buffer = gpu->device.CreateBuffer(&bd); + if (!buffer) throw std::runtime_error("Capture buffer allocation failed"); + auto encoder = gpu->device.CreateCommandEncoder(); + wgpu::TexelCopyTextureInfo source{}; source.texture = texture; + wgpu::TexelCopyBufferInfo destination{}; destination.buffer = buffer; + destination.layout.bytesPerRow = uint32_t(row); destination.layout.rowsPerImage = metadata.height; + wgpu::Extent3D extent{metadata.width, metadata.height, 1}; + encoder.CopyTextureToBuffer(&source, &destination, &extent); + auto commands = encoder.Finish(); + gpu->device.GetQueue().Submit(1, &commands); + // Even a timeout must retain the consumer until actual copy completion. + gpu->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [consumer = std::move(consumer), buffer](wgpu::QueueWorkDoneStatus, wgpu::StringView) {}); + ++captures; captured_bytes += packed; + auto mapped = std::make_shared>(false); + auto future = buffer.MapAsync(wgpu::MapMode::Read, 0, bytes, wgpu::CallbackMode::WaitAnyOnly, + [mapped](wgpu::MapAsyncStatus status, wgpu::StringView) { mapped->store(status == wgpu::MapAsyncStatus::Success); }); + if (gpu->instance.WaitAny(future, options.timeout_ns) != wgpu::WaitStatus::Success || + !mapped->load() || gpu->failed->load()) { + buffer.Destroy(); + throw std::runtime_error("Diagnostic GPU capture timed out or failed"); + } + captured_native_image result{metadata, metadata.width * 4, {}}; + try { + result.pixels.resize(packed); + const auto* input = static_cast(buffer.GetConstMappedRange()); + if (!input) throw std::runtime_error("Capture mapping returned no data"); + for (uint32_t y = 0; y < metadata.height; ++y) + std::memcpy(result.pixels.data() + uint64_t(y) * result.row_bytes, + input + uint64_t(y) * row, result.row_bytes); + } catch (...) { buffer.Unmap(); throw; } + buffer.Unmap(); + return result; + } + }; + struct submission { + std::optional producer; + std::shared_ptr image; + image_metadata metadata; + std::atomic state{webscene_gpu_image_snapshot::status::pending}; + }; + class snapshot final : public webscene_gpu_image_snapshot { + std::shared_ptr value_; + public: + explicit snapshot(std::shared_ptr value) : value_(std::move(value)) {} + image_metadata describe() const override { return value_->metadata; } + status state() const override { return value_->state.load(std::memory_order_acquire); } + std::shared_ptr resolve() override { + return state() == status::ready ? value_->image : nullptr; + } + }; + std::shared_ptr gpu_; + std::shared_ptr storage_; + owned_image_pool pool_; + std::shared_ptr wake_; + std::shared_ptr active_; + std::vector> pending_; + headless_adapter_info adapter_; + uint64_t canvas_, generation_ = 1, serial_{}, budget_; + uint32_t width_, height_, maximum_dimension_{}; + bool closed_{}; + void check_size(uint32_t width, uint32_t height) const { + if (!width || !height || width > maximum_dimension_ || height > maximum_dimension_ || + uint64_t(width) * height > budget_ / 4) + throw std::invalid_argument("Headless surface dimensions exceed device or byte limits"); + } + std::shared_ptr retire(bool publish) { + if (!active_) return {}; + auto item = std::exchange(active_, {}); + std::shared_ptr output; + std::exception_ptr failure; + try { + if (publish) { + if (auto lease = item->producer->publish()) + item->image = std::make_shared(std::move(*lease)); + output = std::make_shared(item); + } + } catch (...) { failure = std::current_exception(); } + gpu_->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [item, gpu = gpu_, wake = wake_](wgpu::QueueWorkDoneStatus status, wgpu::StringView) { + item->producer->complete(); item->producer.reset(); + const bool success = status == wgpu::QueueWorkDoneStatus::Success; + if (!success) gpu->failed->store(true); + item->state.store(success && item->image ? webscene_gpu_image_snapshot::status::ready + : webscene_gpu_image_snapshot::status::failed, + std::memory_order_release); + if (wake) wake->signal(); + }); + std::erase_if(pending_, [](const auto& value) { return value.expired(); }); + pending_.push_back(item); + if (failure) std::rethrow_exception(failure); + return output; + } +public: + native_headless_webgpu_surface(uint64_t canvas, uint32_t width, uint32_t height, + uint64_t budget = 64ULL * 1024 * 1024, + headless_webgpu_options options = headless_webgpu_options::environment()) + : gpu_(std::make_shared(native_webgpu_device::create(options.backend, {}, options.force_software))), + storage_(std::make_shared(gpu_)), pool_(storage_, 128, options.wake), wake_(std::move(options.wake)), + canvas_(canvas), budget_(budget), width_(width), height_(height) { + if (!canvas_) throw std::invalid_argument("Native GPU surface requires a canvas identity"); + wgpu::AdapterInfo info{}; + if (gpu_->adapter.GetInfo(&info) != wgpu::Status::Success) + throw std::runtime_error("Cannot identify headless WebGPU adapter"); + auto text = [](wgpu::StringView value) { return !value.data ? std::string{} : value.length == WGPU_STRLEN ? std::string(value.data) : std::string(value.data, value.length); }; + adapter_ = {text(info.vendor), text(info.architecture), text(info.device), text(info.description), + info.backendType, info.adapterType == wgpu::AdapterType::CPU}; + if (info.backendType != options.backend || info.adapterType == wgpu::AdapterType::Unknown) + throw std::runtime_error("Unqualified headless adapter classification"); + if ((adapter_.software && !options.allow_software) || (options.force_software && !adapter_.software)) + throw std::runtime_error("Software WebGPU requires explicit opt-in and honest adapter classification"); + wgpu::Limits limits{}; + if (gpu_->device.GetLimits(&limits) != wgpu::Status::Success) + throw std::runtime_error("Cannot query headless device limits"); + maximum_dimension_ = limits.maxTextureDimension2D; + check_size(width, height); + } + ~native_headless_webgpu_surface() { close(); } + native_headless_webgpu_surface(const native_headless_webgpu_surface&) = delete; + native_headless_webgpu_surface& operator=(const native_headless_webgpu_surface&) = delete; + const wgpu::Device& device() const { return gpu_->device; } + const headless_adapter_info& adapter_info() const { return adapter_; } + bool failed() const { return gpu_->failed->load(); } + uint64_t diagnostic_captures() const { return storage_->captures.load(); } + uint64_t diagnostic_capture_bytes() const { return storage_->captured_bytes.load(); } + uint64_t allocated_bytes() const { return storage_->allocated_bytes; } + auto occupancy() const { return pool_.inspect_occupancy(); } + wgpu::Texture current_texture() { + storage_->check_thread(); + if (closed_ || failed()) throw std::runtime_error("Headless surface is closed or lost"); + if (active_) return storage_->slots[active_->producer->slot()].texture; + auto writer = pool_.acquire(); + if (!writer) return {}; // Explicit bounded backpressure, never a hidden wait. + auto item = std::make_shared(); + const auto index = writer->slot(); + std::lock_guard lock(storage_->mutex); + auto& slot = storage_->slots[index]; + const uint64_t bytes = uint64_t(width_) * height_ * 4; + if (!slot.texture || slot.metadata.width != width_ || slot.metadata.height != height_) { + if (storage_->allocated_bytes - slot.bytes + bytes > budget_) { + // Only reservations proven idle may be evicted during resize. + std::vector idle; + while (auto other = pool_.acquire()) { + auto& old = storage_->slots[other->slot()]; + storage_->allocated_bytes -= old.bytes; old = {}; + idle.push_back(std::move(*other)); + } + for (auto& reservation : idle) reservation.cancel(false); + if (storage_->allocated_bytes - slot.bytes + bytes > budget_) return {}; + } + wgpu::TextureDescriptor descriptor{}; + descriptor.size = {width_, height_, 1}; descriptor.format = wgpu::TextureFormat::BGRA8Unorm; + descriptor.usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc | + wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::TextureBinding; + auto texture = gpu_->device.CreateTexture(&descriptor); + if (!texture || failed()) throw std::runtime_error("Headless texture allocation failed"); + storage_->allocated_bytes = storage_->allocated_bytes - slot.bytes + bytes; + slot.texture = std::move(texture); slot.bytes = bytes; slot.metadata.allocation = new_owner_token(); + } + auto& metadata = slot.metadata; + metadata.canvas = canvas_; metadata.allocation_generation = generation_; metadata.content_serial = ++serial_; + metadata.width = width_; metadata.height = height_; metadata.format = image_format::bgra8_unorm; + metadata.producer_timeline = canvas_; metadata.producer_value = serial_; + writer->set_metadata(metadata); item->metadata = metadata; + item->producer.emplace(std::move(*writer)); + item->producer->begin(); active_ = std::move(item); + return slot.texture; + } + void resize(uint32_t width, uint32_t height) { + storage_->check_thread(); + if (closed_) throw std::runtime_error("Cannot resize a closed surface"); + check_size(width, height); + if (width == width_ && height == height_) return; + retire(false); width_ = width; height_ = height; ++generation_; + } + std::shared_ptr present() { storage_->check_thread(); return retire(true); } + void process_events() { storage_->check_thread(); gpu_->instance.ProcessEvents(); } + void close() { + if (closed_) return; + storage_->check_thread(); retire(false); closed_ = true; pool_.close(); + } +}; +} // namespace webscene::graphics diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_image_capture.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_image_capture.h new file mode 100644 index 000000000..642b80527 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_image_capture.h @@ -0,0 +1,39 @@ +#pragma once +#include "image_lease_abi.h" +#include +#include + +namespace webscene::graphics { +// Explicit diagnostics/export service. Normal publication never invokes it. +struct image_capture_options { + uint64_t timeout_ns = 30'000'000'000ULL; + uint64_t byte_budget = 128ULL * 1024 * 1024; +}; +struct captured_native_image { + image_metadata metadata; + uint32_t row_bytes{}; + std::vector pixels; +}; +struct native_image_capture_provider : image_provider_lifetime { + virtual captured_native_image capture(std::shared_ptr, + image_capture_options) = 0; +}; +inline captured_native_image capture_native_image(const webscene_gpu_image_lease_v3& image, + image_capture_options options = {}) { + if (!options.timeout_ns || options.timeout_ns > 30'000'000'000ULL || !options.byte_budget) + throw std::invalid_argument("Invalid capture timeout or byte budget"); + if (image.requires_producer_wait) + throw std::invalid_argument("Capture requires a completed producer image"); + auto ticket = image.value.begin_consumer(); + if (!ticket) throw std::runtime_error("Native image consumer capacity exhausted"); + owned_image_pool::consumer* raw; + try { raw = new owned_image_pool::consumer(std::move(*ticket)); } + catch (...) { ticket->complete(); throw; } + auto consumer = std::shared_ptr(raw, [](auto* value) { + value->complete(); delete value; + }); + auto provider = std::dynamic_pointer_cast(consumer->provider()); + if (!provider) throw std::runtime_error("This image provider does not support diagnostic capture"); + return provider->capture(std::move(consumer), options); +} +} // namespace webscene::graphics diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_device.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_device.h index 2e7849dad..b0fa983fa 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_device.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_device.h @@ -17,7 +17,8 @@ struct native_webgpu_device { wgpu::Adapter adapter; wgpu::Device device; static native_webgpu_device create(wgpu::BackendType backend, - std::vector features = {}) { + std::vector features = {}, + bool force_fallback_adapter = false) { native_webgpu_device result; constexpr auto timed_wait = wgpu::InstanceFeatureName::TimedWaitAny; wgpu::InstanceDescriptor descriptor{}; @@ -33,6 +34,7 @@ struct native_webgpu_device { }; wgpu::RequestAdapterOptions options{}; options.backendType = backend; + options.forceFallbackAdapter = force_fallback_adapter; auto future = result.instance.RequestAdapter(&options, wgpu::CallbackMode::WaitAnyOnly, [state, message](wgpu::RequestAdapterStatus status, wgpu::Adapter adapter, wgpu::StringView error) { if (status == wgpu::RequestAdapterStatus::Success) state->adapter = std::move(adapter); diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h index 282610a58..3a00c6ee4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h @@ -59,4 +59,9 @@ class native_webgpu_surface final { bool failed() const { return gpu_->failed->load(); } }; } // namespace webscene::graphics +#elif defined(__linux__) +#include "native_headless_webgpu_surface.h" +namespace webscene::graphics { +using native_webgpu_surface = native_headless_webgpu_surface; +} #endif diff --git a/tests/Headless/CMakeLists.txt b/tests/Headless/CMakeLists.txt new file mode 100644 index 000000000..2b322872d --- /dev/null +++ b/tests/Headless/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.28) +project(WebSceneHeadlessConsumer LANGUAGES CXX) +include(CTest) +find_package(WebScene REQUIRED CONFIG COMPONENTS NativeWeb SharedCSS Compiler) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +option(HEADLESS_TEST_WEBGPU "Exercise the installed Vulkan SDK" ON) +if(HEADLESS_TEST_WEBGPU) + find_package(WebScene REQUIRED CONFIG COMPONENTS WebGPU) + if(NOT TARGET WebScene::WebGPU) + message(FATAL_ERROR "The headless GPU contract requires the real WebGPU component") + endif() + add_executable(headless_gpu headless_gpu.cpp) + target_link_libraries(headless_gpu PRIVATE WebScene::WebGPU) + add_test(NAME headless_gpu COMMAND headless_gpu) + set_tests_properties(headless_gpu PROPERTIES TIMEOUT 180) +endif() diff --git a/tests/Headless/headless_gpu.cpp b/tests/Headless/headless_gpu.cpp new file mode 100644 index 000000000..75c835cbd --- /dev/null +++ b/tests/Headless/headless_gpu.cpp @@ -0,0 +1,72 @@ +#include "native_webgpu_surface.h" +#include +#include +#include +#include +using namespace webscene::graphics; +static void check(bool condition, const char* message) { if (!condition) throw std::runtime_error(message); } +static auto paint(native_headless_webgpu_surface& surface, wgpu::Color color) { + auto texture = surface.current_texture(); check(bool(texture), "Unexpected image backpressure"); + auto encoder = surface.device().CreateCommandEncoder(); + wgpu::RenderPassColorAttachment attachment{}; + attachment.view = texture.CreateView(); attachment.loadOp = wgpu::LoadOp::Clear; + attachment.storeOp = wgpu::StoreOp::Store; attachment.clearValue = color; + wgpu::RenderPassDescriptor pass{}; pass.colorAttachmentCount = 1; pass.colorAttachments = &attachment; + auto render = encoder.BeginRenderPass(&pass); render.End(); + auto commands = encoder.Finish(); surface.device().GetQueue().Submit(1, &commands); + return surface.present(); +} +static auto wait(std::shared_ptr snapshot) { + check(bool(snapshot), "No submitted snapshot"); + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(20); + // Deliberately no ProcessEvents/frame/presentation pumping: idle completion is required. + while (snapshot->state() == webscene_gpu_image_snapshot::status::pending && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + auto result = snapshot->resolve(); check(bool(result), "Headless completion failed while idle"); return result; +} +static void red(const webscene_gpu_image_lease_v3& image, uint32_t width, uint32_t height) { + auto capture = capture_native_image(image); + check(capture.metadata.width == width && capture.metadata.height == height, "Wrong retained dimensions"); + check(capture.row_bytes == width * 4 && capture.pixels.size() == size_t(width) * height * 4, "Bad capture layout"); + for (size_t offset = 0; offset < capture.pixels.size(); offset += 4) + check(capture.pixels[offset] == 0 && capture.pixels[offset + 1] == 0 && capture.pixels[offset + 2] == 255 && capture.pixels[offset + 3] == 255, "GPU pixels corrupted"); +} +int main() { + auto options = headless_webgpu_options::environment(); + native_headless_webgpu_surface surface(99, 65, 47, 1024 * 1024, options); + const auto info = surface.adapter_info(); + std::cout << "backend=Vulkan software=" << info.software << " device=" << info.device << " driver=" << info.description << '\n'; + check(!options.force_software || info.software, "Software adapter request was misreported"); + auto first = wait(paint(surface, {1,0,0,1})); + auto second = wait(paint(surface, {0,1,0,1})); + auto third = wait(paint(surface, {0,0,1,1})); + check(!surface.current_texture(), "Retained frame ring exceeded three images"); + check(surface.diagnostic_captures() == 0, "Ordinary rendering performed CPU readback"); + check(surface.occupancy().retained == 3 && surface.occupancy().producer_pending == 0, "Incorrect image ownership counters"); + red(*first, 65, 47); + auto too_small = false; + try { capture_native_image(*first, {30'000'000'000ULL, 4}); } catch (const std::length_error&) { too_small = true; } + check(too_small, "Capture memory budget was ignored"); + check(surface.occupancy().consumer_pending == 0, "Rejected capture leaked a consumer"); + second.reset(); third.reset(); + surface.resize(83, 59); + red(*first, 65, 47); + auto resized = wait(paint(surface, {1,0,0,1})); red(*resized, 83, 59); + check(resized->value.describe().allocation_generation != first->value.describe().allocation_generation, "Resize generation not changed"); + bool bad_size = false; + try { surface.resize(0, 10); } catch (const std::invalid_argument&) { bad_size = true; } + check(bad_size, "Zero-sized texture accepted"); + const auto captures = surface.diagnostic_captures(); + first.reset(); resized.reset(); + for (int i = 0; i < 200; ++i) { auto image = wait(paint(surface, {1,0,0,1})); } + check(surface.diagnostic_captures() == captures, "Normal frames triggered hidden capture"); + check(surface.allocated_bytes() <= 1024 * 1024, "Image allocations exceed byte budget"); + auto retained = wait(paint(surface, {1,0,0,1})); + surface.close(); red(*retained, 83, 59); retained.reset(); + check(surface.occupancy().busy == 0, "Shutdown leaked image leases"); + for (int i = 0; i < 8; ++i) { + native_headless_webgpu_surface next(100+i, 16, 16, 65536, options); + auto pending = paint(next, {1,0,0,1}); next.close(); auto image = wait(pending); red(*image,16,16); + } + std::cout << "headless GPU contracts passed\n"; +} From 24890854a7d1efd7d69d96fa233ddd705ef6e281 Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:20:32 +0100 Subject: [PATCH 05/10] sdk: validate installed native document consumers and include host JSON dependency --- eng/sdk/header-closure.py | 7 +++++++ tests/Headless/CMakeLists.txt | 5 ++++- tests/Headless/Consumer.html | 3 +++ tests/Headless/document.cpp | 26 ++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) mode change 100644 => 100755 eng/sdk/header-closure.py create mode 100644 tests/Headless/Consumer.html create mode 100644 tests/Headless/document.cpp diff --git a/eng/sdk/header-closure.py b/eng/sdk/header-closure.py old mode 100644 new mode 100755 index 1c902f0fc..138ff8d40 --- a/eng/sdk/header-closure.py +++ b/eng/sdk/header-closure.py @@ -23,3 +23,10 @@ relative=item.relative_to(authoring if item.is_relative_to(authoring) else native) print(f'install(FILES "{item}" DESTINATION "include/{relative.parent.as_posix()}")') print(f'set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "{item}")') +# Private implementation dependency for native SDK tools/hosts. Never part of +# the native document API or a JavaScript/runtime component. +json_root=root/'samples/NativeKestrel/third_party/nlohmann' +print('if(CMAKE_SYSTEM_NAME STREQUAL "Linux")') +print(f'install(FILES "{json_root}/json.hpp" DESTINATION include/third_party/nlohmann)') +print(f'install(FILES "{json_root}/LICENSE.MIT" DESTINATION share/licenses/WebScene RENAME nlohmann-LICENSE)') +print('endif()') diff --git a/tests/Headless/CMakeLists.txt b/tests/Headless/CMakeLists.txt index 2b322872d..c530d7fcc 100644 --- a/tests/Headless/CMakeLists.txt +++ b/tests/Headless/CMakeLists.txt @@ -4,9 +4,12 @@ include(CTest) find_package(WebScene REQUIRED CONFIG COMPONENTS NativeWeb SharedCSS Compiler) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +add_executable(headless_document document.cpp) +target_link_libraries(headless_document PRIVATE WebScene::SharedCSS) +webscene_compile_html(headless_document Consumer.html MODULE headless.consumer.ui CSS_BACKEND shared) +add_test(NAME headless_document COMMAND headless_document) option(HEADLESS_TEST_WEBGPU "Exercise the installed Vulkan SDK" ON) if(HEADLESS_TEST_WEBGPU) - find_package(WebScene REQUIRED CONFIG COMPONENTS WebGPU) if(NOT TARGET WebScene::WebGPU) message(FATAL_ERROR "The headless GPU contract requires the real WebGPU component") endif() diff --git a/tests/Headless/Consumer.html b/tests/Headless/Consumer.html new file mode 100644 index 000000000..0982d29ce --- /dev/null +++ b/tests/Headless/Consumer.html @@ -0,0 +1,3 @@ +

0

diff --git a/tests/Headless/document.cpp b/tests/Headless/document.cpp new file mode 100644 index 000000000..2193cb9a9 --- /dev/null +++ b/tests/Headless/document.cpp @@ -0,0 +1,26 @@ +#include +#include +#include +import headless.consumer.ui; +static void check(bool condition,const char* message){if(!condition)throw std::runtime_error(message);} +int main(){ + webscene::native_web::document document;auto view=compiled_ui::build(document); + check(!document.find("row"),"Compiled template must remain inert"); + auto first=compiled_ui::instantiate(document,view.named("rows"),"row"); + auto second=compiled_ui::instantiate(document,view.named("rows"),"row"); + check(first.named("label")!=second.named("label"),"Template instance references alias"); + document.set_text(first.named("label"),"literal & text"); + document.attribute(second.named("row"),"class","row selected"); + int count=0;auto click=document.on(view.named("increment"),"click",[&](auto&){document.set_text(view.named("count"),std::to_string(++count));}); + document.render(640,480);auto box=document.bounds(view.named("increment")); + document.pointer("pointerdown",box.x+5,box.y+5,1);document.pointer("pointerup",box.x+5,box.y+5,0); + check(count==1&&document.text_content(view.named("count"))=="1","Native pointer did not activate compiled control"); + check(document.text_content(first.named("label"))=="literal & text","User text was interpreted as HTML"); + check(document.bounds(first.named("row")).height==24&&document.bounds(second.named("row")).height==32,"Shared CSS template layout mismatch"); + document.focus(view.named("input"));check(document.text_input("Aé🙂"),"Native UTF-8 input rejected"); + check(document.value(view.named("input"))=="Aé🙂","Native UTF-8 input changed bytes"); + document.set_selection(view.named("input"),1,3);document.text_input("x");check(document.value(view.named("input"))=="Ax🙂","Selection replacement failed"); + document.set_dark_color_scheme(true);document.render(800,600);check(document.bounds(view.named("main")).width==360,"Shared CSS theme did not apply"); + document.set_dark_color_scheme(false);document.render(400,300);check(document.bounds(view.named("main")).width==300,"Shared CSS theme did not revert"); + click.dispose();document.dispose();check(document.disposed(),"Native document did not dispose"); +} From 105161a0f803f0b6c1fdecc1e46a6180f2666a65 Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:32:01 +0100 Subject: [PATCH 06/10] test: run original native Kestrel GPU parity contracts on the Linux installed SDK --- docs/guides/linux-native-headless-sdk.md | 39 ++++++++++++++++++++++++ tests/Headless/CMakeLists.txt | 13 ++++++++ tests/NativeWeb/kestrel_gpu.cpp | 10 ++++++ 3 files changed, 62 insertions(+) create mode 100644 docs/guides/linux-native-headless-sdk.md diff --git a/docs/guides/linux-native-headless-sdk.md b/docs/guides/linux-native-headless-sdk.md new file mode 100644 index 000000000..f5a79dc23 --- /dev/null +++ b/docs/guides/linux-native-headless-sdk.md @@ -0,0 +1,39 @@ +# Linux native headless SDK profile + +The Linux x86_64 SDK is a Native-only producer profile. It installs `WebScene::Core`, `NativeWeb`, `SharedCSS`, `Compiler`, and optionally `WebGPU`. It does not build or package V8, the Runtime component, application scripts or runtime-loaded application HTML. The compiler/ABI pin is LLVM 22.1.1 with libc++. See `src/WebScene.Sdk/cmake/WebSceneLinuxSDK.cmake` and the checksum-pinned installer in `eng/sdk/install-linux-llvm.py`. + +HTML/templates are still compiled to C++20 at build time. Applications explicitly select `CSS_BACKEND shared`; native CSS parsing is allowed. The Linux package exports relocatable imported targets and a platform marker so a macOS binary SDK cannot accidentally be consumed on Linux. Requesting unavailable Runtime components fails rather than substituting another host. + +## Offscreen WebGPU + +On Linux, `native_webgpu_surface` names the reusable `native_headless_webgpu_surface`. The macOS IOSurface and Windows DXGI implementations remain unchanged. The Linux surface uses pinned Dawn Vulkan textures and the existing immutable image-lease/pool contracts. It is an offscreen native authoring/presentation target, **not** a claim of Linux desktop dma-buf/opaque-FD interop, X11/Wayland composition or Avalonia/Uno GPU parity. + +Three color allocations are admitted at most, under an explicit byte budget. A retained image remains usable across resize and after surface close; only idle slots can be reclaimed. Producer work retires from actual Dawn queue completion, independently of application frame pumping. Exhausted slots return an empty texture rather than blocking the owner thread or creating an unbounded queue. Adapter identity is exposed, unknown adapters are rejected, and software devices require explicit authorization (`WEBSCENE_HEADLESS_ALLOW_SOFTWARE=1`). CI additionally forces a fallback adapter and records it as software, never hardware qualification. + +`capture_native_image` is an explicit native diagnostic/export API. The provider interface does not make CPU-only hosts link Dawn. Captures use bounded GPU-to-buffer copies, wait only on the explicit diagnostic operation, and retain the consumer until GPU copy completion even when mapping times out. Normal frame publication performs no CPU readback. Only top-left 8-bit sRGB images are currently composed by the paired AppScene headless PNG path; unsupported formats fail explicitly. + +## Build and validation + +```sh +python3 eng/sdk/install-linux-llvm.py "$HOME/.cache/webscene/llvm-22.1.1" +export WEBSCENE_LLVM_ROOT="$HOME/.cache/webscene/llvm-22.1.1" +cmake -S src/WebScene.Sdk -B build-sdk -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$PWD/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake" \ + -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX="$PWD/sdk" \ + -DWEBSCENE_SDK_WEBGPU=OFF +cmake --build build-sdk --parallel 3 +cmake --install build-sdk +python3 eng/sdk/stage-linux-runtime.py --llvm "$WEBSCENE_LLVM_ROOT" --sdk sdk +cmake -S tests/Headless -B build-consumer -G Ninja \ + -DCMAKE_PREFIX_PATH="$PWD/sdk" \ + -DCMAKE_TOOLCHAIN_FILE="$PWD/sdk/lib/cmake/WebScene/WebSceneToolchain.cmake" \ + -DHEADLESS_TEST_WEBGPU=OFF +cmake --build build-consumer --parallel 3 +ctest --test-dir build-consumer --output-on-failure +``` + +For GPU qualification, first build the existing pinned Dawn Linux package with `eng/graphics/build.py dawn --rid linux-x64`, then set `WEBSCENE_SDK_WEBGPU=ON`, `WEBSCENE_GRAPHICS_SDK_ROOT=/linux-x64`, and `HEADLESS_TEST_WEBGPU=ON`. Use the same LLVM/libc++ producer toolchain. Ubuntu 24.04 prerequisites and explicit software-Vulkan configuration are in the paired AppScene workflow. + +The installed-consumer tests cover compiled shared-CSS document construction, template identity, native pointer/input interaction, theme layout, bounded frame ownership, idle producer progress, exact captured pixels, retained resize, capture budgets and repeated teardown. They also compile the existing native Kestrel GPU modules against the installed SDK and run the original pipeline, mesh/line pixel, scene invalidation, pending-frame and resize assertions. The only platform change in that original test is Vulkan adapter selection on Linux. + +The paired AppScene PR supplies the control host, deterministic stepping, screenshots, native application samples, relocation/bundle auditing and end-to-end tests. This infrastructure does not complete the original JavaScript Kestrel migration. Software-Vulkan results do not establish hardware performance, full conformance, desktop external-memory sharing, media support, or browser/WebGL parity. Issue #46 and the parent graphics epic remain open until their separate acceptance gates are met. diff --git a/tests/Headless/CMakeLists.txt b/tests/Headless/CMakeLists.txt index c530d7fcc..48e2ac427 100644 --- a/tests/Headless/CMakeLists.txt +++ b/tests/Headless/CMakeLists.txt @@ -17,4 +17,17 @@ if(HEADLESS_TEST_WEBGPU) target_link_libraries(headless_gpu PRIVATE WebScene::WebGPU) add_test(NAME headless_gpu COMMAND headless_gpu) set_tests_properties(headless_gpu PROPERTIES TIMEOUT 180) + # Compile the original native Kestrel modules against the installed SDK. + # The existing test retains every pixel, pending-frame and resize assertion. + set(kestrel "${CMAKE_CURRENT_SOURCE_DIR}/../../samples/NativeKestrel/native") + add_library(headless_kestrel_modules STATIC) + target_sources(headless_kestrel_modules PUBLIC FILE_SET CXX_MODULES BASE_DIRS "${kestrel}" FILES + "${kestrel}/math.cppm" "${kestrel}/drawing.cppm" "${kestrel}/examples.cppm" + "${kestrel}/camera.cppm" "${kestrel}/geometry.cppm" "${kestrel}/render_data.cppm" + "${kestrel}/shaders.cppm" "${kestrel}/gpu_pipelines.cppm" "${kestrel}/gpu_renderer.cppm" "${kestrel}/viewport.cppm") + target_link_libraries(headless_kestrel_modules PUBLIC WebScene::NativeWeb WebScene::WebGPU) + add_executable(headless_kestrel "${CMAKE_CURRENT_SOURCE_DIR}/../NativeWeb/kestrel_gpu.cpp") + target_link_libraries(headless_kestrel PRIVATE headless_kestrel_modules) + add_test(NAME headless_kestrel COMMAND headless_kestrel) + set_tests_properties(headless_kestrel PROPERTIES TIMEOUT 180) endif() diff --git a/tests/NativeWeb/kestrel_gpu.cpp b/tests/NativeWeb/kestrel_gpu.cpp index 379a0c083..d3cafa3ee 100644 --- a/tests/NativeWeb/kestrel_gpu.cpp +++ b/tests/NativeWeb/kestrel_gpu.cpp @@ -13,8 +13,18 @@ import kestrel.viewport; import kestrel.gpu_renderer; import kestrel.render_data; int main(int argc, char **argv) { +#if defined(__linux__) + const auto headless_options = webscene::graphics::headless_webgpu_options::environment(); + auto gpu = webscene::graphics::native_webgpu_device::create( + wgpu::BackendType::Vulkan, {}, headless_options.force_software); + wgpu::AdapterInfo adapter_info{}; + if (gpu.adapter.GetInfo(&adapter_info) != wgpu::Status::Success || + (adapter_info.adapterType == wgpu::AdapterType::CPU && !headless_options.allow_software)) + throw std::runtime_error("Linux Kestrel test requires an identified, explicitly authorized adapter"); +#else auto gpu = webscene::graphics::native_webgpu_device::create( wgpu::BackendType::Metal); +#endif kestrel::gpu_pipelines pipelines(gpu.device, wgpu::TextureFormat::BGRA8Unorm); gpu.instance.ProcessEvents(); if (*gpu.failed || !pipelines.lines || !pipelines.mesh || !pipelines.xray || From c87cafbb3fbe9e7958728ec26c3f699532f54f1f Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:46:11 +0100 Subject: [PATCH 07/10] test: cover native image capture ownership and original Kestrel frame benchmarks --- tests/Headless/CMakeLists.txt | 6 +++- tests/Headless/image_capture_ownership.cpp | 40 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/Headless/image_capture_ownership.cpp diff --git a/tests/Headless/CMakeLists.txt b/tests/Headless/CMakeLists.txt index 48e2ac427..547df3ce6 100644 --- a/tests/Headless/CMakeLists.txt +++ b/tests/Headless/CMakeLists.txt @@ -8,6 +8,9 @@ add_executable(headless_document document.cpp) target_link_libraries(headless_document PRIVATE WebScene::SharedCSS) webscene_compile_html(headless_document Consumer.html MODULE headless.consumer.ui CSS_BACKEND shared) add_test(NAME headless_document COMMAND headless_document) +add_executable(image_capture_ownership image_capture_ownership.cpp) +target_link_libraries(image_capture_ownership PRIVATE WebScene::Core) +add_test(NAME image_capture_ownership COMMAND image_capture_ownership) option(HEADLESS_TEST_WEBGPU "Exercise the installed Vulkan SDK" ON) if(HEADLESS_TEST_WEBGPU) if(NOT TARGET WebScene::WebGPU) @@ -29,5 +32,6 @@ if(HEADLESS_TEST_WEBGPU) add_executable(headless_kestrel "${CMAKE_CURRENT_SOURCE_DIR}/../NativeWeb/kestrel_gpu.cpp") target_link_libraries(headless_kestrel PRIVATE headless_kestrel_modules) add_test(NAME headless_kestrel COMMAND headless_kestrel) - set_tests_properties(headless_kestrel PROPERTIES TIMEOUT 180) + add_test(NAME headless_kestrel_benchmark COMMAND headless_kestrel --benchmark) + set_tests_properties(headless_kestrel headless_kestrel_benchmark PROPERTIES TIMEOUT 180) endif() diff --git a/tests/Headless/image_capture_ownership.cpp b/tests/Headless/image_capture_ownership.cpp new file mode 100644 index 000000000..a5c73ae7c --- /dev/null +++ b/tests/Headless/image_capture_ownership.cpp @@ -0,0 +1,40 @@ +#include +#include +#include +using namespace webscene::graphics; +static void check(bool value,const char* message){if(!value)throw std::runtime_error(message);} +struct provider final:native_image_capture_provider { + bool fail=false; + captured_native_image capture(std::shared_ptr image,image_capture_options)override { + if(fail)throw std::runtime_error("deliberate provider failure"); + return {image->describe(),4,{0,0,255,255}}; + } +}; +static auto publish(owned_image_pool& pool){ + auto writer=pool.acquire();check(bool(writer),"Producer reservation failed"); + writer->set_metadata({1,2,3,4,1,4,1,1});writer->begin();auto retained=writer->publish();writer->complete(); + check(bool(retained),"Image publication failed");return std::make_shared(std::move(*retained)); +} +int main(){ + auto native=std::make_shared();std::weak_ptr weak=native; + std::shared_ptr retained; + { + owned_image_pool pool(native);retained=publish(pool); + check(capture_native_image(*retained).pixels.size()==4,"Native provider capture failed"); + check(pool.inspect_occupancy().consumer_pending==0,"Successful synchronous capture leaked a consumer"); + native->fail=true;bool rejected=false; + try{capture_native_image(*retained);}catch(const std::runtime_error&){rejected=true;} + check(rejected&&pool.inspect_occupancy().consumer_pending==0,"Throwing provider leaked a consumer"); + native->fail=false;rejected=false; + try{capture_native_image(*retained,{0,4096});}catch(const std::invalid_argument&){rejected=true;} + check(rejected&&pool.inspect_occupancy().consumer_pending==0,"Invalid options acquired a consumer"); + pool.close(); + } + native.reset();check(!weak.expired(),"Retained image lost its provider at pool close"); + check(capture_native_image(*retained).metadata.content_serial==4,"Closed-pool retained image changed identity"); + retained.reset();check(weak.expired(),"Capture ownership retained a closed provider"); + struct unsupported final:image_provider_lifetime{}; + owned_image_pool other(std::make_shared());auto image=publish(other);bool rejected=false; + try{capture_native_image(*image);}catch(const std::runtime_error&){rejected=true;} + check(rejected&&other.inspect_occupancy().consumer_pending==0,"Unsupported provider leaked or pretended to capture"); +} From 630e305c47c0a9296fd8c246cbe20d23a5f02806 Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:49:12 +0100 Subject: [PATCH 08/10] sdk: ignore generated text when collecting installed header dependencies --- eng/sdk/header-closure.py | 74 +++++++++++++++++++++------------- eng/sdk/test-header-closure.py | 17 ++++++++ 2 files changed, 63 insertions(+), 28 deletions(-) create mode 100755 eng/sdk/test-header-closure.py diff --git a/eng/sdk/header-closure.py b/eng/sdk/header-closure.py index 138ff8d40..98cde4508 100755 --- a/eng/sdk/header-closure.py +++ b/eng/sdk/header-closure.py @@ -1,32 +1,50 @@ #!/usr/bin/env python3 -"""Generate installation rules for public SDK headers and their local includes.""" +"""Generate install rules for actual local includes of public SDK headers.""" from pathlib import Path import re import sys -root=Path(sys.argv[1]).resolve() -native=root/'experiments/WebScene.NativeEngine.Probe/native' -authoring=root/'src/WebScene.NativeWeb/include' -roots=[authoring,native,native/'graphics'] -pending=list(authoring.rglob('*.hpp'))+[native/'webscene/compiled_document.hpp',native/'graphics/native_webgpu_surface.h'] -seen=set() -while pending: - item=pending.pop().resolve() - if item in seen:continue - seen.add(item) - for included in re.findall(r'^\s*#\s*include\s*[<"]([^">]+)[">]',item.read_text(),re.M): - candidates=[item.parent/included]+[base/included for base in roots] - match=next((candidate for candidate in candidates if candidate.is_file()),None) - if match:pending.append(match) - elif included.startswith(('webscene_','webscene/')): - raise RuntimeError(f'Unresolved SDK header dependency {included} in {item}') -for item in sorted(seen): - relative=item.relative_to(authoring if item.is_relative_to(authoring) else native) - print(f'install(FILES "{item}" DESTINATION "include/{relative.parent.as_posix()}")') - print(f'set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "{item}")') -# Private implementation dependency for native SDK tools/hosts. Never part of -# the native document API or a JavaScript/runtime component. -json_root=root/'samples/NativeKestrel/third_party/nlohmann' -print('if(CMAKE_SYSTEM_NAME STREQUAL "Linux")') -print(f'install(FILES "{json_root}/json.hpp" DESTINATION include/third_party/nlohmann)') -print(f'install(FILES "{json_root}/LICENSE.MIT" DESTINATION share/licenses/WebScene RENAME nlohmann-LICENSE)') -print('endif()') + +# Consume comments and string literals before looking for their embedded text. +# Generated source in a raw string is not a preprocessor include directive. +TOKENS = re.compile( + r'(?P^[ \t]*\#[ \t]*include[ \t]*[<"](?P[^">\r\n]+)[">])' + r'|(?:u8|u|U|L)?R"(?P[^ ()\\\t\r\n]{0,16})\(.*?\)(?P=delimiter)"' + r'|/\*.*?\*/|//[^\r\n]*' + r'|"(?:\\.|[^"\\])*"|\x27(?:\\.|[^\x27\\])*\x27', + re.MULTILINE | re.DOTALL, +) + +def includes(text): + return [m.group('path') for m in TOKENS.finditer(text) if m.group('include')] + +def generate(root): + root=Path(root).resolve() + native=root/'experiments/WebScene.NativeEngine.Probe/native' + authoring=root/'src/WebScene.NativeWeb/include' + roots=[authoring,native,native/'graphics'] + pending=list(authoring.rglob('*.hpp'))+[native/'webscene/compiled_document.hpp',native/'graphics/native_webgpu_surface.h'] + seen=set() + while pending: + item=pending.pop().resolve() + if item in seen:continue + if not any(item.is_relative_to(base) for base in (authoring,native)): + raise RuntimeError(f'Public header includes a producer-only source: {item}') + seen.add(item) + for included in includes(item.read_text()): + candidates=[item.parent/included]+[base/included for base in roots] + match=next((candidate for candidate in candidates if candidate.is_file()),None) + if match:pending.append(match) + elif included.startswith(('webscene_','webscene/')): + raise RuntimeError(f'Unresolved SDK header dependency {included} in {item}') + for item in sorted(seen): + relative=item.relative_to(authoring if item.is_relative_to(authoring) else native) + print(f'install(FILES "{item}" DESTINATION "include/{relative.parent.as_posix()}")') + print(f'set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "{item}")') + json_root=root/'samples/NativeKestrel/third_party/nlohmann' + print('if(CMAKE_SYSTEM_NAME STREQUAL "Linux")') + print(f'install(FILES "{json_root}/json.hpp" DESTINATION include/third_party/nlohmann)') + print(f'install(FILES "{json_root}/LICENSE.MIT" DESTINATION share/licenses/WebScene RENAME nlohmann-LICENSE)') + print('endif()') + +if __name__=='__main__': + generate(sys.argv[1]) diff --git a/eng/sdk/test-header-closure.py b/eng/sdk/test-header-closure.py new file mode 100755 index 000000000..26c68bfff --- /dev/null +++ b/eng/sdk/test-header-closure.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path +import unittest +spec=importlib.util.spec_from_file_location('closure',Path(__file__).with_name('header-closure.py')) +closure=importlib.util.module_from_spec(spec);spec.loader.exec_module(closure) +class Includes(unittest.TestCase): + def test_real_directives(self): + self.assertEqual(closure.includes('#include "a.hpp"\n # include // c\n'),['a.hpp','b.h']) + def test_raw_generated_source(self): + text='auto x=R"cpp(\n#include "../../../tooling/webscene-uic/main.cpp"\n#include "unterminated\nmore text)cpp";\n#include "real.h"\n' + self.assertEqual(closure.includes(text),['real.h']) + def test_comments(self): + self.assertEqual(closure.includes('/*\n#include "a.h"\n*/\n// #include "b.h"\n#include "real.h"\n'),['real.h']) + def test_no_multiline_paths(self): + self.assertEqual(closure.includes('#include "not-a-path\nmore text"\n'),[]) +if __name__=='__main__':unittest.main() From b68a79f0e9163ea16e7cf19df369c96be43d739e Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:58:42 +0100 Subject: [PATCH 09/10] test: validate Linux SDK header closure independently of dependency builds --- .github/workflows/linux-sdk-contracts.yml | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/linux-sdk-contracts.yml diff --git a/.github/workflows/linux-sdk-contracts.yml b/.github/workflows/linux-sdk-contracts.yml new file mode 100644 index 000000000..b68550167 --- /dev/null +++ b/.github/workflows/linux-sdk-contracts.yml @@ -0,0 +1,39 @@ +name: Linux SDK source contracts +on: + push: + branches: [codex/linux-headless-sdk] + pull_request: + paths: ['eng/sdk/**', 'experiments/WebScene.NativeEngine.Probe/native/graphics/**', 'src/WebScene.Sdk/**', '.github/workflows/linux-sdk-contracts.yml'] +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Header scanner regressions and public dependency closure + run: | + python3 eng/sdk/test-header-closure.py + python3 - <<'PY' + import importlib.util + from pathlib import Path + import sys + script=Path('eng/sdk/header-closure.py') + spec=importlib.util.spec_from_file_location('closure',script) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + root=Path.cwd();native=root/'experiments/WebScene.NativeEngine.Probe/native';authoring=root/'src/WebScene.NativeWeb/include' + pending=[(p,[]) for p in list(authoring.rglob('*.hpp'))+[native/'webscene/compiled_document.hpp',native/'graphics/native_webgpu_surface.h']] + seen=set() + while pending: + item,parents=pending.pop();item=item.resolve() + if item in seen:continue + seen.add(item) + if not any(item.is_relative_to(p) for p in (native,authoring)): + raise RuntimeError('Outside SDK header roots: '+' -> '.join(str(p.relative_to(root)) for p in parents+[item])) + for name in module.includes(item.read_text()): + candidate=next((p for p in [item.parent/name,authoring/name,native/name,native/'graphics'/name] if p.is_file()),None) + if candidate:pending.append((candidate,parents+[item])) + print('Verified',len(seen),'public header dependencies') + module.generate(root) + PY From 8ddd162f4669eb91b86443900e6dac2c58158c6d Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:00:08 +0100 Subject: [PATCH 10/10] sdk: make include scanning deterministic and diagnose source-only dependency chains --- eng/sdk/header-closure.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/eng/sdk/header-closure.py b/eng/sdk/header-closure.py index 98cde4508..dba1be4fd 100755 --- a/eng/sdk/header-closure.py +++ b/eng/sdk/header-closure.py @@ -4,13 +4,12 @@ import re import sys -# Consume comments and string literals before looking for their embedded text. -# Generated source in a raw string is not a preprocessor include directive. TOKENS = re.compile( r'(?P^[ \t]*\#[ \t]*include[ \t]*[<"](?P[^">\r\n]+)[">])' r'|(?:u8|u|U|L)?R"(?P[^ ()\\\t\r\n]{0,16})\(.*?\)(?P=delimiter)"' r'|/\*.*?\*/|//[^\r\n]*' - r'|"(?:\\.|[^"\\])*"|\x27(?:\\.|[^\x27\\])*\x27', + r'|"(?:\\.|[^"\\\r\n])*"' + r'|(? '.join(str(path.relative_to(root)) for path in chain+[item])) seen.add(item) for included in includes(item.read_text()): candidates=[item.parent/included]+[base/included for base in roots] match=next((candidate for candidate in candidates if candidate.is_file()),None) - if match:pending.append(match) + if match:pending.append((match,chain+[item])) elif included.startswith(('webscene_','webscene/')): raise RuntimeError(f'Unresolved SDK header dependency {included} in {item}') for item in sorted(seen): @@ -45,6 +44,7 @@ def generate(root): print(f'install(FILES "{json_root}/json.hpp" DESTINATION include/third_party/nlohmann)') print(f'install(FILES "{json_root}/LICENSE.MIT" DESTINATION share/licenses/WebScene RENAME nlohmann-LICENSE)') print('endif()') + print(f'WebScene SDK header closure: {len(seen)} files',file=sys.stderr) if __name__=='__main__': generate(sys.argv[1])