From aa15071b7cde0d295c524684fcf62ca6c0cc3f36 Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:46:33 +0100 Subject: [PATCH 01/11] build: transfer checksum-verified native platform source changes --- .automation/headless-change.patch.gz | Bin 0 -> 9601 bytes .github/workflows/land-headless-changes.yml | 26 ++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 .automation/headless-change.patch.gz create mode 100644 .github/workflows/land-headless-changes.yml diff --git a/.automation/headless-change.patch.gz b/.automation/headless-change.patch.gz new file mode 100644 index 0000000000000000000000000000000000000000..1e7520207c4aa8de10b3e296092231cab178d5b5 GIT binary patch literal 9601 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 literal 0 HcmV?d00001 diff --git a/.github/workflows/land-headless-changes.yml b/.github/workflows/land-headless-changes.yml new file mode 100644 index 000000000..ab6478488 --- /dev/null +++ b/.github/workflows/land-headless-changes.yml @@ -0,0 +1,26 @@ +name: Apply reviewed native platform changes +on: + push: + branches: [codex/linux-headless-platform] + paths: ['.automation/headless-change.patch.gz', '.github/workflows/land-headless-changes.yml'] +permissions: + contents: write +jobs: + apply: + if: github.repository == 'SceneTech/WebScene' && github.ref == 'refs/heads/codex/linux-headless-platform' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Apply checksum-verified source patch and remove transport + shell: bash + run: | + set -euo pipefail + echo '3c8f47aef661a657973b1e2304e1b10306e880c3139668282aba0c63cbca00e1 .automation/headless-change.patch.gz' | sha256sum --check + gzip -dc .automation/headless-change.patch.gz | git apply --check + gzip -dc .automation/headless-change.patch.gz | git apply --index + git rm .automation/headless-change.patch.gz .github/workflows/land-headless-changes.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git -c core.hooksPath=/dev/null commit -m 'feat: add native Linux SDK and bounded offscreen WebGPU surfaces' + git push origin HEAD:refs/heads/codex/linux-headless-platform From 8853f0b7a01a31df6c0f76408855d55598917edb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:46:55 +0000 Subject: [PATCH 02/11] feat: add native Linux SDK and bounded offscreen WebGPU surfaces --- .automation/headless-change.patch.gz | Bin 9601 -> 0 bytes .github/workflows/land-headless-changes.yml | 26 --- .../native/graphics/dawn_canvas_images.h | 22 ++ .../native/graphics/native_webgpu_device.h | 4 +- .../native_webgpu_offscreen_surface.h | 211 ++++++++++++++++++ .../native/graphics/native_webgpu_surface.h | 7 +- src/WebScene.Sdk/CMakeLists.txt | 7 +- src/WebScene.Sdk/cmake/LinuxSDK.cmake | 42 ++++ src/WebScene.Sdk/cmake/WebSceneConfig.cmake | 4 + .../cmake/WebSceneLinuxConfig.cmake | 45 ++++ .../cmake/WebSceneToolchain.cmake | 8 + tests/SDKLinux/CMakeLists.txt | 13 ++ tests/SDKLinux/Main.html | 7 + tests/SDKLinux/compiled_native.cpp | 23 ++ tests/SDKLinux/offscreen_gpu.cpp | 81 +++++++ 15 files changed, 471 insertions(+), 29 deletions(-) delete mode 100644 .automation/headless-change.patch.gz delete mode 100644 .github/workflows/land-headless-changes.yml create mode 100644 experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h create mode 100644 src/WebScene.Sdk/cmake/LinuxSDK.cmake create mode 100644 src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake create mode 100644 tests/SDKLinux/CMakeLists.txt create mode 100644 tests/SDKLinux/Main.html create mode 100644 tests/SDKLinux/compiled_native.cpp create mode 100644 tests/SDKLinux/offscreen_gpu.cpp diff --git a/.automation/headless-change.patch.gz b/.automation/headless-change.patch.gz deleted file mode 100644 index 1e7520207c4aa8de10b3e296092231cab178d5b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9601 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 diff --git a/.github/workflows/land-headless-changes.yml b/.github/workflows/land-headless-changes.yml deleted file mode 100644 index ab6478488..000000000 --- a/.github/workflows/land-headless-changes.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Apply reviewed native platform changes -on: - push: - branches: [codex/linux-headless-platform] - paths: ['.automation/headless-change.patch.gz', '.github/workflows/land-headless-changes.yml'] -permissions: - contents: write -jobs: - apply: - if: github.repository == 'SceneTech/WebScene' && github.ref == 'refs/heads/codex/linux-headless-platform' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - name: Apply checksum-verified source patch and remove transport - shell: bash - run: | - set -euo pipefail - echo '3c8f47aef661a657973b1e2304e1b10306e880c3139668282aba0c63cbca00e1 .automation/headless-change.patch.gz' | sha256sum --check - gzip -dc .automation/headless-change.patch.gz | git apply --check - gzip -dc .automation/headless-change.patch.gz | git apply --index - git rm .automation/headless-change.patch.gz .github/workflows/land-headless-changes.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git -c core.hooksPath=/dev/null commit -m 'feat: add native Linux SDK and bounded offscreen WebGPU surfaces' - git push origin HEAD:refs/heads/codex/linux-headless-platform diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h index fbaa13bbe..9b8ad5911 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h @@ -60,6 +60,28 @@ class dawn_canvas_images { }); return submitted_frame{std::move(*image),std::move(status)}; } + // Native applications submit their own command buffers before presenting. + // Even if retaining a snapshot hits the ticket limit, keep the producer + // reserved until ALL work already submitted to its queue has completed. + std::optional retire_submitted(frame&& input, + std::shared_ptr completion={}) { + check_thread(); + if (!input.producer.belongs_to(storage_.get())) + throw std::invalid_argument("foreign canvas producer"); + auto status=std::make_shared>(submission_status::pending); + auto pending=std::make_shared(std::move(input)); + pending->producer.begin(); + auto image=pending->producer.publish(); + storage_->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [pending,status,completion](wgpu::QueueWorkDoneStatus result,wgpu::StringView) { + pending->producer.complete(); + status->store(result==wgpu::QueueWorkDoneStatus::Success + ? submission_status::success : submission_status::failed,std::memory_order_release); + if (completion) completion->signal(); + }); + if (!image) return {}; // callback still owns the submitted producer + return submitted_frame{std::move(*image),std::move(status)}; + } dawn_canvas_images(wgpu::Device device,uint64_t byte_limit,size_t tickets=128,std::shared_ptr wake={}) :storage_(std::make_shared(std::move(device))),pool_(storage_,tickets,std::move(wake)),byte_limit_(byte_limit) { if (!storage_->device || !byte_limit) throw std::invalid_argument("Dawn image storage requires device and budget"); 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..ae5d16cfd 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_software_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_software_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_offscreen_surface.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h new file mode 100644 index 000000000..17d496be8 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h @@ -0,0 +1,211 @@ +#pragma once +#include "native_webgpu_device.h" +#include "dawn_canvas_images.h" +#include "image_lease_abi.h" +#include +#include +#include +#include +#include + +namespace webscene::graphics { +// No OS window or external-memory handle is required. Images remain on the +// application's Dawn device. This is NOT a Vulkan-to-EGL/Wayland presenter. +struct offscreen_gpu_dependencies final : webscene_gpu_producer_dependencies { + std::shared_ptr gpu; + mutable std::atomic captures{}; + explicit offscreen_gpu_dependencies(std::shared_ptr value) + : gpu(std::move(value)) {} + size_t count() const noexcept override { return 0; } + bool metal_event(size_t,void*& event,uint64_t& value) const override { + event=nullptr;value=0;return false; + } +}; +class offscreen_gpu_snapshot final : public webscene_gpu_image_snapshot { + dawn_canvas_images::submitted_frame submitted_; + std::shared_ptr dependencies_; + image_metadata metadata_; + std::shared_ptr resolved_; +public: + offscreen_gpu_snapshot(dawn_canvas_images::submitted_frame frame, + std::shared_ptr dependencies) + : submitted_(std::move(frame)), dependencies_(std::move(dependencies)), + metadata_(submitted_.image.describe()) {} + image_metadata describe() const override { return metadata_; } + status state() const override { + switch(submitted_.status->load(std::memory_order_acquire)) { + case dawn_canvas_images::submission_status::pending: return status::pending; + case dawn_canvas_images::submission_status::success: return status::ready; + default: return status::failed; + } + } + std::shared_ptr resolve() override { + if(state()!=status::ready) return {}; + if(!resolved_) resolved_=std::make_shared( + std::move(submitted_.image),dependencies_); + return resolved_; + } + // The default resolve_with_gpu_waits deliberately remains readiness-gated: + // no external semaphore handoff is advertised by this offscreen backend. +}; +struct offscreen_capture { + image_metadata metadata; + // Tightly packed RGBA8. Alpha convention follows metadata.alpha. + std::vector pixels; +}; + +// Explicit diagnostic/export readback, NEVER called by present/process_events. +// The image consumer survives until the queue has completed the copy, including +// timeout/error paths. Only the capture request may synchronously wait. +inline offscreen_capture capture_offscreen_image(const webscene_gpu_image_lease_v3& image, + uint64_t byte_limit=256ULL*1024*1024, uint64_t timeout_ns=30'000'000'000ULL) { + auto dependencies=std::dynamic_pointer_cast(image.dependencies); + if(!dependencies || image.requires_producer_wait) + throw std::invalid_argument("Capture requires a completed offscreen Dawn image"); + const auto metadata=image.value.describe(); + if(metadata.format!=image_format::bgra8_unorm && metadata.format!=image_format::rgba8_unorm) + throw std::invalid_argument("Capture currently requires BGRA8/RGBA8 unorm"); + const uint64_t row=uint64_t(metadata.width)*4; + const uint64_t stride=(row+255)&~uint64_t(255); + if(!metadata.width || !metadata.height || stride>std::numeric_limits::max() || stride>byte_limit/metadata.height) + throw std::invalid_argument("Capture exceeds the byte budget"); + struct consumer_owner { + std::optional value; + explicit consumer_owner(owned_image_pool::consumer c):value(std::move(c)) {} + ~consumer_owner() { if(value) value->complete(); } + }; + auto consumer=image.value.begin_consumer(); + if(!consumer) throw std::runtime_error("Capture consumer admission backpressure"); + auto owner=std::make_shared(std::move(*consumer)); + const auto& gpu=*dependencies->gpu; + auto texture=dawn_canvas_images::resolve(*owner->value,gpu.device); + wgpu::BufferDescriptor bd{}; + bd.size=stride*metadata.height; + bd.usage=wgpu::BufferUsage::CopyDst|wgpu::BufferUsage::MapRead; + auto buffer=gpu.device.CreateBuffer(&bd); + if(!buffer) throw std::runtime_error("Capture buffer creation failed"); + auto encoder=gpu.device.CreateCommandEncoder(); + wgpu::TexelCopyTextureInfo src{};src.texture=texture; + wgpu::TexelCopyBufferInfo dst{};dst.buffer=buffer; + dst.layout.bytesPerRow=uint32_t(stride);dst.layout.rowsPerImage=metadata.height; + wgpu::Extent3D extent{metadata.width,metadata.height,1}; + encoder.CopyTextureToBuffer(&src,&dst,&extent); + auto commands=encoder.Finish(); + auto queue=gpu.device.GetQueue(); + queue.Submit(1,&commands); + // The captured owner is intentionally released by this completion, not by + // the lifetime of the blocking caller or by a cancelled buffer mapping. + queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [owner](wgpu::QueueWorkDoneStatus,wgpu::StringView) {}); + struct mapped_state { bool ok=false; std::vector pixels; }; + auto state=std::make_shared(); + auto future=buffer.MapAsync(wgpu::MapMode::Read,0,bd.size,wgpu::CallbackMode::WaitAnyOnly, + [state,buffer,metadata,row,stride](wgpu::MapAsyncStatus status,wgpu::StringView) { + if(status!=wgpu::MapAsyncStatus::Success) return; + try { + const auto* data=static_cast(buffer.GetConstMappedRange()); + if(data) { + state->pixels.resize(size_t(row)*metadata.height); + for(uint32_t y=0;ypixels.data()+size_t(y)*row,data+size_t(y)*stride,size_t(row)); + if(metadata.format==image_format::bgra8_unorm) + for(size_t i=0;ipixels.size();i+=4) std::swap(state->pixels[i],state->pixels[i+2]); + state->ok=true; + } + } catch(...) { state->ok=false; } + buffer.Unmap(); + }); + const auto wait=gpu.instance.WaitAny(future,timeout_ns); + if(wait!=wgpu::WaitStatus::Success) { + buffer.Unmap(); + // Service cancellation when available; the queue-completion callback + // independently protects the image even when this wait times out. + gpu.instance.WaitAny(future,0); + throw std::runtime_error("Offscreen capture mapping timed out or was cancelled"); + } + if(!state->ok || gpu.failed->load()) throw std::runtime_error("Offscreen capture failed"); + dependencies->captures.fetch_add(1); + return {metadata,std::move(state->pixels)}; +} + +class native_webgpu_offscreen_surface final { + const std::thread::id thread_=std::this_thread::get_id(); + std::shared_ptr gpu_; + std::shared_ptr dependencies_; + dawn_canvas_images images_; + uint64_t canvas_,generation_=1,serial_=0; + uint32_t width_,height_; + std::optional current_; + static bool software_requested() { + const auto* value=std::getenv("WEBSCENE_HEADLESS_FORCE_SOFTWARE_ADAPTER"); + return value && std::string_view(value)=="1"; + } + static wgpu::BackendType backend() { +#if defined(__APPLE__) + return wgpu::BackendType::Metal; +#elif defined(_WIN32) + return wgpu::BackendType::D3D12; +#else + return wgpu::BackendType::Vulkan; +#endif + } + static std::shared_ptr create_gpu() { + auto gpu=std::make_shared( + native_webgpu_device::create(backend(),{},software_requested())); + wgpu::AdapterInfo info{}; + if(gpu->adapter.GetInfo(&info)!=wgpu::Status::Success) + throw std::runtime_error("Cannot inspect offscreen GPU adapter"); + const bool hardware=info.adapterType==wgpu::AdapterType::DiscreteGPU || + info.adapterType==wgpu::AdapterType::IntegratedGPU; + if(!hardware && !software_requested()) + throw std::runtime_error("Software GPU requires WEBSCENE_HEADLESS_FORCE_SOFTWARE_ADAPTER=1"); + return gpu; + } + void check_thread() const { + if(std::this_thread::get_id()!=thread_) throw std::logic_error("Offscreen surface requires its owner thread"); + } + std::optional retire() { + if(!current_) return {}; + auto value=images_.retire_submitted(std::move(*current_)); + current_.reset();return value; + } +public: + native_webgpu_offscreen_surface(uint64_t canvas,uint32_t width,uint32_t height, + uint64_t byte_budget=64ULL*1024*1024) + :gpu_(create_gpu()),dependencies_(std::make_shared(gpu_)), + images_(gpu_->device,byte_budget),canvas_(canvas),width_(width),height_(height) { + if(!canvas) throw std::invalid_argument("Offscreen surface requires a nonzero canvas identity"); + } + native_webgpu_offscreen_surface(const native_webgpu_offscreen_surface&)=delete; + native_webgpu_offscreen_surface& operator=(const native_webgpu_offscreen_surface&)=delete; + ~native_webgpu_offscreen_surface() { check_thread();retire(); } + const wgpu::Device& device() const { return gpu_->device; } + const wgpu::Adapter& adapter() const { return gpu_->adapter; } + wgpu::Texture current_texture() { + check_thread(); + if(failed()) throw std::runtime_error("Offscreen GPU device lost"); + if(!current_) { + image_metadata m;m.canvas=canvas_;m.allocation_generation=generation_; + m.content_serial=++serial_;m.producer_timeline=canvas_;m.producer_value=serial_; + m.width=width_;m.height=height_;m.format=image_format::bgra8_unorm; + auto acquired=images_.acquire(m); + if(acquired) current_.emplace(std::move(*acquired)); + } + return current_?current_->texture:wgpu::Texture{}; + } + void resize(uint32_t width,uint32_t height) { + check_thread();if(width==width_ && height==height_) return; + retire();width_=width;height_=height;++generation_; + } + std::shared_ptr present() { + check_thread();auto submitted=retire(); + return submitted?std::make_shared(std::move(*submitted),dependencies_):nullptr; + } + void process_events() { check_thread();gpu_->instance.ProcessEvents(); } + bool failed() const { return gpu_->failed->load(); } + uint64_t resident_bytes() const { return images_.resident_bytes(); } + uint64_t created_images() const { return images_.created_images(); } + size_t busy_images() const { return images_.busy_images(); } + uint64_t capture_count() const { return dependencies_->captures.load(); } +}; +} // namespace webscene::graphics 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..dfe16bb35 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h @@ -2,7 +2,12 @@ #include "native_webgpu_device.h" #include "native_webgpu_canvas_context.h" #include "platform_webgpu_canvas.h" -#if defined(__APPLE__) || defined(_WIN32) +#if defined(__linux__) || defined(WEBSCENE_NATIVE_WEBGPU_OFFSCREEN) +#include "native_webgpu_offscreen_surface.h" +namespace webscene::graphics { +using native_webgpu_surface = native_webgpu_offscreen_surface; +} +#elif defined(__APPLE__) || defined(_WIN32) namespace webscene::graphics { // Native application surface using the same imported images and retained // snapshots as the JS binding. Hosts consume snapshots through their compositor; diff --git a/src/WebScene.Sdk/CMakeLists.txt b/src/WebScene.Sdk/CMakeLists.txt index 89a8ba38f..fdb1119d5 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/LinuxSDK.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/LinuxSDK.cmake b/src/WebScene.Sdk/cmake/LinuxSDK.cmake new file mode 100644 index 000000000..bc2de7791 --- /dev/null +++ b/src/WebScene.Sdk/cmake/LinuxSDK.cmake @@ -0,0 +1,42 @@ +# Native-only Linux SDK: no V8, runtime HTML loader, or JavaScript payloads. +if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") + message(FATAL_ERROR "The Linux SDK currently targets x86_64") +endif() +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(WEBSCENE_NATIVE_WEB_SDK_BUILD ON) +set(BUILD_TESTING OFF CACHE BOOL "Test installed consumers separately" FORCE) +get_filename_component(WEBSCENE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +find_package(Threads REQUIRED) +option(WEBSCENE_SDK_ENABLE_WEBGPU "Include the pinned Dawn Vulkan backend" ON) +add_subdirectory("${WEBSCENE_ROOT}/src/WebScene.NativeWeb" native-web) +target_link_libraries(webscene-uic PRIVATE Threads::Threads ${CMAKE_DL_LIBS} m) +target_link_libraries(webscene_native_web_shared_css PRIVATE Threads::Threads ${CMAKE_DL_LIBS} m) +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) +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_ENABLE_WEBGPU) + set(WEBSCENE_GRAPHICS_COMPONENTS dawn) + include("${WEBSCENE_ROOT}/eng/graphics/GraphicsDependencies.cmake") + 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}/WebSceneLinuxBuild.cmake" + "set(WebScene_SDK_COMPILER_ID \"${CMAKE_CXX_COMPILER_ID}\")\nset(WebScene_SDK_COMPILER_VERSION \"${CMAKE_CXX_COMPILER_VERSION}\")\nset(WebScene_SDK_WEBGPU ${WEBSCENE_SDK_ENABLE_WEBGPU})\n") +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/WebSceneLinuxBuild.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) diff --git a/src/WebScene.Sdk/cmake/WebSceneConfig.cmake b/src/WebScene.Sdk/cmake/WebSceneConfig.cmake index 82403f252..0daccf9a7 100644 --- a/src/WebScene.Sdk/cmake/WebSceneConfig.cmake +++ b/src/WebScene.Sdk/cmake/WebSceneConfig.cmake @@ -1,4 +1,8 @@ include_guard(GLOBAL) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + 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..a4a8f131d --- /dev/null +++ b/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake @@ -0,0 +1,45 @@ +include(CMakeFindDependencyMacro) +find_dependency(Threads) +include("${CMAKE_CURRENT_LIST_DIR}/WebSceneLinuxBuild.cmake") +if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") + message(FATAL_ERROR "This WebScene SDK is Linux x86_64") +endif() +if(NOT CMAKE_CXX_COMPILER_ID STREQUAL WebScene_SDK_COMPILER_ID OR + NOT CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL WebScene_SDK_COMPILER_VERSION) + message(FATAL_ERROR "Use this SDK's compiler: ${WebScene_SDK_COMPILER_ID} ${WebScene_SDK_COMPILER_VERSION}") +endif() +get_filename_component(WebScene_SDK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE) +function(_ws_linux_static name file) + 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) +endfunction() +_ws_linux_static(Core libwebscene_core.a) +_ws_linux_static(NativeWeb libwebscene_native_web.a) +set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::Core;Threads::Threads;${CMAKE_DL_LIBS}") +_ws_linux_static(_Parser libwebscene_html_parser.a) +set_property(TARGET WebScene::_Parser PROPERTY INTERFACE_LINK_LIBRARIES "Threads::Threads;${CMAKE_DL_LIBS};m") +_ws_linux_static(SharedCSS libwebscene_native_web_shared_css.a) +set_property(TARGET WebScene::SharedCSS PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::NativeWeb;WebScene::_Parser") +if(WebScene_SDK_WEBGPU) + 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_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include/graphics" + INTERFACE_LINK_LIBRARIES "WebScene::Core;WebScene::_Dawn;Threads::Threads;${CMAKE_DL_LIBS}") +endif() +add_executable(WebScene::Compiler IMPORTED GLOBAL) +set_property(TARGET WebScene::Compiler PROPERTY IMPORTED_LOCATION "${WebScene_SDK_ROOT}/bin/webscene-uic") +include("${CMAKE_CURRENT_LIST_DIR}/WebSceneApplication.cmake") +foreach(component IN LISTS WebScene_FIND_COMPONENTS) + if(NOT TARGET WebScene::${component}) + set(WebScene_FOUND FALSE) + set(WebScene_NOT_FOUND_MESSAGE "Component ${component} is not in this native-only Linux SDK") + return() + endif() +endforeach() diff --git a/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake b/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake index 530758c4e..d987ae622 100644 --- a/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake +++ b/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake @@ -1,3 +1,11 @@ +if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + find_program(_ws_clang NAMES clang-18 REQUIRED) + find_program(_ws_clangxx NAMES clang++-18 REQUIRED) + set(CMAKE_C_COMPILER "${_ws_clang}" CACHE FILEPATH "") + set(CMAKE_CXX_COMPILER "${_ws_clangxx}" CACHE FILEPATH "") + set(CMAKE_CXX_STANDARD 20 CACHE STRING "") + 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") diff --git a/tests/SDKLinux/CMakeLists.txt b/tests/SDKLinux/CMakeLists.txt new file mode 100644 index 000000000..e1b15afaf --- /dev/null +++ b/tests/SDKLinux/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.28) +project(WebSceneLinuxSDKContracts LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 20) +find_package(WebScene REQUIRED CONFIG COMPONENTS NativeWeb SharedCSS WebGPU Compiler) +include(CTest) +add_executable(offscreen_gpu offscreen_gpu.cpp) +target_link_libraries(offscreen_gpu PRIVATE WebScene::WebGPU) +add_test(NAME offscreen_gpu COMMAND offscreen_gpu) +set_tests_properties(offscreen_gpu PROPERTIES TIMEOUT 90) +add_executable(compiled_native compiled_native.cpp) +webscene_compile_html(compiled_native Main.html MODULE linux.contract.ui CSS_BACKEND shared) +target_link_libraries(compiled_native PRIVATE WebScene::NativeWeb WebScene::SharedCSS) +add_test(NAME compiled_native COMMAND compiled_native) diff --git a/tests/SDKLinux/Main.html b/tests/SDKLinux/Main.html new file mode 100644 index 000000000..31eb22df9 --- /dev/null +++ b/tests/SDKLinux/Main.html @@ -0,0 +1,7 @@ +

0

+
diff --git a/tests/SDKLinux/compiled_native.cpp b/tests/SDKLinux/compiled_native.cpp new file mode 100644 index 000000000..b637e34c9 --- /dev/null +++ b/tests/SDKLinux/compiled_native.cpp @@ -0,0 +1,23 @@ +#include +#include +#include +import linux.contract.ui; +void require(bool value,const char* message){if(!value)throw std::runtime_error(message);} +int main() { + using namespace webscene::native_web; + document doc;compiled_ui::build(doc);int count=0; + auto subscription=doc.on(doc.find("increment"),"click",[&](auto&){doc.set_text(doc.find("count"),std::to_string(++count));}); + doc.render(640,480);auto bounds=doc.bounds(doc.find("increment")); + require(bounds.width==160 && bounds.height==40,"shared CSS button bounds"); + doc.pointer("pointerdown",bounds.x+10,bounds.y+10,1); + doc.pointer("pointerup",bounds.x+10,bounds.y+10,0); + require(count==1 && doc.text_content(doc.find("count"))=="1","native click delivery"); + doc.focus(doc.find("name"));doc.text_input("Kestrel \xc3\xa9"); + require(doc.value(doc.find("name"))=="Kestrel \xc3\xa9","native committed UTF-8 text"); + doc.key("Backspace");require(doc.value(doc.find("name"))=="Kestrel ","native Unicode backspace"); + doc.attribute(doc.find("increment"),"style","width:240px");doc.render(800,600); + require(doc.bounds(doc.find("increment")).width==240,"dynamic shared CSS"); + doc.set_dark_color_scheme(true);doc.render(320,240); + subscription={};doc.dispose();require(doc.disposed(),"native document shutdown"); + std::cout<<"{\"compiledHtml\":true,\"sharedCSS\":true,\"nativeEvents\":true,\"javascript\":false}\n"; +} diff --git a/tests/SDKLinux/offscreen_gpu.cpp b/tests/SDKLinux/offscreen_gpu.cpp new file mode 100644 index 000000000..5d0ccb504 --- /dev/null +++ b/tests/SDKLinux/offscreen_gpu.cpp @@ -0,0 +1,81 @@ +#include +#include +#include +#include +#include +#include +using namespace webscene::graphics; +void require(bool value,const char* message){if(!value)throw std::runtime_error(message);} +auto paint(native_webgpu_surface& surface,double red,double green,double blue) { + auto texture=surface.current_texture();require(bool(texture),"surface admission unexpectedly failed"); + require(surface.current_texture().Get()==texture.Get(),"current texture changed inside a frame"); + wgpu::RenderPassColorAttachment color{};color.view=texture.CreateView(); + color.loadOp=wgpu::LoadOp::Clear;color.storeOp=wgpu::StoreOp::Store;color.clearValue={red,green,blue,1}; + wgpu::RenderPassDescriptor pass{};pass.colorAttachmentCount=1;pass.colorAttachments=&color; + auto encoder=surface.device().CreateCommandEncoder();auto render=encoder.BeginRenderPass(&pass);render.End(); + auto command=encoder.Finish();surface.device().GetQueue().Submit(1,&command); + auto snapshot=surface.present();require(bool(snapshot),"snapshot admission unexpectedly failed");return snapshot; +} +auto resolve(native_webgpu_surface& surface,std::shared_ptr snapshot) { + const auto end=std::chrono::steady_clock::now()+std::chrono::seconds(20); + while(snapshot->state()==webscene_gpu_image_snapshot::status::pending && std::chrono::steady_clock::now()state()==webscene_gpu_image_snapshot::status::ready,"GPU submission failed or timed out"); + auto image=snapshot->resolve();require(bool(image),"completed snapshot did not resolve");return image; +} +void pixel(const offscreen_capture& image,int red,int green,int blue) { + require(image.pixels.size()==size_t(image.metadata.width)*image.metadata.height*4,"non-tight row readback"); + for(size_t i=0;i surviving; + uint64_t captures=0,allocations=0; + bool hardware=false; + { + native_webgpu_surface surface(7,17,4,64*1024); + wgpu::AdapterInfo info{};require(surface.adapter().GetInfo(&info)==wgpu::Status::Success,"adapter identity unavailable"); + hardware=info.adapterType==wgpu::AdapterType::DiscreteGPU || info.adapterType==wgpu::AdapterType::IntegratedGPU; + require(info.backendType==wgpu::BackendType::Vulkan,"Linux test did not run Vulkan"); + auto red=resolve(surface,paint(surface,1,0,0)); + auto green=resolve(surface,paint(surface,0,1,0)); + auto blue=resolve(surface,paint(surface,0,0,1)); + require(!surface.current_texture(),"bounded three-slot pool admitted fourth retained image"); + require(surface.capture_count()==0,"normal rendering performed CPU readback"); + pixel(capture_offscreen_image(*red),255,0,0);pixel(capture_offscreen_image(*green),0,255,0); + pixel(capture_offscreen_image(*blue),0,0,255); + const auto generation=red->value.describe().allocation_generation; + green.reset();blue.reset(); + surface.resize(65,7); + auto resized=resolve(surface,paint(surface,0,1,0)); + require(resized->value.describe().allocation_generation>generation,"resize did not advance generation"); + require(resized->value.describe().width==65 && resized->value.describe().height==7,"resized metadata mismatch"); + pixel(capture_offscreen_image(*resized),0,255,0); + pixel(capture_offscreen_image(*red),255,0,0); // old front image remains immutable across resize + require(surface.resident_bytes()<=64*1024,"resident allocation budget exceeded"); + bool wrong_thread=false; + std::thread foreign([&]{try{surface.resize(2,2);}catch(const std::logic_error&){wrong_thread=true;}});foreign.join(); + require(wrong_thread,"surface accepted mutation from a foreign thread"); + { + native_webgpu_surface other(8,2,2); + auto consumer=red->value.begin_consumer();require(bool(consumer),"consumer unavailable"); + bool rejected=false; + try{(void)dawn_canvas_images::resolve(*consumer,other.device());}catch(const std::invalid_argument&){rejected=true;} + consumer->complete();require(rejected,"cross-device image accepted"); + } + bool budget_rejected=false; + try{(void)capture_offscreen_image(*red,1);}catch(const std::invalid_argument&){budget_rejected=true;} + require(budget_rejected,"capture ignored byte budget"); + captures=surface.capture_count();allocations=surface.created_images();surviving=std::move(red); + } + pixel(capture_offscreen_image(*surviving),255,0,0); // retained lease owns device and provider + surviving.reset(); + for(int i=0;i<3;++i) { + native_webgpu_surface surface(10+i,13,5); + auto image=resolve(surface,paint(surface,0,1,0));pixel(capture_offscreen_image(*image),0,255,0); + } + std::cout<<"{\"backend\":\"Vulkan\",\"hardware\":"<<(hardware?"true":"false") + <<",\"readbacksBeforeCapture\":0,\"explicitCaptures\":"< Date: Fri, 11 Sep 2026 22:01:01 +0100 Subject: [PATCH 03/11] test: exercise Linux native contracts and keep runtime HTML out of native links --- .github/workflows/native-linux.yml | 64 +++++++++++++++++++ src/WebScene.Sdk/cmake/LinuxSDK.cmake | 8 +++ .../cmake/WebSceneLinuxConfig.cmake | 1 + 3 files changed, 73 insertions(+) create mode 100644 .github/workflows/native-linux.yml diff --git a/.github/workflows/native-linux.yml b/.github/workflows/native-linux.yml new file mode 100644 index 000000000..b8f68a2cb --- /dev/null +++ b/.github/workflows/native-linux.yml @@ -0,0 +1,64 @@ +name: Native Linux document contracts +on: + push: + branches: [codex/linux-headless-platform] + paths: + - 'src/WebScene.NativeWeb/**' + - 'src/WebScene.Sdk/**' + - 'experiments/WebScene.NativeEngine.Probe/native/**' + - 'tooling/webscene-uic/**' + - 'tests/NativeWeb/**' + - '.github/workflows/native-linux.yml' + pull_request: + paths: + - 'src/WebScene.NativeWeb/**' + - 'src/WebScene.Sdk/**' + - 'experiments/WebScene.NativeEngine.Probe/native/**' + - 'tooling/webscene-uic/**' + - 'tests/NativeWeb/**' + - '.github/workflows/native-linux.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Install pinned build tools + run: | + sudo apt-get update + sudo apt-get install -y clang-18 clang-tools-18 ninja-build + python3 -m pip install 'cmake==3.31.6' + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: native-linux-cargo-${{ hashFiles('experiments/WebScene.NativeEngine.Probe/native/html_parser/Cargo.lock') }} + - name: Build and run original native document/compiler contracts + shell: bash + run: | + set -euo pipefail + cmake -G Ninja -S src/WebScene.NativeWeb -B artifacts/native-linux -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=clang++-18 -DWEBSCENE_NATIVE_WEB_GPU=OFF + cmake --build artifacts/native-linux --parallel 2 2>&1 | tee artifacts/native-linux-build.log + ctest --test-dir artifacts/native-linux --output-on-failure --output-junit native-linux-tests.xml 2>&1 | tee artifacts/native-linux-tests.log + - name: Build independent native-only Linux SDK without optional graphics + shell: bash + run: | + set -euo pipefail + cmake -G Ninja -S src/WebScene.Sdk -B artifacts/native-sdk -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 -DWEBSCENE_SDK_ENABLE_WEBGPU=OFF -DCMAKE_INSTALL_PREFIX="$PWD/artifacts/sdk" + cmake --build artifacts/native-sdk --parallel 2 2>&1 | tee artifacts/native-sdk-build.log + cmake --install artifacts/native-sdk + tar -czf artifacts/native-linux-sdk-no-gpu.tar.gz -C artifacts sdk + - uses: actions/upload-artifact@v4 + if: always() + with: + name: native-linux-contract-evidence + path: | + artifacts/*.log + artifacts/native-linux/native-linux-tests.xml + artifacts/native-linux/Testing/Temporary/ + artifacts/native-linux-sdk-no-gpu.tar.gz + retention-days: 14 diff --git a/src/WebScene.Sdk/cmake/LinuxSDK.cmake b/src/WebScene.Sdk/cmake/LinuxSDK.cmake index bc2de7791..f6adcfc7c 100644 --- a/src/WebScene.Sdk/cmake/LinuxSDK.cmake +++ b/src/WebScene.Sdk/cmake/LinuxSDK.cmake @@ -10,6 +10,10 @@ get_filename_component(WEBSCENE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUT find_package(Threads REQUIRED) option(WEBSCENE_SDK_ENABLE_WEBGPU "Include the pinned Dawn Vulkan backend" ON) add_subdirectory("${WEBSCENE_ROOT}/src/WebScene.NativeWeb" native-web) +foreach(target webscene_core webscene_native_web webscene_native_web_shared_css) + target_compile_options(${target} PRIVATE -ffunction-sections -fdata-sections) +endforeach() +target_link_options(webscene_native_web INTERFACE "LINKER:--gc-sections") target_link_libraries(webscene-uic PRIVATE Threads::Threads ${CMAKE_DL_LIBS} m) target_link_libraries(webscene_native_web_shared_css PRIVATE Threads::Threads ${CMAKE_DL_LIBS} m) add_library(WebScene::Core ALIAS webscene_core) @@ -26,6 +30,10 @@ include("${CMAKE_CURRENT_BINARY_DIR}/sdk-headers.cmake") if(WEBSCENE_SDK_ENABLE_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) + 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) diff --git a/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake b/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake index a4a8f131d..0e2c1f78c 100644 --- a/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake +++ b/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake @@ -19,6 +19,7 @@ endfunction() _ws_linux_static(Core libwebscene_core.a) _ws_linux_static(NativeWeb libwebscene_native_web.a) set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::Core;Threads::Threads;${CMAKE_DL_LIBS}") +set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_OPTIONS "LINKER:--gc-sections") _ws_linux_static(_Parser libwebscene_html_parser.a) set_property(TARGET WebScene::_Parser PROPERTY INTERFACE_LINK_LIBRARIES "Threads::Threads;${CMAKE_DL_LIBS};m") _ws_linux_static(SharedCSS libwebscene_native_web_shared_css.a) From fc3b39f10f9fd9876521de17a90fe9275b62191e Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:05:27 +0100 Subject: [PATCH 04/11] build: transfer and validate native cascade portability correction --- .automation/native-cascade.patch | 19 ++++++++ .github/workflows/land-native-cascade.yml | 56 +++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 .automation/native-cascade.patch create mode 100644 .github/workflows/land-native-cascade.yml diff --git a/.automation/native-cascade.patch b/.automation/native-cascade.patch new file mode 100644 index 000000000..b295c3ae5 --- /dev/null +++ b/.automation/native-cascade.patch @@ -0,0 +1,19 @@ +diff --git a/src/WebScene.NativeWeb/src/native_web.cpp b/src/WebScene.NativeWeb/src/native_web.cpp +index 4e35b22..263a8c2 100644 +--- a/src/WebScene.NativeWeb/src/native_web.cpp ++++ b/src/WebScene.NativeWeb/src/native_web.cpp +@@ -936,8 +936,12 @@ const scene &document::render(float width, float height) { + ++order; + } + } +- std::ranges::sort(declarations, {}, [](const auto &d) { +- return std::tuple(d.important, d.specificity, d.order); ++ // Explicit iterator comparator avoids a Clang 18 concepts-instantiation ++ // crash for a projected ranges::sort inside this recursive generic lambda. ++ // Keep the same importance/specificity/source-order cascade key. ++ std::sort(declarations.begin(), declarations.end(), [](const candidate &a, const candidate &b) { ++ return std::tie(a.important, a.specificity, a.order) < ++ std::tie(b.important, b.specificity, b.order); + }); + specified_variables local; + for (const auto &d : declarations) diff --git a/.github/workflows/land-native-cascade.yml b/.github/workflows/land-native-cascade.yml new file mode 100644 index 000000000..1c881960c --- /dev/null +++ b/.github/workflows/land-native-cascade.yml @@ -0,0 +1,56 @@ +name: Apply and test portable native cascade +on: + push: + branches: [codex/linux-headless-platform] + paths: ['.automation/native-cascade.patch', '.github/workflows/land-native-cascade.yml'] +permissions: + contents: write +jobs: + apply: + if: github.repository == 'SceneTech/WebScene' && github.ref == 'refs/heads/codex/linux-headless-platform' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Apply source correction + shell: bash + run: | + set -euo pipefail + echo '1b1fc47dae092b12ca10dbd45a6a05f0877cd53967319d4387e21263e1647573 .automation/native-cascade.patch' | sha256sum --check + git apply --check .automation/native-cascade.patch + git apply --index .automation/native-cascade.patch + git rm .automation/native-cascade.patch .github/workflows/land-native-cascade.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git -c core.hooksPath=/dev/null commit -m 'fix: preserve cascade ordering without Clang 18 recursive-lambda compiler crash' + git push origin HEAD:refs/heads/codex/linux-headless-platform + - name: Install native toolchain + run: | + sudo apt-get update + sudo apt-get install -y clang-18 clang-tools-18 ninja-build + python3 -m pip install 'cmake==3.31.6' + - name: Build and run unchanged native contracts + shell: bash + run: | + set -euo pipefail + cmake -G Ninja -S src/WebScene.NativeWeb -B artifacts/native-linux -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=clang++-18 -DWEBSCENE_NATIVE_WEB_GPU=OFF + cmake --build artifacts/native-linux --parallel 2 2>&1 | tee artifacts/native-linux-build.log + ctest --test-dir artifacts/native-linux --output-on-failure --output-junit native-linux-tests.xml 2>&1 | tee artifacts/native-linux-tests.log + - name: Build native Linux SDK + shell: bash + run: | + set -euo pipefail + cmake -G Ninja -S src/WebScene.Sdk -B artifacts/native-sdk -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 -DWEBSCENE_SDK_ENABLE_WEBGPU=OFF -DCMAKE_INSTALL_PREFIX="$PWD/artifacts/sdk" + cmake --build artifacts/native-sdk --parallel 2 2>&1 | tee artifacts/native-sdk-build.log + cmake --install artifacts/native-sdk + tar -czf artifacts/native-linux-sdk-no-gpu.tar.gz -C artifacts sdk + - uses: actions/upload-artifact@v4 + if: always() + with: + name: native-linux-contract-evidence + path: | + artifacts/*.log + artifacts/native-linux/native-linux-tests.xml + artifacts/native-linux/Testing/Temporary/ + artifacts/native-linux-sdk-no-gpu.tar.gz + retention-days: 14 From c3edfd7c7a4e69d0ae127b3acb61bb461a59200e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:05:50 +0000 Subject: [PATCH 05/11] fix: preserve cascade ordering without Clang 18 recursive-lambda compiler crash --- .automation/native-cascade.patch | 19 -------- .github/workflows/land-native-cascade.yml | 56 ----------------------- src/WebScene.NativeWeb/src/native_web.cpp | 8 +++- 3 files changed, 6 insertions(+), 77 deletions(-) delete mode 100644 .automation/native-cascade.patch delete mode 100644 .github/workflows/land-native-cascade.yml diff --git a/.automation/native-cascade.patch b/.automation/native-cascade.patch deleted file mode 100644 index b295c3ae5..000000000 --- a/.automation/native-cascade.patch +++ /dev/null @@ -1,19 +0,0 @@ -diff --git a/src/WebScene.NativeWeb/src/native_web.cpp b/src/WebScene.NativeWeb/src/native_web.cpp -index 4e35b22..263a8c2 100644 ---- a/src/WebScene.NativeWeb/src/native_web.cpp -+++ b/src/WebScene.NativeWeb/src/native_web.cpp -@@ -936,8 +936,12 @@ const scene &document::render(float width, float height) { - ++order; - } - } -- std::ranges::sort(declarations, {}, [](const auto &d) { -- return std::tuple(d.important, d.specificity, d.order); -+ // Explicit iterator comparator avoids a Clang 18 concepts-instantiation -+ // crash for a projected ranges::sort inside this recursive generic lambda. -+ // Keep the same importance/specificity/source-order cascade key. -+ std::sort(declarations.begin(), declarations.end(), [](const candidate &a, const candidate &b) { -+ return std::tie(a.important, a.specificity, a.order) < -+ std::tie(b.important, b.specificity, b.order); - }); - specified_variables local; - for (const auto &d : declarations) diff --git a/.github/workflows/land-native-cascade.yml b/.github/workflows/land-native-cascade.yml deleted file mode 100644 index 1c881960c..000000000 --- a/.github/workflows/land-native-cascade.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Apply and test portable native cascade -on: - push: - branches: [codex/linux-headless-platform] - paths: ['.automation/native-cascade.patch', '.github/workflows/land-native-cascade.yml'] -permissions: - contents: write -jobs: - apply: - if: github.repository == 'SceneTech/WebScene' && github.ref == 'refs/heads/codex/linux-headless-platform' - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - name: Apply source correction - shell: bash - run: | - set -euo pipefail - echo '1b1fc47dae092b12ca10dbd45a6a05f0877cd53967319d4387e21263e1647573 .automation/native-cascade.patch' | sha256sum --check - git apply --check .automation/native-cascade.patch - git apply --index .automation/native-cascade.patch - git rm .automation/native-cascade.patch .github/workflows/land-native-cascade.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git -c core.hooksPath=/dev/null commit -m 'fix: preserve cascade ordering without Clang 18 recursive-lambda compiler crash' - git push origin HEAD:refs/heads/codex/linux-headless-platform - - name: Install native toolchain - run: | - sudo apt-get update - sudo apt-get install -y clang-18 clang-tools-18 ninja-build - python3 -m pip install 'cmake==3.31.6' - - name: Build and run unchanged native contracts - shell: bash - run: | - set -euo pipefail - cmake -G Ninja -S src/WebScene.NativeWeb -B artifacts/native-linux -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=clang++-18 -DWEBSCENE_NATIVE_WEB_GPU=OFF - cmake --build artifacts/native-linux --parallel 2 2>&1 | tee artifacts/native-linux-build.log - ctest --test-dir artifacts/native-linux --output-on-failure --output-junit native-linux-tests.xml 2>&1 | tee artifacts/native-linux-tests.log - - name: Build native Linux SDK - shell: bash - run: | - set -euo pipefail - cmake -G Ninja -S src/WebScene.Sdk -B artifacts/native-sdk -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 -DWEBSCENE_SDK_ENABLE_WEBGPU=OFF -DCMAKE_INSTALL_PREFIX="$PWD/artifacts/sdk" - cmake --build artifacts/native-sdk --parallel 2 2>&1 | tee artifacts/native-sdk-build.log - cmake --install artifacts/native-sdk - tar -czf artifacts/native-linux-sdk-no-gpu.tar.gz -C artifacts sdk - - uses: actions/upload-artifact@v4 - if: always() - with: - name: native-linux-contract-evidence - path: | - artifacts/*.log - artifacts/native-linux/native-linux-tests.xml - artifacts/native-linux/Testing/Temporary/ - artifacts/native-linux-sdk-no-gpu.tar.gz - retention-days: 14 diff --git a/src/WebScene.NativeWeb/src/native_web.cpp b/src/WebScene.NativeWeb/src/native_web.cpp index 4e35b2258..263a8c24d 100644 --- a/src/WebScene.NativeWeb/src/native_web.cpp +++ b/src/WebScene.NativeWeb/src/native_web.cpp @@ -936,8 +936,12 @@ const scene &document::render(float width, float height) { ++order; } } - std::ranges::sort(declarations, {}, [](const auto &d) { - return std::tuple(d.important, d.specificity, d.order); + // Explicit iterator comparator avoids a Clang 18 concepts-instantiation + // crash for a projected ranges::sort inside this recursive generic lambda. + // Keep the same importance/specificity/source-order cascade key. + std::sort(declarations.begin(), declarations.end(), [](const candidate &a, const candidate &b) { + return std::tie(a.important, a.specificity, a.order) < + std::tie(b.important, b.specificity, b.order); }); specified_variables local; for (const auto &d : declarations) From 6e2dbf86688cf9fcd43c830373df2c40466bc70c Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:15:34 +0100 Subject: [PATCH 06/11] docs: define native Linux offscreen SDK ownership and qualification boundaries --- docs/graphics/native-linux-offscreen.md | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/graphics/native-linux-offscreen.md diff --git a/docs/graphics/native-linux-offscreen.md b/docs/graphics/native-linux-offscreen.md new file mode 100644 index 000000000..6ca8b6ed6 --- /dev/null +++ b/docs/graphics/native-linux-offscreen.md @@ -0,0 +1,59 @@ +# Native Linux offscreen WebGPU and SDK + +This adds the WebScene half of AppScene's selectable `headless` platform. It is a native-only Linux x86_64 SDK profile, not a new application host. AppScene owns input, scheduling and capture composition; WebScene owns native document/layout and the application WebGPU device and immutable image leases. + +## Profiles and selection + +`src/WebScene.Sdk` selects the Linux producer without enabling Objective-C++. It installs Core, NativeWeb, SharedCSS, Compiler and, by default, WebGPU. Runtime/V8 is not built or installed. `WEBSCENE_SDK_ENABLE_WEBGPU=OFF` produces the independent non-GPU native SDK. Native consumers compile HTML/templates at build time, explicitly select `CSS_BACKEND shared`, and link `WebScene::SharedCSS`. Function/data sections plus native link garbage collection exclude runtime HTML parser code; shared CSS parsing is allowed. + +On Linux `native_webgpu_surface` selects `native_webgpu_offscreen_surface`. Existing macOS Metal/IOSurface and Windows D3D12 surface selection remains unchanged. `WEBSCENE_NATIVE_WEBGPU_OFFSCREEN` can explicitly select the offscreen surface at compile time, but only the Linux profile is qualified by this work. + +## Ownership and completion + +The offscreen backend uses the existing `dawn_canvas_images` bounded three-slot pool, submission status and immutable image lease contracts from the macOS/Windows foundation. An application obtains `current_texture()`, records/submits its Dawn work and calls `present()`. `retire_submitted` holds producer ownership through the queue's submitted-work completion, even if the application drops its snapshot. Completion uses Dawn's spontaneous callback mode rather than a UI frame timer. A snapshot resolves only after success. No early external-image or producer-wait capability is advertised. + +Resize increments the allocation generation. Already accepted leases remain valid while new-size drawing is pending. Pool byte limits and consumers prevent reuse while an image is in use. Device mismatch, foreign surface-thread access, unsupported image format, failed submission, software selection policy and capture size limits are explicit failures. Surface use/destruction is owner-thread-only; retained image dependencies keep the originating device alive after the surface is gone. + +## Captures are explicit readbacks + +Normal `present()` and `process_events()` keep textures on the Dawn device and do not copy pixels to the CPU. `capture_offscreen_image()` is a separately requested diagnostic/export operation. It retains an image consumer through queue completion, copies BGRA8/RGBA8 into a 256-byte-row-aligned map buffer, and returns tightly packed RGBA8 with the original alpha convention. Mapping timeout/cancellation cannot prematurely release the GPU image consumer. A capture counter records completed readbacks. + +AppScene may then raster-compose those captured textures with the same scene-command compositor used by its existing platform. This is an explicit screenshot operation, **not** zero-copy Linux desktop presentation. The capture may wait for GPU completion; ordinary frame processing does not perform queue-idle waits or readbacks. + +## Backend policy + +The Linux backend is Dawn Vulkan at the existing pinned Dawn/Tint revision `2ca8cbfe0f8275aa0f739e7b6b4345a16e2f0378`. No opportunistic dependency upgrade is required. By default non-hardware adapters are rejected. Automated environments must explicitly opt into software: + +```sh +export WEBSCENE_HEADLESS_FORCE_SOFTWARE_ADAPTER=1 +export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json +``` + +Locate the actual ICD filename on the host; it is distribution-specific. Record `vulkaninfo --summary`, the SDK manifest and application output. Lavapipe/SwiftShader results establish offscreen software execution, never hardware performance or X11/Wayland compatibility. + +## Build and validation + +The paired AppScene `eng/sdk/build_headless.py` builds, installs, relocates and qualifies the combined SDK. Independently, the original native document/compiler suite can run without graphics: + +```sh +cmake -G Ninja -S src/WebScene.NativeWeb -B artifacts/native-linux \ + -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_BUILD_TYPE=Release \ + -DWEBSCENE_NATIVE_WEB_GPU=OFF +cmake --build artifacts/native-linux --parallel 2 +ctest --test-dir artifacts/native-linux --output-on-failure + +cmake -G Ninja -S src/WebScene.Sdk -B artifacts/native-sdk \ + -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 \ + -DCMAKE_BUILD_TYPE=Release -DWEBSCENE_SDK_ENABLE_WEBGPU=OFF \ + -DCMAKE_INSTALL_PREFIX="$PWD/artifacts/sdk" +cmake --build artifacts/native-sdk --parallel 2 +cmake --install artifacts/native-sdk +``` + +The Linux SDK records the producer's exact C++ compiler version and rejects a mismatched consumer. The current CI toolchain is Ubuntu Clang 18.1.3 and CMake 3.31.6, with the producer's libstdc++ ABI. It does not change the existing macOS LLVM 22.1.1 profile. + +`tests/SDKLinux` is an independent installed-SDK consumer. It tests compiled shared-CSS UI, native input and actual offscreen GPU pixels; unaligned readback rows; bounded image reuse; retained images after resize; foreign-device and thread rejection; and leases surviving surface destruction. The paired AppScene tests additionally exercise the unchanged NativeWebGPU sample through its real native host and composed PNGs. No missing-adapter result is a pass or skip. + +## Limits + +This does not implement or qualify Linux external-memory FD/dma-buf sharing, Vulkan-to-EGL composition, an X11/Wayland window host, Windows/Linux video decoding, WebGL browser bindings, full CTS/WPT conformance, or hardware GPU timing. It does not qualify Kestrel's complete application behavior or complete its C++ migration. Those are separate requirements, including issue #46. No parent epic is closed by this offscreen SDK milestone. From bd12775eb8582245f0be2103599c8a3177fe3e4e Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:37:01 +0100 Subject: [PATCH 07/11] fix: support explicit Lavapipe selection and test GPU completion without application frames --- .../native/graphics/native_webgpu_device.h | 4 +--- .../graphics/native_webgpu_offscreen_surface.h | 8 +++++++- tests/SDKLinux/offscreen_gpu.cpp | 15 ++++++++++++--- 3 files changed, 20 insertions(+), 7 deletions(-) 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 ae5d16cfd..2e7849dad 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_device.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_device.h @@ -17,8 +17,7 @@ struct native_webgpu_device { wgpu::Adapter adapter; wgpu::Device device; static native_webgpu_device create(wgpu::BackendType backend, - std::vector features = {}, - bool force_software_adapter = false) { + std::vector features = {}) { native_webgpu_device result; constexpr auto timed_wait = wgpu::InstanceFeatureName::TimedWaitAny; wgpu::InstanceDescriptor descriptor{}; @@ -34,7 +33,6 @@ struct native_webgpu_device { }; wgpu::RequestAdapterOptions options{}; options.backendType = backend; - options.forceFallbackAdapter = force_software_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_offscreen_surface.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h index 17d496be8..38caa5d78 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h @@ -150,13 +150,19 @@ class native_webgpu_offscreen_surface final { #endif } static std::shared_ptr create_gpu() { + // Dawn's forceFallbackAdapter currently recognizes only SwiftShader, + // not other software Vulkan ICDs such as Mesa Lavapipe. The caller + // selects its ICD; verify the returned adapter rather than mislabeling + // a hardware/default adapter or silently rejecting a software driver. auto gpu=std::make_shared( - native_webgpu_device::create(backend(),{},software_requested())); + native_webgpu_device::create(backend())); wgpu::AdapterInfo info{}; if(gpu->adapter.GetInfo(&info)!=wgpu::Status::Success) throw std::runtime_error("Cannot inspect offscreen GPU adapter"); const bool hardware=info.adapterType==wgpu::AdapterType::DiscreteGPU || info.adapterType==wgpu::AdapterType::IntegratedGPU; + if(software_requested() && info.adapterType!=wgpu::AdapterType::CPU) + throw std::runtime_error("Software GPU requested: select a CPU Vulkan ICD with VK_ICD_FILENAMES"); if(!hardware && !software_requested()) throw std::runtime_error("Software GPU requires WEBSCENE_HEADLESS_FORCE_SOFTWARE_ADAPTER=1"); return gpu; diff --git a/tests/SDKLinux/offscreen_gpu.cpp b/tests/SDKLinux/offscreen_gpu.cpp index 5d0ccb504..1dd3b7a8d 100644 --- a/tests/SDKLinux/offscreen_gpu.cpp +++ b/tests/SDKLinux/offscreen_gpu.cpp @@ -38,6 +38,15 @@ int main() { wgpu::AdapterInfo info{};require(surface.adapter().GetInfo(&info)==wgpu::Status::Success,"adapter identity unavailable"); hardware=info.adapterType==wgpu::AdapterType::DiscreteGPU || info.adapterType==wgpu::AdapterType::IntegratedGPU; require(info.backendType==wgpu::BackendType::Vulkan,"Linux test did not run Vulkan"); + // GPU completion must retire an abandoned snapshot while application + // frames are paused. No ProcessEvents or render timer drives this wait. + { + auto abandoned=paint(surface,1,0,0);abandoned.reset(); + const auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(20); + while(surface.busy_images() && std::chrono::steady_clock::now()value.describe().allocation_generation>generation,"resize did not advance generation"); require(resized->value.describe().width==65 && resized->value.describe().height==7,"resized metadata mismatch"); pixel(capture_offscreen_image(*resized),0,255,0); - pixel(capture_offscreen_image(*red),255,0,0); // old front image remains immutable across resize + pixel(capture_offscreen_image(*red),255,0,0); require(surface.resident_bytes()<=64*1024,"resident allocation budget exceeded"); bool wrong_thread=false; std::thread foreign([&]{try{surface.resize(2,2);}catch(const std::logic_error&){wrong_thread=true;}});foreign.join(); @@ -69,7 +78,7 @@ int main() { require(budget_rejected,"capture ignored byte budget"); captures=surface.capture_count();allocations=surface.created_images();surviving=std::move(red); } - pixel(capture_offscreen_image(*surviving),255,0,0); // retained lease owns device and provider + pixel(capture_offscreen_image(*surviving),255,0,0); surviving.reset(); for(int i=0;i<3;++i) { native_webgpu_surface surface(10+i,13,5); @@ -77,5 +86,5 @@ int main() { } std::cout<<"{\"backend\":\"Vulkan\",\"hardware\":"<<(hardware?"true":"false") <<",\"readbacksBeforeCapture\":0,\"explicitCaptures\":"< Date: Fri, 11 Sep 2026 22:45:35 +0100 Subject: [PATCH 08/11] fix(sdk): validate Linux components on every package lookup Keep native-only Runtime rejection effective after prior find_package calls, honor optional components, and add installed-consumer regression contracts. Preserve the concurrent Lavapipe/completion fix bd12775. Related to #69. --- src/WebScene.Sdk/cmake/WebSceneConfig.cmake | 2 +- .../cmake/WebSceneLinuxConfig.cmake | 56 +++++++++------ tests/SDKLinux/CMakeLists.txt | 6 ++ tests/SDKLinux/package_components.py | 72 +++++++++++++++++++ 4 files changed, 112 insertions(+), 24 deletions(-) create mode 100644 tests/SDKLinux/package_components.py diff --git a/src/WebScene.Sdk/cmake/WebSceneConfig.cmake b/src/WebScene.Sdk/cmake/WebSceneConfig.cmake index 0daccf9a7..e9df70091 100644 --- a/src/WebScene.Sdk/cmake/WebSceneConfig.cmake +++ b/src/WebScene.Sdk/cmake/WebSceneConfig.cmake @@ -1,8 +1,8 @@ -include_guard(GLOBAL) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") include("${CMAKE_CURRENT_LIST_DIR}/WebSceneLinuxConfig.cmake") return() endif() +include_guard(GLOBAL) 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 index 0e2c1f78c..e555d5f35 100644 --- a/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake +++ b/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake @@ -16,31 +16,41 @@ function(_ws_linux_static name file) INTERFACE_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include" INTERFACE_COMPILE_FEATURES cxx_std_20) endfunction() -_ws_linux_static(Core libwebscene_core.a) -_ws_linux_static(NativeWeb libwebscene_native_web.a) -set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::Core;Threads::Threads;${CMAKE_DL_LIBS}") -set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_OPTIONS "LINKER:--gc-sections") -_ws_linux_static(_Parser libwebscene_html_parser.a) -set_property(TARGET WebScene::_Parser PROPERTY INTERFACE_LINK_LIBRARIES "Threads::Threads;${CMAKE_DL_LIBS};m") -_ws_linux_static(SharedCSS libwebscene_native_web_shared_css.a) -set_property(TARGET WebScene::SharedCSS PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::NativeWeb;WebScene::_Parser") -if(WebScene_SDK_WEBGPU) - 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_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include/graphics" - INTERFACE_LINK_LIBRARIES "WebScene::Core;WebScene::_Dawn;Threads::Threads;${CMAKE_DL_LIBS}") +# Define imported targets once, but validate every find_package request. +if(NOT TARGET WebScene::Core) + _ws_linux_static(Core libwebscene_core.a) + _ws_linux_static(NativeWeb libwebscene_native_web.a) + set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::Core;Threads::Threads;${CMAKE_DL_LIBS}") + set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_OPTIONS "LINKER:--gc-sections") + _ws_linux_static(_Parser libwebscene_html_parser.a) + set_property(TARGET WebScene::_Parser PROPERTY INTERFACE_LINK_LIBRARIES "Threads::Threads;${CMAKE_DL_LIBS};m") + _ws_linux_static(SharedCSS libwebscene_native_web_shared_css.a) + set_property(TARGET WebScene::SharedCSS PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::NativeWeb;WebScene::_Parser") + if(WebScene_SDK_WEBGPU) + 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_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include/graphics" + INTERFACE_LINK_LIBRARIES "WebScene::Core;WebScene::_Dawn;Threads::Threads;${CMAKE_DL_LIBS}") + endif() + add_executable(WebScene::Compiler IMPORTED GLOBAL) + set_property(TARGET WebScene::Compiler PROPERTY IMPORTED_LOCATION "${WebScene_SDK_ROOT}/bin/webscene-uic") endif() -add_executable(WebScene::Compiler IMPORTED GLOBAL) -set_property(TARGET WebScene::Compiler PROPERTY IMPORTED_LOCATION "${WebScene_SDK_ROOT}/bin/webscene-uic") include("${CMAKE_CURRENT_LIST_DIR}/WebSceneApplication.cmake") +# Required/optional components belong to this invocation, not the first one. +set(WebScene_FOUND TRUE) +unset(WebScene_NOT_FOUND_MESSAGE) foreach(component IN LISTS WebScene_FIND_COMPONENTS) - if(NOT TARGET WebScene::${component}) - set(WebScene_FOUND FALSE) - set(WebScene_NOT_FOUND_MESSAGE "Component ${component} is not in this native-only Linux SDK") - return() + if(TARGET WebScene::${component}) + set(WebScene_${component}_FOUND TRUE) + else() + set(WebScene_${component}_FOUND FALSE) + if(WebScene_FIND_REQUIRED_${component}) + set(WebScene_FOUND FALSE) + string(APPEND WebScene_NOT_FOUND_MESSAGE "Component ${component} is not in this native-only Linux SDK. ") + endif() endif() endforeach() diff --git a/tests/SDKLinux/CMakeLists.txt b/tests/SDKLinux/CMakeLists.txt index e1b15afaf..3cb170f14 100644 --- a/tests/SDKLinux/CMakeLists.txt +++ b/tests/SDKLinux/CMakeLists.txt @@ -11,3 +11,9 @@ add_executable(compiled_native compiled_native.cpp) webscene_compile_html(compiled_native Main.html MODULE linux.contract.ui CSS_BACKEND shared) target_link_libraries(compiled_native PRIVATE WebScene::NativeWeb WebScene::SharedCSS) add_test(NAME compiled_native COMMAND compiled_native) + +find_package(Python3 REQUIRED COMPONENTS Interpreter) +add_test(NAME installed_package_components + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/package_components.py" + --sdk "${WebScene_SDK_ROOT}" --compiler "${CMAKE_CXX_COMPILER}") +set_tests_properties(installed_package_components PROPERTIES TIMEOUT 90) diff --git a/tests/SDKLinux/package_components.py b/tests/SDKLinux/package_components.py new file mode 100644 index 000000000..580263cab --- /dev/null +++ b/tests/SDKLinux/package_components.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Configure real installed-package consumers; no replacement SDK declarations.""" +import argparse +from pathlib import Path +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--sdk', type=Path, required=True) + parser.add_argument('--compiler', required=True) + parser.add_argument('--package', choices=['WebScene', 'AppScene'], default='WebScene') + args = parser.parse_args() + package = args.package + supported = 'NativeWeb' if package == 'WebScene' else 'Native' + scenarios = { + 'repeat-required-missing': f''' +find_package({package} REQUIRED CONFIG COMPONENTS {supported}) +find_package({package} QUIET CONFIG COMPONENTS Runtime) +if({package}_FOUND OR {package}_Runtime_FOUND) + message(FATAL_ERROR "A repeated lookup falsely accepted Runtime") +endif() +find_package({package} REQUIRED CONFIG COMPONENTS {supported}) +if(NOT {package}_FOUND OR NOT {package}_{supported}_FOUND) + message(FATAL_ERROR "A rejected lookup poisoned a later supported lookup") +endif() +''', + 'optional-first': f''' +find_package({package} REQUIRED CONFIG OPTIONAL_COMPONENTS Runtime) +if(NOT {package}_FOUND OR {package}_Runtime_FOUND) + message(FATAL_ERROR "Optional unavailable Runtime rejected the native SDK") +endif() +find_package({package} REQUIRED CONFIG COMPONENTS {supported}) +if(NOT {package}_{supported}_FOUND) + message(FATAL_ERROR "Supported component not reported after optional lookup") +endif() +''', + 'repeat-optional-missing': f''' +find_package({package} REQUIRED CONFIG COMPONENTS {supported}) +find_package({package} REQUIRED CONFIG OPTIONAL_COMPONENTS Runtime) +if(NOT {package}_FOUND OR {package}_Runtime_FOUND) + message(FATAL_ERROR "Repeated optional component query is incorrect") +endif() +''', + 'required-missing-fails': f''' +find_package({package} REQUIRED CONFIG COMPONENTS {supported}) +find_package({package} REQUIRED CONFIG COMPONENTS Runtime) +''', + } + with tempfile.TemporaryDirectory(prefix='sdk-component-contract-') as temporary: + root = Path(temporary) + for name, body in scenarios.items(): + source = root / name + source.mkdir() + (source / 'CMakeLists.txt').write_text( + 'cmake_minimum_required(VERSION 3.28)\n' + 'project(InstalledPackageComponents LANGUAGES CXX)\n' + body) + command = ['cmake', '-G', 'Ninja', '-S', str(source), '-B', str(source / 'build'), + '-DCMAKE_CXX_COMPILER=' + args.compiler, + '-DCMAKE_PREFIX_PATH=' + str(args.sdk.resolve())] + result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if name == 'required-missing-fails': + if result.returncode == 0 or 'Runtime' not in result.stdout or 'not' not in result.stdout: + raise RuntimeError('Required missing component did not fail as expected:\n' + result.stdout) + elif result.returncode: + raise RuntimeError(name + '\n' + result.stdout) + print(package + ': ' + name + ' passed') + + +if __name__ == '__main__': + main() From 898b7f1ed49225269ce2b5ea9c6dd2afb09dde5a Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:46:50 +0100 Subject: [PATCH 09/11] build: transfer GPU completion fix while retaining concurrent SDK component validation --- .automation/offscreen-completion.patch | 143 ++++++++++++++++++ .../workflows/land-offscreen-completion.yml | 26 ++++ 2 files changed, 169 insertions(+) create mode 100644 .automation/offscreen-completion.patch create mode 100644 .github/workflows/land-offscreen-completion.yml diff --git a/.automation/offscreen-completion.patch b/.automation/offscreen-completion.patch new file mode 100644 index 000000000..0ea9b9de6 --- /dev/null +++ b/.automation/offscreen-completion.patch @@ -0,0 +1,143 @@ +diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h +--- a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h ++++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h +@@ -64,7 +64,7 @@ + // Even if retaining a snapshot hits the ticket limit, keep the producer + // reserved until ALL work already submitted to its queue has completed. + std::optional retire_submitted(frame&& input, +- std::shared_ptr completion={}) { ++ std::shared_ptr completion={}, wgpu::Future* submitted_future=nullptr) { + check_thread(); + if (!input.producer.belongs_to(storage_.get())) + throw std::invalid_argument("foreign canvas producer"); +@@ -72,13 +72,14 @@ + auto pending=std::make_shared(std::move(input)); + pending->producer.begin(); + auto image=pending->producer.publish(); +- storage_->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, ++ auto future=storage_->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [pending,status,completion](wgpu::QueueWorkDoneStatus result,wgpu::StringView) { + pending->producer.complete(); + status->store(result==wgpu::QueueWorkDoneStatus::Success + ? submission_status::success : submission_status::failed,std::memory_order_release); + if (completion) completion->signal(); + }); ++ if (submitted_future) *submitted_future=future; + if (!image) return {}; // callback still owns the submitted producer + return submitted_frame{std::move(*image),std::move(status)}; + } + diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h +--- a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h ++++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h +@@ -2,20 +2,78 @@ + #include "native_webgpu_device.h" + #include "dawn_canvas_images.h" + #include "image_lease_abi.h" ++#include + #include ++#include + #include + #include + #include + #include + + namespace webscene::graphics { ++// Dedicated native completion progress. Wait on submitted-work futures instead ++// of driving a render timer or waiting on the application/owner thread. Dawn's ++// spontaneous callback mode alone does not guarantee progress when all frames ++// are paused. This service never touches document state or presents a frame. ++class offscreen_completion_service { ++ struct state { ++ std::shared_ptr gpu; ++ std::mutex mutex; ++ std::condition_variable wake; ++ std::deque pending; ++ bool closing=false; ++ explicit state(std::shared_ptr value):gpu(std::move(value)) {} ++ }; ++ std::shared_ptr state_; ++ std::thread worker_; ++ static void work(const std::shared_ptr& state) noexcept { ++ for(;;) { ++ wgpu::Future future; ++ { ++ std::unique_lock lock(state->mutex); ++ state->wake.wait(lock,[&]{return state->closing || !state->pending.empty();}); ++ if(state->pending.empty()) return; ++ future=state->pending.front();state->pending.pop_front(); ++ } ++ // The only blocking GPU wait is on this completion worker (or an ++ // explicitly requested capture). Timeout is a device failure, ++ // never a fabricated successful frame or owner-thread GPU wait. ++ if(state->gpu->instance.WaitAny(future,30'000'000'000ULL)!=wgpu::WaitStatus::Success) { ++ state->gpu->failed->store(true); ++ state->gpu->device.Destroy(); ++ state->gpu->instance.WaitAny(future,0); ++ } ++ } ++ } ++public: ++ explicit offscreen_completion_service(std::shared_ptr gpu) ++ :state_(std::make_shared(std::move(gpu))),worker_([state=state_]{work(state);}) {} ++ offscreen_completion_service(const offscreen_completion_service&)=delete; ++ offscreen_completion_service& operator=(const offscreen_completion_service&)=delete; ++ ~offscreen_completion_service() { ++ {std::lock_guard lock(state_->mutex);state_->closing=true;} ++ state_->wake.notify_one(); ++ // The worker owns its independent state until the final completion. ++ // A lease may have its final release inside a native callback. ++ if(worker_.get_id()==std::this_thread::get_id()) worker_.detach(); ++ else worker_.join(); ++ } ++ void enqueue(wgpu::Future future) const { ++ std::lock_guard lock(state_->mutex); ++ if(state_->closing || state_->pending.size()>=128) ++ throw std::runtime_error("Offscreen GPU completion admission backpressure"); ++ state_->pending.push_back(future);state_->wake.notify_one(); ++ } ++}; ++ + // No OS window or external-memory handle is required. Images remain on the + // application's Dawn device. This is NOT a Vulkan-to-EGL/Wayland presenter. + struct offscreen_gpu_dependencies final : webscene_gpu_producer_dependencies { + std::shared_ptr gpu; ++ offscreen_completion_service completion; + mutable std::atomic captures{}; + explicit offscreen_gpu_dependencies(std::shared_ptr value) +- : gpu(std::move(value)) {} ++ : gpu(std::move(value)),completion(gpu) {} + size_t count() const noexcept override { return 0; } + bool metal_event(size_t,void*& event,uint64_t& value) const override { + event=nullptr;value=0;return false; +@@ -95,8 +153,9 @@ + queue.Submit(1,&commands); + // The captured owner is intentionally released by this completion, not by + // the lifetime of the blocking caller or by a cancelled buffer mapping. +- queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, ++ auto completed=queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [owner](wgpu::QueueWorkDoneStatus,wgpu::StringView) {}); ++ dependencies->completion.enqueue(completed); + struct mapped_state { bool ok=false; std::vector pixels; }; + auto state=std::make_shared(); + auto future=buffer.MapAsync(wgpu::MapMode::Read,0,bd.size,wgpu::CallbackMode::WaitAnyOnly, +@@ -155,7 +214,7 @@ + // selects its ICD; verify the returned adapter rather than mislabeling + // a hardware/default adapter or silently rejecting a software driver. + auto gpu=std::make_shared( +- native_webgpu_device::create(backend())); ++ native_webgpu_device::create(backend(),{wgpu::FeatureName::ImplicitDeviceSynchronization})); + wgpu::AdapterInfo info{}; + if(gpu->adapter.GetInfo(&info)!=wgpu::Status::Success) + throw std::runtime_error("Cannot inspect offscreen GPU adapter"); +@@ -172,8 +231,9 @@ + } + std::optional retire() { + if(!current_) return {}; +- auto value=images_.retire_submitted(std::move(*current_)); +- current_.reset();return value; ++ wgpu::Future future; ++ auto value=images_.retire_submitted(std::move(*current_),{},&future); ++ current_.reset();dependencies_->completion.enqueue(future);return value; + } + public: + native_webgpu_offscreen_surface(uint64_t canvas,uint32_t width,uint32_t height, diff --git a/.github/workflows/land-offscreen-completion.yml b/.github/workflows/land-offscreen-completion.yml new file mode 100644 index 000000000..9434deccf --- /dev/null +++ b/.github/workflows/land-offscreen-completion.yml @@ -0,0 +1,26 @@ +name: Apply native offscreen completion fix +on: + push: + branches: [codex/linux-headless-platform] + paths: ['.automation/offscreen-completion.patch', '.github/workflows/land-offscreen-completion.yml'] +permissions: + contents: write +jobs: + apply: + if: github.repository == 'SceneTech/WebScene' && github.ref == 'refs/heads/codex/linux-headless-platform' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Apply verified source change and remove transport + run: | + set -euo pipefail + sed -i 's/^ diff --git/diff --git/' .automation/offscreen-completion.patch + echo 'cbbb5896fb28b12032d8c654f2a07751d8ad2a9b2e0dcad036337a904ad4474a .automation/offscreen-completion.patch' | sha256sum --check + git apply --check .automation/offscreen-completion.patch + git apply --index .automation/offscreen-completion.patch + git rm -f .automation/offscreen-completion.patch .github/workflows/land-offscreen-completion.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git -c core.hooksPath=/dev/null commit -m 'fix: drive offscreen GPU completion independently of application frames' + git push origin HEAD:refs/heads/codex/linux-headless-platform From c10e397698a9c58417ab1ba9980323b8c1e4db02 Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:50:51 +0100 Subject: [PATCH 10/11] fix: drive offscreen GPU completion independently of application frames --- .automation/offscreen-completion.patch | 143 ------------------ .../workflows/land-offscreen-completion.yml | 26 ---- .../native/graphics/dawn_canvas_images.h | 5 +- .../native_webgpu_offscreen_surface.h | 70 ++++++++- 4 files changed, 68 insertions(+), 176 deletions(-) delete mode 100644 .automation/offscreen-completion.patch delete mode 100644 .github/workflows/land-offscreen-completion.yml diff --git a/.automation/offscreen-completion.patch b/.automation/offscreen-completion.patch deleted file mode 100644 index 0ea9b9de6..000000000 --- a/.automation/offscreen-completion.patch +++ /dev/null @@ -1,143 +0,0 @@ -diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h ---- a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h -+++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h -@@ -64,7 +64,7 @@ - // Even if retaining a snapshot hits the ticket limit, keep the producer - // reserved until ALL work already submitted to its queue has completed. - std::optional retire_submitted(frame&& input, -- std::shared_ptr completion={}) { -+ std::shared_ptr completion={}, wgpu::Future* submitted_future=nullptr) { - check_thread(); - if (!input.producer.belongs_to(storage_.get())) - throw std::invalid_argument("foreign canvas producer"); -@@ -72,13 +72,14 @@ - auto pending=std::make_shared(std::move(input)); - pending->producer.begin(); - auto image=pending->producer.publish(); -- storage_->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, -+ auto future=storage_->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, - [pending,status,completion](wgpu::QueueWorkDoneStatus result,wgpu::StringView) { - pending->producer.complete(); - status->store(result==wgpu::QueueWorkDoneStatus::Success - ? submission_status::success : submission_status::failed,std::memory_order_release); - if (completion) completion->signal(); - }); -+ if (submitted_future) *submitted_future=future; - if (!image) return {}; // callback still owns the submitted producer - return submitted_frame{std::move(*image),std::move(status)}; - } - diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h ---- a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h -+++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h -@@ -2,20 +2,78 @@ - #include "native_webgpu_device.h" - #include "dawn_canvas_images.h" - #include "image_lease_abi.h" -+#include - #include -+#include - #include - #include - #include - #include - - namespace webscene::graphics { -+// Dedicated native completion progress. Wait on submitted-work futures instead -+// of driving a render timer or waiting on the application/owner thread. Dawn's -+// spontaneous callback mode alone does not guarantee progress when all frames -+// are paused. This service never touches document state or presents a frame. -+class offscreen_completion_service { -+ struct state { -+ std::shared_ptr gpu; -+ std::mutex mutex; -+ std::condition_variable wake; -+ std::deque pending; -+ bool closing=false; -+ explicit state(std::shared_ptr value):gpu(std::move(value)) {} -+ }; -+ std::shared_ptr state_; -+ std::thread worker_; -+ static void work(const std::shared_ptr& state) noexcept { -+ for(;;) { -+ wgpu::Future future; -+ { -+ std::unique_lock lock(state->mutex); -+ state->wake.wait(lock,[&]{return state->closing || !state->pending.empty();}); -+ if(state->pending.empty()) return; -+ future=state->pending.front();state->pending.pop_front(); -+ } -+ // The only blocking GPU wait is on this completion worker (or an -+ // explicitly requested capture). Timeout is a device failure, -+ // never a fabricated successful frame or owner-thread GPU wait. -+ if(state->gpu->instance.WaitAny(future,30'000'000'000ULL)!=wgpu::WaitStatus::Success) { -+ state->gpu->failed->store(true); -+ state->gpu->device.Destroy(); -+ state->gpu->instance.WaitAny(future,0); -+ } -+ } -+ } -+public: -+ explicit offscreen_completion_service(std::shared_ptr gpu) -+ :state_(std::make_shared(std::move(gpu))),worker_([state=state_]{work(state);}) {} -+ offscreen_completion_service(const offscreen_completion_service&)=delete; -+ offscreen_completion_service& operator=(const offscreen_completion_service&)=delete; -+ ~offscreen_completion_service() { -+ {std::lock_guard lock(state_->mutex);state_->closing=true;} -+ state_->wake.notify_one(); -+ // The worker owns its independent state until the final completion. -+ // A lease may have its final release inside a native callback. -+ if(worker_.get_id()==std::this_thread::get_id()) worker_.detach(); -+ else worker_.join(); -+ } -+ void enqueue(wgpu::Future future) const { -+ std::lock_guard lock(state_->mutex); -+ if(state_->closing || state_->pending.size()>=128) -+ throw std::runtime_error("Offscreen GPU completion admission backpressure"); -+ state_->pending.push_back(future);state_->wake.notify_one(); -+ } -+}; -+ - // No OS window or external-memory handle is required. Images remain on the - // application's Dawn device. This is NOT a Vulkan-to-EGL/Wayland presenter. - struct offscreen_gpu_dependencies final : webscene_gpu_producer_dependencies { - std::shared_ptr gpu; -+ offscreen_completion_service completion; - mutable std::atomic captures{}; - explicit offscreen_gpu_dependencies(std::shared_ptr value) -- : gpu(std::move(value)) {} -+ : gpu(std::move(value)),completion(gpu) {} - size_t count() const noexcept override { return 0; } - bool metal_event(size_t,void*& event,uint64_t& value) const override { - event=nullptr;value=0;return false; -@@ -95,8 +153,9 @@ - queue.Submit(1,&commands); - // The captured owner is intentionally released by this completion, not by - // the lifetime of the blocking caller or by a cancelled buffer mapping. -- queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, -+ auto completed=queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, - [owner](wgpu::QueueWorkDoneStatus,wgpu::StringView) {}); -+ dependencies->completion.enqueue(completed); - struct mapped_state { bool ok=false; std::vector pixels; }; - auto state=std::make_shared(); - auto future=buffer.MapAsync(wgpu::MapMode::Read,0,bd.size,wgpu::CallbackMode::WaitAnyOnly, -@@ -155,7 +214,7 @@ - // selects its ICD; verify the returned adapter rather than mislabeling - // a hardware/default adapter or silently rejecting a software driver. - auto gpu=std::make_shared( -- native_webgpu_device::create(backend())); -+ native_webgpu_device::create(backend(),{wgpu::FeatureName::ImplicitDeviceSynchronization})); - wgpu::AdapterInfo info{}; - if(gpu->adapter.GetInfo(&info)!=wgpu::Status::Success) - throw std::runtime_error("Cannot inspect offscreen GPU adapter"); -@@ -172,8 +231,9 @@ - } - std::optional retire() { - if(!current_) return {}; -- auto value=images_.retire_submitted(std::move(*current_)); -- current_.reset();return value; -+ wgpu::Future future; -+ auto value=images_.retire_submitted(std::move(*current_),{},&future); -+ current_.reset();dependencies_->completion.enqueue(future);return value; - } - public: - native_webgpu_offscreen_surface(uint64_t canvas,uint32_t width,uint32_t height, diff --git a/.github/workflows/land-offscreen-completion.yml b/.github/workflows/land-offscreen-completion.yml deleted file mode 100644 index 9434deccf..000000000 --- a/.github/workflows/land-offscreen-completion.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Apply native offscreen completion fix -on: - push: - branches: [codex/linux-headless-platform] - paths: ['.automation/offscreen-completion.patch', '.github/workflows/land-offscreen-completion.yml'] -permissions: - contents: write -jobs: - apply: - if: github.repository == 'SceneTech/WebScene' && github.ref == 'refs/heads/codex/linux-headless-platform' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - name: Apply verified source change and remove transport - run: | - set -euo pipefail - sed -i 's/^ diff --git/diff --git/' .automation/offscreen-completion.patch - echo 'cbbb5896fb28b12032d8c654f2a07751d8ad2a9b2e0dcad036337a904ad4474a .automation/offscreen-completion.patch' | sha256sum --check - git apply --check .automation/offscreen-completion.patch - git apply --index .automation/offscreen-completion.patch - git rm -f .automation/offscreen-completion.patch .github/workflows/land-offscreen-completion.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git -c core.hooksPath=/dev/null commit -m 'fix: drive offscreen GPU completion independently of application frames' - git push origin HEAD:refs/heads/codex/linux-headless-platform diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h index 9b8ad5911..efb884a59 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h @@ -64,7 +64,7 @@ class dawn_canvas_images { // Even if retaining a snapshot hits the ticket limit, keep the producer // reserved until ALL work already submitted to its queue has completed. std::optional retire_submitted(frame&& input, - std::shared_ptr completion={}) { + std::shared_ptr completion={}, wgpu::Future* submitted_future=nullptr) { check_thread(); if (!input.producer.belongs_to(storage_.get())) throw std::invalid_argument("foreign canvas producer"); @@ -72,13 +72,14 @@ class dawn_canvas_images { auto pending=std::make_shared(std::move(input)); pending->producer.begin(); auto image=pending->producer.publish(); - storage_->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + auto future=storage_->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, [pending,status,completion](wgpu::QueueWorkDoneStatus result,wgpu::StringView) { pending->producer.complete(); status->store(result==wgpu::QueueWorkDoneStatus::Success ? submission_status::success : submission_status::failed,std::memory_order_release); if (completion) completion->signal(); }); + if (submitted_future) *submitted_future=future; if (!image) return {}; // callback still owns the submitted producer return submitted_frame{std::move(*image),std::move(status)}; } diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h index 38caa5d78..49086767d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h @@ -2,20 +2,78 @@ #include "native_webgpu_device.h" #include "dawn_canvas_images.h" #include "image_lease_abi.h" +#include #include +#include #include #include #include #include namespace webscene::graphics { +// Dedicated native completion progress. Wait on submitted-work futures instead +// of driving a render timer or waiting on the application/owner thread. Dawn's +// spontaneous callback mode alone does not guarantee progress when all frames +// are paused. This service never touches document state or presents a frame. +class offscreen_completion_service { + struct state { + std::shared_ptr gpu; + std::mutex mutex; + std::condition_variable wake; + std::deque pending; + bool closing=false; + explicit state(std::shared_ptr value):gpu(std::move(value)) {} + }; + std::shared_ptr state_; + std::thread worker_; + static void work(const std::shared_ptr& state) noexcept { + for(;;) { + wgpu::Future future; + { + std::unique_lock lock(state->mutex); + state->wake.wait(lock,[&]{return state->closing || !state->pending.empty();}); + if(state->pending.empty()) return; + future=state->pending.front();state->pending.pop_front(); + } + // The only blocking GPU wait is on this completion worker (or an + // explicitly requested capture). Timeout is a device failure, + // never a fabricated successful frame or owner-thread GPU wait. + if(state->gpu->instance.WaitAny(future,30'000'000'000ULL)!=wgpu::WaitStatus::Success) { + state->gpu->failed->store(true); + state->gpu->device.Destroy(); + state->gpu->instance.WaitAny(future,0); + } + } + } +public: + explicit offscreen_completion_service(std::shared_ptr gpu) + :state_(std::make_shared(std::move(gpu))),worker_([state=state_]{work(state);}) {} + offscreen_completion_service(const offscreen_completion_service&)=delete; + offscreen_completion_service& operator=(const offscreen_completion_service&)=delete; + ~offscreen_completion_service() { + {std::lock_guard lock(state_->mutex);state_->closing=true;} + state_->wake.notify_one(); + // The worker owns its independent state until the final completion. + // A lease may have its final release inside a native callback. + if(worker_.get_id()==std::this_thread::get_id()) worker_.detach(); + else worker_.join(); + } + void enqueue(wgpu::Future future) const { + std::lock_guard lock(state_->mutex); + if(state_->closing || state_->pending.size()>=128) + throw std::runtime_error("Offscreen GPU completion admission backpressure"); + state_->pending.push_back(future);state_->wake.notify_one(); + } +}; + // No OS window or external-memory handle is required. Images remain on the // application's Dawn device. This is NOT a Vulkan-to-EGL/Wayland presenter. struct offscreen_gpu_dependencies final : webscene_gpu_producer_dependencies { std::shared_ptr gpu; + offscreen_completion_service completion; mutable std::atomic captures{}; explicit offscreen_gpu_dependencies(std::shared_ptr value) - : gpu(std::move(value)) {} + : gpu(std::move(value)),completion(gpu) {} size_t count() const noexcept override { return 0; } bool metal_event(size_t,void*& event,uint64_t& value) const override { event=nullptr;value=0;return false; @@ -95,8 +153,9 @@ inline offscreen_capture capture_offscreen_image(const webscene_gpu_image_lease_ queue.Submit(1,&commands); // The captured owner is intentionally released by this completion, not by // the lifetime of the blocking caller or by a cancelled buffer mapping. - queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + auto completed=queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, [owner](wgpu::QueueWorkDoneStatus,wgpu::StringView) {}); + dependencies->completion.enqueue(completed); struct mapped_state { bool ok=false; std::vector pixels; }; auto state=std::make_shared(); auto future=buffer.MapAsync(wgpu::MapMode::Read,0,bd.size,wgpu::CallbackMode::WaitAnyOnly, @@ -155,7 +214,7 @@ class native_webgpu_offscreen_surface final { // selects its ICD; verify the returned adapter rather than mislabeling // a hardware/default adapter or silently rejecting a software driver. auto gpu=std::make_shared( - native_webgpu_device::create(backend())); + native_webgpu_device::create(backend(),{wgpu::FeatureName::ImplicitDeviceSynchronization})); wgpu::AdapterInfo info{}; if(gpu->adapter.GetInfo(&info)!=wgpu::Status::Success) throw std::runtime_error("Cannot inspect offscreen GPU adapter"); @@ -172,8 +231,9 @@ class native_webgpu_offscreen_surface final { } std::optional retire() { if(!current_) return {}; - auto value=images_.retire_submitted(std::move(*current_)); - current_.reset();return value; + wgpu::Future future; + auto value=images_.retire_submitted(std::move(*current_),{},&future); + current_.reset();dependencies_->completion.enqueue(future);return value; } public: native_webgpu_offscreen_surface(uint64_t canvas,uint32_t width,uint32_t height, From 531ee5d1a2a71abc3c9389f9bc4f826ad36f98d5 Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:51:34 +0100 Subject: [PATCH 11/11] test(sdk): exercise installed Linux native consumers and component lookup in CI Build and execute the compiled shared-CSS consumer against the installed no-GPU profile. Keep WebGPU contracts required by default in paired headless SDK qualification. Preserve native completion service changes. --- .github/workflows/native-linux.yml | 8 ++++++++ tests/SDKLinux/CMakeLists.txt | 14 +++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/native-linux.yml b/.github/workflows/native-linux.yml index b8f68a2cb..8d7adf07f 100644 --- a/.github/workflows/native-linux.yml +++ b/.github/workflows/native-linux.yml @@ -8,6 +8,7 @@ on: - 'experiments/WebScene.NativeEngine.Probe/native/**' - 'tooling/webscene-uic/**' - 'tests/NativeWeb/**' + - 'tests/SDKLinux/**' - '.github/workflows/native-linux.yml' pull_request: paths: @@ -16,6 +17,7 @@ on: - 'experiments/WebScene.NativeEngine.Probe/native/**' - 'tooling/webscene-uic/**' - 'tests/NativeWeb/**' + - 'tests/SDKLinux/**' - '.github/workflows/native-linux.yml' workflow_dispatch: permissions: @@ -51,6 +53,9 @@ jobs: cmake -G Ninja -S src/WebScene.Sdk -B artifacts/native-sdk -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 -DWEBSCENE_SDK_ENABLE_WEBGPU=OFF -DCMAKE_INSTALL_PREFIX="$PWD/artifacts/sdk" cmake --build artifacts/native-sdk --parallel 2 2>&1 | tee artifacts/native-sdk-build.log cmake --install artifacts/native-sdk + cmake -G Ninja -S tests/SDKLinux -B artifacts/native-sdk-consumer -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_PREFIX_PATH="$PWD/artifacts/sdk" -DWEBSCENE_TEST_WEBGPU=OFF + cmake --build artifacts/native-sdk-consumer --parallel 2 2>&1 | tee artifacts/native-sdk-consumer-build.log + ctest --test-dir artifacts/native-sdk-consumer --output-on-failure --output-junit installed-sdk-tests.xml 2>&1 | tee artifacts/native-sdk-consumer-tests.log tar -czf artifacts/native-linux-sdk-no-gpu.tar.gz -C artifacts sdk - uses: actions/upload-artifact@v4 if: always() @@ -61,4 +66,7 @@ jobs: artifacts/native-linux/native-linux-tests.xml artifacts/native-linux/Testing/Temporary/ artifacts/native-linux-sdk-no-gpu.tar.gz + artifacts/native-sdk-consumer/compiled_native + artifacts/native-sdk-consumer/installed-sdk-tests.xml + artifacts/native-sdk-consumer/Testing/Temporary/ retention-days: 14 diff --git a/tests/SDKLinux/CMakeLists.txt b/tests/SDKLinux/CMakeLists.txt index 3cb170f14..653138f32 100644 --- a/tests/SDKLinux/CMakeLists.txt +++ b/tests/SDKLinux/CMakeLists.txt @@ -1,12 +1,16 @@ cmake_minimum_required(VERSION 3.28) project(WebSceneLinuxSDKContracts LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) -find_package(WebScene REQUIRED CONFIG COMPONENTS NativeWeb SharedCSS WebGPU Compiler) +find_package(WebScene REQUIRED CONFIG COMPONENTS NativeWeb SharedCSS Compiler) include(CTest) -add_executable(offscreen_gpu offscreen_gpu.cpp) -target_link_libraries(offscreen_gpu PRIVATE WebScene::WebGPU) -add_test(NAME offscreen_gpu COMMAND offscreen_gpu) -set_tests_properties(offscreen_gpu PROPERTIES TIMEOUT 90) +option(WEBSCENE_TEST_WEBGPU "Require and exercise real offscreen WebGPU" ON) +if(WEBSCENE_TEST_WEBGPU) + find_package(WebScene REQUIRED CONFIG COMPONENTS WebGPU) + add_executable(offscreen_gpu offscreen_gpu.cpp) + target_link_libraries(offscreen_gpu PRIVATE WebScene::WebGPU) + add_test(NAME offscreen_gpu COMMAND offscreen_gpu) + set_tests_properties(offscreen_gpu PROPERTIES TIMEOUT 90) +endif() add_executable(compiled_native compiled_native.cpp) webscene_compile_html(compiled_native Main.html MODULE linux.contract.ui CSS_BACKEND shared) target_link_libraries(compiled_native PRIVATE WebScene::NativeWeb WebScene::SharedCSS)