diff --git a/crates/console/prometeu-firmware/src/firmware/firmware.rs b/crates/console/prometeu-firmware/src/firmware/firmware.rs index 153fb92f..81e3e8f2 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware.rs @@ -289,16 +289,29 @@ mod tests { } fn add_game(&self, name: &str, with_program: bool) -> PathBuf { + self.add_game_with_identity(name, 42, "Stress", "1.0.0", with_program) + } + + fn add_game_with_identity( + &self, + name: &str, + app_id: u32, + title: &str, + app_version: &str, + with_program: bool, + ) -> PathBuf { let cart = self.path.join(name); fs::create_dir_all(&cart).expect("test cartridge should be created"); - let manifest = r#"{ - "magic": "PMTU", - "cartridge_version": 1, - "app_id": 42, - "title": "Stress", - "app_version": "1.0.0", - "app_mode": "Game" - }"#; + let manifest = format!( + r#"{{ + "magic": "PMTU", + "cartridge_version": 1, + "app_id": {app_id}, + "title": "{title}", + "app_version": "{app_version}", + "app_mode": "Game" + }}"# + ); fs::write(cart.join("manifest.json"), manifest) .expect("test manifest should be written"); if with_program { @@ -728,6 +741,72 @@ mod tests { ); } + #[test] + fn hub_home_clicking_different_game_terminates_previous_resident_session() { + let root = TestGameRoot::new(); + root.add_game("stress", true); + root.add_game_with_identity("zzz-dummy", 43, "Zzz Dummy", "1.0.0", true); + + let (mut firmware, mut platform) = hub_home_firmware(); + firmware.hub.set_game_library(discover_games_root(&root.path)); + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() }; + firmware.tick(&stress_click, &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + let first_game_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected first GameRunning state, got {:?}", other), + }; + + firmware.request_home_from_host(); + firmware.tick(&InputSignals::default(), &mut platform); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(first_game_task)); + + firmware.tick(&InputSignals::default(), &mut platform); + let dummy_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() }; + firmware.tick(&dummy_click, &mut platform); + + assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(first_game_task), + Ok(ProcessState::Stopped) + ); + assert_eq!( + firmware + .os + .sessions() + .vm_session_for_task(first_game_task) + .map(|session| session.task_id), + None + ); + + let second_game_task = firmware + .os + .lifecycle() + .resident_game_task() + .expect("replacement game should be resident"); + assert_ne!(second_game_task, first_game_task); + assert_eq!( + firmware + .os + .sessions() + .vm_session_for_task(second_game_task) + .map(|session| session.app_id), + Some(43) + ); + + firmware.tick(&InputSignals::default(), &mut platform); + + match &firmware.state { + FirmwareState::GameRunning(step) => assert_eq!(step.task_id, second_game_task), + other => panic!("expected replacement GameRunning state, got {:?}", other), + } + } + #[test] fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() { let root = TestGameRoot::new(); diff --git a/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs b/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs index d0c7c293..93c59dd6 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs @@ -69,16 +69,18 @@ impl HubHomeStep { return Some(FirmwareState::ResumeResidentGame); } - ctx.os.log( - LogLevel::Warn, - LogSource::Hub, - 0, - format!( - "Cannot launch Home game {} while resident game app_id {} is active", - cartridge.title, resident_app_id - ), - ); - return None; + if let Err(error) = ctx.os.lifecycle().terminate_resident_game() { + ctx.os.log( + LogLevel::Error, + LogSource::Hub, + 0, + format!( + "Failed to terminate resident game app_id {} before launching {}: {:?}", + resident_app_id, cartridge.title, error + ), + ); + return None; + } } return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new( diff --git a/crates/console/prometeu-system/src/os/facades/lifecycle.rs b/crates/console/prometeu-system/src/os/facades/lifecycle.rs index 680a1cc5..b7f6d06a 100644 --- a/crates/console/prometeu-system/src/os/facades/lifecycle.rs +++ b/crates/console/prometeu-system/src/os/facades/lifecycle.rs @@ -222,6 +222,28 @@ impl<'a> LifecycleFacade<'a> { Ok(()) } + pub fn terminate_resident_game(&mut self) -> Result, LifecycleError> { + let Some(task_id) = self.os.foreground_stack.resident_game_task() else { + return Ok(None); + }; + + let process_id = process_id_for_task(self.os, task_id)?; + + self.os.task_manager.close_task(task_id); + self.os.process_manager.mark_stopped(process_id); + self.os.foreground_stack.clear_resident_game(task_id); + self.os.vm_sessions.remove_for_task(task_id); + self.os.game_lifecycle_events.retain(|event| event.task_id != task_id); + + if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Game(owner) if owner == task_id) + { + self.os.foreground_stack.return_to_hub(); + self.os.task_manager.clear_foreground(); + } + + Ok(Some(task_id)) + } + pub fn crash_task( &mut self, task_id: TaskId, diff --git a/crates/console/prometeu-system/src/os/system_os.rs b/crates/console/prometeu-system/src/os/system_os.rs index a39cb2d3..23d6c048 100644 --- a/crates/console/prometeu-system/src/os/system_os.rs +++ b/crates/console/prometeu-system/src/os/system_os.rs @@ -218,6 +218,42 @@ mod tests { ); } + #[test] + fn terminate_resident_game_closes_task_and_removes_vm_session() { + let mut os = SystemOS::new(None); + let game = os.sessions().create_vm_game_task(42, "Sector Crawl"); + + os.lifecycle().request_home_from_game(game).expect("home request should succeed"); + assert_eq!(os.lifecycle().pending_game_lifecycle_events().len(), 1); + + assert_eq!(os.lifecycle().terminate_resident_game(), Ok(Some(game))); + + assert_eq!(os.lifecycle().resident_game(), None); + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Hub); + assert_eq!(os.lifecycle().task_state(game), Some(TaskState::Closed)); + assert_eq!(process_state_for_task(&os, game), ProcessState::Stopped); + assert_eq!(os.sessions().vm_session_for_task(game).map(|session| session.task_id), None); + assert!(os.lifecycle().pending_game_lifecycle_events().is_empty()); + } + + #[test] + fn terminate_resident_game_preserves_foreground_shell() { + let mut os = SystemOS::new(None); + let game = os.sessions().create_vm_game_task(42, "Sector Crawl"); + os.lifecycle().request_home_from_game(game).expect("home request should succeed"); + os.lifecycle().advance_game_pause_budget(game).expect("budget should expire"); + let shell = os.sessions().create_vm_shell_task(7, "Settings"); + + assert_eq!(os.lifecycle().terminate_resident_game(), Ok(Some(game))); + + assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell)); + assert_eq!(os.lifecycle().resident_game(), None); + assert_eq!(os.lifecycle().task_state(game), Some(TaskState::Closed)); + assert_eq!(process_state_for_task(&os, game), ProcessState::Stopped); + assert_eq!(os.sessions().vm_session_for_task(game).map(|session| session.task_id), None); + assert_eq!(os.lifecycle().task_state(shell), Some(TaskState::Foreground)); + } + #[test] fn vm_game_task_creates_vm_session() { let mut os = SystemOS::new(None); diff --git a/crates/console/prometeu-system/src/services/vm_session.rs b/crates/console/prometeu-system/src/services/vm_session.rs index caa023f3..ffd1dbbe 100644 --- a/crates/console/prometeu-system/src/services/vm_session.rs +++ b/crates/console/prometeu-system/src/services/vm_session.rs @@ -121,6 +121,10 @@ impl VmSessionRegistry { self.get_mut(VmSessionId(task_id)) } + pub fn remove_for_task(&mut self, task_id: TaskId) -> Option { + self.sessions.remove(&VmSessionId(task_id)) + } + pub fn resident_game(&self) -> Option<&VmSession> { self.sessions.values().find(|session| session.app_mode == AppMode::Game) } @@ -231,6 +235,31 @@ mod tests { assert_eq!(registry.resident_game_for_app(43).map(|session| session.task_id), None); } + #[test] + fn remove_for_task_removes_only_matching_session() { + let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame); + let game_task = Task::new(TaskId(7), game_process.id, 42, "Stress", TaskKind::Game); + let shell_process = Process::new(ProcessId(2), 9, "Shell", ProcessKind::VmShell); + let shell_task = Task::new(TaskId(8), shell_process.id, 9, "Shell", TaskKind::Shell); + let mut registry = VmSessionRegistry::new(); + + registry + .create_for_task(&game_task, &game_process, "", None, Arc::new(AtomicU32::new(0))) + .expect("game session should create"); + registry + .create_for_task(&shell_task, &shell_process, "", None, Arc::new(AtomicU32::new(0))) + .expect("shell session should create"); + + let removed = registry.remove_for_task(game_task.id).expect("game session should remove"); + + assert_eq!(removed.task_id, game_task.id); + assert_eq!(registry.for_task(game_task.id).map(|session| session.task_id), None); + assert_eq!( + registry.for_task(shell_task.id).map(|session| session.task_id), + Some(shell_task.id) + ); + } + #[test] fn session_runtime_state_is_independent_per_session() { let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame); diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 8902af04..204218d2 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,5 +1,5 @@ {"type":"meta","next_id":{"DSC":44,"AGD":47,"DEC":40,"PLN":163,"LSN":53,"CLSN":1}} -{"type":"discussion","id":"DSC-0043","status":"in_progress","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0039","file":"DEC-0039-systemos-game-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0044"}],"plans":[{"id":"PLN-0157","file":"PLN-0157-introduce-systemos-game-switch-target-contract.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0158","file":"PLN-0158-terminate-resident-game-before-switching-cartridges.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0159","file":"PLN-0159-recover-failed-game-switches-back-to-home.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0160","file":"PLN-0160-add-dummy-boy-visual-switch-test-cartridge.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0161","file":"PLN-0161-validate-stress-to-dummy-boy-game-switch-end-to-end.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0162","file":"PLN-0162-specify-game-switch-contract-and-direct-boot-separation.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0043","status":"in_progress","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0039","file":"DEC-0039-systemos-game-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0044"}],"plans":[{"id":"PLN-0157","file":"PLN-0157-introduce-systemos-game-switch-target-contract.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0158","file":"PLN-0158-terminate-resident-game-before-switching-cartridges.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0159","file":"PLN-0159-recover-failed-game-switches-back-to-home.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0160","file":"PLN-0160-add-dummy-boy-visual-switch-test-cartridge.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0161","file":"PLN-0161-validate-stress-to-dummy-boy-game-switch-end-to-end.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0162","file":"PLN-0162-specify-game-switch-contract-and-direct-boot-separation.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]}],"lessons":[]} {"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":"done","ticket":"vm-render-parallel-execution-boundary","title":"VM and Render Parallel Execution Boundary","created_at":"2026-06-04","updated_at":"2026-06-06","tags":["runtime","renderer","vm","concurrency","architecture","perf"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0048","file":"discussion/lessons/DSC-0040-vm-render-parallel-execution-boundary/LSN-0048-render-workers-need-a-closed-packet-contract.md","status":"done","created_at":"2026-06-06","updated_at":"2026-06-06"}]} {"type":"discussion","id":"DSC-0041","status":"done","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-07-05","tags":["runtime","os","lifecycle","shell","game","vm","foreground","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0052","file":"discussion/lessons/DSC-0041-foreground-stack-game-pause-shell-vm-backed/LSN-0052-foreground-ownership-and-vm-session-ownership-are-separate.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]} diff --git a/discussion/workflow/plans/PLN-0158-terminate-resident-game-before-switching-cartridges.md b/discussion/workflow/plans/PLN-0158-terminate-resident-game-before-switching-cartridges.md index d2176204..ae4c957f 100644 --- a/discussion/workflow/plans/PLN-0158-terminate-resident-game-before-switching-cartridges.md +++ b/discussion/workflow/plans/PLN-0158-terminate-resident-game-before-switching-cartridges.md @@ -2,7 +2,7 @@ id: PLN-0158 ticket: system-os-cartridge-switch-orchestrator title: Terminate Resident Game Before Switching Cartridges -status: open +status: done created: 2026-07-05 ref_decisions: [DEC-0039] tags: [runtime, os, lifecycle, game, cartridge, architecture]