mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
chore(shell-native): add transient P0 probe gate (Step 1a)
Drives the three-OS CI matrix verification of the skia-safe + glutin + glow + winit dep stack per Step 1a spec §7. - examples/p0_probe.rs: stencil_visibility + readback chain runner (must own a real OS main thread because winit on macOS rejects EventLoop::new() from cargo test worker threads). - tests/p0_probe.rs: subprocess-invoke wrapper, gated #[ignore = "P0_PROBE_GATE"] so default cargo test stays untouched. - Cargo.toml: add transient [target.'cfg(not(target_arch = "wasm32"))'. dev-dependencies] block (skia-safe 0.97 + glutin 0.32.3 + glutin-winit 0.5.0 + glow 0.17.0 + raw-window-handle 0.6.2 + scopeguard 1.2.0 + winit defaults). Pinned to versions resolved in /tmp/skia-glow-probe. - .github/workflows/rust-check.yml: install Linux GL prereqs (xvfb, mesa, libxkbcommon, libwayland) and add a P0-probe-gate step running cargo test --ignored on each OS (Linux through xvfb-run; Windows early-returns per spec §8.2 WINDOWS_GPU_DEFERRED_NO_RUNNER). All three artefacts are TRANSIENT — reverted in a follow-up cleanup commit after CI is green and the loader-compat notes commit lands. Task 1 owns the permanent integration.
This commit is contained in:
parent
d57f9816c2
commit
881cdd2a46
5 changed files with 1209 additions and 34 deletions
23
.github/workflows/rust-check.yml
vendored
23
.github/workflows/rust-check.yml
vendored
|
|
@ -35,10 +35,33 @@ jobs:
|
|||
toolchain: "1.85"
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# Linux GL prerequisites for the P0 probe ignored test step. xvfb gives
|
||||
# winit a virtual display; mesa software-rasterizes GL when no GPU is
|
||||
# available; libxkbcommon-x11-dev / libwayland-dev are winit's link-time
|
||||
# dependencies on Linux. (No-op on macOS / Windows runners.)
|
||||
- name: Install Linux GL prereqs (P0 probe gate)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
xvfb \
|
||||
libgl1-mesa-dri libglu1-mesa libegl1 libgles2 \
|
||||
libxkbcommon-dev libxkbcommon-x11-dev \
|
||||
libwayland-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo build --workspace
|
||||
- run: cargo test --workspace
|
||||
- run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
# P0 probe gate (Step 1a) — runs only the `#[ignore = "P0_PROBE_GATE"]`
|
||||
# tests. Linux: route through `xvfb-run` for a virtual display.
|
||||
# Windows: the test body early-returns with a "deferred" message
|
||||
# (spec §8.2 WINDOWS_GPU_DEFERRED_NO_RUNNER); macOS: runs natively.
|
||||
- name: P0 probe gate (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run -a cargo test -p openpencil-shell-native --test p0_probe -- --ignored
|
||||
- name: P0 probe gate (macOS / Windows)
|
||||
if: runner.os != 'Linux'
|
||||
run: cargo test -p openpencil-shell-native --test p0_probe -- --ignored
|
||||
|
||||
deny:
|
||||
name: cargo-deny (native)
|
||||
|
|
|
|||
845
Cargo.lock
generated
845
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -24,3 +24,18 @@ openpencil-shell-core = { path = "../openpencil-shell-core", version = "0.1.0" }
|
|||
# 详见提交说明 + Phase 1 Gate codex review。
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
# TRANSIENT (Step 1a P0 CI gate only — reverted after notes commit, before Task 1 starts).
|
||||
# These deps power `examples/p0_probe.rs` + `tests/p0_probe.rs` (gated `#[ignore = "P0_PROBE_GATE"]`).
|
||||
# Pinned to versions resolved in /tmp/skia-glow-probe per spec §7. Task 1 will
|
||||
# re-introduce them as permanent prod deps in this same target block.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
skia-safe = { version = "0.97.0", features = ["gl"] }
|
||||
glutin = "0.32.3"
|
||||
glutin-winit = "0.5.0"
|
||||
glow = "0.17.0"
|
||||
raw-window-handle = "0.6.2"
|
||||
scopeguard = "1.2.0"
|
||||
# Re-enable winit defaults for the dev probe (prod dep is no-default — Stage F
|
||||
# will choose the final feature set). Defaults pull in x11+wayland on Linux.
|
||||
winit = { version = "0.30" }
|
||||
|
|
|
|||
290
crates/openpencil-shell-native/examples/p0_probe.rs
Normal file
290
crates/openpencil-shell-native/examples/p0_probe.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
//! P0 probe runner — runs verification (2) cross-API GL state visibility +
|
||||
//! verification (3) full readback chain. Invoked as a subprocess from
|
||||
//! `tests/p0_probe.rs` because winit on macOS requires `EventLoop::new()` on
|
||||
//! the OS main thread (cargo test puts test fns on a worker thread).
|
||||
//!
|
||||
//! TRANSIENT (Step 1a P0 CI gate). Reverted before Task 1.
|
||||
//!
|
||||
//! Usage: `cargo run --example p0_probe -- <stencil|readback>`
|
||||
//! Exit code 0 = PASS; non-zero with stderr message = FAIL.
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::num::NonZeroU32;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use glow::HasContext;
|
||||
use glutin::config::ConfigTemplateBuilder;
|
||||
use glutin::context::{ContextApi, ContextAttributesBuilder, NotCurrentGlContext};
|
||||
use glutin::display::{GetGlDisplay, GlDisplay};
|
||||
use glutin::surface::{GlSurface, SurfaceAttributesBuilder, WindowSurface};
|
||||
use glutin_winit::DisplayBuilder;
|
||||
use raw_window_handle::HasWindowHandle;
|
||||
use skia_safe::gpu::{
|
||||
backend_render_targets, direct_contexts, gl as skgl, surfaces, SurfaceOrigin,
|
||||
};
|
||||
use skia_safe::{Color, ColorType, Paint, Rect};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::WindowEvent;
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||
use winit::window::{Window, WindowAttributes, WindowId};
|
||||
|
||||
const WIDTH: i32 = 64;
|
||||
const HEIGHT: i32 = 64;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Mode {
|
||||
StencilVisibility,
|
||||
Readback,
|
||||
}
|
||||
|
||||
struct Probe {
|
||||
mode: Mode,
|
||||
result: Option<Result<(), String>>,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Probe {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.result = Some(match self.mode {
|
||||
Mode::StencilVisibility => run_stencil_visibility(event_loop),
|
||||
Mode::Readback => run_readback(event_loop),
|
||||
});
|
||||
event_loop.exit();
|
||||
}
|
||||
|
||||
fn window_event(&mut self, _event_loop: &ActiveEventLoop, _id: WindowId, _ev: WindowEvent) {}
|
||||
}
|
||||
|
||||
struct GlStack {
|
||||
_window: Window,
|
||||
gl_surface: glutin::surface::Surface<WindowSurface>,
|
||||
gl_context: glutin::context::PossiblyCurrentContext,
|
||||
glow: glow::Context,
|
||||
skia: skia_safe::gpu::DirectContext,
|
||||
surface: skia_safe::Surface,
|
||||
fb_info: skgl::FramebufferInfo,
|
||||
}
|
||||
|
||||
fn build_stack(event_loop: &ActiveEventLoop) -> Result<GlStack, String> {
|
||||
let window_attrs = WindowAttributes::default()
|
||||
.with_visible(false)
|
||||
.with_inner_size(winit::dpi::PhysicalSize::new(WIDTH as u32, HEIGHT as u32))
|
||||
.with_title("p0-probe");
|
||||
|
||||
let template = ConfigTemplateBuilder::new()
|
||||
.with_alpha_size(8)
|
||||
.with_stencil_size(8);
|
||||
|
||||
let (window, gl_config) = DisplayBuilder::new()
|
||||
.with_window_attributes(Some(window_attrs))
|
||||
.build(event_loop, template, |configs| {
|
||||
configs.into_iter().next().expect("no GL config available")
|
||||
})
|
||||
.map_err(|e| format!("DisplayBuilder::build failed: {e}"))?;
|
||||
|
||||
let window = window.ok_or("DisplayBuilder returned no window")?;
|
||||
let raw_window_handle = window
|
||||
.window_handle()
|
||||
.map_err(|e| format!("window_handle: {e}"))?
|
||||
.as_raw();
|
||||
let gl_display = gl_config.display();
|
||||
|
||||
let context_attrs = ContextAttributesBuilder::new()
|
||||
.with_context_api(ContextApi::OpenGl(None))
|
||||
.build(Some(raw_window_handle));
|
||||
let not_current = unsafe {
|
||||
gl_display
|
||||
.create_context(&gl_config, &context_attrs)
|
||||
.map_err(|e| format!("create_context: {e}"))?
|
||||
};
|
||||
|
||||
let surface_attrs = SurfaceAttributesBuilder::<WindowSurface>::new().build(
|
||||
raw_window_handle,
|
||||
NonZeroU32::new(WIDTH as u32).unwrap(),
|
||||
NonZeroU32::new(HEIGHT as u32).unwrap(),
|
||||
);
|
||||
let gl_surface = unsafe {
|
||||
gl_display
|
||||
.create_window_surface(&gl_config, &surface_attrs)
|
||||
.map_err(|e| format!("create_window_surface: {e}"))?
|
||||
};
|
||||
let gl_context = not_current
|
||||
.make_current(&gl_surface)
|
||||
.map_err(|e| format!("make_current: {e}"))?;
|
||||
|
||||
let glow = unsafe {
|
||||
glow::Context::from_loader_function(|s| {
|
||||
let cs = CString::new(s).unwrap();
|
||||
gl_display.get_proc_address(&cs)
|
||||
})
|
||||
};
|
||||
|
||||
let interface = skgl::Interface::new_load_with(|s| {
|
||||
if s == "eglGetCurrentDisplay" {
|
||||
return std::ptr::null();
|
||||
}
|
||||
let cs = CString::new(s).unwrap();
|
||||
gl_display.get_proc_address(&cs)
|
||||
})
|
||||
.ok_or("skia gl::Interface::new_load_with returned None")?;
|
||||
|
||||
let mut skia =
|
||||
direct_contexts::make_gl(interface, None).ok_or("DirectContext::make_gl returned None")?;
|
||||
|
||||
let fb_info = skgl::FramebufferInfo {
|
||||
fboid: 0,
|
||||
// glow::RGBA8 is u32; skia's gl::Enum aliases GrGLenum = c_uint = u32
|
||||
// on every platform in `deny.toml`'s target list. Use `as _` instead
|
||||
// of `.try_into().unwrap()` so clippy::useless_conversion stays quiet.
|
||||
format: glow::RGBA8 as _,
|
||||
protected: skia_safe::gpu::Protected::No,
|
||||
};
|
||||
let backend_rt = backend_render_targets::make_gl((WIDTH, HEIGHT), 0, 8, fb_info);
|
||||
let surface = surfaces::wrap_backend_render_target(
|
||||
&mut skia,
|
||||
&backend_rt,
|
||||
SurfaceOrigin::BottomLeft,
|
||||
ColorType::RGBA8888,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.ok_or("wrap_backend_render_target returned None")?;
|
||||
|
||||
Ok(GlStack {
|
||||
_window: window,
|
||||
gl_surface,
|
||||
gl_context,
|
||||
glow,
|
||||
skia,
|
||||
surface,
|
||||
fb_info,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_stencil_visibility(event_loop: &ActiveEventLoop) -> Result<(), String> {
|
||||
let mut stack = build_stack(event_loop)?;
|
||||
unsafe { stack.glow.enable(glow::STENCIL_TEST) };
|
||||
|
||||
let canvas = stack.surface.canvas();
|
||||
canvas.clear(Color::TRANSPARENT);
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(Color::RED);
|
||||
canvas.draw_rect(
|
||||
Rect::from_xywh(0.0, 0.0, WIDTH as f32, HEIGHT as f32),
|
||||
&paint,
|
||||
);
|
||||
stack.skia.flush_and_submit();
|
||||
|
||||
let still_enabled = unsafe { stack.glow.is_enabled(glow::STENCIL_TEST) };
|
||||
unsafe { stack.glow.disable(glow::STENCIL_TEST) };
|
||||
let _ = stack.gl_surface.swap_buffers(&stack.gl_context);
|
||||
|
||||
if !still_enabled {
|
||||
return Err("STENCIL_TEST flag lost across Skia flush — context state NOT shared".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_readback(event_loop: &ActiveEventLoop) -> Result<(), String> {
|
||||
let mut stack = build_stack(event_loop)?;
|
||||
|
||||
// c0. save current READ_FRAMEBUFFER binding
|
||||
let prev_read_fb = unsafe { stack.glow.get_parameter_i32(glow::READ_FRAMEBUFFER_BINDING) };
|
||||
|
||||
// c1. draw red into Skia surface
|
||||
let canvas = stack.surface.canvas();
|
||||
canvas.clear(Color::from_argb(255, 0, 0, 0));
|
||||
let mut red = Paint::default();
|
||||
red.set_color(Color::RED);
|
||||
red.set_anti_alias(false);
|
||||
canvas.draw_rect(Rect::from_xywh(0.0, 0.0, WIDTH as f32, HEIGHT as f32), &red);
|
||||
stack.skia.flush_and_submit();
|
||||
unsafe { stack.glow.finish() };
|
||||
|
||||
// c2-c3. resolve Skia FBO id (we wrapped fboid=0 = window default FBO)
|
||||
let fboid = stack.fb_info.fboid;
|
||||
let target_fb = NonZeroU32::new(fboid).map(glow::NativeFramebuffer);
|
||||
|
||||
// c4. RAII restore prev binding (panic-safe)
|
||||
let glow_ref = &stack.glow;
|
||||
let _restore = scopeguard::guard(prev_read_fb, |prev| unsafe {
|
||||
let nfb = NonZeroU32::new(prev as u32).map(glow::NativeFramebuffer);
|
||||
glow_ref.bind_framebuffer(glow::READ_FRAMEBUFFER, nfb);
|
||||
});
|
||||
|
||||
// c5. bind Skia FBO + finish
|
||||
unsafe {
|
||||
stack
|
||||
.glow
|
||||
.bind_framebuffer(glow::READ_FRAMEBUFFER, target_fb)
|
||||
};
|
||||
unsafe { stack.glow.finish() };
|
||||
|
||||
// c6. readback
|
||||
let mut buf = vec![0u8; (WIDTH * HEIGHT * 4) as usize];
|
||||
unsafe {
|
||||
stack.glow.read_pixels(
|
||||
0,
|
||||
0,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
glow::RGBA,
|
||||
glow::UNSIGNED_BYTE,
|
||||
glow::PixelPackData::Slice(Some(&mut buf)),
|
||||
);
|
||||
}
|
||||
|
||||
// c7. assert center pixel is red
|
||||
let cx = WIDTH / 2;
|
||||
let cy = HEIGHT / 2;
|
||||
let i = ((cy * WIDTH + cx) * 4) as usize;
|
||||
let center = (buf[i], buf[i + 1], buf[i + 2], buf[i + 3]);
|
||||
drop(_restore);
|
||||
let _ = stack.gl_surface.swap_buffers(&stack.gl_context);
|
||||
|
||||
if center.0 < 200 || center.1 > 80 || center.2 > 80 {
|
||||
return Err(format!(
|
||||
"center pixel not red — got rgba({}, {}, {}, {}); buf[0..4]={:?}",
|
||||
center.0,
|
||||
center.1,
|
||||
center.2,
|
||||
center.3,
|
||||
&buf[..4]
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mode = match std::env::args().nth(1).as_deref() {
|
||||
Some("stencil") => Mode::StencilVisibility,
|
||||
Some("readback") => Mode::Readback,
|
||||
other => {
|
||||
eprintln!("usage: readback <stencil|readback>; got {:?}", other);
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
let event_loop = match EventLoop::new() {
|
||||
Ok(el) => el,
|
||||
Err(e) => {
|
||||
eprintln!("EventLoop::new failed: {e}");
|
||||
return ExitCode::from(3);
|
||||
}
|
||||
};
|
||||
let mut probe = Probe { mode, result: None };
|
||||
if let Err(e) = event_loop.run_app(&mut probe) {
|
||||
eprintln!("run_app failed: {e}");
|
||||
return ExitCode::from(4);
|
||||
}
|
||||
match probe.result {
|
||||
Some(Ok(())) => ExitCode::SUCCESS,
|
||||
Some(Err(e)) => {
|
||||
eprintln!("FAIL: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
None => {
|
||||
eprintln!("FAIL: handler never produced a result (window never resumed?)");
|
||||
ExitCode::from(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
crates/openpencil-shell-native/tests/p0_probe.rs
Normal file
70
crates/openpencil-shell-native/tests/p0_probe.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//! P0 probe — verifications (2) and (3) per Step 1a spec §7.2.
|
||||
//!
|
||||
//! TRANSIENT: this test exists only to drive the three-OS CI matrix for the
|
||||
//! P0 dep-stack probe gate. After CI is green and probe pin versions are
|
||||
//! recorded in `openpencil-docs/superpowers/notes/2026-05-05-skia-glow-loader-compat-probe.md`,
|
||||
//! this file plus `examples/p0_probe.rs` plus the matching dev-dep block in
|
||||
//! `Cargo.toml` are reverted. Task 1 owns the permanent integration.
|
||||
//!
|
||||
//! Why this test shells out to `examples/p0_probe.rs` instead of running the
|
||||
//! winit `EventLoop` inline: on macOS, winit refuses to construct an
|
||||
//! `EventLoop` off the OS main thread (panics with "EventLoop must be
|
||||
//! created on the main thread"). `cargo test` always runs `#[test]` fns on
|
||||
//! a libtest worker thread. The cross-OS-portable workaround is to put the
|
||||
//! `fn main()` driver in `examples/p0_probe.rs` (where rustc owns the real
|
||||
//! main thread) and invoke it as a subprocess from each test. Each
|
||||
//! `#[test]` subprocess gets a fresh process, side-stepping winit's
|
||||
//! "EventLoop already created" guard for the second test.
|
||||
//!
|
||||
//! `#[ignore = "P0_PROBE_GATE"]` keeps the default `cargo test --workspace`
|
||||
//! green; CI runs a separate `cargo test --workspace -- --ignored
|
||||
//! P0_PROBE_GATE` step.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn run_example(arg: &str) {
|
||||
if cfg!(target_os = "windows") {
|
||||
// spec §8.2: standard GitHub Actions Windows runner has no GL driver
|
||||
// (WINDOWS_GPU_DEFERRED_NO_RUNNER); manual smoke required there.
|
||||
eprintln!("WINDOWS_GPU_DEFERRED_NO_RUNNER: skipping GL probe on Windows runner");
|
||||
return;
|
||||
}
|
||||
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.args([
|
||||
"run",
|
||||
"--quiet",
|
||||
"-p",
|
||||
"openpencil-shell-native",
|
||||
"--example",
|
||||
"p0_probe",
|
||||
"--",
|
||||
arg,
|
||||
]);
|
||||
let output = cmd
|
||||
.output()
|
||||
.expect("failed to spawn `cargo run --example p0_probe`");
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
panic!(
|
||||
"probe example `{arg}` failed (status {:?}):\n--- stderr ---\n{}\n--- stdout ---\n{}",
|
||||
output.status.code(),
|
||||
stderr,
|
||||
stdout
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "P0_PROBE_GATE"]
|
||||
fn cross_api_gl_state_visibility() {
|
||||
run_example("stencil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "P0_PROBE_GATE"]
|
||||
fn gpu_readback() {
|
||||
run_example("readback");
|
||||
}
|
||||
Loading…
Reference in a new issue