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
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ services:
# Longer than the backend's 10 s internal shutdown deadline, so Docker
# never SIGKILLs the backend while it removes its sandbox containers.
stop_grace_period: 15s
# Bounded on-disk log: Docker's json-file default keeps every line forever.
logging:
driver: json-file
options:
max-size: "${UNOSIM_LOG_MAX_SIZE:-10m}"
max-file: "${UNOSIM_LOG_MAX_FILE:-5}"
group_add:
- "${DOCKER_GID:?Set DOCKER_GID to the numeric GID of /var/run/docker.sock}"
environment:
Expand Down
1 change: 1 addition & 0 deletions docs/INSTALL_SERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ muss diese Werte nicht ändern. Für größere Installationen können sie über
| `SANDBOX_MEMORY_MB` | Memory-Limit pro Sandbox |
| `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 |
| `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
7 changes: 7 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ development startup reject the flag.
- Each WebSocket connection is limited to 500 messages per second (burst 1000);
excess messages are dropped. At most 1 MiB of sketch input may wait in a
sketch's stdin. Connections that miss a heartbeat pong are closed.
- Sketch code controls the simulation's stdout and stderr. Plain runtime text
reaches the client at up to 50 lines per second after a burst of 200 lines
per run; further lines are dropped and counted, the client is told once and
the log reports drops aggregated (at most every 10 s), never per line. At
most 4 I/O-registry cycles per run are processed; the generated program
emits one. Compose rotates the backend log (`json-file`, 5 × 10 MB by
default).
- Compilers stop after 50 errors (`-fmax-errors`) and after 8 MiB of captured
output. At most 256 KiB of compiler diagnostics reach the client; the rest is
reported as omitted.
Expand Down
4 changes: 3 additions & 1 deletion server/routes/simulation.ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,10 @@ export function registerSimulationWebSocket(
outputBuffer.sendSerialOutputBatched(ws, line, isComplete);
};

// Runtime diagnostics are sketch-controlled and already bounded per run;
// a log entry per line would let a sketch flood the backend log.
const onError = (err: string) => {
logger.warn(`[Client WS][ERR] diagnostic received (${Buffer.byteLength(err)} bytes)`);
logger.debug(`[Client WS][ERR] diagnostic received (${Buffer.byteLength(err)} bytes)`);
outputBuffer.flushSerialOutputBuffer(ws);
sendMessageToClient(ws, {
type: WSMessageType.SERIAL_OUTPUT,
Expand Down
41 changes: 37 additions & 4 deletions server/services/registry-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,23 @@ interface RegistryManagerConfig {
* It provides debouncing to minimize WebSocket traffic and change detection
* to avoid sending duplicate registry data.
*/
/**
* The generated main() emits one registry per run. Registry markers are written
* to stdout/stderr, which sketch code controls, so further cycles are ignored:
* each would otherwise send, log and persist a registry.
*/
export const MAX_REGISTRY_COLLECTIONS_PER_RUN = 4;

export class RegistryManager {
private registry: IOPinRecord[] = [];
/** Runtime pin modes observed before the first IO_REGISTRY_START marker. */
private preCollectionRegistry: IOPinRecord[] = [];
private hasStartedCollection = false;
private isCollecting = false;
private collectionsThisRun = 0;
/** Inside a registry cycle beyond the per-run bound; its markers are ignored. */
private ignoringCollection = false;
private ignoredRegistryCycles = 0;
private registryHash = "";
private debounceTimer: NodeJS.Timeout | null = null;
private waitTimer: NodeJS.Timeout | null = null;
Expand Down Expand Up @@ -390,6 +401,11 @@ export class RegistryManager {
*/
startCollection(): void {
if (this.destroyed) return;
if (this.collectionsThisRun >= MAX_REGISTRY_COLLECTIONS_PER_RUN) {
this.ignoreCollection();
return;
}
this.collectionsThisRun++;
// Collection start (not logged individually — too noisy).

// ROBUSTNESS: Flush current registry state before clearing
Expand Down Expand Up @@ -436,6 +452,18 @@ export class RegistryManager {
}
}

/** Skips a registry cycle beyond the per-run bound; logs only the first one. */
private ignoreCollection(): void {
this.ignoringCollection = true;
this.isCollecting = false;
this.ignoredRegistryCycles++;
if (this.ignoredRegistryCycles === 1) {
this.logger.warn(
`Ignoring further I/O registry cycles of this run (bound: ${MAX_REGISTRY_COLLECTIONS_PER_RUN})`,
);
}
}

/**
* Detect and annotate conflicts on a single pin record in-place.
*
Expand All @@ -455,10 +483,8 @@ export class RegistryManager {
*/
addPin(pinRecord: IOPinRecord): void {
if (this.destroyed) return;
if (!this.isCollecting) {
this.logger.warn("Received pin record while not collecting - ignoring");
return;
}
// Sketch-controlled marker outside a collection: ignored without a log per line.
if (!this.isCollecting) return;
// Individual pin additions are not logged (20 per start is too noisy).
const existingIndex = this.registry.findIndex((p) => p.pin === pinRecord.pin);
if (existingIndex >= 0) {
Expand Down Expand Up @@ -501,6 +527,10 @@ export class RegistryManager {
*/
finishCollection(): void {
if (this.destroyed) return;
if (this.ignoringCollection) {
this.ignoringCollection = false;
return;
}
this.logger.debug(
`Registry collection complete: ${this.registry.length} pins`,
);
Expand Down Expand Up @@ -720,6 +750,9 @@ export class RegistryManager {
* Reset the manager state (called when simulation stops)
*/
reset(): void {
this.collectionsThisRun = 0;
this.ignoringCollection = false;
this.ignoredRegistryCycles = 0;
this.registry = [];
this.preCollectionRegistry = [];
this.hasStartedCollection = false;
Expand Down
21 changes: 6 additions & 15 deletions server/services/sandbox-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { StreamHandler } from "./sandbox/stream-handler";
import { FilesystemHelper } from "./sandbox/filesystem-helper";
import { ExecutionManager, type ExecutionState, SimulationState, SANDBOX_CONFIG } from "./sandbox/execution-manager";
import { cleanupExecutionContainer, flushMessageQueue } from "./sandbox/execution-phases/cleanup-phase";
import { delegateParsedLineToStreamHandler } from "./sandbox/execution-phases/stream-phase";
import { config } from "../config";

export class SandboxRunner {
Expand Down Expand Up @@ -89,21 +90,11 @@ export class SandboxRunner {
this.processController,
stderrParser,
this.timeoutManager,
(parsed, callbacks) => {
// Delegate parsed line to stream handler
if (this.executionState) {
const streamState = {
pinStateBatcher: this.executionState.pinStateBatcher,
serialOutputBatcher: this.executionState.serialOutputBatcher,
backpressurePaused: this.executionState.backpressurePaused,
isPaused: this.executionState.state === SimulationState.PAUSED,
baudrate: this.executionState.baudrate,
registryManager: this.registryManager,
};
this.streamHandler.handleParsedLine(parsed, streamState, callbacks);
this.executionState.backpressurePaused = streamState.backpressurePaused;
}
},
// Same routing (and run budget) as the local path.
(parsed, callbacks) => delegateParsedLineToStreamHandler(parsed, this.executionState, callbacks, {
registryManager: this.registryManager,
streamHandler: this.streamHandler,
}),
() => { this.executionState.terminationRequested = true; },
);
this.streamHandler = new StreamHandler(this.processController);
Expand Down
4 changes: 4 additions & 0 deletions server/services/sandbox/execution-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { normalizeBaudrate, normalizeSimulationTimeout } from "@shared/input-lim
import { canTransition } from "../simulation-state-machine";
import { createProcessExecutionPort, type ProcessExecution } from "../process-execution-port";
import { OutputCollector } from "../output-collector";
import { RuntimeTextLimiter } from "./runtime-text-limiter";
import { flushMessageQueue, flushBatchers, cleanupExecutionContainer } from "./execution-phases/cleanup-phase";
import { scheduleExecutionTimeout } from "./execution-phases/timeout-phase";
import { createStreamCallbacks, delegateParsedLineToStreamHandler, handleStderrFallbackData } from "./execution-phases/stream-phase";
Expand Down Expand Up @@ -138,6 +139,8 @@ export interface ExecutionState {
runGeneration?: number;
/** Aborted when the current run is stopped or superseded. */
runAbort?: AbortController | null;
/** Budget of the current run for sketch-controlled runtime text lines. */
runtimeTextLimiter?: RuntimeTextLimiter | null;
}

/**
Expand Down Expand Up @@ -384,6 +387,7 @@ export class ExecutionManager {
state.isSendingOutput = false;
state.totalOutputBytes = 0;
state.outputCollector = new OutputCollector(SANDBOX_CONFIG.maxOutputBytes);
state.runtimeTextLimiter = new RuntimeTextLimiter();
state.onOutputCallback = onOutput;
state.ioRegistryCallback = onIORegistry;
}
Expand Down
1 change: 1 addition & 0 deletions server/services/sandbox/execution-phases/stream-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function delegateParsedLineToStreamHandler(
isPaused: state.state === "paused",
baudrate: state.baudrate,
registryManager: deps.registryManager,
runtimeTextLimiter: state.runtimeTextLimiter ?? undefined,
};

deps.streamHandler.handleParsedLine(parsed, streamState, callbacks);
Expand Down
87 changes: 87 additions & 0 deletions server/services/sandbox/runtime-text-limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Logger } from "@shared/logger";

/** Sustained runtime text lines per second that reach the client. */
export const RUNTIME_TEXT_LINES_PER_SECOND = 50;
/** Lines a run may emit at once before the sustained rate applies. */
export const RUNTIME_TEXT_LINE_BURST = 200;
/** A run that keeps dropping lines reports the count at most this often. */
export const RUNTIME_TEXT_DROP_LOG_INTERVAL_MS = 10_000;
/** Sent to the client once per run, when the first line is dropped. */
export const RUNTIME_TEXT_LIMIT_NOTICE =
`Runtime output rate limit exceeded (${RUNTIME_TEXT_LINES_PER_SECOND} lines/s); further lines are dropped`;

export type RuntimeTextDecision = "forward" | "notice" | "drop";

interface RuntimeTextLimiterOptions {
linesPerSecond?: number;
burst?: number;
logIntervalMs?: number;
now?: () => number;
logger?: Pick<Logger, "warn">;
}

/**
* Budget of one run for runtime text lines: stdout/stderr output that is neither
* a simulator protocol marker nor serial output. Sketch code controls these
* lines and can write them far faster than any client reads them, so a token
* bucket admits a burst and a sustained rate; every further line is dropped and
* counted. The client is told once, the log only aggregated.
*/
export class RuntimeTextLimiter {
private readonly linesPerSecond: number;
private readonly burst: number;
private readonly logIntervalMs: number;
private readonly now: () => number;
private readonly logger: Pick<Logger, "warn">;
private tokens: number;
private refilledAt: number;
private dropped = 0;
private droppedSinceLog = 0;
private loggedAt = 0;

constructor(options: RuntimeTextLimiterOptions = {}) {
this.linesPerSecond = options.linesPerSecond ?? RUNTIME_TEXT_LINES_PER_SECOND;
this.burst = options.burst ?? RUNTIME_TEXT_LINE_BURST;
this.logIntervalMs = options.logIntervalMs ?? RUNTIME_TEXT_DROP_LOG_INTERVAL_MS;
this.now = options.now ?? Date.now;
this.logger = options.logger ?? new Logger("RuntimeTextLimiter");
this.tokens = this.burst;
this.refilledAt = this.now();
}

/** Decides one line: forward it, replace it by the one-time notice, or drop it. */
admit(): RuntimeTextDecision {
const now = this.now();
this.refill(now);
if (this.tokens >= 1) {
this.tokens -= 1;
return "forward";
}
this.dropped += 1;
this.droppedSinceLog += 1;
if (this.dropped === 1) {
this.logDrops(now, "Runtime output rate limit exceeded; dropping excess lines of this run");
return "notice";
}
if (now - this.loggedAt >= this.logIntervalMs) {
this.logDrops(now, "Runtime output still exceeds its rate limit");
}
return "drop";
}

get droppedLines(): number {
return this.dropped;
}

private refill(now: number): void {
const elapsedMs = Math.max(0, now - this.refilledAt);
this.refilledAt = now;
this.tokens = Math.min(this.burst, this.tokens + (elapsedMs * this.linesPerSecond) / 1_000);
}

private logDrops(now: number, message: string): void {
this.logger.warn(`${message} (${this.droppedSinceLog} dropped since last report, ${this.dropped} total)`);
this.loggedAt = now;
this.droppedSinceLog = 0;
}
}
19 changes: 15 additions & 4 deletions server/services/sandbox/stream-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { SerialOutputBatcher } from "../serial-output-batcher";
import type { RegistryManager } from "../registry-manager";
import type { ParsedStderrOutput } from "../arduino-output-parser";
import { Logger } from "@shared/logger";
import { RUNTIME_TEXT_LIMIT_NOTICE, type RuntimeTextLimiter } from "./runtime-text-limiter";
import type { PinStateChange } from "@shared/types/arduino.types";

interface StreamHandlerCallbacks {
Expand All @@ -24,6 +25,8 @@ interface StreamHandlerState {
isPaused: boolean;
baudrate: number;
registryManager: RegistryManager;
/** Budget of the current run for runtime text lines; absent means unbounded. */
runtimeTextLimiter?: Pick<RuntimeTextLimiter, "admit">;
}

export class StreamHandler {
Expand Down Expand Up @@ -119,11 +122,19 @@ export class StreamHandler {
break;

case "text":
if (callbacks.onError) {
this.logger.warn(`[STDERR] diagnostic received (${Buffer.byteLength(parsed.line)} bytes)`);
callbacks.onError(parsed.line);
}
this.handleRuntimeText(parsed.line, state, callbacks);
break;
}
}

/**
* Runtime text is sketch-controlled. The run's limiter bounds what reaches the
* client; nothing is logged per line (the limiter logs drops aggregated).
*/
private handleRuntimeText(line: string, state: StreamHandlerState, callbacks: StreamHandlerCallbacks): void {
if (!callbacks.onError) return;
const decision = state.runtimeTextLimiter?.admit() ?? "forward";
if (decision === "forward") callbacks.onError(line);
else if (decision === "notice") callbacks.onError(RUNTIME_TEXT_LIMIT_NOTICE);
}
}
Loading
Loading