diff --git a/crates/console/prometeu-system/src/services/vm_runtime/async_render_contract_tests.rs b/crates/console/prometeu-system/src/services/vm_runtime/async_render_contract_tests.rs new file mode 100644 index 00000000..bc145990 --- /dev/null +++ b/crates/console/prometeu-system/src/services/vm_runtime/async_render_contract_tests.rs @@ -0,0 +1,236 @@ +use super::FrameScheduler; +use super::frame_scheduler::FrameAuthorizationError; +use super::render_manager::{ + LocalRenderWorker, RenderConsumeOutcome, RenderConsumerPath, RenderExecutionPolicy, + RenderPacingPolicy, RenderPolicy, RenderRuntimeCapabilities, RenderSurface, +}; +use prometeu_hal::app_mode::AppMode; +use prometeu_hal::telemetry::AtomicTelemetry; +use prometeu_hal::{ + FrameId, Game2DFramePacket, RenderOwnership, RenderSubmission, RenderSubmissionPacket, + ShellUiFramePacket, +}; +use std::sync::Arc; +use std::sync::atomic::AtomicU32; + +use super::render_manager::RenderManager; + +struct ContractMatrixRow { + invariant: &'static str, + validations: &'static [&'static str], +} + +const ASYNC_RENDER_CONTRACT_MATRIX: &[ContractMatrixRow] = &[ + ContractMatrixRow { + invariant: "complete packet and AppMode policy are explicit", + validations: &["matrix_packet_policy_and_pacing_are_mode_specific"], + }, + ContractMatrixRow { + invariant: "handoff is single-slot latest-wins in sync and worker-capable paths", + validations: &["matrix_sync_and_worker_paths_share_latest_wins_handoff_contract"], + }, + ContractMatrixRow { + invariant: "render consumers see resource banks through read-only access", + validations: &[ + "prometeu-drivers::render_resource_access_traits_are_thread_safe", + "prometeu-drivers::render_resource_access_exposes_read_only_glyph_and_scene_banks", + ], + }, + ContractMatrixRow { + invariant: "Game pacing is frame scheduled and does not catch up skipped frames", + validations: &["matrix_packet_policy_and_pacing_are_mode_specific"], + }, + ContractMatrixRow { + invariant: "ownership epoch prevents obsolete work from presenting", + validations: &["matrix_epoch_stale_discard_and_telemetry_sync_remain_consistent"], + }, + ContractMatrixRow { + invariant: "telemetry exposes production, drops, consumes, presents, repeats, and errors", + validations: &[ + "matrix_epoch_stale_discard_and_telemetry_sync_remain_consistent", + "matrix_shutdown_failure_and_repeat_are_observable", + ], + }, + ContractMatrixRow { + invariant: "shutdown discards pending work and bounded local worker failure is contained", + validations: &["matrix_shutdown_failure_and_repeat_are_observable"], + }, + ContractMatrixRow { + invariant: "active-scene Game2D overlay is composed after layer blit", + validations: &[ + "prometeu-drivers::publish_game2d_submission_applies_gfx2d_overlay_after_active_scene", + ], + }, +]; + +#[derive(Default)] +struct RecordingSurface { + seen: Vec, +} + +impl RenderSurface for RecordingSurface { + fn consume_submission(&mut self, submission: &RenderSubmission) { + self.seen.push(submission.frame_id); + } +} + +struct PanickingSurface; + +impl RenderSurface for PanickingSurface { + fn consume_submission(&mut self, _submission: &RenderSubmission) { + panic!("matrix present failure"); + } +} + +fn close_game(manager: &mut RenderManager) { + manager + .close_frame_with_packet(RenderSubmissionPacket::Game2D(Game2DFramePacket::default())) + .expect("game packet must match Game mode"); +} + +fn close_shell(manager: &mut RenderManager) { + manager + .close_frame_with_packet(RenderSubmissionPacket::ShellUi(ShellUiFramePacket::new( + Vec::new(), + ))) + .expect("shell packet must match Shell mode"); +} + +#[test] +fn async_render_contract_matrix_rows_have_validation() { + assert!(ASYNC_RENDER_CONTRACT_MATRIX.len() >= 7); + for row in ASYNC_RENDER_CONTRACT_MATRIX { + assert!(!row.invariant.is_empty()); + assert!(!row.validations.is_empty(), "missing validation for {}", row.invariant); + } +} + +#[test] +fn matrix_packet_policy_and_pacing_are_mode_specific() { + let game_policy = RenderPolicy::for_app_mode(AppMode::Game); + assert_eq!(game_policy.pacing, RenderPacingPolicy::FrameScheduled); + assert_eq!(game_policy.execution, RenderExecutionPolicy::WorkerCapableWithLocalFallback); + assert_eq!( + game_policy.resolve_consumer_path(RenderRuntimeCapabilities { game_render_worker: true }), + RenderConsumerPath::LocalWorkerPrototype + ); + + let shell_policy = RenderPolicy::for_app_mode(AppMode::Shell); + assert_eq!(shell_policy.pacing, RenderPacingPolicy::LifecycleDriven); + assert_eq!(shell_policy.execution, RenderExecutionPolicy::LocalSynchronous); + assert_eq!( + shell_policy.resolve_consumer_path(RenderRuntimeCapabilities { game_render_worker: true }), + RenderConsumerPath::LocalSynchronous + ); + + let mut game_manager = RenderManager::new(AppMode::Game); + close_game(&mut game_manager); + assert!( + game_manager + .close_frame_with_packet(RenderSubmissionPacket::ShellUi(ShellUiFramePacket::new( + Vec::new() + ))) + .is_err() + ); + + let mut shell_manager = RenderManager::new(AppMode::Shell); + close_shell(&mut shell_manager); + assert!( + shell_manager + .close_frame_with_packet(RenderSubmissionPacket::Game2D(Game2DFramePacket::default())) + .is_err() + ); + + let mut scheduler = FrameScheduler::default(); + assert_eq!(scheduler.authorize_game_frame(), Ok(FrameId::ZERO)); + scheduler.record_logical_frame_overrun(); + assert_eq!( + scheduler.authorize_game_frame(), + Err(FrameAuthorizationError::GameFrameAlreadyActive { active_frame_id: FrameId::ZERO }) + ); + assert_eq!(scheduler.complete_game_frame(), Some(FrameId::ZERO)); + assert_eq!(scheduler.authorize_game_frame(), Ok(FrameId::new(1))); +} + +#[test] +fn matrix_sync_and_worker_paths_share_latest_wins_handoff_contract() { + fn consume_with(path: RenderConsumerPath) -> (RenderConsumeOutcome, Vec, u64) { + let mut manager = RenderManager::new(AppMode::Game); + let mut surface = RecordingSurface::default(); + + close_game(&mut manager); + close_game(&mut manager); + + let outcome = match path { + RenderConsumerPath::LocalSynchronous => manager.consume_latest(&mut surface), + RenderConsumerPath::LocalWorkerPrototype => { + let mut worker = LocalRenderWorker; + worker.consume_latest(&mut manager, &mut surface) + } + }; + + (outcome, surface.seen, manager.render_telemetry().replaced_before_consume) + } + + for path in [RenderConsumerPath::LocalSynchronous, RenderConsumerPath::LocalWorkerPrototype] { + let (outcome, seen, replaced) = consume_with(path); + assert_eq!(outcome, RenderConsumeOutcome::Presented { frame_id: FrameId::new(1) }); + assert_eq!(seen, vec![FrameId::new(1)]); + assert_eq!(replaced, 1); + } +} + +#[test] +fn matrix_epoch_stale_discard_and_telemetry_sync_remain_consistent() { + let mut manager = RenderManager::new(AppMode::Game); + let mut surface = RecordingSurface::default(); + let atomic = AtomicTelemetry::new(Arc::new(AtomicU32::new(0))); + + manager.transition_render_owner(AppMode::Game, 7); + close_game(&mut manager); + manager.transition_render_owner(AppMode::Game, 8); + + assert_eq!( + manager.consume_latest(&mut surface), + RenderConsumeOutcome::DiscardedStaleEpoch { frame_id: FrameId::ZERO } + ); + assert!(surface.seen.is_empty()); + + manager.sync_telemetry(&atomic); + let snapshot = atomic.snapshot(); + assert_eq!(snapshot.produced_submissions, 1); + assert_eq!(snapshot.stale_epoch_discards, 1); + assert_eq!(snapshot.presented_frames, 0); + assert_eq!(snapshot.last_dropped_frame_id, 0); + assert_eq!(snapshot.active_render_epoch, 2); + assert_eq!(manager.active_ownership(), RenderOwnership::new(2, AppMode::Game, 8)); +} + +#[test] +fn matrix_shutdown_failure_and_repeat_are_observable() { + let mut manager = RenderManager::new(AppMode::Game); + + close_game(&mut manager); + let discarded = manager.request_shutdown().expect("pending frame discarded"); + assert_eq!(discarded.frame_id, FrameId::ZERO); + + manager.resume_consumer(); + close_game(&mut manager); + let mut surface = PanickingSurface; + assert_eq!( + manager.consume_latest(&mut surface), + RenderConsumeOutcome::PresentFailed { + frame_id: FrameId::new(1), + message: "matrix present failure".to_string() + } + ); + manager.record_repeated_present(FrameId::new(1)); + + let telemetry = manager.render_telemetry(); + assert_eq!(telemetry.shutdown_discards, 1); + assert_eq!(telemetry.present_errors, 1); + assert_eq!(telemetry.presented_frames, 0); + assert_eq!(telemetry.repeated_presents, 1); + assert_eq!(telemetry.last_error_frame_id, 1); + assert_eq!(telemetry.last_presented_frame_id, 1); +} diff --git a/crates/console/prometeu-system/src/services/vm_runtime/mod.rs b/crates/console/prometeu-system/src/services/vm_runtime/mod.rs index 4c889889..a522bb4f 100644 --- a/crates/console/prometeu-system/src/services/vm_runtime/mod.rs +++ b/crates/console/prometeu-system/src/services/vm_runtime/mod.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +mod async_render_contract_tests; mod dispatch; mod frame_scheduler; mod lifecycle; diff --git a/discussion/index.ndjson b/discussion/index.ndjson index d9ab7c35..2af89f18 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,6 +1,6 @@ {"type":"meta","next_id":{"DSC":42,"AGD":42,"DEC":32,"PLN":98,"LSN":48,"CLSN":1}} {"type":"discussion","id":"DSC-0039","status":"abandoned","ticket":"render-pipeline-family-and-future-3d","title":"Render Pipeline Family and Future 3D","created_at":"2026-06-04","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","architecture","pipeline"],"agendas":[{"id":"AGD-0039","file":"AGD-0039-render-pipeline-family-and-future-3d.md","status":"abandoned","created_at":"2026-06-04","updated_at":"2026-06-04","_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."} -{"type":"discussion","id":"DSC-0040","status":"in_progress","ticket":"vm-render-parallel-execution-boundary","title":"VM and Render Parallel Execution Boundary","created_at":"2026-06-04","updated_at":"2026-06-05","tags":["runtime","renderer","vm","concurrency","architecture","perf"],"agendas":[{"id":"AGD-0040","file":"AGD-0040-vm-and-render-parallel-execution-boundary.md","status":"accepted","created_at":"2026-06-04","updated_at":"2026-06-05"}],"decisions":[{"id":"DEC-0031","file":"DEC-0031-vm-and-render-parallel-execution-boundary.md","status":"accepted","created_at":"2026-06-05","updated_at":"2026-06-05","ref_agenda":"AGD-0040"}],"plans":[{"id":"PLN-0087","file":"PLN-0087-fix-game2d-packet-overlay-composition.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0088","file":"PLN-0088-define-read-only-render-resource-boundary.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0089","file":"PLN-0089-introduce-render-handoff-abstraction.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0090","file":"PLN-0090-add-render-frame-pacing-contract.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0091","file":"PLN-0091-implement-appmode-render-policy-matrix.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0092","file":"PLN-0092-add-render-epoch-ownership-transitions.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0093","file":"PLN-0093-add-render-telemetry-counters-and-events.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0094","file":"PLN-0094-specify-async-render-boundary.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0095","file":"PLN-0095-prototype-local-render-worker-capability.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0096","file":"PLN-0096-define-render-shutdown-and-failure-handling.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0097","file":"PLN-0097-add-async-render-integration-test-matrix.md","status":"open","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0040","status":"in_progress","ticket":"vm-render-parallel-execution-boundary","title":"VM and Render Parallel Execution Boundary","created_at":"2026-06-04","updated_at":"2026-06-05","tags":["runtime","renderer","vm","concurrency","architecture","perf"],"agendas":[{"id":"AGD-0040","file":"AGD-0040-vm-and-render-parallel-execution-boundary.md","status":"accepted","created_at":"2026-06-04","updated_at":"2026-06-05"}],"decisions":[{"id":"DEC-0031","file":"DEC-0031-vm-and-render-parallel-execution-boundary.md","status":"accepted","created_at":"2026-06-05","updated_at":"2026-06-05","ref_agenda":"AGD-0040"}],"plans":[{"id":"PLN-0087","file":"PLN-0087-fix-game2d-packet-overlay-composition.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0088","file":"PLN-0088-define-read-only-render-resource-boundary.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0089","file":"PLN-0089-introduce-render-handoff-abstraction.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0090","file":"PLN-0090-add-render-frame-pacing-contract.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0091","file":"PLN-0091-implement-appmode-render-policy-matrix.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0092","file":"PLN-0092-add-render-epoch-ownership-transitions.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0093","file":"PLN-0093-add-render-telemetry-counters-and-events.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0094","file":"PLN-0094-specify-async-render-boundary.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0095","file":"PLN-0095-prototype-local-render-worker-capability.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0096","file":"PLN-0096-define-render-shutdown-and-failure-handling.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0097","file":"PLN-0097-add-async-render-integration-test-matrix.md","status":"done","created_at":"2026-06-05","updated_at":"2026-06-05","ref_decisions":["DEC-0031"]}],"lessons":[]} {"type":"discussion","id":"DSC-0041","status":"open","ticket":"foreground-stack-game-pause-shell-vm-backed","title":"Foreground Stack, Game Pause, and VM-Backed Shell Coexistence","created_at":"2026-06-05","updated_at":"2026-06-05","tags":["runtime","os","lifecycle","shell","game","vm","foreground","architecture"],"agendas":[{"id":"AGD-0041","file":"AGD-0041-foreground-stack-game-pause-shell-vm-backed.md","status":"open","created_at":"2026-06-05","updated_at":"2026-06-05"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0038","status":"done","ticket":"render-frame-packet-boundary","title":"Logical Render Pipelines and Command Packets","created_at":"2026-05-25","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","frame-composer","architecture","ui","pipeline"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0047","file":"discussion/lessons/DSC-0038-render-frame-packet-boundary/LSN-0047-typed-render-submissions-preserve-domain-boundaries.md","status":"done","created_at":"2026-06-04","updated_at":"2026-06-04"}]} {"type":"discussion","id":"DSC-0035","status":"done","ticket":"task-owned-shell-windows","title":"Agenda - Task-Owned Shell Windows","created_at":"2026-05-15","updated_at":"2026-05-15","tags":["runtime","os","task","window-manager","shell","lifecycle"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0044","file":"discussion/lessons/DSC-0035-task-owned-shell-windows/LSN-0044-task-window-liveness-belongs-to-the-task.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]} diff --git a/discussion/workflow/plans/PLN-0097-add-async-render-integration-test-matrix.md b/discussion/workflow/plans/PLN-0097-add-async-render-integration-test-matrix.md index baecaef0..18e007ea 100644 --- a/discussion/workflow/plans/PLN-0097-add-async-render-integration-test-matrix.md +++ b/discussion/workflow/plans/PLN-0097-add-async-render-integration-test-matrix.md @@ -2,7 +2,7 @@ id: PLN-0097 ticket: vm-render-parallel-execution-boundary title: Add Async Render Integration Test Matrix -status: open +status: done created: 2026-06-05 ref_decisions: [DEC-0031] tags: [runtime, renderer, tests, integration]