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
13 changes: 11 additions & 2 deletions client/src/lib/runtime/browser-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -49,8 +51,12 @@ export interface BrowserRuntimeOptions {
createWorker(): EmulatorWorker;
timers?: RuntimeTimers;
limits?: Partial<SupervisionLimits>;
/** 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),
Expand Down Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 11 additions & 4 deletions docs/adr/0009-browser-emulation-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/proposals/browser-emulation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion ssot/ssot_function_definition_SimulationRuntime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
54 changes: 52 additions & 2 deletions tests/client/lib/runtime/browser-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends RunEvent["type"]>(runId: number, event: Omit<Extract<RunEvent, { type: T }>, "runId">) => ({ ...event, runId });
Expand All @@ -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)", () => {
Expand Down Expand Up @@ -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();
Expand Down
Loading