openpencil/crates/op-host-native/tests/common/mod.rs

244 lines
9.4 KiB
Rust
Raw Normal View History

//! Shared test helpers for `op-host-native`.
feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7). - `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface, `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)` callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with Android surface drop contract; tracing spans + events on every per-frame entry point. - `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS / Android stubs; trait carries no `Send` bound (per spec §3.1). - `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes STENCIL_TEST + blend func to verify chrome-paint isolation. - `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend` trait surface (no direct trait impl in 1a; Step 1c+ wraps via `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect / draw_text / clip_rect / save / restore / translate` to `jian_core::render::DrawOp` and submits via `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper + `to_jian_color` / `to_jian_rect` converters. - Tests: - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence. - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3 with sysinfo RSS budget < 5 %. - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches begin_frame / with_frame / present / resize / teardown / on_pause / on_resume / on_low_memory events. - `raster_composition.rs` — chrome-only fill_rect on raster surface, pixel-asserts red + black + untouched-bg. - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature, asserts visible glyph rasterisation. - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible winit window (graceful inconclusive when off main thread; full path runs from `cargo run --example basic_window`) + Windows `#[ignore]` per spec §8.1. - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL surface, asserts chrome pixels survive stub's GL pollution. - Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror` workspace deps; dev-deps `sysinfo`, `tracing-test` (with `no-env-filter`), Linux-only `khronos-egl` + `libloading`. `cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --all -- --check` all green on macOS.
2026-05-05 13:06:00 +00:00
//!
//! Spec v19 §9 / plan v7 Task 2 Step 16a-16f: this module supplies
fix(shell-native): Phase A Gate round 1 fixes (Task 2 patches) Applies Codex Phase A Gate round 1 review (3 BLOCK + 2 CONCERN + 1 NIT) against the Task 2 SharedSkiaContext + NativeBackend implementation. BLOCK 1 — `ProviderError::from_msg` `pub(crate)` blocked the Linux EGL pbuffer test helper from constructing typed provider errors. Promoted to `pub` so out-of-tree provider impls (test pbuffer, future Step 1f mobile providers) can produce diagnostically-identical errors. BLOCK 2 — Linux GPU smoke + chrome-stub-composition tests silently returned `Ok(())` on EGL pbuffer setup failure, turning acceptance #3 / #4 into false positives on hosted CI without GPU. Now gated by `STEP1A_REQUIRE_GPU=1`: real-GPU runners panic on setup failure; dev / hostless runs surface an explicit `INCONCLUSIVE` marker before returning. Mirrors the macOS `catch_unwind` skip path in the same file. BLOCK 3 — `tests/memory_loop.rs` was running 100 cycles against `SharedSkiaContext::inert_for_test()` (every Option<> field None), so the RSS budget proved nothing about real allocation lifecycle. Renamed constructor to `inert_for_lifecycle_test()` (clearer intent) and split the test into: - Phase 0 warmup (100 inert + 100 raster) so Skia's lazy glyph/path/binding caches are populated before measurement; - Phase 1 lifecycle idempotence (100 inert); - Phase 2 real-resource cycle: raster surface on macOS / Windows (winit::EventLoop main-thread-only on macOS; Win Actions runner has no GPU per spec §8.1), full EGL pbuffer + GL surface on Linux when `STEP1A_REQUIRE_GPU=1`, raster fallback otherwise. Budget kept at 5 % per acceptance #6 with a 1.5 MB absolute floor to absorb macOS sysinfo's coarse RSS sampling jitter on small baselines. CONCERN 1 — `GlContextProvider` had three non-spec methods (`resize`, `size`, `default_framebuffer_id`). Audit: - `resize`: actually used by `SharedSkiaContext::resize` (window / pbuffer resize → Skia FBO rewrap). KEPT, spec mini-patch documented in comment, escalation needed for spec v19 → v19.1. - `default_framebuffer_id`: used by `SharedSkiaContext::new` / `resize` for the FBO id Skia wraps; iOS EAGL provider (Step 1f) will need non-zero values. KEPT, same escalation path. - `size`: unused anywhere. DELETED (YAGNI), along with the unused `size: (u32, u32)` field on `GlutinProvider` and the iOS / Android stub impls. CONCERN 2 — `glow_handle: Option<Arc<glow::Context>>` deviates from spec v19 lines 120-125 + 191 (`Arc<glow::Context>`). Real lifecycle needs the handle droppable: teardown releases the loaded function table, `inert_for_lifecycle_test` has no GL backing, Step 1f Android `on_pause` must drop alongside the EGL context. KEPT as Option<Arc>, spec mini-patch documented for v19.1 escalation. NIT 1 — Removed Task 1 link-check helper `placeholder()`. Task 2's full re-export chain (`SharedSkiaContext`, `NativeBackend`, …) already proves shell-core ↔ shell-native linkage; placeholder is YAGNI now. Verification (macOS local): - cargo build -p openpencil-shell-native: clean - cargo test -p openpencil-shell-native: 12/12 pass (8 binaries) - cargo clippy -p openpencil-shell-native --tests --all-targets -- -D warnings: clean - cargo fmt -p openpencil-shell-native -- --check: clean - memory_loop stress 8 consecutive runs: 8/8 pass
2026-05-05 13:09:00 +00:00
//! - `setup_headless_context()` for tests that pin lifecycle
//! idempotence on a fully torn-down context (teardown, tracing);
//! - `RasterCycle` for tests that need real Skia resources (RSS
//! sanity loop) without spinning up a GL stack — this is the
//! macOS / Windows / no-GPU-Linux memory-loop path;
feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7). - `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface, `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)` callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with Android surface drop contract; tracing spans + events on every per-frame entry point. - `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS / Android stubs; trait carries no `Send` bound (per spec §3.1). - `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes STENCIL_TEST + blend func to verify chrome-paint isolation. - `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend` trait surface (no direct trait impl in 1a; Step 1c+ wraps via `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect / draw_text / clip_rect / save / restore / translate` to `jian_core::render::DrawOp` and submits via `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper + `to_jian_color` / `to_jian_rect` converters. - Tests: - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence. - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3 with sysinfo RSS budget < 5 %. - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches begin_frame / with_frame / present / resize / teardown / on_pause / on_resume / on_low_memory events. - `raster_composition.rs` — chrome-only fill_rect on raster surface, pixel-asserts red + black + untouched-bg. - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature, asserts visible glyph rasterisation. - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible winit window (graceful inconclusive when off main thread; full path runs from `cargo run --example basic_window`) + Windows `#[ignore]` per spec §8.1. - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL surface, asserts chrome pixels survive stub's GL pollution. - Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror` workspace deps; dev-deps `sysinfo`, `tracing-test` (with `no-env-filter`), Linux-only `khronos-egl` + `libloading`. `cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --all -- --check` all green on macOS.
2026-05-05 13:06:00 +00:00
//! - `egl_pbuffer::EglPbufferProvider` (Linux only) for the GPU-smoke
//! tests, providing an off-screen GL context via Mesa softpipe.
#![allow(dead_code)]
use op_host_native::SharedSkiaContext;
feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7). - `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface, `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)` callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with Android surface drop contract; tracing spans + events on every per-frame entry point. - `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS / Android stubs; trait carries no `Send` bound (per spec §3.1). - `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes STENCIL_TEST + blend func to verify chrome-paint isolation. - `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend` trait surface (no direct trait impl in 1a; Step 1c+ wraps via `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect / draw_text / clip_rect / save / restore / translate` to `jian_core::render::DrawOp` and submits via `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper + `to_jian_color` / `to_jian_rect` converters. - Tests: - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence. - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3 with sysinfo RSS budget < 5 %. - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches begin_frame / with_frame / present / resize / teardown / on_pause / on_resume / on_low_memory events. - `raster_composition.rs` — chrome-only fill_rect on raster surface, pixel-asserts red + black + untouched-bg. - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature, asserts visible glyph rasterisation. - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible winit window (graceful inconclusive when off main thread; full path runs from `cargo run --example basic_window`) + Windows `#[ignore]` per spec §8.1. - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL surface, asserts chrome pixels survive stub's GL pollution. - Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror` workspace deps; dev-deps `sysinfo`, `tracing-test` (with `no-env-filter`), Linux-only `khronos-egl` + `libloading`. `cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --all -- --check` all green on macOS.
2026-05-05 13:06:00 +00:00
/// Returns a `SharedSkiaContext` whose every `Option<>` field is
/// `None`. The tests that exercise idempotence + tracing don't care
/// what's behind the handles — they care that the public API behaves
/// the same on a torn-down context. Avoids the per-test cost of
/// spinning up an EGL pbuffer / winit window.
fix(shell-native): Phase A Gate round 1 fixes (Task 2 patches) Applies Codex Phase A Gate round 1 review (3 BLOCK + 2 CONCERN + 1 NIT) against the Task 2 SharedSkiaContext + NativeBackend implementation. BLOCK 1 — `ProviderError::from_msg` `pub(crate)` blocked the Linux EGL pbuffer test helper from constructing typed provider errors. Promoted to `pub` so out-of-tree provider impls (test pbuffer, future Step 1f mobile providers) can produce diagnostically-identical errors. BLOCK 2 — Linux GPU smoke + chrome-stub-composition tests silently returned `Ok(())` on EGL pbuffer setup failure, turning acceptance #3 / #4 into false positives on hosted CI without GPU. Now gated by `STEP1A_REQUIRE_GPU=1`: real-GPU runners panic on setup failure; dev / hostless runs surface an explicit `INCONCLUSIVE` marker before returning. Mirrors the macOS `catch_unwind` skip path in the same file. BLOCK 3 — `tests/memory_loop.rs` was running 100 cycles against `SharedSkiaContext::inert_for_test()` (every Option<> field None), so the RSS budget proved nothing about real allocation lifecycle. Renamed constructor to `inert_for_lifecycle_test()` (clearer intent) and split the test into: - Phase 0 warmup (100 inert + 100 raster) so Skia's lazy glyph/path/binding caches are populated before measurement; - Phase 1 lifecycle idempotence (100 inert); - Phase 2 real-resource cycle: raster surface on macOS / Windows (winit::EventLoop main-thread-only on macOS; Win Actions runner has no GPU per spec §8.1), full EGL pbuffer + GL surface on Linux when `STEP1A_REQUIRE_GPU=1`, raster fallback otherwise. Budget kept at 5 % per acceptance #6 with a 1.5 MB absolute floor to absorb macOS sysinfo's coarse RSS sampling jitter on small baselines. CONCERN 1 — `GlContextProvider` had three non-spec methods (`resize`, `size`, `default_framebuffer_id`). Audit: - `resize`: actually used by `SharedSkiaContext::resize` (window / pbuffer resize → Skia FBO rewrap). KEPT, spec mini-patch documented in comment, escalation needed for spec v19 → v19.1. - `default_framebuffer_id`: used by `SharedSkiaContext::new` / `resize` for the FBO id Skia wraps; iOS EAGL provider (Step 1f) will need non-zero values. KEPT, same escalation path. - `size`: unused anywhere. DELETED (YAGNI), along with the unused `size: (u32, u32)` field on `GlutinProvider` and the iOS / Android stub impls. CONCERN 2 — `glow_handle: Option<Arc<glow::Context>>` deviates from spec v19 lines 120-125 + 191 (`Arc<glow::Context>`). Real lifecycle needs the handle droppable: teardown releases the loaded function table, `inert_for_lifecycle_test` has no GL backing, Step 1f Android `on_pause` must drop alongside the EGL context. KEPT as Option<Arc>, spec mini-patch documented for v19.1 escalation. NIT 1 — Removed Task 1 link-check helper `placeholder()`. Task 2's full re-export chain (`SharedSkiaContext`, `NativeBackend`, …) already proves shell-core ↔ shell-native linkage; placeholder is YAGNI now. Verification (macOS local): - cargo build -p openpencil-shell-native: clean - cargo test -p openpencil-shell-native: 12/12 pass (8 binaries) - cargo clippy -p openpencil-shell-native --tests --all-targets -- -D warnings: clean - cargo fmt -p openpencil-shell-native -- --check: clean - memory_loop stress 8 consecutive runs: 8/8 pass
2026-05-05 13:09:00 +00:00
///
/// **Do not use for resource-accounting tests** — see
/// `RasterCycle` below. (Codex Phase A Gate round 1 BLOCK 3.)
feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7). - `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface, `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)` callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with Android surface drop contract; tracing spans + events on every per-frame entry point. - `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS / Android stubs; trait carries no `Send` bound (per spec §3.1). - `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes STENCIL_TEST + blend func to verify chrome-paint isolation. - `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend` trait surface (no direct trait impl in 1a; Step 1c+ wraps via `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect / draw_text / clip_rect / save / restore / translate` to `jian_core::render::DrawOp` and submits via `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper + `to_jian_color` / `to_jian_rect` converters. - Tests: - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence. - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3 with sysinfo RSS budget < 5 %. - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches begin_frame / with_frame / present / resize / teardown / on_pause / on_resume / on_low_memory events. - `raster_composition.rs` — chrome-only fill_rect on raster surface, pixel-asserts red + black + untouched-bg. - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature, asserts visible glyph rasterisation. - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible winit window (graceful inconclusive when off main thread; full path runs from `cargo run --example basic_window`) + Windows `#[ignore]` per spec §8.1. - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL surface, asserts chrome pixels survive stub's GL pollution. - Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror` workspace deps; dev-deps `sysinfo`, `tracing-test` (with `no-env-filter`), Linux-only `khronos-egl` + `libloading`. `cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --all -- --check` all green on macOS.
2026-05-05 13:06:00 +00:00
pub fn setup_headless_context() -> SharedSkiaContext {
fix(shell-native): Phase A Gate round 1 fixes (Task 2 patches) Applies Codex Phase A Gate round 1 review (3 BLOCK + 2 CONCERN + 1 NIT) against the Task 2 SharedSkiaContext + NativeBackend implementation. BLOCK 1 — `ProviderError::from_msg` `pub(crate)` blocked the Linux EGL pbuffer test helper from constructing typed provider errors. Promoted to `pub` so out-of-tree provider impls (test pbuffer, future Step 1f mobile providers) can produce diagnostically-identical errors. BLOCK 2 — Linux GPU smoke + chrome-stub-composition tests silently returned `Ok(())` on EGL pbuffer setup failure, turning acceptance #3 / #4 into false positives on hosted CI without GPU. Now gated by `STEP1A_REQUIRE_GPU=1`: real-GPU runners panic on setup failure; dev / hostless runs surface an explicit `INCONCLUSIVE` marker before returning. Mirrors the macOS `catch_unwind` skip path in the same file. BLOCK 3 — `tests/memory_loop.rs` was running 100 cycles against `SharedSkiaContext::inert_for_test()` (every Option<> field None), so the RSS budget proved nothing about real allocation lifecycle. Renamed constructor to `inert_for_lifecycle_test()` (clearer intent) and split the test into: - Phase 0 warmup (100 inert + 100 raster) so Skia's lazy glyph/path/binding caches are populated before measurement; - Phase 1 lifecycle idempotence (100 inert); - Phase 2 real-resource cycle: raster surface on macOS / Windows (winit::EventLoop main-thread-only on macOS; Win Actions runner has no GPU per spec §8.1), full EGL pbuffer + GL surface on Linux when `STEP1A_REQUIRE_GPU=1`, raster fallback otherwise. Budget kept at 5 % per acceptance #6 with a 1.5 MB absolute floor to absorb macOS sysinfo's coarse RSS sampling jitter on small baselines. CONCERN 1 — `GlContextProvider` had three non-spec methods (`resize`, `size`, `default_framebuffer_id`). Audit: - `resize`: actually used by `SharedSkiaContext::resize` (window / pbuffer resize → Skia FBO rewrap). KEPT, spec mini-patch documented in comment, escalation needed for spec v19 → v19.1. - `default_framebuffer_id`: used by `SharedSkiaContext::new` / `resize` for the FBO id Skia wraps; iOS EAGL provider (Step 1f) will need non-zero values. KEPT, same escalation path. - `size`: unused anywhere. DELETED (YAGNI), along with the unused `size: (u32, u32)` field on `GlutinProvider` and the iOS / Android stub impls. CONCERN 2 — `glow_handle: Option<Arc<glow::Context>>` deviates from spec v19 lines 120-125 + 191 (`Arc<glow::Context>`). Real lifecycle needs the handle droppable: teardown releases the loaded function table, `inert_for_lifecycle_test` has no GL backing, Step 1f Android `on_pause` must drop alongside the EGL context. KEPT as Option<Arc>, spec mini-patch documented for v19.1 escalation. NIT 1 — Removed Task 1 link-check helper `placeholder()`. Task 2's full re-export chain (`SharedSkiaContext`, `NativeBackend`, …) already proves shell-core ↔ shell-native linkage; placeholder is YAGNI now. Verification (macOS local): - cargo build -p openpencil-shell-native: clean - cargo test -p openpencil-shell-native: 12/12 pass (8 binaries) - cargo clippy -p openpencil-shell-native --tests --all-targets -- -D warnings: clean - cargo fmt -p openpencil-shell-native -- --check: clean - memory_loop stress 8 consecutive runs: 8/8 pass
2026-05-05 13:09:00 +00:00
SharedSkiaContext::inert_for_lifecycle_test()
}
/// Real-resource cycle helper for the memory-loop test (Codex Phase A
/// Gate round 1 BLOCK 3 fix).
///
/// Runs `iterations` of `{ build raster Skia surface → paint via
/// `NativeBackend::fill_rect` → drop the surface }` against a real
/// `skia_safe::Surface`. This exercises the same Skia allocation
/// paths (`raster_n32_premul`, `Canvas::draw_rect`) that the GPU
/// path goes through after `wrap_backend_render_target`, so the RSS
/// budget assertion measures real driver-side accounting rather than
/// a phantom no-op.
///
/// We intentionally do **not** route through `SharedSkiaContext`
/// here: that struct's full lifecycle requires a `GlContextProvider`,
/// and on hosted-CI macOS / Windows runners the only realistic
/// no-display GL stack is a winit window (which can't run on a
/// worker thread, see `gpu_smoke.rs`). Raster surfaces are the
/// largest non-GL allocation we can hammer in a `cargo test`
/// process; they're enough to flush a leak in the Skia bindings or
/// our `to_jian_*` translation layer.
pub fn raster_memory_cycle(iterations: usize, side: i32) {
use op_editor_ui::{Color, Point2D, Rect};
use op_host_native::NativeBackend;
fix(shell-native): Phase A Gate round 1 fixes (Task 2 patches) Applies Codex Phase A Gate round 1 review (3 BLOCK + 2 CONCERN + 1 NIT) against the Task 2 SharedSkiaContext + NativeBackend implementation. BLOCK 1 — `ProviderError::from_msg` `pub(crate)` blocked the Linux EGL pbuffer test helper from constructing typed provider errors. Promoted to `pub` so out-of-tree provider impls (test pbuffer, future Step 1f mobile providers) can produce diagnostically-identical errors. BLOCK 2 — Linux GPU smoke + chrome-stub-composition tests silently returned `Ok(())` on EGL pbuffer setup failure, turning acceptance #3 / #4 into false positives on hosted CI without GPU. Now gated by `STEP1A_REQUIRE_GPU=1`: real-GPU runners panic on setup failure; dev / hostless runs surface an explicit `INCONCLUSIVE` marker before returning. Mirrors the macOS `catch_unwind` skip path in the same file. BLOCK 3 — `tests/memory_loop.rs` was running 100 cycles against `SharedSkiaContext::inert_for_test()` (every Option<> field None), so the RSS budget proved nothing about real allocation lifecycle. Renamed constructor to `inert_for_lifecycle_test()` (clearer intent) and split the test into: - Phase 0 warmup (100 inert + 100 raster) so Skia's lazy glyph/path/binding caches are populated before measurement; - Phase 1 lifecycle idempotence (100 inert); - Phase 2 real-resource cycle: raster surface on macOS / Windows (winit::EventLoop main-thread-only on macOS; Win Actions runner has no GPU per spec §8.1), full EGL pbuffer + GL surface on Linux when `STEP1A_REQUIRE_GPU=1`, raster fallback otherwise. Budget kept at 5 % per acceptance #6 with a 1.5 MB absolute floor to absorb macOS sysinfo's coarse RSS sampling jitter on small baselines. CONCERN 1 — `GlContextProvider` had three non-spec methods (`resize`, `size`, `default_framebuffer_id`). Audit: - `resize`: actually used by `SharedSkiaContext::resize` (window / pbuffer resize → Skia FBO rewrap). KEPT, spec mini-patch documented in comment, escalation needed for spec v19 → v19.1. - `default_framebuffer_id`: used by `SharedSkiaContext::new` / `resize` for the FBO id Skia wraps; iOS EAGL provider (Step 1f) will need non-zero values. KEPT, same escalation path. - `size`: unused anywhere. DELETED (YAGNI), along with the unused `size: (u32, u32)` field on `GlutinProvider` and the iOS / Android stub impls. CONCERN 2 — `glow_handle: Option<Arc<glow::Context>>` deviates from spec v19 lines 120-125 + 191 (`Arc<glow::Context>`). Real lifecycle needs the handle droppable: teardown releases the loaded function table, `inert_for_lifecycle_test` has no GL backing, Step 1f Android `on_pause` must drop alongside the EGL context. KEPT as Option<Arc>, spec mini-patch documented for v19.1 escalation. NIT 1 — Removed Task 1 link-check helper `placeholder()`. Task 2's full re-export chain (`SharedSkiaContext`, `NativeBackend`, …) already proves shell-core ↔ shell-native linkage; placeholder is YAGNI now. Verification (macOS local): - cargo build -p openpencil-shell-native: clean - cargo test -p openpencil-shell-native: 12/12 pass (8 binaries) - cargo clippy -p openpencil-shell-native --tests --all-targets -- -D warnings: clean - cargo fmt -p openpencil-shell-native -- --check: clean - memory_loop stress 8 consecutive runs: 8/8 pass
2026-05-05 13:09:00 +00:00
let mut backend = NativeBackend::with_dpi(1.0);
for _ in 0..iterations {
let mut surface = skia_safe::surfaces::raster_n32_premul((side, side))
.expect("raster_n32_premul allocated");
// Fill a real rectangle to prevent the optimiser from eliding
// the surface and to force Skia to actually touch the GPU
// (raster) backing memory.
backend.fill_rect(
surface.canvas(),
Rect {
origin: Point2D::new(10.0, 10.0),
size: Point2D::new((side - 20) as f32, (side - 20) as f32),
},
Color::RED,
);
// Surface drops here — Skia must release its raster backing.
drop(surface);
}
feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7). - `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface, `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)` callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with Android surface drop contract; tracing spans + events on every per-frame entry point. - `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS / Android stubs; trait carries no `Send` bound (per spec §3.1). - `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes STENCIL_TEST + blend func to verify chrome-paint isolation. - `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend` trait surface (no direct trait impl in 1a; Step 1c+ wraps via `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect / draw_text / clip_rect / save / restore / translate` to `jian_core::render::DrawOp` and submits via `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper + `to_jian_color` / `to_jian_rect` converters. - Tests: - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence. - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3 with sysinfo RSS budget < 5 %. - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches begin_frame / with_frame / present / resize / teardown / on_pause / on_resume / on_low_memory events. - `raster_composition.rs` — chrome-only fill_rect on raster surface, pixel-asserts red + black + untouched-bg. - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature, asserts visible glyph rasterisation. - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible winit window (graceful inconclusive when off main thread; full path runs from `cargo run --example basic_window`) + Windows `#[ignore]` per spec §8.1. - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL surface, asserts chrome pixels survive stub's GL pollution. - Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror` workspace deps; dev-deps `sysinfo`, `tracing-test` (with `no-env-filter`), Linux-only `khronos-egl` + `libloading`. `cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --all -- --check` all green on macOS.
2026-05-05 13:06:00 +00:00
}
#[cfg(target_os = "linux")]
pub mod egl_pbuffer {
//! `EglPbufferProvider` — a `GlContextProvider` backed by a Mesa
//! softpipe EGL pbuffer. No display server / X11 / Wayland needed,
//! so the test runs on hosted Linux runners (GitHub Actions
//! `ubuntu-latest` ships Mesa). Spec §9.2 + plan v7 Task 2 Step
//! 16f Linux path.
//!
//! The provider is tiny on purpose — it only does what the GPU
//! smoke + chrome-stub composition tests need, and it intentionally
//! does not try to unify with the desktop `GlutinProvider` API
//! beyond the `GlContextProvider` trait.
use std::ffi::c_void;
use std::sync::Arc;
use khronos_egl as egl;
use op_host_native::{GlContextProvider, ProviderError, ProviderResult};
feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7). - `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface, `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)` callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with Android surface drop contract; tracing spans + events on every per-frame entry point. - `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS / Android stubs; trait carries no `Send` bound (per spec §3.1). - `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes STENCIL_TEST + blend func to verify chrome-paint isolation. - `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend` trait surface (no direct trait impl in 1a; Step 1c+ wraps via `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect / draw_text / clip_rect / save / restore / translate` to `jian_core::render::DrawOp` and submits via `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper + `to_jian_color` / `to_jian_rect` converters. - Tests: - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence. - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3 with sysinfo RSS budget < 5 %. - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches begin_frame / with_frame / present / resize / teardown / on_pause / on_resume / on_low_memory events. - `raster_composition.rs` — chrome-only fill_rect on raster surface, pixel-asserts red + black + untouched-bg. - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature, asserts visible glyph rasterisation. - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible winit window (graceful inconclusive when off main thread; full path runs from `cargo run --example basic_window`) + Windows `#[ignore]` per spec §8.1. - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL surface, asserts chrome pixels survive stub's GL pollution. - Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror` workspace deps; dev-deps `sysinfo`, `tracing-test` (with `no-env-filter`), Linux-only `khronos-egl` + `libloading`. `cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --all -- --check` all green on macOS.
2026-05-05 13:06:00 +00:00
type Egl = egl::DynamicInstance<egl::EGL1_4>;
pub struct EglPbufferProvider {
egl: Arc<Egl>,
display: egl::Display,
context: egl::Context,
surface: egl::Surface,
glow: Arc<glow::Context>,
released: bool,
}
impl EglPbufferProvider {
pub fn new(size: (u32, u32)) -> ProviderResult<Self> {
let egl: Arc<Egl> = unsafe {
Arc::new(
egl::DynamicInstance::<egl::EGL1_4>::load_required()
.map_err(|e| ProviderError::from_msg(format!("libEGL load: {e}")))?,
)
};
// SAFETY: khronos-egl 6.x marks `get_display` unsafe (it dereferences
// a raw display pointer). DEFAULT_DISPLAY is a well-known sentinel
// (NULL on most Linux platforms) handled correctly by libEGL.
let display = unsafe { egl.get_display(egl::DEFAULT_DISPLAY) }
feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7). - `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface, `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)` callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with Android surface drop contract; tracing spans + events on every per-frame entry point. - `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS / Android stubs; trait carries no `Send` bound (per spec §3.1). - `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes STENCIL_TEST + blend func to verify chrome-paint isolation. - `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend` trait surface (no direct trait impl in 1a; Step 1c+ wraps via `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect / draw_text / clip_rect / save / restore / translate` to `jian_core::render::DrawOp` and submits via `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper + `to_jian_color` / `to_jian_rect` converters. - Tests: - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence. - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3 with sysinfo RSS budget < 5 %. - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches begin_frame / with_frame / present / resize / teardown / on_pause / on_resume / on_low_memory events. - `raster_composition.rs` — chrome-only fill_rect on raster surface, pixel-asserts red + black + untouched-bg. - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature, asserts visible glyph rasterisation. - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible winit window (graceful inconclusive when off main thread; full path runs from `cargo run --example basic_window`) + Windows `#[ignore]` per spec §8.1. - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL surface, asserts chrome pixels survive stub's GL pollution. - Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror` workspace deps; dev-deps `sysinfo`, `tracing-test` (with `no-env-filter`), Linux-only `khronos-egl` + `libloading`. `cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --all -- --check` all green on macOS.
2026-05-05 13:06:00 +00:00
.ok_or_else(|| ProviderError::from_msg("no default EGL display"))?;
egl.initialize(display)
.map_err(|e| ProviderError::from_msg(format!("EGL init: {e}")))?;
egl.bind_api(egl::OPENGL_API)
.map_err(|e| ProviderError::from_msg(format!("EGL bind_api: {e}")))?;
let attribs = [
egl::SURFACE_TYPE,
egl::PBUFFER_BIT,
egl::RENDERABLE_TYPE,
egl::OPENGL_BIT,
egl::RED_SIZE,
8,
egl::GREEN_SIZE,
8,
egl::BLUE_SIZE,
8,
egl::ALPHA_SIZE,
8,
egl::DEPTH_SIZE,
0,
egl::STENCIL_SIZE,
8,
egl::NONE,
];
let config = egl
.choose_first_config(display, &attribs)
.map_err(|e| ProviderError::from_msg(format!("choose_config: {e}")))?
.ok_or_else(|| ProviderError::from_msg("no matching EGL config"))?;
let context_attribs = [egl::NONE];
let context = egl
.create_context(display, config, None, &context_attribs)
.map_err(|e| ProviderError::from_msg(format!("create_context: {e}")))?;
let pbuffer_attribs = [
egl::WIDTH,
size.0 as i32,
egl::HEIGHT,
size.1 as i32,
egl::NONE,
];
let surface = egl
.create_pbuffer_surface(display, config, &pbuffer_attribs)
.map_err(|e| ProviderError::from_msg(format!("create_pbuffer: {e}")))?;
egl.make_current(display, Some(surface), Some(surface), Some(context))
.map_err(|e| ProviderError::from_msg(format!("make_current: {e}")))?;
let egl_for_loader = Arc::clone(&egl);
let glow_ctx = unsafe {
glow::Context::from_loader_function(move |sym| {
egl_for_loader
.get_proc_address(sym)
.map(|p| p as *const c_void)
.unwrap_or(std::ptr::null())
})
};
Ok(Self {
egl,
display,
context,
surface,
glow: Arc::new(glow_ctx),
released: false,
})
}
}
impl GlContextProvider for EglPbufferProvider {
fn make_current(&mut self) -> ProviderResult<()> {
self.egl
.make_current(
self.display,
Some(self.surface),
Some(self.surface),
Some(self.context),
)
.map_err(|e| ProviderError::from_msg(format!("make_current: {e}")))
}
fn swap_buffers(&mut self) -> ProviderResult<()> {
// Pbuffers don't have a real "swap" — the EGL spec defines
// it as a no-op. Treat the call as success so the present
// path still runs to completion.
self.egl
.swap_buffers(self.display, self.surface)
.map_err(|e| ProviderError::from_msg(format!("swap_buffers: {e}")))
}
fn glow(&self) -> Arc<glow::Context> {
self.glow.clone()
}
2026-05-05 13:15:00 +00:00
/// EGL pbuffer default FBO = 0 (matches `GlutinProvider`).
/// Phase A Gate round 2 CONCERN 1 fix — explicit override
/// required, no trait default body.
fn default_framebuffer_id(&self) -> u32 {
0
}
feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7). - `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface, `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)` callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with Android surface drop contract; tracing spans + events on every per-frame entry point. - `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS / Android stubs; trait carries no `Send` bound (per spec §3.1). - `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes STENCIL_TEST + blend func to verify chrome-paint isolation. - `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend` trait surface (no direct trait impl in 1a; Step 1c+ wraps via `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect / draw_text / clip_rect / save / restore / translate` to `jian_core::render::DrawOp` and submits via `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper + `to_jian_color` / `to_jian_rect` converters. - Tests: - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence. - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3 with sysinfo RSS budget < 5 %. - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches begin_frame / with_frame / present / resize / teardown / on_pause / on_resume / on_low_memory events. - `raster_composition.rs` — chrome-only fill_rect on raster surface, pixel-asserts red + black + untouched-bg. - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature, asserts visible glyph rasterisation. - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible winit window (graceful inconclusive when off main thread; full path runs from `cargo run --example basic_window`) + Windows `#[ignore]` per spec §8.1. - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL surface, asserts chrome pixels survive stub's GL pollution. - Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror` workspace deps; dev-deps `sysinfo`, `tracing-test` (with `no-env-filter`), Linux-only `khronos-egl` + `libloading`. `cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --all -- --check` all green on macOS.
2026-05-05 13:06:00 +00:00
fn resize(&mut self, _w: u32, _h: u32) -> ProviderResult<()> {
// Pbuffer can't be resized once created — tests build the
// surface at the size they need.
Ok(())
}
fn release(&mut self) -> ProviderResult<()> {
if self.released {
return Ok(());
}
// Make-not-current first; some Mesa builds segfault if the
// pbuffer surface drops while still bound.
let _ = self.egl.make_current(self.display, None, None, None);
let _ = self.egl.destroy_surface(self.display, self.surface);
let _ = self.egl.destroy_context(self.display, self.context);
// Keep the EGL display open for other tests in the same
// process; eglTerminate is process-global on Mesa and a
// shared display is normal.
self.released = true;
Ok(())
}
}
impl Drop for EglPbufferProvider {
fn drop(&mut self) {
let _ = self.release();
}
}
}