dev/system-os-cartridge-switch-orchestrator #36
@ -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();
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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"}]}
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user