feat(stage0): close native smoke acceptance gate

This commit is contained in:
2026-06-25 04:18:32 +04:00
parent 5cc2c5819f
commit ba69bdb6ea
15 changed files with 2449 additions and 1656 deletions
+35 -14
View File
@@ -48,7 +48,7 @@ jobs:
--locked
stage0-matrix:
name: Stage 0-2 CI (${{ matrix.os }})
name: Stage 0 CI (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
@@ -73,22 +73,43 @@ jobs:
run: cargo install cargo-deny --version 0.19.9 --locked
- name: Run canonical CI gate
run: cargo xtask ci
- name: Record native Vulkan smoke status
if: always()
shell: bash
- name: Run native Vulkan smoke
run: >
cargo run -p fparkan-vulkan-smoke --locked --
--platform "${{ matrix.smoke_platform }}"
--out "target/fparkan/native-smoke/${{ runner.os }}.json"
--status blocked
--probe-surface
--reason "native Vulkan smoke runner is not enabled on this CI lane yet"
- name: Upload acceptance evidence
--out "target/fparkan/native-smoke/${{ matrix.smoke_platform }}.json"
- name: Upload acceptance audit
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: stage-0-2-acceptance-${{ matrix.os }}
path: |
target/fparkan/acceptance/stage-0-2-audit.json
target/fparkan/native-smoke/*.json
name: stage-0-acceptance-${{ matrix.os }}
path: target/fparkan/acceptance/stage-0-audit.json
if-no-files-found: error
- name: Upload native smoke report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: native-smoke-${{ matrix.smoke_platform }}
path: target/fparkan/native-smoke/*.json
if-no-files-found: error
native-smoke-audit:
name: Native smoke audit
runs-on: ubuntu-latest
timeout-minutes: 15
needs: stage0-matrix
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with:
toolchain: 1.87.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: native-smoke-*
path: target/fparkan/native-smoke-artifacts
merge-multiple: true
- name: Aggregate native smoke reports
run: >
cargo xtask native-smoke audit
--dir target/fparkan/native-smoke-artifacts
Generated
+1
View File
@@ -717,6 +717,7 @@ dependencies = [
"fparkan-platform",
"fparkan-platform-winit",
"fparkan-render-vulkan",
"winit",
]
[[package]]
+34
View File
@@ -63,6 +63,40 @@ FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \
cargo xtask acceptance report --suite licensed --stage 5
```
## Stage 0 Vulkan smoke
Локальный Stage 0 smoke запускает реальный `winit` lifecycle и Vulkan triangle path с включёнными validation layers. Успешный прогон обязан:
- отрисовать 300 кадров;
- выполнить как минимум один реальный resize;
- пересоздать swapchain после resize;
- завершиться без validation warnings/errors.
Команда запуска:
```bash
cargo run -p fparkan-vulkan-smoke --locked -- \
--out target/fparkan/native-smoke/local.json
```
Перед запуском убедитесь, что на машине доступен Vulkan loader и рабочий ICD:
- macOS: установлены Vulkan SDK и MoltenVK; если используется нестандартная установка, проверьте `VK_ICD_FILENAMES`, `VK_LAYER_PATH` и наличие `VK_LAYER_KHRONOS_validation`.
- Linux: установлен `libvulkan` и драйвер/ICD (`mesa-vulkan-drivers`, Lavapipe или vendor GPU stack); smoke нужно запускать из активной графической сессии X11/Wayland.
- Windows: установлен Vulkan runtime от GPU vendor или LunarG Vulkan SDK; validation layer должен быть доступен из активного runtime.
Для полного локального closure gate используйте:
```bash
cargo xtask ci
```
GitHub workflow дополнительно собирает три platform reports и проверяет их aggregate gate:
```bash
cargo xtask native-smoke audit --dir target/fparkan/native-smoke-artifacts
```
## Contributing & Support
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
+15 -144
View File
@@ -27,15 +27,14 @@ use fparkan_platform::{
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use winit::application::ApplicationHandler;
use winit::dpi::PhysicalSize as WinitPhysicalSize;
use std::sync::OnceLock;
use std::time::Instant;
use winit::event::{Event, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::platform::scancode::PhysicalKeyExtScancode;
use winit::window::{Window, WindowId};
use winit::window::Window;
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
static CLOCK_START: OnceLock<Instant> = OnceLock::new();
const DEFAULT_SMOKE_WIDTH: u32 = 1280;
const DEFAULT_SMOKE_HEIGHT: u32 = 720;
@@ -49,10 +48,8 @@ pub struct WinitClock;
impl MonotonicClock for WinitClock {
fn now(&self) -> MonotonicInstant {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
MonotonicInstant(duration.as_millis().try_into().unwrap_or(u64::MAX))
let elapsed = CLOCK_START.get_or_init(Instant::now).elapsed();
MonotonicInstant(elapsed.as_millis().try_into().unwrap_or(u64::MAX))
}
}
@@ -187,113 +184,6 @@ impl WinitWindowPlan {
}
}
/// Native smoke window creation result.
#[derive(Clone, Copy, Debug)]
pub struct WinitSmokeWindowProbe {
/// Validated creation plan.
pub plan: WinitWindowPlan,
/// Captured window descriptor.
pub window: WinitWindow,
}
impl WinitSmokeWindowProbe {
/// Returns raw native handles captured from the native window.
#[must_use]
pub fn native_handles(&self) -> Option<NativeWindowHandles> {
self.window.native_handles()
}
}
/// Creates a native smoke window, captures raw handles, then exits the event loop.
///
/// # Errors
///
/// Returns [`PlatformError`] when the plan is invalid, the event loop/window
/// cannot be created, or raw native handles are unavailable.
pub fn probe_smoke_window() -> Result<WinitSmokeWindowProbe, PlatformError> {
let plan = WinitWindowPlan::smoke().validate()?;
let event_loop = EventLoop::new().map_err(|err| PlatformError::Backend {
context: "winit event loop",
message: err.to_string(),
})?;
let mut app = SmokeWindowApp::new(plan);
event_loop
.run_app(&mut app)
.map_err(|err| PlatformError::Backend {
context: "winit event loop",
message: err.to_string(),
})?;
app.into_probe()
}
struct SmokeWindowApp {
plan: WinitWindowPlan,
window: Option<WinitWindow>,
error: Option<String>,
}
impl SmokeWindowApp {
const fn new(plan: WinitWindowPlan) -> Self {
Self {
plan,
window: None,
error: None,
}
}
fn into_probe(self) -> Result<WinitSmokeWindowProbe, PlatformError> {
if let Some(message) = self.error {
return Err(PlatformError::Backend {
context: "winit smoke window",
message,
});
}
let window = self.window.ok_or_else(|| PlatformError::Backend {
context: "winit smoke window",
message: "event loop exited before creating a window".to_string(),
})?;
if self.plan.requires_native_handles && window.native_handles().is_none() {
return Err(PlatformError::Backend {
context: "winit smoke window",
message: "native window/display handles are unavailable".to_string(),
});
}
Ok(WinitSmokeWindowProbe {
plan: self.plan,
window,
})
}
}
impl ApplicationHandler for SmokeWindowApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() || self.error.is_some() {
event_loop.exit();
return;
}
let attributes = Window::default_attributes()
.with_title("FParkan Vulkan smoke")
.with_inner_size(WinitPhysicalSize::new(self.plan.width, self.plan.height));
match event_loop.create_window(attributes) {
Ok(window) => {
self.window = Some(WinitWindow::from_window(&window));
}
Err(err) => {
self.error = Some(err.to_string());
}
}
event_loop.exit();
}
fn window_event(
&mut self,
_event_loop: &ActiveEventLoop,
_window_id: WindowId,
_event: WindowEvent,
) {
}
}
/// Minimal window view over a `winit` window.
#[derive(Clone, Copy, Debug)]
pub struct WinitWindow {
@@ -323,7 +213,7 @@ impl WinitWindow {
focused: true,
minimized: false,
occluded: false,
native_handles: native_handles(window),
native_handles: window_native_handles(window),
}
}
@@ -384,7 +274,9 @@ impl WindowPort for WinitWindow {
}
}
fn native_handles(window: &Window) -> Option<NativeWindowHandles> {
/// Extracts raw handles from a live `winit::Window`.
#[must_use]
pub fn window_native_handles(window: &Window) -> Option<NativeWindowHandles> {
let display = window.display_handle().ok()?.as_raw();
let window = window.window_handle().ok()?.as_raw();
Some(NativeWindowHandles { display, window })
@@ -454,33 +346,12 @@ mod tests {
}
#[test]
fn smoke_window_app_requires_created_native_window() {
let app = SmokeWindowApp::new(WinitWindowPlan::smoke());
fn monotonic_clock_uses_process_local_epoch() {
let clock = WinitClock;
let first = clock.now();
let second = clock.now();
assert!(matches!(
app.into_probe(),
Err(PlatformError::Backend {
context: "winit smoke window",
..
})
));
}
#[test]
fn smoke_window_app_rejects_synthetic_window_without_native_handles() {
let mut app = SmokeWindowApp::new(WinitWindowPlan::smoke());
app.window = Some(WinitWindow::synthetic(
DEFAULT_SMOKE_WIDTH,
DEFAULT_SMOKE_HEIGHT,
));
assert!(matches!(
app.into_probe(),
Err(PlatformError::Backend {
context: "winit smoke window",
..
})
));
assert!(second >= first);
}
#[test]
@@ -0,0 +1,8 @@
#version 450
layout(location = 0) in vec3 in_color;
layout(location = 0) out vec4 out_color;
void main() {
out_color = vec4(in_color, 1.0);
}
@@ -0,0 +1,11 @@
#version 450
layout(location = 0) in vec2 in_position;
layout(location = 1) in vec3 in_color;
layout(location = 0) out vec3 out_color;
void main() {
out_color = in_color;
gl_Position = vec4(in_position, 0.0, 1.0);
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -28,7 +28,7 @@ use fparkan_render::{
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand,
RenderCommandList, RenderPhase,
};
use fparkan_render_vulkan::VulkanBackend;
use fparkan_render_vulkan::VulkanPlanningBackend;
use fparkan_runtime::{
create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices,
MissionAssets, MissionRequest,
@@ -71,7 +71,7 @@ fn run(args: &[String]) -> Result<String, String> {
)
.map_err(|err| err.to_string())?;
let mut backend = VulkanBackend::new();
let mut backend = VulkanPlanningBackend::new();
let _request = WinitWindow::default_render_request();
let window = WinitWindow::synthetic(1280, 720);
let _ = window.drawable_size();
+1
View File
@@ -9,6 +9,7 @@ repository.workspace = true
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
winit = "0.30"
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@ S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rus
S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
S0-ARCH-010 covered cargo xtask acceptance audit emits commit_sha, rust_toolchain, and msrv metadata into the JSON artifact
S0-ARCH-011 blocked cargo run -p fparkan-vulkan-smoke emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available
S0-ARCH-011 covered .github/workflows/ci.yml runs cargo run -p fparkan-vulkan-smoke --locked -- --out target/fparkan/native-smoke/<platform>.json and cargo xtask native-smoke audit enforces passed 300-frame reports with measured resize/recreate and validation=0
S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
1 # Acceptance coverage manifest.
15 S0-ARCH-008
16 S0-ARCH-009
17 S0-ARCH-010
18 S0-ARCH-011
19 S0-DIAG-001
20 S0-DIAG-002
21 S0-CORPUS-001
+68
View File
@@ -0,0 +1,68 @@
# Stage 0 acceptance IDs
`L0-COPYRIGHT-001`
`L0-P1-001`
`L0-P1-002`
`L0-P2-001`
`L0-P2-002`
`S0-ARCH-001`
`S0-ARCH-002`
`S0-ARCH-003`
`S0-ARCH-004`
`S0-ARCH-005`
`S0-ARCH-006`
`S0-ARCH-007`
`S0-ARCH-008`
`S0-ARCH-009`
`S0-ARCH-010`
`S0-ARCH-011`
`S0-DIAG-001`
`S0-DIAG-002`
`S0-CORPUS-001`
`S0-CORPUS-002`
`S0-CORPUS-003`
`S0-CORPUS-004`
`S0-CORPUS-005`
`S0-CORPUS-006`
`S0-CLI-001`
`S0-CLI-002`
`S0-PLAT-001`
`S0-PLAT-002`
`S0-PLAT-003`
`S0-PLAT-004`
`S0-VK-001`
`S0-VK-002`
`S0-VK-003`
`S0-VK-004`
`S0-VK-005`
`S0-VK-006`
`S0-VK-007`
`S0-VK-008`
`S0-VK-009`
`S0-VK-010`
`S0-VK-011`
`S0-VK-012`
`S0-VK-013`
`S0-VK-014`
`S0-VK-015`
`S0-VK-016`
`S0-VK-017`
`S0-VK-018`
`S0-VK-019`
`S0-VK-020`
`S0-VK-021`
`S0-VK-022`
`S0-VK-023`
`S0-VK-024`
`S0-VK-025`
`S0-VK-026`
`S0-VK-027`
`S0-VK-028`
`S0-VK-029`
`S0-VK-030`
`S0-VK-031`
`S0-VK-032`
`S0-VK-033`
`S0-VK-034`
`S0-LIMIT-001`
`S0-LIMIT-002`
+28 -16
View File
@@ -34,9 +34,9 @@ use std::process::Command;
const CORPORA_MANIFEST_ENV: &str = "FPARKAN_CORPORA_MANIFEST";
const PART1_ROOT_ENV: &str = "FPARKAN_CORPUS_PART1_ROOT";
const PART2_ROOT_ENV: &str = "FPARKAN_CORPUS_PART2_ROOT";
const CI_ACCEPTANCE_ROADMAP: &str = "fixtures/acceptance/stage_0_2_roadmap.md";
const CI_ACCEPTANCE_ROADMAP: &str = "fixtures/acceptance/stage_0_roadmap.md";
const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv";
const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-2-audit.json";
const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-audit.json";
const STAGE_PACKAGE_MANIFEST: &str = "fixtures/acceptance/stage_packages.toml";
const REQUIRED_NATIVE_SMOKE_PLATFORMS: &[&str] = &["linux", "macos", "windows"];
const APPROVED_REGISTRY_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index";
@@ -193,16 +193,16 @@ fn run_cargo_fmt_check() -> Result<(), String> {
fn run_cargo_deny() -> Result<(), String> {
let cargo_deny = std::env::var_os("CARGO_DENY").unwrap_or_else(|| "cargo-deny".into());
let version_output = Command::new(&cargo_deny)
.arg("--version")
.output()
.map_err(|err| {
format!(
"cargo-deny is required; install cargo-deny {PINNED_CARGO_DENY_VERSION} or set {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV}=1 for the built-in fallback: {err}"
)
})?;
let version_output = match Command::new(&cargo_deny).arg("--version").output() {
Ok(output) => output,
Err(err) => {
return handle_cargo_deny_fallback(&format!(
"failed to run cargo-deny --version: {err}"
));
}
};
if !version_output.status.success() {
return handle_cargo_deny_fallback(format!(
return handle_cargo_deny_fallback(&format!(
"cargo-deny --version exited with {}",
version_output.status
));
@@ -210,7 +210,7 @@ fn run_cargo_deny() -> Result<(), String> {
let version_text = String::from_utf8(version_output.stdout)
.map_err(|err| format!("cargo-deny --version produced invalid UTF-8: {err}"))?;
if !version_text.contains(PINNED_CARGO_DENY_VERSION) {
return handle_cargo_deny_fallback(format!(
return handle_cargo_deny_fallback(&format!(
"cargo-deny version mismatch: expected {PINNED_CARGO_DENY_VERSION}, found {}",
version_text.trim()
));
@@ -237,7 +237,7 @@ fn run_cargo_deny() -> Result<(), String> {
const PINNED_CARGO_DENY_VERSION: &str = "0.19.9";
fn handle_cargo_deny_fallback(reason: String) -> Result<(), String> {
fn handle_cargo_deny_fallback(reason: &str) -> Result<(), String> {
if std::env::var_os(ALLOW_SUPPLY_CHAIN_FALLBACK_ENV).is_some() {
eprintln!(
"{reason}; running built-in supply-chain policy fallback because {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV} is set"
@@ -1605,6 +1605,7 @@ fn validate_native_smoke_report(
expect_u64_at_least(platform, report, "frames", 300, failures);
expect_u64_at_least(platform, report, "resize_count", 1, failures);
expect_u64_at_least(platform, report, "swapchain_recreate_count", 1, failures);
expect_u64_field(platform, report, "validation_warning_count", 0, failures);
expect_u64_field(platform, report, "validation_error_count", 0, failures);
expect_nonempty_string(platform, report, "commit_sha", failures);
expect_string_field(
@@ -1889,6 +1890,10 @@ fn build_acceptance_audit(
let mut missing = Vec::new();
let mut by_stage = BTreeMap::new();
let mut coverage_evidence = BTreeMap::new();
let required_scopes = required
.iter()
.filter_map(|id| id.get(0..2).map(ToString::to_string))
.collect::<BTreeSet<_>>();
for id in required {
let stage = id
@@ -1909,7 +1914,12 @@ fn build_acceptance_audit(
let unknown_coverage = coverage
.keys()
.filter(|id| !required.contains(*id))
.filter(|id| {
!required.contains(*id)
&& id
.get(0..2)
.is_some_and(|scope| required_scopes.contains(scope))
})
.cloned()
.collect();
@@ -2367,7 +2377,7 @@ mod tests {
},
),
(
"S9-UNKNOWN-001".to_string(),
"S0-ARCH-099".to_string(),
CoverageEntry {
status: CoverageStatus::Partial,
evidence: "bad id".to_string(),
@@ -2383,7 +2393,7 @@ mod tests {
assert_eq!(audit.blocked, ["L5-RG40-001"]);
assert_eq!(audit.omitted, ["L3-DEVICE-001"]);
assert_eq!(audit.missing, ["S0-ARCH-002"]);
assert_eq!(audit.unknown_coverage, ["S9-UNKNOWN-001"]);
assert_eq!(audit.unknown_coverage, ["S0-ARCH-099"]);
assert_eq!(audit.by_stage.get("S0"), Some(&2));
assert_eq!(
audit.strict_failures(),
@@ -2436,6 +2446,7 @@ mod tests {
"frames": 300,
"resize_count": 1,
"swapchain_recreate_count": 1,
"validation_warning_count": 0,
"validation_error_count": 0,
"shader_manifest_hash": "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c",
"vulkan_loader_status": "available",
@@ -2474,6 +2485,7 @@ mod tests {
"frames": 0,
"resize_count": 0,
"swapchain_recreate_count": 0,
"validation_warning_count": null,
"validation_error_count": null,
"shader_manifest_hash": "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c",
"vulkan_loader_status": "unavailable",