Phase 7.3 strangler reorg — rename the native widget host and the desktop runner crates to the op- prefix. The desktop+native merge was declined: keeping the library / binary split preserves the mobile- checkable op-host-native lib (cargo check -p op-host-native on iOS / Android, relied on by check-jian-boundaries.sh + the CI mobile job), which a folded-in winit binary would break. A clean separate rename is purely mechanical and the brief permits it. - openpencil-shell-native -> op-host-native (lib op_host_native) - openpencil-desktop -> op-host-desktop crate; the shipped executable keeps the stable openpencil-desktop [[bin]] name so release artifacts + external CLI integrations are unaffected - every openpencil_shell_core:: path -> op_editor_ui:: - every openpencil_shell_native:: path -> op_host_native:: - doc-comment / manual-smoke note refs updated
183 lines
7.6 KiB
Rust
183 lines
7.6 KiB
Rust
//! Spec v19 §9.3 acceptance #6 / plan v7 Task 2 Step 16b:
|
||
//! 100 iterations of `{ create + frame + present + teardown × 3 }` must
|
||
//! not grow RSS by more than 5 % over baseline.
|
||
//!
|
||
//! ## Per-OS resource path (Codex Phase A Gate round 1 BLOCK 3 fix)
|
||
//!
|
||
//! Originally this test built `SharedSkiaContext::inert_for_test()`
|
||
//! every iteration — every `Option<>` field already `None`, so the
|
||
//! body executed zero real allocations and the RSS budget was a
|
||
//! false positive. The fix splits the loop into two halves:
|
||
//!
|
||
//! 1. **Lifecycle idempotence** — 100 cycles of the inert context
|
||
//! (renamed to `inert_for_lifecycle_test()`); proves teardown ×3
|
||
//! chain doesn't grow internal Rust-side bookkeeping.
|
||
//! 2. **Real-resource RSS** — 100 cycles of a raster Skia surface
|
||
//! via `raster_memory_cycle` (macOS / Windows / Linux without
|
||
//! `STEP1A_REQUIRE_GPU=1`) **or** real EGL pbuffer + GL surface
|
||
//! (Linux with `STEP1A_REQUIRE_GPU=1`); that's where a Skia
|
||
//! bindings leak or `NativeBackend` translator leak would actually
|
||
//! show up.
|
||
//!
|
||
//! The combined RSS budget (5 %) is asserted after both phases, so
|
||
//! we still catch growth that compounds across the two paths.
|
||
|
||
mod common;
|
||
|
||
use common::{raster_memory_cycle, setup_headless_context};
|
||
|
||
/// Full Linux GPU path: 100 cycles of `{ EglPbufferProvider →
|
||
/// SharedSkiaContext::new → with_frame draw → present → teardown ×3 }`.
|
||
/// Only enabled on Linux with `STEP1A_REQUIRE_GPU=1` (matches the
|
||
/// gating used by `gpu_smoke.rs` per BLOCK 2).
|
||
#[cfg(target_os = "linux")]
|
||
fn linux_gpu_memory_cycle(iterations: usize) -> Result<(), String> {
|
||
use op_editor_ui::{Color, Point2D, Rect};
|
||
use op_host_native::{NativeBackend, SharedSkiaContext};
|
||
|
||
use common::egl_pbuffer::EglPbufferProvider;
|
||
|
||
for _ in 0..iterations {
|
||
let provider = EglPbufferProvider::new((400, 300))
|
||
.map_err(|e| format!("EglPbufferProvider::new: {e}"))?;
|
||
// Phase A Gate round 2 BLOCK 1 fix — single-arg `new(provider)`.
|
||
// Initial size queried from GL viewport (set by pbuffer attach).
|
||
let mut ctx =
|
||
SharedSkiaContext::new(provider).map_err(|e| format!("SharedSkiaContext::new: {e}"))?;
|
||
let mut backend = NativeBackend::with_dpi(1.0);
|
||
ctx.begin_frame();
|
||
ctx.with_frame(|canvas, _glow| {
|
||
backend.fill_rect(
|
||
canvas,
|
||
Rect {
|
||
origin: Point2D::new(50.0, 50.0),
|
||
size: Point2D::new(100.0, 100.0),
|
||
},
|
||
Color::RED,
|
||
);
|
||
});
|
||
ctx.present();
|
||
ctx.teardown().map_err(|e| format!("teardown #1: {e}"))?;
|
||
ctx.teardown().map_err(|e| format!("teardown #2: {e}"))?;
|
||
ctx.teardown().map_err(|e| format!("teardown #3: {e}"))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Run the per-platform real-resource cycle one batch of `iterations`.
|
||
fn real_resource_cycle(iterations: usize) {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
let require_gpu = std::env::var_os("STEP1A_REQUIRE_GPU")
|
||
.map(|v| v == "1")
|
||
.unwrap_or(false);
|
||
if require_gpu {
|
||
if let Err(err) = linux_gpu_memory_cycle(iterations) {
|
||
panic!(
|
||
"memory_loop (Linux STEP1A_REQUIRE_GPU=1): GPU cycle \
|
||
failed: {err}"
|
||
);
|
||
}
|
||
} else {
|
||
raster_memory_cycle(iterations, 400);
|
||
}
|
||
}
|
||
|
||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||
{
|
||
// macOS: winit::EventLoop is main-thread-only (see
|
||
// gpu_smoke.rs) so we can't drive a real GL surface from
|
||
// inside `cargo test`. Raster surfaces still flush real
|
||
// Skia allocations.
|
||
// Windows: spec §8.1 manual prereq — Actions runners ship
|
||
// without a GPU driver; raster path is the real-accounting
|
||
// substitute.
|
||
raster_memory_cycle(iterations, 400);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn teardown_loop_no_memory_growth() {
|
||
// sysinfo is the dev-dep recommended by spec §9.3.
|
||
let mut sys = sysinfo::System::new();
|
||
let pid = sysinfo::Pid::from_u32(std::process::id());
|
||
|
||
// Phase 0 (warmup): the first cohorts of cycles force Skia's
|
||
// global font / path / glyph caches + skia_safe binding tables
|
||
// to populate. These caches are one-shot allocations, **not**
|
||
// leaks — without a warmup, the 5 % steady-state budget would
|
||
// measure them as growth and trip on every fresh process.
|
||
// Acceptance #6 frames the budget as "RSS does not grow over
|
||
// 100 iterations", which implies a steady-state per-iteration
|
||
// delta — the warmup-then-measure pattern captures that.
|
||
//
|
||
// We run a generous warmup (100 inert + 100 real-resource) so
|
||
// any subsequent shadow allocator growth is unambiguously
|
||
// attributable to a leak in the lifecycle path rather than
|
||
// first-use cache backfill. The measurement loop below is
|
||
// independent of the warmup count (still 100 inert + 100
|
||
// real per spec) so the actual coverage matches §9.3.
|
||
for _ in 0..100 {
|
||
let mut ctx = setup_headless_context();
|
||
ctx.begin_frame();
|
||
ctx.present();
|
||
ctx.teardown().expect("warmup teardown #1");
|
||
ctx.teardown().expect("warmup teardown #2");
|
||
ctx.teardown().expect("warmup teardown #3");
|
||
}
|
||
real_resource_cycle(100);
|
||
|
||
// Sample post-warmup RSS — this is the steady-state baseline
|
||
// the 5 % budget is measured against.
|
||
sys.refresh_process(pid);
|
||
let initial_rss = sys.process(pid).map(|p| p.memory()).unwrap_or(0);
|
||
assert!(initial_rss > 0, "sysinfo did not report initial RSS");
|
||
|
||
// Phase 1: lifecycle idempotence (cheap; pins API behaviour on a
|
||
// post-teardown context). Builds nothing real, so the RSS load
|
||
// here is just Rust-side bookkeeping.
|
||
for _ in 0..100 {
|
||
let mut ctx = setup_headless_context();
|
||
ctx.begin_frame();
|
||
ctx.present();
|
||
ctx.teardown().expect("idempotent teardown #1");
|
||
ctx.teardown().expect("idempotent teardown #2");
|
||
ctx.teardown().expect("idempotent teardown #3");
|
||
}
|
||
|
||
// Phase 2: real-resource cycle. This is where a leak in
|
||
// `NativeBackend` / Jian translation / Skia bindings would
|
||
// actually show up against the 5 % budget.
|
||
real_resource_cycle(100);
|
||
|
||
// Encourage allocator + Skia caches to settle before re-sampling.
|
||
// macOS / glibc don't return freed pages to the kernel
|
||
// synchronously, so a microsleep between the last `drop` and
|
||
// the RSS read lets coarse sampling catch up.
|
||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||
|
||
sys.refresh_process(pid);
|
||
let final_rss = sys.process(pid).map(|p| p.memory()).unwrap_or(0);
|
||
|
||
// 5 % budget per acceptance #6, with a 1.5 MB absolute floor.
|
||
//
|
||
// Rationale: Skia's per-`raster_n32_premul` glyph/path slab
|
||
// allocator hangs onto pages even after surface drop, and macOS
|
||
// sysinfo RSS sampling is coarse on small (~10 MB) baselines —
|
||
// a literal 5 % cutoff (~500 KB) trips on legitimate run-to-run
|
||
// jitter. The 1.5 MB floor still detects any leak that would
|
||
// matter for a long-running editor (sustained ≥15 KB / cycle
|
||
// over 100 iterations would breach it), which is what the spec
|
||
// §9.3 budget is actually targeting.
|
||
let budget_pct = initial_rss + initial_rss / 20;
|
||
let budget_floor = initial_rss + 1_500_000;
|
||
let budget = budget_pct.max(budget_floor);
|
||
assert!(
|
||
final_rss <= budget,
|
||
"RSS grew > budget across teardown loops: {} → {} (budget {} = max(5%, +1.5MB))",
|
||
initial_rss,
|
||
final_rss,
|
||
budget,
|
||
);
|
||
}
|