Bug 106% raceCondition fix2
This commit is contained in:
@@ -43,16 +43,89 @@ Der Konflikt in Schritt 5 versetzt `cam0_hires` in einen Fehlerzustand in go2rtc
|
|||||||
Beim nächsten Reconnect ("neu anmelden") startet go2rtc's Retry `cam0_hires` erneut —
|
Beim nächsten Reconnect ("neu anmelden") startet go2rtc's Retry `cam0_hires` erneut —
|
||||||
gleichzeitig mit `cam0` → wieder 108% + Stream friert ein.
|
gleichzeitig mit `cam0` → wieder 108% + Stream friert ein.
|
||||||
|
|
||||||
### Fix (umgesetzt in src/snapshotService.js)
|
### ❌ Fix-Versuch 1 (2026-06-05) GESCHEITERT — „Warten bis cam_hires gestoppt"
|
||||||
|
|
||||||
Server wartet nach dem frame.jpeg-Fetch, bis `cam_hires`-Producer in go2rtc wirklich
|
Idee war: Server pollt nach dem frame.jpeg-Fetch, bis `cam_hires`-Producer nicht mehr
|
||||||
gestoppt ist (`pRunning=false`, `nConsumers=0`), plus 400ms Puffer für den FFmpeg-Exit.
|
`state=='running'` ist (+400ms Puffer), erst dann Antwort an Client.
|
||||||
Erst dann geht die Antwort zum Client → `/dev/videoN` ist garantiert frei wenn
|
|
||||||
`startStream(cam)` startet.
|
**Der Log beweist: der Poll ist ein No-op.**
|
||||||
|
```
|
||||||
|
[hires][cam1] Versuch 1: 90586 bytes, Breite=1280
|
||||||
|
[hires][cam1] cam_hires gestoppt nach 1ms – Gerät frei ← bricht SOFORT ab
|
||||||
|
[hires][cam0] cam_hires gestoppt nach 1ms – Gerät frei
|
||||||
|
```
|
||||||
|
go2rtc meldet den hires-Producer schon **1ms** nach dem Frame als „nicht running" —
|
||||||
|
obwohl der FFmpeg gerade eben noch ein 1280-Bild geliefert hat. Die tragende Annahme
|
||||||
|
(*API-State `running` ⇒ FFmpeg hält das Device*) ist **falsch**. Übrig bleibt nur das
|
||||||
|
blinde `sleep(400)`. Das ist exakt der Fehler-Typ aus 04 (#5/#7): „verifiziert"
|
||||||
|
behauptet, aber die sicherheitskritische Annahme war ungemessen.
|
||||||
|
|
||||||
|
**Folge: jetzt löst schon EIN Screenshot 106% aus** (vorher erst beim Reconnect). Erklärung:
|
||||||
|
Es war immer ein **Race auf dem geteilten `/dev/videoN`**. Die 400ms haben das Timing nur
|
||||||
|
verschoben — jetzt landen wir auf der schlechten Seite. **Timing-Pflaster verschieben das
|
||||||
|
Race, sie beseitigen es nicht.** → zurückgerollt (Schritt 3 wieder entfernt).
|
||||||
|
|
||||||
|
### Eigentliches Problem (eine Ebene tiefer)
|
||||||
|
|
||||||
|
`cam` (640) und `cam_hires` (1280) zeigen auf **dasselbe physische `/dev/videoN`**.
|
||||||
|
Eine USB-Kamera = ein Öffner (eiserne Regel 04). 106% = **zwei Encoder gleichzeitig** auf
|
||||||
|
einem Device (bestätigt: nicht „device busy → exit", sondern beide laufen).
|
||||||
|
|
||||||
|
**Kernhürde:** go2rtc's REST-API kann **nicht** zuverlässig sagen, wann FFmpeg den
|
||||||
|
Device-FD wirklich freigegeben hat. Die State-Felder flippen, bevor der Kernel-FD frei
|
||||||
|
ist (beim Start zeigte der Monitor sogar `cam1_hires producer state="unknown"`). Damit
|
||||||
|
ist **jede Timing-basierte Übergabe ein Ratespiel**.
|
||||||
|
|
||||||
|
Der **Hinweg** (Schritt 1: warten bis `cam` frei, bevor `cam_hires` startet) funktioniert
|
||||||
|
— er wartet echt (4870ms / 5069ms im Log). Kaputt ist der **Rückweg** (`cam_hires` → `cam`):
|
||||||
|
dort wird nicht zuverlässig gewartet.
|
||||||
|
|
||||||
|
### Lösungsvorschläge (geordnet nach Robustheit)
|
||||||
|
|
||||||
|
**A — Separate Hi-Res-Kamera (Weg A aus 04). GARANTIERT.**
|
||||||
|
Zusätzliche USB-Kamera, die go2rtc nicht öffnet; Node grabbt sie on-demand per one-shot
|
||||||
|
FFmpeg. Anderes Device → kein Race möglich. Kostet Hardware. Einzige 100%-Lösung.
|
||||||
|
|
||||||
|
**B — Feature streichen, zurück auf KONSOLIDIERT (04). GARANTIERT.**
|
||||||
|
`cam0_hires`/`cam1_hires` aus docker-compose, `/hires` aus snapshotService, `HD`-Button
|
||||||
|
aus dem Viewer. Nur noch 640er-Snapshot (read-only `frame.jpeg`). Stabil, kein Hi-Res.
|
||||||
|
|
||||||
|
**C — No-Hardware-Versuch: Rückweg robust machen. MITTLERE Sicherheit, MUSS gemessen werden.**
|
||||||
|
Statt auf `state` zu pollen: warten bis go2rtc das **Producer-Objekt entfernt hat**
|
||||||
|
(`producers`-Array leer für `cam_hires`) + großzügiger Settle. Vorher zwingend
|
||||||
|
**empirisch messen** (rein lesend, erlaubt): bei abgeschaltetem Live-Stream einmal
|
||||||
|
`frame.jpeg?src=cam0_hires` holen, dann `GET /api/streams` alle 100ms für ~10s loggen —
|
||||||
|
zeigt, ob/wann der Producer wirklich verschwindet. Erst wenn die Daten das belegen,
|
||||||
|
ausliefern. Bleibt prinzipiell ein Race auf geteiltem Device → kein Versprechen.
|
||||||
|
|
||||||
|
**Empfehlung:** Wenn Hi-Res verzichtbar → **B**. Wenn Hi-Res zwingend → **A**.
|
||||||
|
**C** nur, wenn kein Hardware-Budget UND Hi-Res nötig — und nur nach Messung (nie wieder
|
||||||
|
„verifiziert" ohne Messung auf `cam`, 04 eiserne Regel 3).
|
||||||
|
|
||||||
|
### Messung Weg C (Probe) — Anleitung & Ergebnis
|
||||||
|
|
||||||
|
Temporäre, rein lesende Diagnose-Route in `snapshotService.js`: `GET /:id/hires-probe`.
|
||||||
|
Sie wartet bis `cam` frei ist, holt **einen** `cam_hires`-Frame und schreibt dann 12s lang
|
||||||
|
alle 100ms den `cam_hires`-Zustand mit (`cons`, `prods`, `states`).
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
1. Code auf Server syncen, **`AppRobotWebcam` neu starten** (lädt `server.js`; go2rtc unberührt).
|
||||||
|
2. Im Viewer die zu messende Kamera **ausschalten** (⏸) → `cam` hat 0 Consumer.
|
||||||
|
3. `curl http://<host>:8444/api/snapshot/cam0/hires-probe` (oder im Browser öffnen).
|
||||||
|
4. JSON-Antwort + Container-Log (`[probe]…`) hierher.
|
||||||
|
|
||||||
|
Entscheidend: **`producerGoneAtMs`** (wann `prods` auf 0 fällt) und wie sich `states`
|
||||||
|
entwickelt. Daraus wird der robuste Rückweg gebaut (warten bis `prods===0` + Settle).
|
||||||
|
Wenn `prods` **nie** 0 wird → go2rtc baut den Producer gar nicht ab → Weg C ist tot,
|
||||||
|
dann bleibt nur A oder B.
|
||||||
|
|
||||||
|
**Ergebnis:** _(hier eintragen nach der Messung)_
|
||||||
|
|
||||||
|
Danach die `hires-probe`-Route wieder entfernen.
|
||||||
|
|
||||||
### Noch offen: Multi-User (siehe Abschnitt oben)
|
### Noch offen: Multi-User (siehe Abschnitt oben)
|
||||||
|
|
||||||
Das Multi-User-Problem bleibt. Bei ≥2 aktiven Clients kann `/hires` nicht starten,
|
Unabhängig vom 106%-Race: bei ≥2 aktiven Clients kann `/hires` nicht starten, weil
|
||||||
weil der `/hires`-Endpoint wartet bis `cam` 0 Consumer hat (max 8s), aber ein
|
Schritt 1 wartet bis `cam` 0 Consumer hat (max 8s), ein zweiter Browser die Consumer-Zahl
|
||||||
zweiter Browser die Consumer-Zahl nie auf 0 sinken lässt → Timeout → 503.
|
aber nie auf 0 fallen lässt → Timeout → 503. Variante A löst das mit (separates Device,
|
||||||
Fix-Optionen: siehe Multi-User-Abschnitt oben.
|
kein Warten auf 0 Consumer). Sonst: „Schalter"-Idee oben (ein Producer, Server verteilt).
|
||||||
@@ -114,30 +114,6 @@ function createSnapshotRouter(go2rtcUrl) {
|
|||||||
return res.status(503).json({ error: 'kein verwertbarer Hi-Res-Frame (Warmup-Timeout)' });
|
return res.status(503).json({ error: 'kein verwertbarer Hi-Res-Frame (Warmup-Timeout)' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schritt 3: Warten bis cam_hires Producer gestoppt ist bevor Client cam0 reconnectet.
|
|
||||||
// Ohne dieses Warten: cam_hires-FFmpeg hält /dev/videoN noch offen, wenn startStream(cam)
|
|
||||||
// go2rtc's cam-Producer startet → Race, zwei FFmpeg auf demselben Device → 108% CPU.
|
|
||||||
{
|
|
||||||
const t2 = Date.now();
|
|
||||||
while (Date.now() - t2 < 5000) {
|
|
||||||
try {
|
|
||||||
const rp = await fetch(`${go2rtcUrl}/api/streams`, { signal: AbortSignal.timeout(1000) });
|
|
||||||
if (rp.ok) {
|
|
||||||
const ss = await rp.json();
|
|
||||||
const sh = ss[hiresId];
|
|
||||||
const nCh = sh ? (sh.consumers ?? []).length : 0;
|
|
||||||
const pHRunning = sh ? (sh.producers ?? []).some(p => (p.state ?? '') === 'running') : false;
|
|
||||||
if (nCh === 0 && !pHRunning) {
|
|
||||||
console.log(`[hires][${id}] cam_hires gestoppt nach ${Date.now() - t2}ms – Gerät frei`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_e) { /* ignore */ }
|
|
||||||
await sleep(300);
|
|
||||||
}
|
|
||||||
await sleep(400); // Puffer: FFmpeg-Prozess-Exit bis Kernel Device-FD freigibt
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[hires][${id}] OK – ${jpeg.length} bytes, Breite=${lastWidth}, Dauer=${Date.now() - t0}ms`);
|
console.log(`[hires][${id}] OK – ${jpeg.length} bytes, Breite=${lastWidth}, Dauer=${Date.now() - t0}ms`);
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': 'image/jpeg',
|
||||||
@@ -157,6 +133,82 @@ function createSnapshotRouter(go2rtcUrl) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 🔬 TEMPORÄR: Diagnose-Probe für den cam_hires-Teardown (Bug 106%) ─────────
|
||||||
|
// Misst REIN LESEND, wann go2rtc den cam_hires-Producer nach einem frame.jpeg
|
||||||
|
// wirklich abbaut. Beantwortet: Leert sich das producers-Array? Wann? Geht
|
||||||
|
// consumers sofort auf 0? Daraus wird der robuste Rückweg gebaut (Weg C).
|
||||||
|
//
|
||||||
|
// VORHER im Viewer die betreffende Kamera AUSschalten (⏸), damit cam frei ist
|
||||||
|
// (sonst zwei Encoder auf einem Device = genau der 106%-Konflikt).
|
||||||
|
// curl http://<host>:8444/api/snapshot/cam0/hires-probe
|
||||||
|
// Nach der Messung diese Route + doc-Eintrag wieder entfernen.
|
||||||
|
router.get('/:id/hires-probe', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const hiresId = `${id}_hires`;
|
||||||
|
if (hiresLocks[id]) return res.status(429).json({ error: `${id} belegt` });
|
||||||
|
hiresLocks[id] = true;
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
const snapHires = (streams) => {
|
||||||
|
const s = streams[hiresId];
|
||||||
|
const prods = s ? (s.producers ?? []) : [];
|
||||||
|
return {
|
||||||
|
cons: s ? (s.consumers ?? []).length : 0,
|
||||||
|
prods: prods.length,
|
||||||
|
states: prods.map(p => p.state ?? '?').join(',') || '-',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Schritt 1: warten bis cam frei (max 8s) – sonst messen wir den Konflikt mit
|
||||||
|
while (Date.now() - t0 < 8000) {
|
||||||
|
const r = await fetch(`${go2rtcUrl}/api/streams`, { signal: AbortSignal.timeout(1000) }).catch(() => null);
|
||||||
|
if (r && r.ok) {
|
||||||
|
const s = (await r.json())[id];
|
||||||
|
const nC = s ? (s.consumers ?? []).length : 0;
|
||||||
|
const pR = s ? (s.producers ?? []).some(p => (p.state ?? '') === 'running') : false;
|
||||||
|
if (nC === 0 && !pR) break;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 2: einen cam_hires-Frame holen (startet den Producer)
|
||||||
|
const fr = await fetch(`${go2rtcUrl}/api/frame.jpeg?src=${encodeURIComponent(hiresId)}`,
|
||||||
|
{ signal: AbortSignal.timeout(6000) });
|
||||||
|
const buf = fr.ok ? Buffer.from(await fr.arrayBuffer()) : null;
|
||||||
|
const tFrame = Date.now();
|
||||||
|
const frameBytes = buf ? buf.length : 0;
|
||||||
|
const frameWidth = buf ? readJpegWidth(buf) : null;
|
||||||
|
console.log(`[probe][${id}] frame: ${frameBytes} bytes, Breite=${frameWidth ?? '?'} → poll teardown…`);
|
||||||
|
|
||||||
|
// Schritt 3: 12s lang alle 100ms den cam_hires-Zustand mitschreiben
|
||||||
|
const timeline = [];
|
||||||
|
let producerGoneAtMs = null;
|
||||||
|
let consumersZeroAtMs = null;
|
||||||
|
while (Date.now() - tFrame < 12000) {
|
||||||
|
const r = await fetch(`${go2rtcUrl}/api/streams`, { signal: AbortSignal.timeout(1000) }).catch(() => null);
|
||||||
|
const t = Date.now() - tFrame;
|
||||||
|
if (r && r.ok) {
|
||||||
|
const snap = snapHires(await r.json());
|
||||||
|
timeline.push({ t, ...snap });
|
||||||
|
if (producerGoneAtMs === null && snap.prods === 0) producerGoneAtMs = t;
|
||||||
|
if (consumersZeroAtMs === null && snap.cons === 0) consumersZeroAtMs = t;
|
||||||
|
} else {
|
||||||
|
timeline.push({ t, err: true });
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[probe][${id}] producerGoneAtMs=${producerGoneAtMs} consumersZeroAtMs=${consumersZeroAtMs}`);
|
||||||
|
console.log(`[probe][${id}] timeline:`, JSON.stringify(timeline));
|
||||||
|
res.json({ hiresId, frameBytes, frameWidth, producerGoneAtMs, consumersZeroAtMs, timeline });
|
||||||
|
} catch (err) {
|
||||||
|
if (!res.headersSent) res.status(503).json({ error: `probe: ${err.message}` });
|
||||||
|
} finally {
|
||||||
|
hiresLocks[id] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${go2rtcUrl}/api/streams`);
|
const r = await fetch(`${go2rtcUrl}/api/streams`);
|
||||||
|
|||||||
Reference in New Issue
Block a user