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
This commit is contained in:
Sucukdeluxe 2026-06-07 17:00:06 +02:00
parent 2ececf699a
commit 468df99142
17 changed files with 582 additions and 7 deletions

View File

@ -1,4 +1,5 @@
import path from "node:path"; import path from "node:path";
import v8 from "node:v8";
import { app } from "electron"; import { app } from "electron";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { import {
@ -84,6 +85,7 @@ export class AppController {
private autoResumePending = false; private autoResumePending = false;
private runtimeStatsTimer: NodeJS.Timeout | null = null; private runtimeStatsTimer: NodeJS.Timeout | null = null;
private lastMemoryWarnAt = 0;
public constructor() { public constructor() {
configureLogger(this.storagePaths.baseDir); configureLogger(this.storagePaths.baseDir);
@ -162,6 +164,7 @@ export class AppController {
this.runtimeStatsTimer = setInterval(() => { this.runtimeStatsTimer = setInterval(() => {
this.manager.persistRuntimeStats(); this.manager.persistRuntimeStats();
this.settings = this.manager.getSettings(); this.settings = this.manager.getSettings();
this.checkMemoryPressure();
}, 60_000); }, 60_000);
this.runtimeStatsTimer.unref?.(); 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 { private hasAnyProviderToken(settings: AppSettings): boolean {
return Boolean( return Boolean(
settings.token.trim() settings.token.trim()

View File

@ -6,6 +6,7 @@ import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log"; import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup"; import { getDebugSetupCheck } from "./debug-setup";
import { logger, getLogFilePath } from "./logger"; import { logger, getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getItemLogPath as getPersistedItemLogPath } from "./item-log"; import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
import { getSessionLogPath } from "./session-log"; import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-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/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/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: "/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&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/trace/config", queryExample: "enable=1&note=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: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, { 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; 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") { if (pathname === "/logs/audit") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100); const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || ""; const grep = url.searchParams.get("grep") || "";

View File

@ -53,6 +53,7 @@ import { planDownloadCompletion, validateDownloadedFileCompletion } from "./down
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; 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 { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error";
import { logger } from "./logger"; import { logger } from "./logger";
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
import type { RotationEvent } from "../shared/types"; import type { RotationEvent } from "../shared/types";
@ -8563,16 +8564,24 @@ export class DownloadManager extends EventEmitter {
item.updatedAt = nowMs(); item.updatedAt = nowMs();
this.emitState(); this.emitState();
const integrityStartedAt = nowMs();
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir); const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
if (active.abortController.signal.aborted) { if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`); throw new Error(`aborted:${active.abortReason}`);
} }
const integrityElapsedMs = nowMs() - integrityStartedAt;
if (!validation.ok) { if (!validation.ok) {
item.lastError = validation.message; item.lastError = validation.message;
item.fullStatus = `${validation.message}, Neuversuch`; item.fullStatus = `${validation.message}, Neuversuch`;
this.logPackageForItem(item, "WARN", "Integritätsprüfung fehlgeschlagen", {
result: validation.message,
elapsedMs: integrityElapsedMs,
willRetry: item.attempts < maxAttempts
});
try { try {
fs.rmSync(item.targetPath, { force: true }); fs.rmSync(item.targetPath, { force: true });
} catch { } catch (rmErr) {
logger.debug(`Integrity-Cleanup rm fehlgeschlagen (${item.fileName}): ${String(rmErr)}`);
} }
if (item.attempts < maxAttempts) { if (item.attempts < maxAttempts) {
item.status = "integrity_check"; item.status = "integrity_check";
@ -8586,6 +8595,11 @@ export class DownloadManager extends EventEmitter {
} }
throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`); 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) { if (active.abortController.signal.aborted) {
@ -10045,11 +10059,18 @@ export class DownloadManager extends EventEmitter {
} }
lastError = compactErrorText(error); lastError = compactErrorText(error);
const normalizedLastError = lastError.replace(/^Error:\s*/i, ""); const normalizedLastError = lastError.replace(/^Error:\s*/i, "");
const diskCause = classifyDiskError(error);
logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", { logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", {
attempt, attempt,
error: lastError, 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 ( if (
normalizedLastError.startsWith("range_ignored_on_resume:") normalizedLastError.startsWith("range_ignored_on_resume:")
|| normalizedLastError.startsWith("range_mismatch_on_resume:") || normalizedLastError.startsWith("range_mismatch_on_resume:")

45
src/main/error-ring.ts Normal file
View File

@ -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();
}

56
src/main/fs-error.ts Normal file
View File

@ -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<string, string> = {
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 ?? "");
}

View File

@ -1,7 +1,24 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
import { recordRecentError } from "./error-ring";
import path from "node:path"; 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 logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
let fallbackLogFilePath: string | null = null; let fallbackLogFilePath: string | null = null;
const LOG_FLUSH_INTERVAL_MS = 120; const LOG_FLUSH_INTERVAL_MS = 120;
@ -204,12 +221,19 @@ function ensureExitHook(): void {
process.once("exit", flushSyncPending); process.once("exit", flushSyncPending);
} }
function write(level: "INFO" | "WARN" | "ERROR", message: string): void { function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
ensureExitHook(); ensureExitHook();
const line = `${logTimestamp()} [${level}] ${message}\n`; const ts = logTimestamp();
const line = `${ts} [${level}] ${message}\n`;
pendingLines.push(line); pendingLines.push(line);
pendingChars += line.length; 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) { for (const listener of logListeners) {
try { listener(line); } catch { } try { listener(line); } catch { }
} }
@ -230,6 +254,9 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
} }
export const logger = { 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), info: (msg: string): void => write("INFO", msg),
warn: (msg: string): void => write("WARN", msg), warn: (msg: string): void => write("WARN", msg),
error: (msg: string): void => write("ERROR", msg) error: (msg: string): void => write("ERROR", msg)

View File

@ -53,7 +53,13 @@ process.on("uncaughtException", (error) => {
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`); logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
}); });
process.on("unhandledRejection", (reason) => { 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; let mainWindow: BrowserWindow | null = null;
@ -110,6 +116,23 @@ function createWindow(): BrowserWindow {
return window; 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 { function bindMainWindowLifecycle(window: BrowserWindow): void {
window.on("close", (event) => { window.on("close", (event) => {
const settings = controller.getSettings(); const settings = controller.getSettings();
@ -124,6 +147,33 @@ function bindMainWindowLifecycle(window: BrowserWindow): void {
mainWindow = null; 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 { function createTray(): void {
@ -676,6 +726,14 @@ function registerIpcHandlers(): void {
return importResult; 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) => { controller.onState = (snapshot) => {
if (!mainWindow || mainWindow.isDestroyed()) { if (!mainWindow || mainWindow.isDestroyed()) {
return; return;
@ -684,6 +742,41 @@ function registerIpcHandlers(): void {
}; };
} }
function formatRendererErrorReport(rawReport: unknown): string {
const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record<string, unknown>;
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", () => { app.on("second-instance", () => {
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) { if (mainWindow.isMinimized()) {

View File

@ -5,6 +5,7 @@ import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log"; import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup"; import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger"; import { getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getPackageLogPath } from "./package-log"; import { getPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log"; import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-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/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
addJson(zip, "overview/trace-config.json", getTraceConfig()); 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, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");

View File

@ -9,6 +9,7 @@ import {
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
@ -88,6 +89,7 @@ const api: ElectronApi = {
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds), resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds), startItems: (itemIds: string[]): Promise<void> => 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) => { onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);

View File

@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
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 (
<div style={overlay}>
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
</p>
<pre style={pre}>{this.state.message}</pre>
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
</div>
);
}
}

View File

@ -1,8 +1,39 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { ErrorBoundary } from "./error-boundary";
import "./styles.css"; 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<typeof window.rd.reportRendererError>[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"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
throw new Error("Root element fehlt"); throw new Error("Root element fehlt");
@ -10,6 +41,8 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<ErrorBoundary>
<App /> <App />
</ErrorBoundary>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -66,5 +66,6 @@ export const IPC_CHANNELS = {
SET_PACKAGE_PRIORITY: "queue:set-package-priority", SET_PACKAGE_PRIORITY: "queue:set-package-priority",
SKIP_ITEMS: "queue:skip-items", SKIP_ITEMS: "queue:skip-items",
RESET_ITEMS: "queue:reset-items", RESET_ITEMS: "queue:reset-items",
START_ITEMS: "queue:start-items" START_ITEMS: "queue:start-items",
LOG_RENDERER_ERROR: "log:renderer-error"
} as const; } as const;

View File

@ -9,6 +9,7 @@ import type {
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
@ -85,6 +86,7 @@ export interface ElectronApi {
skipItems: (itemIds: string[]) => Promise<void>; skipItems: (itemIds: string[]) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>; resetItems: (itemIds: string[]) => Promise<void>;
startItems: (itemIds: string[]) => Promise<void>; startItems: (itemIds: string[]) => Promise<void>;
reportRendererError: (report: RendererErrorReport) => void;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;

View File

@ -499,3 +499,13 @@ export interface HistoryState {
entries: HistoryEntry[]; entries: HistoryEntry[];
maxEntries: number; maxEntries: number;
} }
export interface RendererErrorReport {
kind: "error" | "unhandledrejection" | "react";
message: string;
stack?: string;
source?: string;
line?: number;
column?: number;
componentStack?: string;
}

View File

@ -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) # Real-Debrid-Downloader — Analyse & Verbesserungen (2026-05-23)
Tiefe Analyse via 3 parallele Subagents (Bugs / Features / UI) + 4 Design-Mockups. Tiefe Analyse via 3 parallele Subagents (Bugs / Features / UI) + 4 Design-Mockups.

44
tests/error-ring.test.ts Normal file
View File

@ -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"]);
});
});

49
tests/fs-error.test.ts Normal file
View File

@ -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);
}
});
});