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
8 changes: 5 additions & 3 deletions docs/UNOSIM_REFACTORING_OPL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Abweichungen werden unter „Reihenfolge-Änderungen“ begründet.
| R2 | `lastCompiledCode`-Fallback und Sketch-CRUD pro Identität absichern (nicht entfernen) | Isolation | S2, S3 [Code] | mittel–hoch | Nutzerisolation ohne Bruch des REST-/WS-Vertrags | klein | – | fix/isolate-legacy-global-state | DONE | RED→GREEN: `simulation-last-compiled-code.test.ts` (fremder Code wird nie ausgeführt, eigener Fallback bleibt), `last-compiled-code-store.test.ts`, `sketches.routes.test.ts` (Seed read-only, fremde Sketches 404); zwei Bestandstests an Identität angepasst (Begründung im PR) | PR-Merge siehe Verlauf |
| R6 | Einheitlicher Compile-Hash inkl. Header (Worker-Identität); kein Binary in REST-Payload/LRU | Korrektheit/Performance/Security | A4, A5, S1-INCBIN [Code] | mittel | keine veralteten Cache-Treffer; Worker und direkter Pfad teilen Cache-Einträge; Antwort 59.653 → 562 Byte (Blink-Sketch, Worker-Pfad) | klein | – | fix/compile-cache-key-and-payload | DONE | RED→GREEN: `arduino-compiler-cache-key.test.ts` (Header-Änderung kompiliert neu; gleiche Identität wie der Worker), `compiler-binary-payload.test.ts` (frisch, gecacht, LRU ohne `binary`) | PR-Merge siehe Verlauf |
| R7 | Globaler API-Limiter im Gateway-Modus nach authentifiziertem `subject` statt IP | Skalierung | P1 [Code] | mittel (topologieabhängig) | keine kursweiten 429 hinter Campus-NAT | klein | – | fix/api-rate-limit-identity | DONE | RED→GREEN: `api-rate-limit-key.test.ts` (zwei Subjects hinter einer IP mit getrenntem Budget; ungültiges Gateway-Secret und Local-Modus bleiben pro IP) | PR-Merge siehe Verlauf |
| R3a | Lauf-Generation + Abbruch im Runner-Lifecycle | Isolation/Lifecycle | S4 [plausibel] | hoch | keine fremde Ausgabe, keine verwaisten Container | mittel | – | fix/runner-run-generation | OPEN | deterministischer Race-Test A wartet → A stoppt → B übernimmt | – |
| R3a | Lauf-Generation + Abbruch im Runner-Lifecycle; `ProcessController` leitet nur Events des aktuellen Kindprozesses weiter | Isolation/Lifecycle | S4, S4-CHILD [Code, deterministisch reproduziert] | hoch | keine fremde Ausgabe, kein Start mit fremdem/aufgeräumtem Verzeichnis, kein Eingriff in den Container des Nachfolgers | mittel | – | fix/runner-run-generation | DONE | RED→GREEN: `runner-reuse-race.test.ts` (echter Pool/Runner/ExecutionManager/Semaphore; vorher startete A mit eigenem, bereits gelöschtem Verzeichnis für B), `docker-compile-semaphore-abort.test.ts`, `process-controller-stale-child.test.ts`; Unit 2714, Docker-Integration 26/26 | PR-Merge siehe Verlauf |
| R3b | Reset-Ownership in `runner.resetForReuse()` | Kapselung | A6 [Code] | mittel | Reset an einer Stelle | mittel | R3a | refactor/runner-reset-ownership | OPEN | Pool-/Isolationstests | – |
| R4a | Orphan-Sweep für Sandbox-Container | Lifecycle | A7 [Code] | mittel | Ressourcen nach Crash frei | klein–mittel | R3a | fix/sandbox-orphan-sweep | OPEN | Sweep-Test (Fake-Executor), Docker-Gate | – |
| R4b | WS-Heartbeat, Serialisierung pro Verbindung, Nachrichtenlimit | Lifecycle | A7 [Code] | mittel | halb offene Verbindungen und Floods begrenzt | mittel | – | fix/ws-connection-lifecycle | OPEN | Lifecycle-Tests mit Fake-Timern | – |
Expand All @@ -39,7 +39,8 @@ Abweichungen werden unter „Reihenfolge-Änderungen“ begründet.
| S1-ASM | GAS `.include` im Inline-Assembler gibt die ersten ~10 Zeichen je Zeile einer beliebigen Datei als Fehlermeldung aus; per String-Konkatenation nicht robust textuell filterbar | Security | [Code] verifiziert (Marker-Präfix in der Assemblermeldung) | mittel (Teilinhalt; Default-Deployment hält Secrets nur in Env) | – | groß | – | – | BLOCKED_DECISION | – | Robuster Fix = REST-Compile ohne Zugriff auf Backend-Dateien (Sandbox/Namespace); Architekturentscheidung |
| S2 | Globaler `lastCompiledCode` | Isolation | [Code] bestätigt | mittel–hoch | – | – | – | R2 | DONE | WS-Test zweier Subjects | Fallback jetzt pro Subject (LRU, 1000 Subjects) |
| S3 | Sketch-CRUD ohne Besitzer | Isolation | [Code] bestätigt | mittel | – | – | – | R2 | DONE | Route-Test zweier Identitäten | Schreiben nur auf eigene Sketches, Seed schreibgeschützt |
| S4 | Runner-Reuse-Race | Isolation/Lifecycle | [plausibel] | hoch | – | – | – | R3a | OPEN | Race-Test | – |
| S4 | Runner-Reuse-Race | Isolation/Lifecycle | [Code] deterministisch reproduziert | hoch | – | – | – | R3a | DONE | Race-Test | Restfenster: Stop genau während `spawn` (ms); verwaiste Container fängt R4a |
| S4-CHILD | `ProcessController` leitet stdout/stderr/close/error eines ersetzten Kindprozesses an die Listener des nächsten Laufs weiter | Isolation/Lifecycle | [Code] reproduziert (neu bei R3a-Verifikation) | mittel | – | – | – | R3a | DONE | `process-controller-stale-child.test.ts` | – |
| A1 | Überlappende Concurrency-Mechanismen, vermischte Statusmetriken | Concurrency | [Code] | mittel | – | – | – | R5b | OPEN | – | – |
| A2 | Gatekeeper-TTL ohne Queue-Fortsetzung, tote Cache-Locks | Concurrency | [Code] | mittel | – | – | – | R5a | OPEN | – | – |
| A3 | Fallback umgeht Lastgrenze, keine Worker-Recovery, unbegrenzte Queue | Concurrency | [Code] | mittel | – | – | – | R5b | OPEN | – | – |
Expand Down Expand Up @@ -81,4 +82,5 @@ Abweichungen werden unter „Reihenfolge-Änderungen“ begründet.
| #160 | R1: Include-Grenze für den REST-Compiler | `1c2995d9` | PR-CI 5/5 grün; Post-Merge-CI von #159 grün |
| #161 | R2: Per-Identity-Isolation von Code-Fallback und Sketch-CRUD | `18c7d89b` | PR-CI 5/5 grün; Post-Merge-CI von #160 grün |
| #162 | R6: Einheitlicher Compile-Hash, kein Binary in der REST-Antwort | `9716910d` | PR-CI 5/5 grün; Post-Merge-CI von #161 grün |
| R7 | Globaler API-Limiter nach Gateway-Subject | – | – |
| #163 | R7: Globaler API-Limiter nach Gateway-Subject | `7a39d554` | PR-CI 5/5 grün; Post-Merge-CI von #162 grün |
| R3a | Lauf-Generation, abbrechbares Start-Slot-Warten, Kindprozess-Guard | – | – |
16 changes: 14 additions & 2 deletions server/services/process-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,12 @@ export class ProcessController implements IProcessController {
/* ignore */
}

// attach existing listeners (guard for nullability)
// attach existing listeners (guard for nullability). Every forwarder checks
// that its child is still the current one: a later spawn belongs to another
// run, and the previous child's late output or close must not reach it.
const child = this.proc;
this.proc?.stdout?.on("data", (d: Buffer) => {
if (this.proc !== child) return;
this.stdoutListeners.forEach((cb) => cb(d));
});

Expand All @@ -110,8 +114,10 @@ export class ProcessController implements IProcessController {

private _setupStderrHandling(createInterface: (options: any) => import("node:readline").Interface): void {
if (!this.proc?.stderr) return;
const child = this.proc;

this.proc.stderr.on("data", (d: Buffer) => {
if (this.proc !== child) return;
if (process.env.NODE_ENV === "test") {
// convert low-level wrapper events into buffered debug logs
try {
Expand All @@ -135,18 +141,24 @@ export class ProcessController implements IProcessController {
crlfDelay: Infinity,
});
this.stderrReadline.on("line", (line: string) => {
if (this.proc !== child) return;
this.stderrLineListeners.forEach((cb) => cb(line));
});
}
}

private _setupProcessEventListeners(): void {
if (!this.proc) return;
const child = this.proc;

this.proc.on("close", (code: number | null) => {
if (this.proc !== child) return;
this.closeListeners.forEach((cb) => cb(code));
});
this.proc.on("error", (err: Error) => this.errorListeners.forEach((cb) => cb(err)));
this.proc.on("error", (err: Error) => {
if (this.proc !== child) return;
this.errorListeners.forEach((cb) => cb(err));
});
}

onStdout(cb: StdDataCb) {
Expand Down
2 changes: 2 additions & 0 deletions server/services/sandbox-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,8 @@ export class SandboxRunner {

async stop(): Promise<void> {
const s = this.executionState;
// Cancels a run that is still preparing or waiting for a start slot.
s.runAbort?.abort();
if (this.state === SimulationState.STOPPED || s.processKilled) return;
this.state = SimulationState.STOPPED;
s.processKilled = true;
Expand Down
23 changes: 19 additions & 4 deletions server/services/sandbox/docker-compile-semaphore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,40 @@ export class SandboxStartSemaphore {
*
* @param onQueued Optional callback invoked exactly once when this caller is
* placed in the queue (i.e. no slot is immediately available).
* @param signal Optional cancellation: an aborted waiter leaves the queue
* and never takes a slot.
* @returns A release function. Must be called exactly once.
*/
acquire(onQueued?: () => void, timeoutMs = 60_000): Promise<() => void> {
acquire(onQueued?: () => void, timeoutMs = 60_000, signal?: AbortSignal): Promise<() => void> {
return new Promise<() => void>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Sandbox start slot acquire cancelled"));
return;
}
let settled = false;
let attempt: () => void;
const timer = setTimeout(() => {
const leaveQueue = (error: Error) => {
if (settled) return;
const index = this.queue.findIndex((entry) => entry.attempt === attempt);
if (index !== -1) this.queue.splice(index, 1);
settled = true;
reject(new Error(`Sandbox start slot timeout after ${timeoutMs}ms`));
}, timeoutMs);
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(error);
};
const onAbort = () => leaveQueue(new Error("Sandbox start slot acquire cancelled"));
const timer = setTimeout(
() => leaveQueue(new Error(`Sandbox start slot timeout after ${timeoutMs}ms`)),
timeoutMs,
);
signal?.addEventListener("abort", onAbort, { once: true });

attempt = () => {
if (settled) return;
if (this._active < this.max) {
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
this._active++;
resolve(this._makeRelease());
} else {
Expand Down
Loading
Loading