Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ services:
# measured on its reference VM. See docs/CAPACITY_VALIDATION_PLAN.md before raising.
- SIMULATION_ADMISSION_MAX=${SIMULATION_ADMISSION_MAX:-25}
- SIMULATION_MAX_CONCURRENT=200
# Total paused time of one run; a paused run still holds its capacity.
- SIMULATION_MAX_PAUSED_SECONDS=${SIMULATION_MAX_PAUSED_SECONDS:-600}
- SANDBOX_POOL_IDLE_TIMEOUT_MS=300000
# Per-sandbox Docker resource limits
# ┌─ Scenario breakdown ────────────────────────────────────────────────────┐
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ graph TD
- **Wiederverwendung:** Jeder Lauf trägt eine Generation und ein Abbruchsignal. Ein gestoppter Lauf, der noch auf einen Start-Slot wartet, startet nicht mehr und berührt den Folgelauf auf demselben Runner nicht; Ereignisse eines ersetzten Kindprozesses werden verworfen. Den Reset vor der Wiederverwendung besitzt der Runner (`resetForReuse`).
- **Verwaiste Container:** Jeder Sandbox-Container trägt das Label `unosim.owner` – `instance.<UNOSIM_INSTANCE_ID>` (Compose: `unosim-server`) oder ohne ID `<host>:<pid>`. Beim Start und am Ende eines geordneten Shutdowns entfernt das Backend die Container dieses Owners, auch nach Absturz oder Redeploy; Container anderer Owner bleiben unberührt.
- **Shutdown:** Runner werden parallel (höchstens 8) und je Runner höchstens 6 s lang gestoppt, danach entfernt ein Label-Sweep Reste; Compose gibt dem Backend 15 s vor SIGKILL.
- **Harte Lebensdauer:** `timeout --signal=KILL` beendet jede Sandbox nach `SANDBOX_MAX_LIFETIME_SECONDS` (Standard 7200 s Wanduhrzeit inklusive Pausen), unabhängig vom Backend.
- **Harte Lebensdauer:** `timeout --signal=KILL` beendet jede laufende Sandbox nach `SANDBOX_MAX_LIFETIME_SECONDS` (Standard 7200 s Wanduhrzeit), unabhängig vom Backend. `docker pause` friert diesen Timer ein; pausierte Läufe beendet deshalb das Backend nach insgesamt `SIMULATION_MAX_PAUSED_SECONDS` (Standard 600 s) Pausenzeit und gibt Runner, Admission und Container frei.
- **Isolation:** Jeder Sketch läuft in eigenem Container
- **Ressourcenkontrolle:** CPU/Memory/PID-Limits pro Container

Expand Down
11 changes: 8 additions & 3 deletions docs/INSTALL_SERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,13 @@ die aktuelle Entscheidung zur optionalen Benutzeranmeldung steht in
geordneten Shutdowns entfernt der Server die Container dieses Owners, auch
nach Absturz oder Redeploy mit neuem Backend-Container. Gleichzeitig laufende
Backends auf demselben Docker-Host brauchen verschiedene IDs.
- Jede Sandbox endet spätestens nach `SANDBOX_MAX_LIFETIME_SECONDS`
(Standard 7200 s, Wanduhrzeit inklusive Pausen), auch wenn das Backend nicht
mehr läuft.
- Jede laufende Sandbox endet spätestens nach `SANDBOX_MAX_LIFETIME_SECONDS`
(Standard 7200 s Wanduhrzeit), auch wenn das Backend nicht mehr läuft.
`docker pause` friert auch diesen Timer ein; pausierte Läufe begrenzt
deshalb das Backend: Ein Lauf darf insgesamt höchstens
`SIMULATION_MAX_PAUSED_SECONDS` (Standard 600 s, stets kleiner als die harte
Lebensdauer) pausiert sein, danach wird er beendet und gibt Runner, Admission
und Container frei.

Auf macOS muss der Projektpfad in Docker Desktop für File Sharing freigegeben
sein.
Expand Down Expand Up @@ -147,6 +151,7 @@ muss diese Werte nicht ändern. Für größere Installationen können sie über
| `SANDBOX_CPU_LIMIT` | CPU-Limit pro Sandbox |
| `UNOSIM_GITHUB_TOKEN` | optionaler Read-only-Token nur für `api.github.com` (Ref-Auflösung der Kursinhalte); ohne Token gilt das GitHub-Limit von 60 Abfragen pro Stunde und Server-IP |
| `UNOSIM_LOG_MAX_SIZE`, `UNOSIM_LOG_MAX_FILE` | Rotation des Backend-Logs (Docker `json-file`); Standard `10m` je Datei und `5` Dateien |
| `SIMULATION_MAX_PAUSED_SECONDS` | maximale Gesamtpausenzeit eines Simulationslaufs; danach wird er beendet und gibt seine Kapazität frei; Standard `600`, muss kleiner als `SANDBOX_MAX_LIFETIME_SECONDS` sein |
| `WS_HEARTBEAT_INTERVAL_MS` | Ping-Intervall der WebSocket-Verbindungen; eine Verbindung ohne Antwort auf den vorherigen Ping wird getrennt und gibt ihre Simulation frei; Standard `30000` ms |

Die Defaults der Anwendung stehen in `server/config.ts` (unter anderem
Expand Down
9 changes: 7 additions & 2 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,14 @@ Every sandbox container carries the label `unosim.owner`: `instance.<id>` when
removes the containers of its owner, so a crashed or redeployed backend of the
same deployment cleans up its predecessor's sandboxes; containers of other
owners are not touched. Concurrently running backends must use distinct IDs.
Independently of the backend, every sandbox runs under
Independently of the backend, every running sandbox runs under
`timeout --signal=KILL` and ends after `SANDBOX_MAX_LIFETIME_SECONDS` (default
7200 s wall clock, including paused time).
7200 s wall clock). `docker pause` freezes that timer's process too, so a
paused sandbox is bounded by the backend instead: the paused time of a run is
limited to `SIMULATION_MAX_PAUSED_SECONDS` (default 600 s, always below the
hard lifetime); then the run ends and its runner, admission and container are
released. A paused container left by a crashed backend is removed by the next
start's owner sweep.

## Compile boundary

Expand Down
26 changes: 23 additions & 3 deletions server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,20 @@ const simulationAdmissionMax = envInt("SIMULATION_ADMISSION_MAX", 25, { min: 1,
const simulationQueueTimeoutMs = envInt("SIMULATION_QUEUE_TIMEOUT_MS", 60_000, { min: 1_000, max: 900_000 });
const sandboxStartSlotTimeoutMs = envInt("SANDBOX_START_SLOT_TIMEOUT_MS", 30_000, { min: 1_000, max: 900_000 });

const sandboxMaxLifetimeSeconds = envInt("SANDBOX_MAX_LIFETIME_SECONDS", 7_200, { min: 900, max: 86_400 });
const simulationMaxPausedSeconds = envInt("SIMULATION_MAX_PAUSED_SECONDS", 600, { min: 10, max: 86_400 });

/**
* A paused run must end before the sandbox's own hard lifetime, which cannot
* fire while `docker pause` freezes the container.
*/
export function validatePausedBudget(maxPausedSeconds: number, maxLifetimeSeconds: number): void {
if (maxPausedSeconds >= maxLifetimeSeconds) {
throw new Error("SIMULATION_MAX_PAUSED_SECONDS must be lower than SANDBOX_MAX_LIFETIME_SECONDS");
}
}
validatePausedBudget(simulationMaxPausedSeconds, sandboxMaxLifetimeSeconds);

/** Logical warm-object floor. It never pre-creates Docker containers. */
const logicalWarmRunnerFloor = Math.min(5, simulationMaxConcurrent);

Expand Down Expand Up @@ -593,10 +607,16 @@ export const config = {
instanceId: parseInstanceId(process.env.UNOSIM_INSTANCE_ID),
/**
* Absolute wall-clock lifetime of a sandbox container, enforced inside the
* container so it also ends when the backend dies. Includes paused time;
* far above compile time plus the longest simulation timeout.
* container so it also ends when the backend dies; far above compile time
* plus the longest simulation timeout. `docker pause` freezes this clock's
* process, so a paused container ends on its pause budget instead.
*/
maxLifetimeSeconds: sandboxMaxLifetimeSeconds,
/**
* Total time one run may spend paused. The backend ends a run that exceeds
* it, which releases its runner, admission and container.
*/
maxLifetimeSeconds: envInt("SANDBOX_MAX_LIFETIME_SECONDS", 7_200, { min: 900, max: 86_400 }),
maxPausedSeconds: simulationMaxPausedSeconds,
},

// ── Compilation ─────────────────────────────────────────────────
Expand Down
39 changes: 38 additions & 1 deletion server/services/sandbox-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export class SandboxRunner {

private controlCompletion?: Promise<boolean>;
private controlPending = false;
/** Total time one run may spend paused before the runner ends it. */
private readonly maxPausedMs: number;
private pauseBudgetTimer: NodeJS.Timeout | null = null;

/** Completion of the last accepted control; Stop never waits for it. */
get controlResult(): Promise<boolean> | undefined {
Expand All @@ -60,8 +63,9 @@ export class SandboxRunner {
private get state(): SimulationState { return this.executionState?.state ?? SimulationState.STOPPED; }
private set state(v: SimulationState | string) { this.executionState.state = v as SimulationState; }

constructor(options?: { tempDir?: string; processController?: IProcessController }) {
constructor(options?: { tempDir?: string; processController?: IProcessController; maxPausedMs?: number }) {
this.processController = options?.processController ?? new ProcessController();
this.maxPausedMs = options?.maxPausedMs ?? config.sandbox.maxPausedSeconds * 1_000;
this.tempDir = options?.tempDir ?? sandboxTempDir();
this.timeoutManager = new SimulationTimeoutManager();
this.fileBuilder = new SketchFileBuilder(this.tempDir);
Expand Down Expand Up @@ -330,9 +334,39 @@ export class SandboxRunner {
if (!s.processKilled) this.processController.writeStdin("[[PAUSE_TIME]]\n");
s.pauseStartTime = Date.now();
this.registryManager.markPauseTime(s.pauseStartTime);
this.startPauseBudget();
this.logger.info("Simulation paused");
}

/**
* The paused time of all pauses of a run is bounded: a paused run keeps its
* runner, admission and (frozen) container, and `docker pause` also freezes
* the sandbox's own hard lifetime.
*/
private startPauseBudget(): void {
this.clearPauseBudget();
const remainingMs = Math.max(0, this.maxPausedMs - this.executionState.totalPausedTime);
this.pauseBudgetTimer = setTimeout(() => this.endPausedRun(), remainingMs);
this.pauseBudgetTimer.unref?.();
}

private clearPauseBudget(): void {
if (this.pauseBudgetTimer) clearTimeout(this.pauseBudgetTimer);
this.pauseBudgetTimer = null;
}

/** Ends the run like the runtime timeout does: its exit releases the session. */
private endPausedRun(): void {
this.pauseBudgetTimer = null;
const s = this.executionState;
if (this.state !== SimulationState.PAUSED || s.processKilled || s.terminationRequested) return;
const limitSeconds = Math.round(this.maxPausedMs / 1_000);
this.logger.info(`Paused run exceeded its pause budget of ${limitSeconds}s; ending it`);
s.onOutputCallback?.(`--- Simulation stopped: paused longer than ${limitSeconds} s ---`, true);
s.terminationRequested = true;
this.processController.kill("SIGKILL");
}

pause(): boolean {
if (this.controlPending || this.executionState.terminationRequested || this.state !== SimulationState.RUNNING || !this.processController.hasProcess()) return false;
if (this.executionState.currentContainerName) {
Expand All @@ -347,6 +381,7 @@ export class SandboxRunner {

private commitResume(): void {
const s = this.executionState;
this.clearPauseBudget();
const pauseDuration = Date.now() - (this.pauseStartTime ?? Date.now());
s.totalPausedTime += pauseDuration;
if (!s.processKilled) this.processController.writeStdin(`[[RESUME_TIME:${pauseDuration}]]\n`);
Expand Down Expand Up @@ -413,6 +448,7 @@ export class SandboxRunner {
// Cancels a run that is still preparing or waiting for a start slot.
s.runAbort?.abort();
this.controlPending = false;
this.clearPauseBudget();
if (this.state === SimulationState.STOPPED || s.processKilled) {
await this.cleanupDockerContainer(s.currentContainerName);
return;
Expand Down Expand Up @@ -483,6 +519,7 @@ export class SandboxRunner {
s.runAbort?.abort();
this.controlPending = false;
this.controlCompletion = undefined;
this.clearPauseBudget();
if (s.currentContainerName) {
await this.cleanupDockerContainer(s.currentContainerName);
if (s.currentContainerName) throw new Error(`Docker cleanup unconfirmed for ${s.currentContainerName}`);
Expand Down
13 changes: 12 additions & 1 deletion ssot/ssot_function_definition_PauseResume.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Die **Pause/Resume-Funktionalität** ermöglicht es dem Benutzer, die laufende A
**Gültige Übergänge:**
- `RUNNING` → `PAUSED` (nur via Pause-Button möglich)
- `PAUSED` → `RUNNING` (nur via Resume-Button möglich)
- `PAUSED` → `STOPPED` (nur via Stop-Button möglich)
- `PAUSED` → `STOPPED` (via Stop-Button oder serverseitig nach Ablauf des Pausenbudgets, siehe 7.2)
- `RUNNING` → `STOPPED` (Code-Änderung oder Stop-Button)

---
Expand Down Expand Up @@ -188,6 +188,16 @@ Beim Start der Simulation wird ein Execution-Timeout geplant. Beim Pausieren wir

Beim Fortsetzen wird ein neuer Timeout-Timer mit der gespeicherten Restzeit gestartet. Die reale Dauer der Pause zählt nicht gegen das Simulations-Timeout. Für Benutzer bedeutet das: Eine Simulation, die z. B. nach 50 von 60 Sekunden pausiert wird, hat nach Resume weiterhin etwa 10 Sekunden Restlaufzeit.

### 7.2 Pausenbudget

Ein pausierter Lauf belegt weiterhin Simulationskapazität (Runner, Admission und den eingefrorenen Sandbox-Container). Die Pausenzeit aller Pausen eines Laufs ist deshalb insgesamt auf `SIMULATION_MAX_PAUSED_SECONDS` begrenzt (Standard 600 s). Ist das Budget während einer Pause aufgebraucht, beendet der Server den Lauf wie bei einem Timeout:

1. Ausgabe `--- Simulation stopped: paused longer than <N> s ---`
2. Prozess bzw. Container wird beendet und entfernt; Runner und Admission werden freigegeben
3. Nachricht an den Client: `{ "type": "simulation_status", "status": "stopped" }`

Das Budget gehört zum Lauf, nicht zur einzelnen Pause; ein neuer Start beginnt mit vollem Budget.

---

## 8. Backend-Implementierung
Expand Down Expand Up @@ -411,6 +421,7 @@ useEffect(() => {
**Stress-Tests:**
- Schnelle Pause/Resume-Zyklen
- Pause länger als Original-Timeout
- Pause länger als das Pausenbudget (Lauf endet, Kapazität wird frei; auch mit echtem pausiertem Docker-Container)
- Serielle Eingaben versuchen während Pause (werden abgelehnt)

---
Expand Down
74 changes: 74 additions & 0 deletions tests/integration/paused-run-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Integration Test: pause budget of a real, frozen Docker sandbox
*
* `docker pause` freezes every process of the container, including the
* `timeout --signal=KILL` hard lifetime, so a paused sandbox would otherwise
* live (and hold its runner and admission) as long as its WebSocket does. The
* backend's pause budget must end the run and remove the frozen container.
*/

import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, test } from "vitest";
import { SandboxRunner } from "../../server/services/sandbox-runner";

const run = promisify(execFile);
const skipHeavy = process.env.SKIP_HEAVY_TESTS !== "0" && process.env.SKIP_HEAVY_TESTS !== "false";
const maybeDescribe = skipHeavy ? describe.skip : describe;
const PAUSE_BUDGET_MS = 3_000;

const SKETCH = String.raw`
void setup() { Serial.begin(115200); }
void loop() { Serial.println("tick"); delay(100); }
`.trim();

async function containerStatus(name: string): Promise<string> {
const { stdout } = await run("docker", ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Status}}"]);
return stdout.trim();
}

async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs: number, label: string): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!(await condition())) {
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`);
await new Promise((resolve) => setTimeout(resolve, 100));
}
}

maybeDescribe("paused run budget (real Docker)", () => {
let runner: SandboxRunner | undefined;

afterEach(async () => {
await runner?.stop();
});

test("ends a paused run after its pause budget and removes the frozen container", async () => {
runner = new SandboxRunner({ maxPausedMs: PAUSE_BUDGET_MS });
const outputs: string[] = [];
let exitCode: number | null | undefined;
const ready = await runner.runSketch({
code: SKETCH,
onOutput: (line) => outputs.push(line),
onError: () => {},
onExit: (code) => { exitCode = code; },
onCompileError: (error) => { throw new Error(`compile failed: ${error}`); },
timeoutSec: 60,
});
expect(ready).toBe(true);
const containerName = (runner as unknown as { executionState: { currentContainerName: string } })
.executionState.currentContainerName;
await waitFor(() => outputs.join("").includes("tick"), 15_000, "serial output");

expect(runner.pause()).toBe(true);
expect(await runner.controlResult).toBe(true);
expect(await containerStatus(containerName)).toContain("Paused");
const pausedAt = Date.now();

await waitFor(() => exitCode !== undefined, PAUSE_BUDGET_MS + 15_000, "end of the paused run");
expect(Date.now() - pausedAt).toBeGreaterThanOrEqual(PAUSE_BUDGET_MS - 100);
expect(outputs.join("")).toContain("paused longer than");
// The caller's onExit releases the run; this mirrors what the WebSocket session does.
await runner.stop();
await waitFor(async () => (await containerStatus(containerName)) === "", 15_000, "container removal");
}, 90_000);
});
Loading
Loading