diff --git a/backend/app/api/routes/simulation.py b/backend/app/api/routes/simulation.py index 681991b3..8f660ca6 100644 --- a/backend/app/api/routes/simulation.py +++ b/backend/app/api/routes/simulation.py @@ -1,3 +1,4 @@ +import hashlib import json import logging import socket @@ -10,6 +11,24 @@ from app.services.stm32_lib_manager import stm32_lib_manager from app.core.hooks import dispatch_ws_sim_message +def _owner_key(websocket: WebSocket) -> str | None: + """Stable, opaque id for "the same person" across tabs. + + The session cookie is hashed rather than stored: this is only used to + count concurrent guests per user, so the value never needs to be read + back. Falls back to the client host when there is no cookie (desktop + sidecar, tests), and to None when there is neither. + """ + try: + token = websocket.cookies.get('access_token') + except Exception: + token = None + if token: + return 'u:' + hashlib.sha256(token.encode()).hexdigest()[:16] + host = getattr(getattr(websocket, 'client', None), 'host', None) + return f'h:{host}' if host else None + + def _find_free_port() -> int: """Allocate a free TCP port for WiFi hostfwd.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: @@ -76,9 +95,19 @@ async def simulation_websocket(websocket: WebSocket, client_id: str): if not await board_allowed(websocket, board): await qemu_callback('error', {'message': PRO_BOARD_MESSAGE}) else: - # msg_data carries whatever the client declared for this - # session (an overlay may materialise extra drives from it). - qemu_manager.start_instance(client_id, board, qemu_callback, msg_data) + # Capacity: a guest is a real QEMU process with its own + # GBs, so the box is the limit. Refuse with words the + # user can act on rather than letting the machine swap. + owner = _owner_key(websocket) + full = qemu_manager.capacity_error(owner) + if full: + await qemu_callback('error', {'message': full}) + else: + # msg_data carries whatever the client declared for this + # session (an overlay may materialise extra drives from it). + qemu_manager.start_instance( + client_id, board, qemu_callback, msg_data, owner=owner, + ) elif msg_type == 'stop_pi': qemu_manager.stop_instance(client_id) diff --git a/backend/app/services/qemu_manager.py b/backend/app/services/qemu_manager.py index 5ae1e4f9..4d5736df 100644 --- a/backend/app/services/qemu_manager.py +++ b/backend/app/services/qemu_manager.py @@ -155,6 +155,15 @@ DEFAULT_PI_BOARD = 'raspberry-pi-3' # 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')) +# Capacity. Every guest is a real QEMU process holding its own RAM (1-2 GB +# per board profile) and vCPU threads, so the machine — not the code — is +# the limit. Without a ceiling the Nth user simply pushes the box into +# swap and everyone's session gets slow, which is worse than telling the +# Nth user to wait a minute. Per-owner keeps one person's tabs from +# eating the pool: opening the same project in six tabs is six guests. +MAX_INSTANCES = int(os.environ.get('VELXIO_PI_MAX_INSTANCES', '6')) +MAX_INSTANCES_PER_OWNER = int(os.environ.get('VELXIO_PI_MAX_PER_OWNER', '2')) + # Every key a profile must carry — the boot path reads exactly these. _PI_PROFILE_KEYS = frozenset( {'qemu', 'cpu', 'smp', 'memory', 'image_set', 'kernel', 'initramfs', @@ -302,6 +311,9 @@ class PiInstance: # Raw `start_pi` payload — carries whatever the client declared for # this session (e.g. the packages an overlay must materialise). self.start_payload: dict = {} + # Who this guest belongs to (opaque key from the route), for the + # per-owner capacity check. None when the caller has no identity. + self.owner: str | None = None async def emit(self, event_type: str, data: dict) -> None: try: @@ -321,9 +333,29 @@ class QemuManager: # ── Public API ──────────────────────────────────────────────────────────── + def capacity_error(self, owner: str | None) -> str | None: + """Why this start must be refused, or None when there is room. + + Returned as a message the user reads, so it says what to do next + instead of just failing.""" + if len(self._instances) >= MAX_INSTANCES: + return ( + 'All the Linux machines are busy right now. ' + 'Try again in a minute — sessions free up as people stop them.' + ) + if owner: + mine = sum(1 for i in self._instances.values() if i.owner == owner) + if mine >= MAX_INSTANCES_PER_OWNER: + return ( + f'You already have {mine} Linux sessions running. ' + 'Stop one before starting another.' + ) + return None + def start_instance(self, client_id: str, board_type: str, callback: EventCallback, - payload: dict | None = None) -> None: + payload: dict | None = None, + owner: str | None = None) -> None: if client_id in self._instances: logger.warning('start_instance: %s already running', client_id) return @@ -335,7 +367,12 @@ class QemuManager: board_type = DEFAULT_PI_BOARD inst = PiInstance(client_id, callback, board_type=board_type) inst.start_payload = dict(payload or {}) + inst.owner = owner self._instances[client_id] = inst + logger.info( + 'pi capacity: %d/%d guests running (starting %s)', + len(self._instances), MAX_INSTANCES, client_id, + ) asyncio.create_task(self._boot(inst)) def stop_instance(self, client_id: str) -> None: diff --git a/backend/tests/test_pi_capacity.py b/backend/tests/test_pi_capacity.py new file mode 100644 index 00000000..9c9adf4d --- /dev/null +++ b/backend/tests/test_pi_capacity.py @@ -0,0 +1,55 @@ +"""Capacity limits for the QEMU-Linux guests. + +Every guest is a real process holding 1-2 GB and its own vCPU threads, so +the machine is the ceiling, not the code. These check that the ceiling is +enforced BEFORE a process is spawned and that the refusal says what the +user should do next. +""" +import app.services.qemu_manager as qm + + +class _Mgr(qm.QemuManager): + """Manager with a fake instance table (no processes involved).""" + + def add(self, client_id: str, owner: str | None) -> None: + inst = qm.PiInstance(client_id, lambda *_a, **_k: None, + board_type='raspberry-pi-3') + inst.owner = owner + self._instances[client_id] = inst + + +def test_room_when_empty(): + assert _Mgr().capacity_error('u:alice') is None + + +def test_global_ceiling(monkeypatch): + monkeypatch.setattr(qm, 'MAX_INSTANCES', 2) + monkeypatch.setattr(qm, 'MAX_INSTANCES_PER_OWNER', 99) + mgr = _Mgr() + mgr.add('a', 'u:alice') + mgr.add('b', 'u:bob') + msg = mgr.capacity_error('u:carol') + assert msg and 'busy' in msg.lower() + + +def test_per_owner_ceiling(monkeypatch): + monkeypatch.setattr(qm, 'MAX_INSTANCES', 99) + monkeypatch.setattr(qm, 'MAX_INSTANCES_PER_OWNER', 2) + mgr = _Mgr() + mgr.add('tab1', 'u:alice') + mgr.add('tab2', 'u:alice') + # Alice's third tab is refused... + msg = mgr.capacity_error('u:alice') + assert msg and 'already have' in msg.lower() + # ...while somebody else still gets in. + assert mgr.capacity_error('u:bob') is None + + +def test_anonymous_owner_is_not_pooled(monkeypatch): + """No identity (desktop sidecar, tests) only hits the global ceiling.""" + monkeypatch.setattr(qm, 'MAX_INSTANCES', 99) + monkeypatch.setattr(qm, 'MAX_INSTANCES_PER_OWNER', 1) + mgr = _Mgr() + mgr.add('a', None) + mgr.add('b', None) + assert mgr.capacity_error(None) is None