Compare commits
2 Commits
2ececf699a
...
20c803302d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c803302d | ||
|
|
468df99142 |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.7.184",
|
||||
"version": "1.7.185",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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") || "";
|
||||
|
||||
@ -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:")
|
||||
|
||||
45
src/main/error-ring.ts
Normal file
45
src/main/error-ring.ts
Normal 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
56
src/main/fs-error.ts
Normal 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 ?? "");
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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<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", () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
@ -88,6 +89,7 @@ const api: ElectronApi = {
|
||||
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_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),
|
||||
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);
|
||||
|
||||
94
src/renderer/error-boundary.tsx
Normal file
94
src/renderer/error-boundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<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");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element fehlt");
|
||||
@ -10,6 +41,8 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
@ -85,6 +86,7 @@ export interface ElectronApi {
|
||||
skipItems: (itemIds: string[]) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
startItems: (itemIds: string[]) => Promise<void>;
|
||||
reportRendererError: (report: RendererErrorReport) => void;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
44
tests/error-ring.test.ts
Normal file
44
tests/error-ring.test.ts
Normal 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
49
tests/fs-error.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user