From 67db9d3caad0dc63b47e9b6260c057f3880067d7 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Sat, 24 Oct 2026 10:54:37 +0200 Subject: [PATCH] fix(client): apply the worker deadlines in the visible tab only Plan 4.5 says the supervision checks a hidden tab only after its return, so that a throttled or frozen tab does not end a healthy run; the runtime did not look at the visibility. Silence now counts from the return and a deadline that runs out while hidden starts again. SSOT, plan and ADR 0009 (E12 thresholds, which P0.3 measured) name the deadlines. Co-Authored-By: Claude Opus 5.5 --- client/src/lib/runtime/browser-runtime.ts | 13 ++++- docs/adr/0009-browser-emulation-runtime.md | 15 ++++-- docs/proposals/browser-emulation-plan.md | 6 ++- ...t_function_definition_SimulationRuntime.md | 2 +- .../lib/runtime/browser-runtime.test.ts | 54 ++++++++++++++++++- 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/client/src/lib/runtime/browser-runtime.ts b/client/src/lib/runtime/browser-runtime.ts index 2cfc7dceb..6b747efc2 100644 --- a/client/src/lib/runtime/browser-runtime.ts +++ b/client/src/lib/runtime/browser-runtime.ts @@ -23,7 +23,9 @@ export interface RuntimeTimers { /** * Deadlines of the supervision (plan 4.5): the worker cannot watch itself, - * so the main thread ends a worker that does not answer in time. + * so the main thread ends a worker that does not answer in time. They apply + * in the visible tab: browsers throttle or freeze hidden tabs, so a deadline + * that runs out there starts again, and silence counts from the return. */ export interface SupervisionLimits { /** `load` until `ready`: includes fetching and starting the worker module. */ @@ -49,8 +51,12 @@ export interface BrowserRuntimeOptions { createWorker(): EmulatorWorker; timers?: RuntimeTimers; limits?: Partial; + /** Whether the tab is hidden; by default `document.visibilityState`. */ + isHidden?: () => boolean; } +const documentHidden = (): boolean => globalThis.document?.visibilityState === "hidden"; + const systemTimers: RuntimeTimers = { now: () => Date.now(), setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms), @@ -87,6 +93,7 @@ export class BrowserRuntime implements SimulationRuntime { private readonly listeners = new Set<(event: AnyRuntimeEvent) => void>(); private readonly timers: RuntimeTimers; private readonly limits: SupervisionLimits; + private readonly isHidden: () => boolean; private readonly gate = new RunGate(); private worker: EmulatorWorker | undefined; private phase: Phase = "idle"; @@ -108,6 +115,7 @@ export class BrowserRuntime implements SimulationRuntime { constructor(private readonly options: BrowserRuntimeOptions) { this.timers = options.timers ?? systemTimers; this.limits = { ...DEFAULT_SUPERVISION, ...options.limits }; + this.isHidden = options.isHidden ?? documentHidden; } start(request: StartRequest): void { @@ -344,13 +352,14 @@ export class BrowserRuntime implements SimulationRuntime { private expect(ms: number, message: string): void { this.clearDeadline(); - this.deadline = this.timers.setTimeout(() => this.fail(message), ms); + this.deadline = this.timers.setTimeout(() => (this.isHidden() ? this.expect(ms, message) : this.fail(message)), ms); } private armHeartbeat(): void { this.clearHeartbeat(); const check = (): void => { if (this.phase === "idle") return; + if (this.isHidden()) this.lastHeard = this.timers.now(); // While loading, the `ready` deadline governs: the worker module may take longer to arrive than a heartbeat period. if (this.phase !== "loading" && this.timers.now() - this.lastHeard >= this.limits.heartbeatMs) this.fail("the emulator stopped responding"); else this.heartbeatTimer = this.timers.setTimeout(check, this.limits.heartbeatMs / 2); diff --git a/docs/adr/0009-browser-emulation-runtime.md b/docs/adr/0009-browser-emulation-runtime.md index 707146ced..2f2f81106 100644 --- a/docs/adr/0009-browser-emulation-runtime.md +++ b/docs/adr/0009-browser-emulation-runtime.md @@ -7,9 +7,9 @@ (decisions E1–E5, E7–E14, E16) - Proposal: [browser-emulation.md](../proposals/browser-emulation.md) -> Draft. The decisions marked *pending spike* stay open until the named spike -> report exists; this ADR is accepted only after those reports and by the -> maintainers, not by an implementation session. +> Draft. Every spike report this ADR depends on exists (P0.2–P0.6); P0.3 is +> a synthetic measurement. The ADR is accepted by the maintainers, not by an +> implementation session. ## Context @@ -86,7 +86,14 @@ the server and little for each student's browser. the simulation slower and shows the real-time factor. A backlog above 100 ms is not caught up: the wall-clock reference is reset and emulation continues without skipping cycles. A worker ahead of time waits. Hidden - tabs keep running. *Thresholds of the worker watchdog pending spike P0.3.* + tabs keep running. The main thread supervises the worker: heartbeat about + every 500 ms, also paused; at most 3 s of silence, 15 s until `ready`, 5 s + for a confirmation, 2 s for `stop`. The deadlines apply in the visible tab + only, because browsers throttle or freeze hidden ones. Spike P0.3 + (synthetic, [report](../proposals/report-p0-3-vm-matrix.md)) found a + real-time factor of 1.00 and UI delays of at most 24 ms with one vCPU, far + within these limits; a measurement on a real school device is still + recommended. 12. **Board per sketch (E13).** The board is chosen per browser and workspace (localStorage). Examples may name a board (optional, default `uno`). 13. **Contracts (E14).** WebSocket `1.0.0` stays and serves only the host mock diff --git a/docs/proposals/browser-emulation-plan.md b/docs/proposals/browser-emulation-plan.md index bc0973393..af0c011ac 100644 --- a/docs/proposals/browser-emulation-plan.md +++ b/docs/proposals/browser-emulation-plan.md @@ -368,8 +368,10 @@ festhängender Worker kann kein `fault` mehr senden: - `error` und `messageerror` am Worker gelten als Ausfall. - Ein Worker mit geladener Maschine sendet etwa alle 500 ms `heartbeat`, auch - in der Pause. Bleibt der Heartbeat länger als 3 s aus oder die Bestätigung - eines Befehls länger als 2 s, gilt der Worker als ausgefallen. + in der Pause. Bleibt der Heartbeat länger als 3 s aus, `ready` länger als + 15 s, die Bestätigung eines Befehls länger als 5 s oder die von `stop` länger + als 2 s, gilt der Worker als ausgefallen (umgesetzt in P3.2, SSOT + SimulationRuntime). - Bei einem Ausfall beendet der Hauptthread den Worker mit `terminate()`, setzt den Lauf auf `stopped`, schreibt eine Systemzeile, meldet `operation_error` mit `SIMULATION_FAULT` und erzeugt beim nächsten Start einen neuen Worker. diff --git a/ssot/ssot_function_definition_SimulationRuntime.md b/ssot/ssot_function_definition_SimulationRuntime.md index 041518c4b..749bf26ec 100644 --- a/ssot/ssot_function_definition_SimulationRuntime.md +++ b/ssot/ssot_function_definition_SimulationRuntime.md @@ -94,7 +94,7 @@ Der Hauptthread vergibt eine aufsteigende `runId` (`RunGate.runIdFor`). `load`, - **Ablauf:** `start` sendet `load` (Flash übertragen, nicht kopiert), wartet auf `ready` und sendet erst dann `start` (der zweite Befehl beginnt eine neue `runId`; `ready` des Ladelaufs würde sonst verworfen). Der Zustand `running` gilt mit der Bestätigung. `pause`/`resume` wirken nur im Zustand `running`/`paused`; `reset` (neue `runId`) nur in einem der beiden. `stop` beendet den Lauf in der UI sofort (`RunGate.end`), die Antwort des Workers fällt weg. - **Ereignisse:** `serial` → `serial_output`; `pins` → `pin_state_batch` (je Pin `mode` 0/1/2, `value`, bei PWM `pwm`; unveränderte Zustände entfallen); `status` → `simulation_status`; `fault` → `operation_error` (`RUNTIME_FAILED`). Zusätzlich nur im Browser: `runtime_usage`, `runtime_diagnostic`, `runtime_telemetry` (der bestehende Handler ignoriert sie). - **Systemzeilen** wie beim Server: `--- Simulation paused ---`, `--- Simulation resumed ---`, `--- Simulation stopped ---`, `--- Simulation timeout (Ns) ---`, jeweils mit Zeilenende, als `serial_output`. -- **Fristen** (`SupervisionLimits`): `ready` 15 s nach `load`; Bestätigung von `start`/`pause`/`resume`/`reset` 5 s; `stop` 2 s (danach `terminate()`); Heartbeat höchstens 3 s Stille, sobald die Maschine geladen ist (der Worker sendet etwa alle 500 ms, auch in der Pause). Wer eine Frist reißt, wird beendet; die UI bekommt `operation_error`, eine Fehlerzeile und `stopped`. +- **Fristen** (`SupervisionLimits`): `ready` 15 s nach `load`; Bestätigung von `start`/`pause`/`resume`/`reset` 5 s; `stop` 2 s (danach `terminate()`); Heartbeat höchstens 3 s Stille, sobald die Maschine geladen ist (der Worker sendet etwa alle 500 ms, auch in der Pause). Wer eine Frist reißt, wird beendet; die UI bekommt `operation_error`, eine Fehlerzeile und `stopped`. Die Fristen gelten im sichtbaren Tab: Browser drosseln oder frieren verborgene Tabs ein, deshalb beginnt eine Frist, die dort abläuft, neu, und Stille zählt erst ab der Rückkehr (`document.visibilityState`). - `error` und `messageerror` des Workers enden den Lauf auf dieselbe Weise. - Serielle Eingabe geht in Stücken zu höchstens 1024 Zeichen an den genannten Port (`sendSerial(text, port = 0)`, Port 0–3; der Host-Mock kennt nur Port 0 und ignoriert andere), nur im Zustand `running`. Ein `serial`-Ereignis eines Ports 1–3 wird zu `serial_output` mit `port`. diff --git a/tests/client/lib/runtime/browser-runtime.test.ts b/tests/client/lib/runtime/browser-runtime.test.ts index 4811ecc1e..5af4db741 100644 --- a/tests/client/lib/runtime/browser-runtime.test.ts +++ b/tests/client/lib/runtime/browser-runtime.test.ts @@ -66,7 +66,8 @@ const artifact = { function setup() { const workers: FakeWorker[] = []; const timers = new FakeTimers(); - const runtime = new BrowserRuntime({ createWorker: () => { const worker = new FakeWorker(); workers.push(worker); return worker; }, timers }); + const tab = { hidden: false }; + const runtime = new BrowserRuntime({ createWorker: () => { const worker = new FakeWorker(); workers.push(worker); return worker; }, timers, isHidden: () => tab.hidden }); const events: AnyRuntimeEvent[] = []; runtime.subscribe((event) => events.push(event)); const ev = (runId: number, event: Omit, "runId">) => ({ ...event, runId }); @@ -82,7 +83,7 @@ function setup() { }; const statuses = () => events.filter((event) => event.type === "simulation_status").map((event) => (event as { status: string }).status); const lines = () => events.filter((event) => event.type === "serial_output").map((event) => (event as { data: string }).data); - return { runtime, workers, worker, timers, events, ev, startRunning, statuses, lines }; + return { runtime, workers, worker, timers, tab, events, ev, startRunning, statuses, lines }; } describe("BrowserRuntime (plan P3.2)", () => { @@ -502,6 +503,55 @@ describe("BrowserRuntime (plan P3.2)", () => { expect(statuses()).toEqual(["running", "paused", "stopped"]); }); + it("does not count silence in a hidden tab, and counts it again from the return (plan 4.5)", () => { + const { startRunning, worker, timers, tab, statuses } = setup(); + startRunning(); + + tab.hidden = true; // the browser throttles or freezes the tab: no heartbeat arrives + timers.advance(DEFAULT_SUPERVISION.heartbeatMs * 10); + expect(worker().terminated).toBe(false); + + tab.hidden = false; + timers.advance(DEFAULT_SUPERVISION.heartbeatMs / 2); + expect(worker().terminated).toBe(false); + timers.advance(DEFAULT_SUPERVISION.heartbeatMs * 2); + expect(worker().terminated).toBe(true); + expect(statuses()).toEqual(["running", "stopped"]); + }); + + it("starts a deadline that runs out in a hidden tab again instead of ending the run", () => { + const { runtime, startRunning, worker, ev, timers, tab, statuses } = setup(); + const runId = startRunning(); + + runtime.pause(); + tab.hidden = true; + timers.advance(DEFAULT_SUPERVISION.confirmMs * 3); + expect(worker().terminated).toBe(false); + + tab.hidden = false; + worker().emit(ev(runId, { type: "status", state: "paused" })); + for (let i = 0; i < 20; i++) { + timers.advance(500); + worker().emit(ev(runId, { type: "heartbeat", simTimeMs: 0 })); + } + expect(worker().terminated).toBe(false); + expect(statuses()).toEqual(["running", "paused"]); + }); + + it("ends an unconfirmed command once the tab is visible again", () => { + const { runtime, startRunning, worker, timers, tab, statuses } = setup(); + startRunning(); + + runtime.pause(); + tab.hidden = true; + timers.advance(DEFAULT_SUPERVISION.confirmMs * 3); + tab.hidden = false; + timers.advance(DEFAULT_SUPERVISION.confirmMs); + + expect(worker().terminated).toBe(true); + expect(statuses()).toEqual(["running", "stopped"]); + }); + it("starting again replaces the run: the old one stops, the new one gets a higher runId", () => { const { runtime, startRunning, worker, statuses } = setup(); const first = startRunning();