From 468df99142bf04372f2dfa3176ce8e6caa09aa42 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 7 Jun 2026 17:00:06 +0200 Subject: [PATCH] Add extended diagnostics logging - Electron crash handlers (render-process-gone, child-process-gone, unresponsive/responsive, process warnings) with a circuit-breaker auto-reload for renderer crashes - Renderer error capture (window.onerror, unhandledrejection, React ErrorBoundary) forwarded to the main log via a one-way IPC channel - Memory-pressure heartbeat measured against the V8 heap_size_limit - Gated DEBUG log level (RD_DEBUG) and an in-memory ring of recent WARN/ERROR lines, exposed via the /errors endpoint and support bundle - Disk-error classification (ENOSPC etc.) on download failures and integrity-check pass/fail logging --- src/main/app-controller.ts | 31 +++++++++++ src/main/debug-server.ts | 14 +++++ src/main/download-manager.ts | 25 ++++++++- src/main/error-ring.ts | 45 ++++++++++++++++ src/main/fs-error.ts | 56 +++++++++++++++++++ src/main/logger.ts | 31 ++++++++++- src/main/main.ts | 95 ++++++++++++++++++++++++++++++++- src/main/support-bundle.ts | 3 ++ src/preload/preload.ts | 2 + src/renderer/error-boundary.tsx | 94 ++++++++++++++++++++++++++++++++ src/renderer/main.tsx | 35 +++++++++++- src/shared/ipc.ts | 3 +- src/shared/preload-api.ts | 2 + src/shared/types.ts | 10 ++++ tasks/todo.md | 50 +++++++++++++++++ tests/error-ring.test.ts | 44 +++++++++++++++ tests/fs-error.test.ts | 49 +++++++++++++++++ 17 files changed, 582 insertions(+), 7 deletions(-) create mode 100644 src/main/error-ring.ts create mode 100644 src/main/fs-error.ts create mode 100644 src/renderer/error-boundary.tsx create mode 100644 tests/error-ring.test.ts create mode 100644 tests/fs-error.test.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index a3c8828..4cc2443 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import v8 from "node:v8"; import { app } from "electron"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { @@ -84,6 +85,7 @@ export class AppController { private autoResumePending = false; private runtimeStatsTimer: NodeJS.Timeout | null = null; + private lastMemoryWarnAt = 0; public constructor() { configureLogger(this.storagePaths.baseDir); @@ -162,6 +164,7 @@ export class AppController { this.runtimeStatsTimer = setInterval(() => { this.manager.persistRuntimeStats(); this.settings = this.manager.getSettings(); + this.checkMemoryPressure(); }, 60_000); this.runtimeStatsTimer.unref?.(); @@ -187,6 +190,34 @@ export class AppController { } } + // Early-warning for OOM on a long-running process. Measured against the V8 + // heap_size_limit (the real ceiling at which the process is killed), NOT against + // heapTotal: V8 routinely runs near-full of its current heapTotal just before it + // grows it, so a heapUsed/heapTotal ratio would cry wolf and — since every WARN + // now feeds the error ring — crowd real failures out. Throttled to 1 warning per + // 5 min so a genuine sustained-pressure run does not spam the log/ring. + private checkMemoryPressure(): void { + try { + const mem = process.memoryUsage(); + const heapLimit = v8.getHeapStatistics().heap_size_limit; + const ratio = heapLimit > 0 ? mem.heapUsed / heapLimit : 0; + if (ratio < 0.9) { + return; + } + const now = Date.now(); + if (now - this.lastMemoryWarnAt < 5 * 60_000) { + return; + } + this.lastMemoryWarnAt = now; + const mb = (bytes: number): number => Math.round(bytes / 1048576); + logger.warn( + `Speicherdruck: heapUsed=${mb(mem.heapUsed)}MB von Limit ${mb(heapLimit)}MB ` + + `(${Math.round(ratio * 100)}%), heapTotal=${mb(mem.heapTotal)}MB, rss=${mb(mem.rss)}MB, external=${mb(mem.external)}MB` + ); + } catch { + } + } + private hasAnyProviderToken(settings: AppSettings): boolean { return Boolean( settings.token.trim() diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 07630e4..905e625 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -6,6 +6,7 @@ import { APP_VERSION } from "./constants"; import { getAuditLogPath } from "./audit-log"; import { getDebugSetupCheck } from "./debug-setup"; import { logger, getLogFilePath } from "./logger"; +import { getRecentErrors } from "./error-ring"; import { getItemLogPath as getPersistedItemLogPath } from "./item-log"; import { getSessionLogPath } from "./session-log"; import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log"; @@ -44,6 +45,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, + { method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." }, { method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, @@ -528,6 +530,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/errors") { + const levelFilter = (url.searchParams.get("level") || "").toUpperCase(); + const limit = normalizeLinesParam(url.searchParams.get("limit"), 100); + let entries = getRecentErrors(); + if (levelFilter === "ERROR" || levelFilter === "WARN") { + entries = entries.filter((entry) => entry.level === levelFilter); + } + const limited = entries.slice(-limit); + jsonResponse(res, 200, { count: limited.length, total: entries.length, entries: limited }); + return; + } + if (pathname === "/logs/audit") { const count = normalizeLinesParam(url.searchParams.get("lines"), 100); const grep = url.searchParams.get("grep") || ""; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 9979bff..3e26c31 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -53,6 +53,7 @@ import { planDownloadCompletion, validateDownloadedFileCompletion } from "./down import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; +import { classifyDiskError } from "./fs-error"; import { logger } from "./logger"; import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; import type { RotationEvent } from "../shared/types"; @@ -8563,16 +8564,24 @@ export class DownloadManager extends EventEmitter { item.updatedAt = nowMs(); this.emitState(); + const integrityStartedAt = nowMs(); const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir); if (active.abortController.signal.aborted) { throw new Error(`aborted:${active.abortReason}`); } + const integrityElapsedMs = nowMs() - integrityStartedAt; if (!validation.ok) { item.lastError = validation.message; item.fullStatus = `${validation.message}, Neuversuch`; + this.logPackageForItem(item, "WARN", "Integritätsprüfung fehlgeschlagen", { + result: validation.message, + elapsedMs: integrityElapsedMs, + willRetry: item.attempts < maxAttempts + }); try { fs.rmSync(item.targetPath, { force: true }); - } catch { + } catch (rmErr) { + logger.debug(`Integrity-Cleanup rm fehlgeschlagen (${item.fileName}): ${String(rmErr)}`); } if (item.attempts < maxAttempts) { item.status = "integrity_check"; @@ -8586,6 +8595,11 @@ export class DownloadManager extends EventEmitter { } throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`); } + // Symmetry: a passed check was previously silent in the item log, so a + // reader could not tell whether integrity ran and passed vs was skipped. + this.logPackageForItem(item, "INFO", "Integritätsprüfung bestanden", { + elapsedMs: integrityElapsedMs + }); } if (active.abortController.signal.aborted) { @@ -10045,11 +10059,18 @@ export class DownloadManager extends EventEmitter { } lastError = compactErrorText(error); const normalizedLastError = lastError.replace(/^Error:\s*/i, ""); + const diskCause = classifyDiskError(error); logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", { attempt, error: lastError, - targetPath: effectiveTargetPath + targetPath: effectiveTargetPath, + ...(diskCause ? { diskCause } : {}) }); + if (diskCause) { + // Surface the concrete OS cause (disk full, permission, ...) prominently + // instead of leaving only a generic write/stream error in the log. + logger.error(`Schreibfehler beim Download: ${diskCause} - ${item.fileName} (ziel=${effectiveTargetPath})`); + } if ( normalizedLastError.startsWith("range_ignored_on_resume:") || normalizedLastError.startsWith("range_mismatch_on_resume:") diff --git a/src/main/error-ring.ts b/src/main/error-ring.ts new file mode 100644 index 0000000..756bcdf --- /dev/null +++ b/src/main/error-ring.ts @@ -0,0 +1,45 @@ +export interface ErrorRingEntry { + ts: string; + level: string; + message: string; +} + +export interface ErrorRing { + push: (entry: ErrorRingEntry) => void; + snapshot: () => ErrorRingEntry[]; + clear: () => void; + size: () => number; +} + +export function createErrorRing(capacity: number): ErrorRing { + const limit = Math.max(1, Math.floor(capacity)); + const buffer: ErrorRingEntry[] = []; + return { + push(entry: ErrorRingEntry): void { + buffer.push(entry); + while (buffer.length > limit) { + buffer.shift(); + } + }, + snapshot(): ErrorRingEntry[] { + return buffer.slice(); + }, + clear(): void { + buffer.length = 0; + }, + size(): number { + return buffer.length; + } + }; +} + +const RECENT_ERROR_CAPACITY = 200; +const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY); + +export function recordRecentError(level: string, message: string, ts: string): void { + recentErrors.push({ level, message, ts }); +} + +export function getRecentErrors(): ErrorRingEntry[] { + return recentErrors.snapshot(); +} diff --git a/src/main/fs-error.ts b/src/main/fs-error.ts new file mode 100644 index 0000000..cac0c61 --- /dev/null +++ b/src/main/fs-error.ts @@ -0,0 +1,56 @@ +// Maps low-level filesystem/OS error codes to a human-readable cause so that a +// generic "write failed" or "timeout" can be reported as the specific root cause +// (disk full, permission denied, ...). Pure + side-effect-free for testing. + +const DISK_ERROR_REASONS: Record = { + ENOSPC: "Festplatte voll (ENOSPC)", + EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)", + EROFS: "Laufwerk schreibgeschützt (EROFS)", + EACCES: "Zugriff verweigert (EACCES)", + EPERM: "Operation nicht erlaubt (EPERM)", + EMFILE: "Zu viele offene Dateien (EMFILE)", + ENFILE: "System-Limit offener Dateien erreicht (ENFILE)", + EBUSY: "Datei/Laufwerk belegt (EBUSY)", + ENODEV: "Gerät nicht vorhanden (ENODEV)", + ENXIO: "Gerät getrennt (ENXIO)", + EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)" +}; + +export function classifyDiskError(err: unknown): string | null { + const code = extractErrorCode(err); + if (code && DISK_ERROR_REASONS[code]) { + return DISK_ERROR_REASONS[code]; + } + // Some errors arrive as plain strings/messages without a `.code`; fall back to + // scanning the text for a known code token. + const text = errorText(err); + for (const knownCode of Object.keys(DISK_ERROR_REASONS)) { + if (text.includes(knownCode)) { + return DISK_ERROR_REASONS[knownCode]; + } + } + return null; +} + +function extractErrorCode(err: unknown): string { + if (err && typeof err === "object") { + const code = (err as { code?: unknown }).code; + if (typeof code === "string") { + return code.toUpperCase(); + } + } + return ""; +} + +function errorText(err: unknown): string { + if (typeof err === "string") { + return err; + } + if (err && typeof err === "object") { + const message = (err as { message?: unknown }).message; + if (typeof message === "string") { + return message; + } + } + return String(err ?? ""); +} diff --git a/src/main/logger.ts b/src/main/logger.ts index bc01c94..76b3078 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -1,7 +1,24 @@ import fs from "node:fs"; import { logTimestamp } from "./log-timestamp"; +import { recordRecentError } from "./error-ring"; import path from "node:path"; +export function isDebugFlagEnabled(value: string | undefined): boolean { + if (!value) { + return false; + } + return /^(1|true|yes|on)$/i.test(value.trim()); +} + +// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server +// is a deliberate support action that requires a restart — the runtime-toggleable +// channel is the trace log, not this. +const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG); + +export function isDebugLoggingEnabled(): boolean { + return DEBUG_ENABLED; +} + let logFilePath = path.resolve(process.cwd(), "rd_downloader.log"); let fallbackLogFilePath: string | null = null; const LOG_FLUSH_INTERVAL_MS = 120; @@ -204,12 +221,19 @@ function ensureExitHook(): void { process.once("exit", flushSyncPending); } -function write(level: "INFO" | "WARN" | "ERROR", message: string): void { +function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void { ensureExitHook(); - const line = `${logTimestamp()} [${level}] ${message}\n`; + const ts = logTimestamp(); + const line = `${ts} [${level}] ${message}\n`; pendingLines.push(line); pendingChars += line.length; + // Single chokepoint: every WARN/ERROR also lands in the in-memory ring so + // "what failed recently" is answerable even after the file rotates. + if (level === "ERROR" || level === "WARN") { + recordRecentError(level, message, ts); + } + for (const listener of logListeners) { try { listener(line); } catch { } } @@ -230,6 +254,9 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void { } export const logger = { + // Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing + // (no formatting, no allocation) in the normal/production path. + debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {}, info: (msg: string): void => write("INFO", msg), warn: (msg: string): void => write("WARN", msg), error: (msg: string): void => write("ERROR", msg) diff --git a/src/main/main.ts b/src/main/main.ts index 1e21db6..c524de9 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -53,7 +53,13 @@ process.on("uncaughtException", (error) => { logger.error(`Uncaught Exception: ${String(error?.stack || error)}`); }); process.on("unhandledRejection", (reason) => { - logger.error(`Unhandled Rejection: ${String(reason)}`); + const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason); + logger.error(`Unhandled Rejection: ${detail}`); +}); +// Node-Warnungen (z.B. MaxListenersExceeded, DeprecationWarning) sind ein +// Frühindikator für Leaks/Fehlnutzung in einem langlaufenden Server-Prozess. +process.on("warning", (warning) => { + logger.warn(`Node-Warnung: ${warning.name}: ${warning.message}${warning.stack ? ` | ${warning.stack.replace(/\s*\n\s*/g, " ⏎ ")}` : ""}`); }); let mainWindow: BrowserWindow | null = null; @@ -110,6 +116,23 @@ function createWindow(): BrowserWindow { return window; } +let rendererReloadTimes: number[] = []; +const RENDERER_RELOAD_WINDOW_MS = 5 * 60 * 1000; +const RENDERER_RELOAD_MAX = 3; + +// Circuit breaker: recover from a one-off renderer crash by reloading, but stop +// after a few crashes in a short window so a reproducible crash can't spin into a +// reload loop that pegs an unattended server. +function allowRendererReload(): boolean { + const now = Date.now(); + rendererReloadTimes = rendererReloadTimes.filter((t) => now - t < RENDERER_RELOAD_WINDOW_MS); + if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) { + return false; + } + rendererReloadTimes.push(now); + return true; +} + function bindMainWindowLifecycle(window: BrowserWindow): void { window.on("close", (event) => { const settings = controller.getSettings(); @@ -124,6 +147,33 @@ function bindMainWindowLifecycle(window: BrowserWindow): void { mainWindow = null; } }); + + window.webContents.on("render-process-gone", (_event, details) => { + logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`); + if (details.reason === "clean-exit" || window.isDestroyed()) { + return; + } + if (allowRendererReload()) { + logger.warn("Renderer wird automatisch neu geladen (Wiederherstellung nach Absturz)"); + try { + window.webContents.reload(); + } catch (error) { + logger.error(`Renderer-Reload fehlgeschlagen: ${String(error)}`); + } + } else { + logger.error(`Renderer-Absturz: Auto-Reload gestoppt (mehr als ${RENDERER_RELOAD_MAX} Abstürze in ${RENDERER_RELOAD_WINDOW_MS / 60000} Min) - manueller Neustart nötig`); + } + }); + + // Nur protokollieren, niemals killen/neu laden: "unresponsive" feuert auch + // während legitimer langer Sync-Arbeit (große JSON-Serialisierung) und erholt + // sich meist von selbst. Eingreifen würde einen Schluckauf zum Ausfall machen. + window.webContents.on("unresponsive", () => { + logger.warn("Renderer reagiert nicht (unresponsive) - evtl. langer Sync-Task, warte auf Erholung"); + }); + window.webContents.on("responsive", () => { + logger.info("Renderer wieder reaktionsfähig (responsive)"); + }); } function createTray(): void { @@ -676,6 +726,14 @@ function registerIpcHandlers(): void { return importResult; }); + ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => { + try { + logger.error(formatRendererErrorReport(rawReport)); + } catch (error) { + logger.error(`[Renderer] Fehlerbericht konnte nicht verarbeitet werden: ${String(error)}`); + } + }); + controller.onState = (snapshot) => { if (!mainWindow || mainWindow.isDestroyed()) { return; @@ -684,6 +742,41 @@ function registerIpcHandlers(): void { }; } +function formatRendererErrorReport(rawReport: unknown): string { + const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record; + const str = (value: unknown): string => (typeof value === "string" ? value : ""); + const num = (value: unknown): string => (typeof value === "number" && Number.isFinite(value) ? String(value) : ""); + const kind = str(report.kind) || "error"; + const message = (str(report.message) || "(ohne Nachricht)").slice(0, 2000); + const source = str(report.source); + const line = num(report.line); + const column = num(report.column); + const stack = str(report.stack).slice(0, 4000); + const componentStack = str(report.componentStack).slice(0, 4000); + + const parts: string[] = [`[Renderer:${kind}] ${message}`]; + if (source) { + parts.push(`@ ${source}${line ? `:${line}${column ? `:${column}` : ""}` : ""}`); + } + if (stack) { + parts.push(`| stack: ${stack.replace(/\s*\n\s*/g, " ⏎ ")}`); + } + if (componentStack) { + parts.push(`| react: ${componentStack.replace(/\s*\n\s*/g, " ⏎ ")}`); + } + return parts.join(" "); +} + +app.on("child-process-gone", (_event, details) => { + const killed = details.reason !== "clean-exit" && details.reason !== "killed"; + const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`; + if (killed) { + logger.error(line); + } else { + logger.warn(line); + } +}); + app.on("second-instance", () => { if (mainWindow) { if (mainWindow.isMinimized()) { diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index b47a3c1..aa56976 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -5,6 +5,7 @@ import { APP_VERSION } from "./constants"; import { getAuditLogPath } from "./audit-log"; import { getDebugSetupCheck } from "./debug-setup"; import { getLogFilePath } from "./logger"; +import { getRecentErrors } from "./error-ring"; import { getPackageLogPath } from "./package-log"; import { getRenameLogPath } from "./rename-log"; import { getDesktopRenameLogPath } from "./desktop-rename-log"; @@ -169,6 +170,8 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op }); addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode)); addJson(zip, "overview/trace-config.json", getTraceConfig()); + const recentErrors = getRecentErrors(); + addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }); addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`); addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index acd84b1..83d2302 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -9,6 +9,7 @@ import { DuplicatePolicy, HistoryEntry, PackagePriority, + RendererErrorReport, SessionStats, StartConflictEntry, StartConflictResolutionResult, @@ -88,6 +89,7 @@ const api: ElectronApi = { skipItems: (itemIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), resetItems: (itemIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds), startItems: (itemIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds), + reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report), onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); diff --git a/src/renderer/error-boundary.tsx b/src/renderer/error-boundary.tsx new file mode 100644 index 0000000..a820611 --- /dev/null +++ b/src/renderer/error-boundary.tsx @@ -0,0 +1,94 @@ +import React from "react"; + +interface ErrorBoundaryProps { + children: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + message: string; +} + +// Catches render-time errors in the component tree so a crash shows a minimal +// recovery surface instead of a silent white screen, and forwards the error to +// the main process log. Kept deliberately dead-simple and state-independent: an +// error inside the error path is how you get a second white screen or a loop. +export class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, message: "" }; + } + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { hasError: true, message: error instanceof Error ? error.message : String(error) }; + } + + componentDidCatch(error: unknown, info: React.ErrorInfo): void { + try { + window.rd?.reportRendererError({ + kind: "react", + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + componentStack: info?.componentStack || undefined + }); + } catch { + } + } + + private handleReload = (): void => { + window.location.reload(); + }; + + render(): React.ReactNode { + if (!this.state.hasError) { + return this.props.children; + } + const overlay: React.CSSProperties = { + position: "fixed", + inset: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 16, + padding: 32, + background: "#070b14", + color: "#e6edf6", + fontFamily: "Segoe UI, system-ui, sans-serif", + textAlign: "center" + }; + const pre: React.CSSProperties = { + maxWidth: 640, + maxHeight: 200, + overflow: "auto", + padding: 12, + background: "#0d1422", + border: "1px solid #243049", + borderRadius: 6, + color: "#ff9a8c", + fontSize: 12, + whiteSpace: "pre-wrap", + textAlign: "left" + }; + const button: React.CSSProperties = { + padding: "8px 20px", + background: "#2d5cff", + color: "#fff", + border: "none", + borderRadius: 6, + cursor: "pointer", + fontSize: 14 + }; + return ( +
+

Die Oberfläche hat einen Fehler ausgelöst

+

+ Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im + Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben. +

+
{this.state.message}
+ +
+ ); + } +} diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 7a77edd..d72fa2a 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -1,8 +1,39 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { ErrorBoundary } from "./error-boundary"; import "./styles.css"; +// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise +// rejections) to the main process log. Without this, a renderer crash leaves no +// trace anywhere on an unattended server. +function reportRendererError(report: Parameters[0]): void { + try { + window.rd?.reportRendererError(report); + } catch { + } +} + +window.addEventListener("error", (event) => { + reportRendererError({ + kind: "error", + message: event.message || String(event.error || "Unbekannter Fehler"), + stack: event.error instanceof Error ? event.error.stack : undefined, + source: event.filename || undefined, + line: typeof event.lineno === "number" ? event.lineno : undefined, + column: typeof event.colno === "number" ? event.colno : undefined + }); +}); + +window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason; + reportRendererError({ + kind: "unhandledrejection", + message: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined + }); +}); + const rootElement = document.getElementById("root"); if (!rootElement) { throw new Error("Root element fehlt"); @@ -10,6 +41,8 @@ if (!rootElement) { createRoot(rootElement).render( - + + + ); diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index de5155a..7284e73 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -66,5 +66,6 @@ export const IPC_CHANNELS = { SET_PACKAGE_PRIORITY: "queue:set-package-priority", SKIP_ITEMS: "queue:skip-items", RESET_ITEMS: "queue:reset-items", - START_ITEMS: "queue:start-items" + START_ITEMS: "queue:start-items", + LOG_RENDERER_ERROR: "log:renderer-error" } as const; diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 5b808f5..e5d11fa 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -9,6 +9,7 @@ import type { DuplicatePolicy, HistoryEntry, PackagePriority, + RendererErrorReport, SessionStats, StartConflictEntry, StartConflictResolutionResult, @@ -85,6 +86,7 @@ export interface ElectronApi { skipItems: (itemIds: string[]) => Promise; resetItems: (itemIds: string[]) => Promise; startItems: (itemIds: string[]) => Promise; + reportRendererError: (report: RendererErrorReport) => void; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; diff --git a/src/shared/types.ts b/src/shared/types.ts index 93ef52f..371e881 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -499,3 +499,13 @@ export interface HistoryState { entries: HistoryEntry[]; maxEntries: number; } + +export interface RendererErrorReport { + kind: "error" | "unhandledrejection" | "react"; + message: string; + stack?: string; + source?: string; + line?: number; + column?: number; + componentStack?: string; +} diff --git a/tasks/todo.md b/tasks/todo.md index 210a855..496c7a0 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,3 +1,53 @@ +# Erweitertes Logging (2026-06-07) — Goal: Tool besser kontrollieren bei Problemen/Fehlern + +Recon (4-Agent-Workflow) fand 40+ Lücken. Bewusste Scope-Disziplin: NICHT alle 40 +instrumentieren (die meisten leeren catches sind legitimes Cleanup → würden das Log +fluten). Fokus: strukturelle Sichtbarkeit für unbeaufsichtigten Windows-Server, wo +"Crash/Hang ohne Log" der schlimmste Fall ist. Advisor-gegengeprüft. + +## Tier 1 — Prozess-/Renderer-Crash-Sichtbarkeit (höchster Wert) +- [ ] main.ts: `render-process-gone` (+ Auto-Reload mit Circuit-Breaker max 3/5min) +- [ ] main.ts: `app.on("child-process-gone")` (GPU/Utility/Renderer-Subprozesse) +- [ ] main.ts: `webContents.on("unresponsive"/"responsive")` — NUR loggen, nie killen +- [ ] main.ts: `process.on("warning")` (Node-Warnungen, z.B. MaxListenersExceeded) +- [ ] Renderer-Fehler-Capture: window.onerror + unhandledrejection + React ErrorBoundary + → über neuen Einweg-IPC `LOG_RENDERER_ERROR` ins Main-Log (kein stilles White-Screen) +- [ ] Memory-Heartbeat: im vorhandenen runtimeStatsTimer (60s), warn bei heapUsed/heapTotal > 0.9 + +## Tier 2 — Logging-Infrastruktur +- [ ] logger.ts: DEBUG-Level, gated über `RD_DEBUG` env (no-op wenn aus → keine Format-Kosten) +- [ ] error-ring.ts (NEU, pure+getestet): letzte N WARN/ERROR im RAM, gefüttert im write()-Chokepoint +- [ ] debug-server.ts: `/errors` Endpoint +- [ ] support-bundle.ts: overview/recent-errors.json + +## Tier 3 — Gezielte High-Value-Catches +- [ ] fs-error.ts (NEU, pure+getestet): classifyDiskError(err) — ENOSPC/EACCES/EROFS/EMFILE +- [ ] download-manager.ts: ENOSPC-Klassifizierung am vorhandenen Attempt-catch (reine Log-Anreicherung) +- [ ] download-manager.ts: Integrity-Check PASS ins item-log (Symmetrie: bisher nur Fail geloggt) + +## Verifikation — ERLEDIGT 2026-06-07 +- [x] Alle Tier 1/2/3 Punkte umgesetzt (siehe unten) +- [x] tsc = 6 (Baseline unverändert — eine versehentlich eingeführte `pkg`-Referenz sofort gefixt) +- [x] 728 Tests grün (715 Baseline + 13 neu: error-ring 5, fs-error/debug-gate 8), 41 Dateien +- [x] `npm run build` grün (tsup main 1.04MB + vite renderer 33 Module) +- [x] Grep-Konsistenz LOG_RENDERER_ERROR/reportRendererError über alle 5 Dateien (Kette lückenlos: + ipc.ts → preload-api.ts → preload.ts(send) → main.ts(ipcMain.on, einweg) → main.tsx + error-boundary.tsx) +- [x] Renderer-tsc-Abdeckung BEWIESEN (nicht angenommen): absichtlicher Typfehler in error-boundary.tsx + wurde von `tsc --noEmit` geflaggt → die neuen Renderer-Dateien sind echt typgeprüft (vite build prüft NICHT) +- [x] Advisor-Feinschliff: Memory-Heartbeat misst gegen `v8.getHeapStatistics().heap_size_limit` + (echte OOM-Decke) statt heapUsed/heapTotal — sonst Fehlalarm, der die Error-Ring zumüllt + +### Geänderte/neue Dateien +NEU: error-ring.ts, fs-error.ts, renderer/error-boundary.tsx, tests/error-ring.test.ts, tests/fs-error.test.ts +GEÄNDERT: logger.ts (DEBUG-Level+Gate+Ring-Feed), main.ts (4 Crash-Handler + Renderer-IPC), +app-controller.ts (Memory-Heartbeat), debug-server.ts (/errors), support-bundle.ts (recent-errors.json), +download-manager.ts (ENOSPC-Klassifizierung + Integrity-PASS-Log), shared: ipc.ts/types.ts/preload-api.ts, preload.ts, renderer/main.tsx + +### NOCH OFFEN +- [ ] Release (Gitea + GitHub-Mirror) — wartet auf User-Go ("jo mach releasen") + +--- + # Real-Debrid-Downloader — Analyse & Verbesserungen (2026-05-23) Tiefe Analyse via 3 parallele Subagents (Bugs / Features / UI) + 4 Design-Mockups. diff --git a/tests/error-ring.test.ts b/tests/error-ring.test.ts new file mode 100644 index 0000000..87708c1 --- /dev/null +++ b/tests/error-ring.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { createErrorRing } from "../src/main/error-ring"; + +describe("createErrorRing", () => { + it("keeps entries in insertion order", () => { + const ring = createErrorRing(10); + ring.push({ ts: "t1", level: "ERROR", message: "a" }); + ring.push({ ts: "t2", level: "WARN", message: "b" }); + expect(ring.snapshot().map((e) => e.message)).toEqual(["a", "b"]); + expect(ring.size()).toBe(2); + }); + + it("caps at capacity by dropping the oldest", () => { + const ring = createErrorRing(3); + for (const m of ["a", "b", "c", "d", "e"]) { + ring.push({ ts: m, level: "ERROR", message: m }); + } + expect(ring.snapshot().map((e) => e.message)).toEqual(["c", "d", "e"]); + expect(ring.size()).toBe(3); + }); + + it("snapshot returns a copy, not the live buffer", () => { + const ring = createErrorRing(5); + ring.push({ ts: "t", level: "WARN", message: "x" }); + const snap = ring.snapshot(); + snap.push({ ts: "t2", level: "ERROR", message: "injected" }); + expect(ring.snapshot().map((e) => e.message)).toEqual(["x"]); + }); + + it("clear empties the ring", () => { + const ring = createErrorRing(5); + ring.push({ ts: "t", level: "ERROR", message: "x" }); + ring.clear(); + expect(ring.snapshot()).toEqual([]); + expect(ring.size()).toBe(0); + }); + + it("coerces a non-positive capacity to at least 1", () => { + const ring = createErrorRing(0); + ring.push({ ts: "t1", level: "ERROR", message: "a" }); + ring.push({ ts: "t2", level: "ERROR", message: "b" }); + expect(ring.snapshot().map((e) => e.message)).toEqual(["b"]); + }); +}); diff --git a/tests/fs-error.test.ts b/tests/fs-error.test.ts new file mode 100644 index 0000000..fc95d6b --- /dev/null +++ b/tests/fs-error.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { classifyDiskError } from "../src/main/fs-error"; +import { isDebugFlagEnabled } from "../src/main/logger"; + +describe("classifyDiskError", () => { + it("maps ENOSPC from an error code to a disk-full reason", () => { + const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }); + expect(classifyDiskError(err)).toMatch(/Festplatte voll/); + }); + + it("maps EACCES from a code to a permission reason", () => { + const err = Object.assign(new Error("nope"), { code: "EACCES" }); + expect(classifyDiskError(err)).toMatch(/Zugriff verweigert/); + }); + + it("lower-case codes are normalized", () => { + const err = Object.assign(new Error("x"), { code: "enospc" }); + expect(classifyDiskError(err)).toMatch(/ENOSPC/); + }); + + it("falls back to scanning the message text when no code is present", () => { + expect(classifyDiskError(new Error("operation failed: ENOSPC on volume"))).toMatch(/Festplatte voll/); + }); + + it("handles a plain string error", () => { + expect(classifyDiskError("EROFS: read-only file system")).toMatch(/schreibgeschützt/); + }); + + it("returns null for an unrelated error", () => { + expect(classifyDiskError(new Error("write_drain_timeout"))).toBeNull(); + expect(classifyDiskError(new Error("premature close"))).toBeNull(); + expect(classifyDiskError(null)).toBeNull(); + expect(classifyDiskError(undefined)).toBeNull(); + }); +}); + +describe("isDebugFlagEnabled", () => { + it("is true for affirmative values", () => { + for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) { + expect(isDebugFlagEnabled(v)).toBe(true); + } + }); + + it("is false for empty/negative/garbage values", () => { + for (const v of [undefined, "", "0", "false", "off", "no", "maybe"]) { + expect(isDebugFlagEnabled(v)).toBe(false); + } + }); +});