dev/system-os-cartridge-switch-orchestrator #36

Merged
bquarkz merged 13 commits from dev/system-os-cartridge-switch-orchestrator into master 2026-07-05 17:47:27 +00:00
7 changed files with 188 additions and 20 deletions
Showing only changes of commit b30ff73e3d - Show all commits

View File

@ -289,16 +289,29 @@ mod tests {
} }
fn add_game(&self, name: &str, with_program: bool) -> PathBuf { 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); let cart = self.path.join(name);
fs::create_dir_all(&cart).expect("test cartridge should be created"); fs::create_dir_all(&cart).expect("test cartridge should be created");
let manifest = r#"{ let manifest = format!(
"magic": "PMTU", r#"{{
"cartridge_version": 1, "magic": "PMTU",
"app_id": 42, "cartridge_version": 1,
"title": "Stress", "app_id": {app_id},
"app_version": "1.0.0", "title": "{title}",
"app_mode": "Game" "app_version": "{app_version}",
}"#; "app_mode": "Game"
}}"#
);
fs::write(cart.join("manifest.json"), manifest) fs::write(cart.join("manifest.json"), manifest)
.expect("test manifest should be written"); .expect("test manifest should be written");
if with_program { 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] #[test]
fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() { fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() {
let root = TestGameRoot::new(); let root = TestGameRoot::new();

View File

@ -69,16 +69,18 @@ impl HubHomeStep {
return Some(FirmwareState::ResumeResidentGame); return Some(FirmwareState::ResumeResidentGame);
} }
ctx.os.log( if let Err(error) = ctx.os.lifecycle().terminate_resident_game() {
LogLevel::Warn, ctx.os.log(
LogSource::Hub, LogLevel::Error,
0, LogSource::Hub,
format!( 0,
"Cannot launch Home game {} while resident game app_id {} is active", format!(
cartridge.title, resident_app_id "Failed to terminate resident game app_id {} before launching {}: {:?}",
), resident_app_id, cartridge.title, error
); ),
return None; );
return None;
}
} }
return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new( return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(

View File

@ -222,6 +222,28 @@ impl<'a> LifecycleFacade<'a> {
Ok(()) Ok(())
} }
pub fn terminate_resident_game(&mut self) -> Result<Option<TaskId>, 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( pub fn crash_task(
&mut self, &mut self,
task_id: TaskId, task_id: TaskId,

View File

@ -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] #[test]
fn vm_game_task_creates_vm_session() { fn vm_game_task_creates_vm_session() {
let mut os = SystemOS::new(None); let mut os = SystemOS::new(None);

View File

@ -121,6 +121,10 @@ impl VmSessionRegistry {
self.get_mut(VmSessionId(task_id)) self.get_mut(VmSessionId(task_id))
} }
pub fn remove_for_task(&mut self, task_id: TaskId) -> Option<VmSession> {
self.sessions.remove(&VmSessionId(task_id))
}
pub fn resident_game(&self) -> Option<&VmSession> { pub fn resident_game(&self) -> Option<&VmSession> {
self.sessions.values().find(|session| session.app_mode == AppMode::Game) 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); 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] #[test]
fn session_runtime_state_is_independent_per_session() { fn session_runtime_state_is_independent_per_session() {
let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame); let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame);

View File

@ -1,5 +1,5 @@
{"type":"meta","next_id":{"DSC":44,"AGD":47,"DEC":40,"PLN":163,"LSN":53,"CLSN":1}} {"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-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-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"}]} {"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"}]}

View File

@ -2,7 +2,7 @@
id: PLN-0158 id: PLN-0158
ticket: system-os-cartridge-switch-orchestrator ticket: system-os-cartridge-switch-orchestrator
title: Terminate Resident Game Before Switching Cartridges title: Terminate Resident Game Before Switching Cartridges
status: open status: done
created: 2026-07-05 created: 2026-07-05
ref_decisions: [DEC-0039] ref_decisions: [DEC-0039]
tags: [runtime, os, lifecycle, game, cartridge, architecture] tags: [runtime, os, lifecycle, game, cartridge, architecture]