fix: Bridge-EPIPE-Endlosschleife + monitor_events-Schneeball verhindern [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m24s

Ursache: Bridge schrieb bei EPIPE-Fehler (Tauri-Pipe geschlossen) einen
monitor_event, der selbst wieder EPIPE warf -> uncaughtException-Loop.
Ergebnis: 1.082.260 identische Fehler-Eintraege, DB auf 293 MB angewachsen,
App hing beim Start am Index-Scan dieser Tabelle.

Bridge (scripts/claude-bridge.js):
- crashHandlerActive-Flag verhindert Re-Eintreten der Handler
- isPipeError() erkennt EPIPE/ERR_STREAM_DESTROYED/ERR_STREAM_WRITE_AFTER_END
- Bei Pipe-Fehler: process.exit(0) statt Schreibversuch
- stdout/stderr error-Listener als Erstausloeser-Sperre
- Alle sendEvent/sendMonitorEvent-Aufrufe in try/catch isoliert

Schema (src-tauri/src/db.rs):
- Trigger cleanup_old_monitor_events war AFTER INSERT (lief bei jedem Insert)
  -> bei 1 Mio Zeilen O(n) DELETE pro Event = O(n^2)-Schneeball
- Neuer Trigger: WHEN COUNT > 50000, behaelt juengste 30000, loescht >7d alt
- DROP TRIGGER vor CREATE damit Migration auf bestehenden DBs greift

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-27 15:58:10 +02:00
parent 549727d681
commit 6d3a0d8740
2 changed files with 52 additions and 12 deletions

View file

@ -1156,6 +1156,10 @@ process.on('exit', () => { cleanupDaemon(); });
// WICHTIG: err.stack ist ein lazy Getter der bei jedem Zugriff neu formatiert wird // WICHTIG: err.stack ist ein lazy Getter der bei jedem Zugriff neu formatiert wird
// und bei V8-OOM selbst einen OOM-Abort auslösen kann. Daher: einmal lesen, kürzen, // und bei V8-OOM selbst einen OOM-Abort auslösen kann. Daher: einmal lesen, kürzen,
// try/catch drumrum — der Handler darf nicht selbst crashen. // try/catch drumrum — der Handler darf nicht selbst crashen.
//
// Re-Entrancy-Schutz: Wenn der Crash aus dem Schreiben auf stdout/stderr kommt
// (EPIPE — Tauri liest nicht mehr), darf der Handler nicht erneut schreiben,
// sonst Endlosschleife mit ~5 Events/s in monitor_events (siehe DB-Crash 04/2026).
function safeStack(err) { function safeStack(err) {
try { try {
const raw = (err && err.stack) ? String(err.stack) : ''; const raw = (err && err.stack) ? String(err.stack) : '';
@ -1164,30 +1168,55 @@ function safeStack(err) {
return '[stack nicht lesbar]'; return '[stack nicht lesbar]';
} }
} }
let crashHandlerActive = false;
function isPipeError(err) {
if (!err) return false;
const code = err.code || (err.cause && err.cause.code);
return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED' || code === 'ERR_STREAM_WRITE_AFTER_END';
}
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
if (crashHandlerActive) return;
crashHandlerActive = true;
try { try {
// Pipe ist tot → kein Schreibversuch, sonst Loop. Stiller Exit ist hier korrekt.
if (isPipeError(err)) {
process.exit(0);
}
const msg = (err && err.message) ? String(err.message).slice(0, 500) : String(err).slice(0, 500); const msg = (err && err.message) ? String(err.message).slice(0, 500) : String(err).slice(0, 500);
const stack = safeStack(err); const stack = safeStack(err);
process.stderr.write(`❌ Unbehandelter Fehler: ${msg}\n${stack}\n`); try { process.stderr.write(`❌ Unbehandelter Fehler: ${msg}\n${stack}\n`); } catch {}
sendEvent('bridge-error', { type: 'uncaughtException', message: msg, stack }); try { sendEvent('bridge-error', { type: 'uncaughtException', message: msg, stack }); } catch {}
sendMonitorEvent('error', `Bridge Crash: ${msg}`, {}); try { sendMonitorEvent('error', `Bridge Crash: ${msg}`, {}); } catch {}
} catch (inner) { } finally {
try { process.stderr.write(`❌ Handler-Fehler: ${inner && inner.message}\n`); } catch {} crashHandlerActive = false;
} }
}); });
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
if (crashHandlerActive) return;
crashHandlerActive = true;
try { try {
if (isPipeError(reason)) {
process.exit(0);
}
const msg = reason instanceof Error const msg = reason instanceof Error
? String(reason.message).slice(0, 500) ? String(reason.message).slice(0, 500)
: String(reason).slice(0, 500); : String(reason).slice(0, 500);
process.stderr.write(`❌ Unhandled Promise Rejection: ${msg}\n`); try { process.stderr.write(`❌ Unhandled Promise Rejection: ${msg}\n`); } catch {}
sendEvent('bridge-error', { type: 'unhandledRejection', message: msg }); try { sendEvent('bridge-error', { type: 'unhandledRejection', message: msg }); } catch {}
sendMonitorEvent('error', `Unhandled Rejection: ${msg}`, {}); try { sendMonitorEvent('error', `Unhandled Rejection: ${msg}`, {}); } catch {}
} catch (inner) { } finally {
try { process.stderr.write(`❌ Handler-Fehler: ${inner && inner.message}\n`); } catch {} crashHandlerActive = false;
} }
}); });
// stdout/stderr-Streams: EPIPE als Erstauslöser abfangen, damit der globale
// uncaughtException-Pfad gar nicht erst antritt.
for (const stream of [process.stdout, process.stderr]) {
stream.on('error', (err) => {
if (isPipeError(err)) process.exit(0);
});
}
// Bereit-Signal (im stdio-Modus sofort senden, im Daemon-Modus pro Client bei Connect) // Bereit-Signal (im stdio-Modus sofort senden, im Daemon-Modus pro Client bei Connect)
if (!IS_DAEMON) { if (!IS_DAEMON) {
sendEvent('ready', { version: '1.2.0', pid: process.pid, model: currentModel, availableModels: AVAILABLE_MODELS }); sendEvent('ready', { version: '1.2.0', pid: process.pid, model: currentModel, availableModels: AVAILABLE_MODELS });

View file

@ -210,12 +210,23 @@ impl Database {
CREATE INDEX IF NOT EXISTS idx_monitor_timestamp ON monitor_events(timestamp DESC); CREATE INDEX IF NOT EXISTS idx_monitor_timestamp ON monitor_events(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_monitor_type ON monitor_events(event_type); CREATE INDEX IF NOT EXISTS idx_monitor_type ON monitor_events(event_type);
-- Automatisch alte Monitor-Events löschen (älter als 7 Tage) -- Threshold-basierter Cleanup: greift nur wenn die Tabelle mehr als
-- 50_000 Zeilen hat, behaelt dann die juengsten 30_000. Loescht ausserdem
-- Eintraege aelter als 7 Tage. Verhindert die Endlosschleife aus 04/2026
-- (Bridge-EPIPE-Crash schrieb 5 Events/s und der Trigger scannte bei
-- jedem Insert die ganze Tabelle = O(n^2)-Schneeball).
DROP TRIGGER IF EXISTS cleanup_old_monitor_events;
CREATE TRIGGER IF NOT EXISTS cleanup_old_monitor_events CREATE TRIGGER IF NOT EXISTS cleanup_old_monitor_events
AFTER INSERT ON monitor_events AFTER INSERT ON monitor_events
WHEN (SELECT COUNT(*) FROM monitor_events) > 50000
BEGIN BEGIN
DELETE FROM monitor_events DELETE FROM monitor_events
WHERE timestamp < datetime('now', '-7 days'); WHERE timestamp < datetime('now', '-7 days')
OR id IN (
SELECT id FROM monitor_events
ORDER BY timestamp ASC
LIMIT MAX((SELECT COUNT(*) FROM monitor_events) - 30000, 0)
);
END; END;
-- Projekte (für schnellen Wechsel) -- Projekte (für schnellen Wechsel)