dev/system-os-cartridge-switch-orchestrator #36
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -222,6 +222,28 @@ impl<'a> LifecycleFacade<'a> {
|
||||
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(
|
||||
&mut self,
|
||||
task_id: TaskId,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -121,6 +121,10 @@ impl VmSessionRegistry {
|
||||
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> {
|
||||
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);
|
||||
|
||||
@ -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"}]}
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user