From 909ed73eff09c2ada0b9030fcc7c841454f0a161 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 07:26:01 +0200 Subject: [PATCH] Zurueck aus dem Hintergrund: nicht warten, nicht schweigen, nicht luegen Die Seite behandelte das Entsperren bis hierher GAR NICHT. `visibilitychange` gab es genau einmal, und zwar um das Mikrofon loszulassen. Was dabei wirklich passiert: * Der WebSocket ist tot, aber `onclose` kommt erst, wenn das System die Seite auftaut -- und der Wiederholungsabstand ist inzwischen auf 10 s gewachsen. Man sieht sekundenlang nichts, obwohl das Funkgeraet bereit ist. LIVE GEMESSEN, gleiche Ausfalldauer (55 s): ohne Wecken: Server zurueck 05:23:37, Seite da 05:23:45 -> 8 s mit Wecken: Server zurueck 05:25:10, Seite da 05:25:13 -> 0 s (dieselbe Sekunde wie das Wecken) * Der AudioContext ist von iOS angehalten. Ein angehaltener Kontext ruft seinen Rueckruf nie wieder auf -- der Ton bliebe stumm, bis jemand TON AUS und wieder EIN drueckt. * Wasserfall und S-Meter zeigen den Stand von VOR dem Sperren, ohne das zu sagen. Dieselbe Gattung Luege wie ein stehender Wasserfall: er behauptet ein leeres Band, und danach wird nicht gerufen. Jetzt zieht das Aufwachen denselben Strich, den ein Bandwechsel zieht -- oben das Neue, unten das Alte. Entschieden wird in `aufwachen.js`, und NICHT am `readyState` allein: iOS friert die Seite ein, die Gegenseite raeumt auf, und der Socket meldet noch OFFEN. Wer darauf wartet, dass `onclose` kommt, wartet unter Umstaenden ewig. Darum zaehlt der Zustand UND die Stille -- kommt seit drei Sekunden nichts an, wird neu verbunden, ganz gleich was der Socket behauptet. Eine ueberfluessige Neuverbindung kostet eine halbe Sekunde, eine ausgelassene den Moment, in dem man das Telefon in die Hand nimmt. Die Grenze fuer "das Bild ist alt" liegt mit 1,5 s UNTER der fuer "der Socket ist tot" (3 s): lieber einmal zu frueh sagen, dass das Bild von vorhin ist, als einmal zu spaet. Ein falsch beschriftetes altes Bild ist harmlos, ein unbeschriftetes nicht. Die Abonnements muessen nicht erneuert werden -- sie haengen am `open`-Ereignis und kommen mit der neuen Verbindung von selbst. Die Spots schon, die laufen waehrend des Schlafens ab. Drei Wege ins Aufwachen, weil nicht jedes System `visibilitychange` zuverlaessig meldet: `visibilitychange`, `pageshow` mit `persisted`, `focus`. Mehrfaches Aufwachen schadet nicht -- die Entscheidung wirft eine lebendige Verbindung nicht weg, und genau das ist geprueft. 17 Pruefpunkte (pruefe-aufwachen.mjs), dazu die Live-Messung oben. Co-Authored-By: Claude Opus 5 --- handfunke/app.js | 61 ++++++++++++++++++++++++++++++++ handfunke/aufwachen.js | 59 +++++++++++++++++++++++++++++++ handfunke/pruefe-aufwachen.mjs | 63 ++++++++++++++++++++++++++++++++++ handfunke/tci.js | 26 ++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 handfunke/aufwachen.js create mode 100644 handfunke/pruefe-aufwachen.mjs diff --git a/handfunke/app.js b/handfunke/app.js index a73ab865b..df9e22fc4 100644 --- a/handfunke/app.js +++ b/handfunke/app.js @@ -12,6 +12,7 @@ import { Sammelstelle, zeileLesen, befundLesen, zeitKurz, dupeSatz } from './logbuch.js'; import { spotLesen, marken, trefferBei } from './spots.js'; import { Zeichenbremse } from './ton-vorrang.js'; +import { bildIstAlt } from './aufwachen.js'; const $ = (id) => document.getElementById(id); const link = new TciLink(); @@ -2016,6 +2017,66 @@ link.addEventListener('spectrum', (e) => { }); link.addEventListener('ready', () => zeichneBedienung()); link.addEventListener('ready', () => spotsBaldHolen(300)); + +// ── Zurueck aus dem Hintergrund (2026-10-05) ────────────────────────────── +// +// Bis hierher behandelte die Seite das Entsperren GAR NICHT. Was dabei +// wirklich passiert: +// +// * Der WebSocket ist tot, aber `onclose` kommt erst, wenn das System die +// Seite wieder auftaut -- und der Wiederholungsabstand ist inzwischen +// auf 10 s gewachsen. Man sieht sekundenlang nichts, obwohl das +// Funkgeraet bereit ist. +// * Der AudioContext ist von iOS angehalten; niemand weckt ihn. +// * Wasserfall und S-Meter zeigen den Stand von VOR dem Sperren, ohne das +// zu sagen. Das ist dieselbe Gattung Luege wie ein stehender +// Wasserfall: er behauptet ein leeres Band, und danach wird nicht +// gerufen. +// +// Die Abonnements selbst muessen hier nicht erneuert werden -- sie haengen +// am `open`-Ereignis und kommen mit der neuen Verbindung von selbst. +let warVerborgen = false; + +function aufwachen() { + const stille = performance.now() - letzteIq; + + // 1. Das Bild als alt kennzeichnen, BEVOR etwas anderes passiert. Der + // Strich ist derselbe, den ein Bandwechsel zieht: oben das Neue, unten + // das Alte. Ohne ihn sieht ein zehn Minuten alter Wasserfall aus wie + // ein lebendiger. + if (bildIstAlt(stille)) { + wasserfallSchnitt(); + state.hatSpektrumstrom = false; + } + + // 2. Sofort neu verbinden, wenn noetig (Entscheidung in aufwachen.js). + link.nachDemAufwachen(stille); + + // 3. Den Ton wecken. iOS haelt den AudioContext beim Sperren an, und ein + // angehaltener Kontext ruft seinen Rueckruf nie wieder auf -- der Ton + // bliebe stumm, bis jemand TON AUS und wieder EIN drueckt. + if (state.audio && state.audio.state === 'suspended') { + state.audio.resume().catch(() => { /* ohne Zutun des Bedieners nicht erlaubt */ }); + } + + // 4. Die Spots gehoeren ebenfalls aufgefrischt: waehrend des Schlafens + // sind welche abgelaufen und neue gekommen. + spotsBaldHolen(400); +} + +document.addEventListener('visibilitychange', () => { + if (document.hidden) { warVerborgen = true; return; } + if (!warVerborgen) { return; } + warVerborgen = false; + aufwachen(); +}); + +// Nicht jedes System meldet `visibilitychange` zuverlaessig, wenn die Seite +// aus dem Ruhezustand kommt -- `pageshow` mit `persisted` ist der zweite +// Weg, und `focus` der dritte. Mehrfaches Aufwachen schadet nicht: die +// Entscheidung in aufwachen.js wirft eine lebendige Verbindung nicht weg. +window.addEventListener('pageshow', (e) => { if (e.persisted) { aufwachen(); } }); +window.addEventListener('focus', () => { if (warVerborgen) { warVerborgen = false; aufwachen(); } }); link.addEventListener('state', () => { // Der Server hat das letzte Wort über die Rate. Weicht sie von unserer // Bitte ab, muss das Worklet es erfahren — sonst stimmt die Tonhöhe nicht. diff --git a/handfunke/aufwachen.js b/handfunke/aufwachen.js new file mode 100644 index 000000000..3c882a856 --- /dev/null +++ b/handfunke/aufwachen.js @@ -0,0 +1,59 @@ +// Zurück aus dem Hintergrund — die Entscheidung, rein. +// +// Was auf einem Telefon beim Entsperren wirklich passiert, und was die +// Seite bis heute davon unbemerkt lässt: +// +// * Der WebSocket ist tot, aber `onclose` kommt erst, wenn das System +// die Seite wieder auftaut. Bis dahin steht der Wiederholungsabstand +// womöglich schon bei 10 s — der Bediener sieht also sekundenlang +// nichts, obwohl das Funkgerät bereit ist. +// +// * Schlimmer: der Socket kann als OFFEN gemeldet werden und trotzdem +// tot sein. iOS friert die Seite ein, die Gegenseite räumt auf, und +// `readyState` hinkt hinterher. Wer nur auf den Zustand sieht, +// wartet auf ein `onclose`, das nie kommt. +// +// Darum entscheidet hier nicht der Zustand allein, sondern der Zustand +// UND die Stille: kommt seit Sekunden nichts mehr an, wird neu verbunden, +// ganz gleich was der Socket behauptet. Eine überflüssige Neuverbindung +// kostet eine halbe Sekunde; eine ausgelassene kostet den Moment, in dem +// man das Telefon in die Hand genommen hat. + +/** Ab dieser Stille gilt eine Verbindung als tot, auch wenn sie OFFEN sagt. */ +export const kTotStilleMs = 3000; + +/** WebSocket-Zustände, hier benannt statt als Zahl verstreut. */ +export const VERBINDET = 0, OFFEN = 1, SCHLIESST = 2, ZU = 3; + +/** + * Soll nach dem Aufwachen sofort neu verbunden werden? + * + * @param {{gewollt:boolean, zustand:(number|null), stilleMs:number}} lage + * `zustand` ist `WebSocket.readyState` oder null, wenn gar keiner da ist. + * `stilleMs` ist, wie lange schon nichts mehr ankam. + */ +export function sollNeuVerbinden(lage) { + const l = lage || {}; + // Wer gar nicht verbunden sein will, will es auch nach dem Aufwachen nicht. + if (!l.gewollt) { return false; } + const z = (l.zustand === null || l.zustand === undefined) ? ZU : l.zustand; + if (z === ZU || z === SCHLIESST) { return true; } + // VERBINDET: einen laufenden Versuch nicht abwürgen, SOLANGE er jung ist. + // Hängt er schon länger als die Stillegrenze, ist er mit dem Telefon + // eingeschlafen und wird von selbst nicht mehr wach. + if (z === VERBINDET) { return (l.stilleMs || 0) > kTotStilleMs; } + // OFFEN: nur bei Stille. Sonst würde jeder Blick auf den Bildschirm die + // laufende Verbindung wegwerfen. + return (l.stilleMs || 0) > kTotStilleMs; +} + +/** + * Ist das Bild, das gerade dasteht, noch von vor dem Schlafen? + * + * Darauf kommt es an: ein Wasserfall, der den Stand von vor zehn Minuten + * zeigt, ist von einem lebendigen nicht zu unterscheiden. Er behauptet ein + * leeres Band, und danach wird nicht gerufen. + */ +export function bildIstAlt(stilleMs, grenzeMs = 1500) { + return (stilleMs || 0) > grenzeMs; +} diff --git a/handfunke/pruefe-aufwachen.mjs b/handfunke/pruefe-aufwachen.mjs new file mode 100644 index 000000000..fee0ccf4c --- /dev/null +++ b/handfunke/pruefe-aufwachen.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Pruefstand fuer das Zurueckkommen aus dem Hintergrund. +// +// Der Fehler, gegen den das meiste hier steht, ist der LEISE: ein Socket, +// der OFFEN meldet und trotzdem tot ist. Wer nur auf `readyState` sieht, +// wartet auf ein `onclose`, das nie kommt -- und der Bediener steht vor +// einem Bild von vorhin, das wie ein leeres Band aussieht. +// +// node pruefe-aufwachen.mjs + +import { sollNeuVerbinden, bildIstAlt, kTotStilleMs, + VERBINDET, OFFEN, SCHLIESST, ZU } from './aufwachen.js'; + +let fehler = 0; +const pruefe = (name, ok, text = '') => { + if (ok) { console.log(` ok ${name}`); } + else { console.log(` FEHLT ${name}${text ? ': ' + text : ''}`); fehler++; } +}; + +console.log('Neu verbinden?\n'); +{ + const l = (zustand, stilleMs, gewollt = true) => ({ gewollt, zustand, stilleMs }); + pruefe('geschlossen -> ja', sollNeuVerbinden(l(ZU, 0)) === true); + pruefe('schliesst gerade -> ja', sollNeuVerbinden(l(SCHLIESST, 0)) === true); + pruefe('gar kein Socket -> ja', sollNeuVerbinden(l(null, 0)) === true); + + // DER Fall: offen, aber seit Sekunden still. + pruefe('offen und still -> ja (der tote Socket)', + sollNeuVerbinden(l(OFFEN, kTotStilleMs + 1)) === true); + pruefe('offen und lebendig -> nein', + sollNeuVerbinden(l(OFFEN, 200)) === false); + pruefe('offen, Stille genau an der Grenze -> nein', + sollNeuVerbinden(l(OFFEN, kTotStilleMs)) === false); + + // Ein laufender Versuch darf nicht bei jedem Blick abgewuergt werden. + pruefe('verbindet gerade, frisch -> nein', + sollNeuVerbinden(l(VERBINDET, 500)) === false); + pruefe('verbindet gerade, aber eingeschlafen -> ja', + sollNeuVerbinden(l(VERBINDET, kTotStilleMs + 1)) === true); + + // Wer getrennt sein WILL, bleibt getrennt. + pruefe('nicht gewollt -> nie', sollNeuVerbinden(l(ZU, 99999, false)) === false); + pruefe('nicht gewollt, auch nicht bei Stille', + sollNeuVerbinden(l(OFFEN, 99999, false)) === false); + pruefe('ohne Angaben wird nichts weggeworfen', sollNeuVerbinden(null) === false); +} + +console.log('\nIst das Bild alt?'); +{ + pruefe('frisch -> nein', bildIstAlt(100) === false); + pruefe('zwei Sekunden Stille -> ja', bildIstAlt(2000) === true); + pruefe('an der Grenze -> nein', bildIstAlt(1500) === false); + pruefe('nichts gemessen -> nein', bildIstAlt(undefined) === false); + // Die Grenze liegt UNTER der fuer die Neuverbindung: lieber einmal zu + // frueh sagen "das Bild ist von vorhin" als einmal zu spaet. Ein falsch + // beschriftetes altes Bild ist harmlos, ein unbeschriftetes nicht. + pruefe('Bild gilt frueher als alt als der Socket als tot', + bildIstAlt(2000) === true && sollNeuVerbinden( + { gewollt: true, zustand: OFFEN, stilleMs: 2000 }) === false); +} + +console.log(fehler === 0 ? '\nAlles gut.' : `\n${fehler} Punkt(e) offen.`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/handfunke/tci.js b/handfunke/tci.js index 0b1fae3cd..6fcc66147 100644 --- a/handfunke/tci.js +++ b/handfunke/tci.js @@ -16,6 +16,8 @@ export const HDR = 64; +import { sollNeuVerbinden } from './aufwachen.js'; + export class TciLink extends EventTarget { constructor() { super(); @@ -146,6 +148,30 @@ export class TciLink extends EventTarget { }; } + /** + * Zurueck aus dem Hintergrund: sofort wieder versuchen, ohne den + * gewachsenen Abstand abzuwarten. + * + * Entschieden wird in aufwachen.js -- und zwar NICHT am `readyState` + * allein: iOS friert die Seite ein, die Gegenseite raeumt auf, und der + * Socket meldet noch OFFEN. Wer darauf wartet, dass `onclose` kommt, + * wartet unter Umstaenden ewig. + * + * @param {number} stilleMs wie lange schon nichts mehr ankam + * @returns {boolean} true, wenn neu verbunden wurde + */ + nachDemAufwachen(stilleMs) { + const zustand = this.ws ? this.ws.readyState : null; + if (!sollNeuVerbinden({ gewollt: this.wanted, zustand, stilleMs })) { + return false; + } + // Den Abstand zuruecksetzen: er ist beim Schlafen gewachsen, und das + // Telefon ist gerade JETZT in der Hand. + this.retryMs = 500; + this._open(); + return true; + } + _retry() { if (!this.wanted) return; clearTimeout(this.retryTimer);