Reaction: der Gleichlauf rechnet ohne Uhrenvergleich

Filipe: „die videos muessen perfekt laufen wenn ich die reaktions
mache."

GEMESSEN, NICHT VERMUTET. mess-reaktion hat einen neuen Abschnitt:
Er liest den Spielstand am ECHTEN <video>-Element in beiden Browsern,
rechnet beide auf denselben Augenblick um und sagt, wie weit Host und
Zuschauerin auseinanderliegen. Die vorhandene Pruefung fragte nur, ob
Start und Stopp ANKOMMEN -- zwei Videos koennen beide laufen und
trotzdem zwanzig Sekunden auseinander sein.

                        vorher      nachher
  Im Lauf                0,12 s     -0,04 s
  Wer dazukommt          6,91 s      0,26 s
  Uhr des Geraets 30 s vor  -29,97 s      0,26 s

DER SCHLIMMSTE FUND: DIE UHR DES TELEFONS

Der Zuschauer rechnete `Date.now() - stand.gesendet`. `gesendet` kam
vom SERVER, `Date.now()` vom Geraet -- zwei Uhren, die nie jemand
verglichen hat. Wessen Telefon dreissig Sekunden vorgeht, schaute
29,97 s weiter als alle anderen. Kein langsames Netz, kein schwaches
Handy. Oertlich faellt das nie auf: Auf einem Rechner sind beide
Uhren dieselbe.

Jetzt geht ein ALTER hinaus statt einer Uhrzeit -- die Differenz
zweier SERVERzeiten. Der Empfaenger braucht dafuer gar keine Uhr,
nur eine Stoppuhr (`performance.now()`), und die haelt Zeitumstellung
und Aufwachen aus dem Ruhezustand aus.

WEITER

* WER DAZUKOMMT, STEIGT RICHTIG EIN. Der gespeicherte Stand war so
  alt wie der letzte Takt des Hosts; neue Spalte `sekunde_am` sagt,
  wann er galt.
* DAS TEMPO GEHT IN DIE HOCHRECHNUNG EIN. Bei 2x laeuft die Videouhr
  doppelt so schnell -- das fehlte ganz.
* JEDER RECHNET ALLE ZWEI SEKUNDEN SELBST NACH statt nur auf Zuruf.
  Wer stockte, hing bis zu fuenf Sekunden hinterher.
* DER VORLAUF NACH EINEM SPRUNG WIRD GEREGELT, nicht geschaetzt --
  gemessen wird der Rest, nicht die Ladezeit (siehe unten).
* PUFFERN IST AUCH IN DER TRANSPORTLEISTE KEINE PAUSE. Der
  Start-Stopp-Knopf rief beim Puffern `playVideo()` -- wer Pause
  drueckte, bekam „weiter". Lampe und Knopfbild flackerten bei jedem
  Stocken. Die Lektion vom 29.09. war nur an einer Stelle eingeloest.
* DAS TEMPO MELDET UEBER `hostMelden()` statt ein zweites Mal von
  Hand -- es hielt Puffern fuer Stillstand und hielt damit beim
  Umschalten alle an.
* EIN VIDEO-RUNDRUF STATT DREI ABSCHRIFTEN. Beim Einbau hatte ich
  zuerst nur eine nachgezogen; das Tauschen der beiden Videos haette
  still die alte Rechnung weiterverschickt.

DREI EIGENE FEHLER UNTERWEGS, alle im Text festgehalten

1. ZWEI FUNKTIONEN HIESSEN `springen`. Meine „spring AUF die Stelle"
   wurde von der vorhandenen „spring UM so viele Sekunden"
   ueberschrieben -- lautlos, rueckwirkend fuer die ganze Datei. Ein
   Zuschauer bei Sekunde 100, dem „geh auf 101" gesagt wird, waere
   bei 201 gelandet. DREI BROWSERLAEUFE HABEN ES NICHT GEFUNDEN:
   oertlich blieb der Abstand unter der Sprungschwelle, die Zeile
   lief kein einziges Mal. Gefunden hat es erst eine Frage an die
   ganze Datei -- die steht jetzt als Pruefung drin und wurde
   nachgestellt (rot).
2. DER VORLAUF LERNTE NUR NACH OBEN. Gemessen wurde die Ladezeit
   nach einem Sprung; ein Sprung in geladenes Material dauert aber
   gar nicht, meldet nichts und lehrt nichts. Ergebnis: -0,95 s
   stabil. Jetzt wird das ERGEBNIS geregelt statt der Ursache.
3. DER AUSREISSER VON 39,84 s WAR MEINE EIGENE MESSUNG -- der Block
   davor schickt eine erfundene Position. Jetzt wird abgeklungen und
   in Messreihenfolge ausgegeben.

GEPRUEFT
pruef-reaktion 421/0 (14 neue, darunter der Riegel gegen die
Rueckkehr der Uhrenrechnung und die Wache gegen doppelte
Funktionsnamen -- beide nachgestellt), mess-reaktion ohne ein
einziges ACHTUNG, pruef-buehne 38/0, pruef-struktur, pruef-
zwischenspeicher 34/0.

Schemaaenderung: ADD COLUMN sekunde_am. Datenbank vorher gesichert
und geprueft (integrity_check ok, 20 Personen).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-30 15:45:48 +02:00
co-authored by Claude Opus 5
parent d5c84c7608
commit a3ec558127
50 changed files with 1483 additions and 719 deletions
+160
View File
@@ -1660,6 +1660,166 @@ melde("=== 17. Tempo, Anordnung und das zweite Video ===");
method: "POST", keks: k.admin, rumpf: { tempo: 1, laeuft: true, sekunde: 25 },
});
/* =====================================================================
DER GLEICHLAUF: WIE ALT IST DER STAND? (30.09.2026)
Bis heute schickte der Server seine UHRZEIT mit (`gesendet`), und
der Browser rechnete `Date.now() - gesendet`. Das vergleicht die
Uhr des Servers mit der Uhr des Telefons -- zwei Uhren, die nie
jemand abgeglichen hat.
Gemessen am echten Browser (mess-reaktion): Ein Geraet, dessen Uhr
30 Sekunden vorgeht, spielte das Video 29,97 Sekunden weiter als
alle anderen. Und wer mitten in der Sendung dazukam, stieg 6,91
Sekunden hinter allen ein, weil der gespeicherte Stand so alt war
wie der letzte Takt des Hosts.
Jetzt geht ein ALTER hinaus -- die Differenz zweier SERVERzeiten.
Der Empfaenger braucht dafuer keine eigene Uhr.
===================================================================== */
{
const jetztStellen = (sek, alter) => roh(CREW, "/workspace/api/reaktion/video", {
method: "POST", keks: k.admin, rumpf: { laeuft: true, sekunde: sek, alter },
});
const holen = async () => (await roh(CREW, "/workspace/api/reaktion", { keks: k.gast })).j;
await jetztStellen(100, 0);
const frisch = await holen();
ok(Number.isFinite(frisch?.alter) && frisch.alter >= 0 && frisch.alter < 500,
`ein frischer Stand ist fast null Millisekunden alt (${frisch?.alter})`);
/* ES IST WIRKLICH EIN ALTER UND KEINE FESTE ZAHL. Ohne diese
Zeile hiesse „0 ms" oben womoeglich nur, dass dort immer 0
steht -- und dann rechnete niemand je etwas dazu. */
await new Promise((r) => setTimeout(r, 1300));
const gereift = await holen();
ok(gereift?.alter >= 1200 && gereift?.alter <= 2600,
`nach 1,3 s ist derselbe Stand 1,3 s alt (${gereift?.alter} ms)`);
/* UND DIE RECHNUNG GEHT AUF. Das ist der eigentliche Zweck:
sekunde + alter muss die Stelle ergeben, an der die Sendung
jetzt steht. */
const stelle = (gereift.sekunde || 0) + (gereift.alter || 0) / 1000;
ok(Math.abs(stelle - 101.3) < 0.4,
`daraus folgt die richtige Stelle: ${stelle.toFixed(2)} s (erwartet ~101,3)`);
/* ---- Was der Host ueber seine eigene Ablesung sagt ----------
Er misst oertlich, wie alt sie beim Absenden war. Das gehoert
dazugerechnet -- sonst saesse jeder Zuschauer genau um diese
Spanne zu weit hinten. */
await jetztStellen(200, 3000);
const mitAlter = await holen();
ok(mitAlter?.alter >= 3000 && mitAlter?.alter < 3600,
`eine 3 s alte Ablesung kommt auch als 3 s alt an (${mitAlter?.alter})`);
/* GEDECKELT, WEIL ES VOM BROWSER KOMMT. Was hereinkommt, wird
geprueft und nicht geglaubt -- sonst setzte eine einzige
falsche Zahl alle Zuschauer an eine erfundene Stelle. */
await jetztStellen(300, 999999);
const frech = await holen();
ok(frech?.alter !== null && frech.alter <= 5600,
`ein unsinniges Alter wird auf 5 s gedeckelt (${frech?.alter})`);
await jetztStellen(310, -8000);
const negativ = await holen();
ok(negativ?.alter !== null && negativ.alter >= 0 && negativ.alter < 600,
`ein negatives Alter gilt als null (${negativ?.alter})`);
/* ---- Der dritte Ausgang: „weiss ich nicht" -------------------
Ein Stand ohne Zeitstempel (alte Zeile, frisch aufgeraeumte
Sendung) darf NICHT als „null Millisekunden alt" durchgehen.
`null` heisst beim Empfaenger „nicht hochrechnen"; eine 0
hiesse „taufrisch" und schickte alle an eine alte Stelle. */
/* EIGENES HANDLE. Das vom Anfang der Datei ist laengst
geschlossen (Zeile 108) -- die Pruefung legt die Leute an und
ueberlaesst die Datenbank danach dem Server. */
const { DatabaseSync: DB3 } = await import("node:sqlite");
const d3 = new DB3(process.env.WORKSPACE_DB);
d3.prepare("UPDATE reaktion SET sekunde_am = 0 WHERE id = 1").run();
const ohne = await holen();
ok(ohne?.alter === null,
`ohne Zeitstempel ist das Alter ausdruecklich unbekannt (${JSON.stringify(ohne?.alter)})`);
/* UND EIN UNMOEGLICH ALTER STAND EBENSO. Eine Zeile aus der
vorigen Woche wuerde sonst „604800000 ms" melden, und der
Browser rechnete sie brav dazu. */
d3.prepare("UPDATE reaktion SET sekunde_am = ? WHERE id = 1")
.run(Date.now() - 7 * 86400000);
const uralt = await holen();
ok(uralt?.alter === null,
`ein eine Woche alter Stand gilt als unbekannt (${JSON.stringify(uralt?.alter)})`);
d3.close();
/* Und wieder in einen brauchbaren Zustand -- die Pruefungen
danach sollen keinen Scherbenhaufen vorfinden. */
await jetztStellen(0, 0);
/* ---- UND EIN RIEGEL GEGEN DIE RUECKKEHR ---------------------
Die alte Zeile sah harmlos aus:
const unterwegs = Date.now() - stand.gesendet;
Der Kommentar daneben erklaerte sogar richtig, warum die
Laufzeit dazugehoert. Nur verglich sie die Uhr des Servers mit
der Uhr des Telefons -- und das faellt oertlich NIE auf: Auf
einem Rechner sind beide Uhren dieselbe. Erst als jemand die
Uhr absichtlich verstellt hat, kam es heraus.
GEMESSEN WIRD AM KOMMENTARFREIEN TEXT. Im Kopf der Datei steht
die alte Zeile als Beispiel, und ein Beispiel im Kommentar ist
kein Programm. Genau diese Falle hat mich heute frueh beim
Abzeichen schon einmal erwischt. */
const { jsOhneKommentar: ohneK } = await import("./helfer-ohne-kommentar.mjs");
const rjs = ohneK(readFileSync(
join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8"));
ok(!/\bgesendet\b/.test(rjs),
"der Browser rechnet nicht mehr mit der Uhrzeit des Servers");
const kern = rjs.slice(rjs.indexOf("function sollJetzt"));
const rumpf = kern.slice(0, kern.indexOf("\n }"));
ok(rumpf.length > 40 && /performance\.now\(\)/.test(rumpf),
"die Soll-Stelle wird mit der Stoppuhr gerechnet, nicht mit dem Kalender");
ok(/anker\.tempo/.test(rumpf),
"und das Tempo geht in die Hochrechnung ein (bei 2x laeuft die Videouhr doppelt)");
ok(!/Date\.now\(\)/.test(rumpf),
"in der Rechnung selbst steckt keine Tagesuhr mehr");
/* GEGENPROBE: Die Suche liest wirklich etwas. Ohne sie hiessen
die drei Zeilen darueber womoeglich nur, dass `rumpf` leer
ist -- und ein leerer Text besteht jede Verbotspruefung. */
ok(/const her =/.test(rumpf),
`die Suche findet den Rumpf von sollJetzt (${rumpf.length} Zeichen)`);
/* ---- KEIN NAME ZWEIMAL ---------------------------------------
`reaktion.js` ist ein einziger Rumpf von 4400 Zeilen. Wer dort
einen Funktionsnamen ein zweites Mal vergibt, ueberschreibt
die erste -- und zwar RUECKWIRKEND fuer die ganze Datei, auch
fuer Aufrufe weit davor. Es gibt keine Fehlermeldung.
GENAU DAS IST AM 30.09.2026 PASSIERT. Die neue Nachfuehrung
hiess `springen(ziel)` („spring AUF diese Stelle"), der Host
hatte laengst ein `springen(um)` („spring UM so viele
Sekunden"). Gewonnen hat das zweite. Ein Zuschauer bei
Sekunde 100, dem „geh auf 101" gesagt wird, waere bei 201
gelandet.
DIE MESSUNG IM BROWSER HAT ES NICHT GEFUNDEN -- oertlich blieb
der Abstand unter der Sprungschwelle, die Zeile lief in drei
Laeufen kein einziges Mal. Gefunden hat es erst diese Frage an
die ganze Datei. */
const namen = [...rjs.matchAll(/^\s{0,4}(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)]
.map((t) => t[1]);
const doppelt = [...new Set(namen.filter((n, i) => namen.indexOf(n) !== i))];
ok(namen.length > 40 && doppelt.length === 0,
doppelt.length
? `ZWEIMAL VERGEBEN: ${doppelt.join(", ")} — die spaetere gewinnt, lautlos`
: `alle ${namen.length} Funktionsnamen in reaktion.js sind einmalig`);
}
/* ---- Das zweite Video ---- */
const A = "dQw4w9WgXcQ";
const B = "9bZkp7q19f0";