From 8d4f298f68d7b7fac0a030faf0c824001c70a9ed Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Wed, 29 Jul 2026 04:33:12 +0200 Subject: [PATCH] feat(qemu): session ceiling for guest instances A forgotten tab pinned a QEMU process and its RAM for as long as the browser stayed open. Instances now shut themselves down after MAX_SESSION_SECONDS (2 h default, env-tunable) and say so on the serial line instead of vanishing. --- backend/app/services/qemu_manager.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/app/services/qemu_manager.py b/backend/app/services/qemu_manager.py index 183bc442..6cdc7dfb 100644 --- a/backend/app/services/qemu_manager.py +++ b/backend/app/services/qemu_manager.py @@ -148,6 +148,12 @@ PI_CONFIGS: dict[str, dict] = { # that still send the legacy "start_pi" message without a board field. DEFAULT_PI_BOARD = 'raspberry-pi-3' +# Hard ceiling for one guest session, in seconds. A QEMU instance costs a +# process and its RAM for as long as it lives, and a tab left open all day +# used to keep one pinned. 2 h is far past any interactive session; the +# client is told plainly when it trips. Override with VELXIO_PI_MAX_SESSION_S. +MAX_SESSION_SECONDS = int(os.environ.get('VELXIO_PI_MAX_SESSION_S', '7200')) + # Every key a profile must carry — the boot path reads exactly these. _PI_PROFILE_KEYS = frozenset( {'qemu', 'cpu', 'smp', 'memory', 'image_set', 'kernel', 'initramfs', @@ -557,6 +563,11 @@ class QemuManager: inst.running = True await inst.emit('system', {'event': 'booting'}) + # Session ceiling: a forgotten tab used to hold a QEMU process (and + # its RAM) for as long as the browser stayed open. After this many + # seconds the instance shuts itself down and tells the client why. + inst._tasks.append(asyncio.create_task(self._expire(inst))) + # Give QEMU a moment to open its TCP sockets await asyncio.sleep(1.0) @@ -565,6 +576,22 @@ class QemuManager: inst._tasks.append(asyncio.create_task(self._connect_gpio(inst))) inst._tasks.append(asyncio.create_task(self._watch_stderr(inst))) + async def _expire(self, inst: PiInstance) -> None: + """Stop an instance that has outlived MAX_SESSION_SECONDS.""" + try: + await asyncio.sleep(MAX_SESSION_SECONDS) + except asyncio.CancelledError: + return + if not inst.running: + return + logger.info('[%s] session ceiling reached (%ds) — shutting down', + inst.client_id, MAX_SESSION_SECONDS) + await inst.emit('serial_output', { + 'data': ('\r\n[Velxio] This Linux session reached its time limit ' + 'and was stopped. Press Run to start a fresh one.\r\n'), + }) + self.stop_instance(inst.client_id) + # ── Console (virtio-console / /dev/hvc0) ────────────────────────────────── async def _connect_serial(self, inst: PiInstance) -> None: