Fix: Support-Bundle-Export friert die UI nicht mehr 1-2 Minuten ein (asynchrones IO)

buildSupportBundle lief komplett synchron auf dem Electron-Main-Thread:
existsSync/readdirSync/statSync/readFileSync (via AdmZip.addLocalFile) ueber
ALLE Log-Dateien — bei Hunderten item-logs und gleichzeitig laufenden
Downloads (Platte ausgelastet) blockierte das den Event-Loop fuer 1-2 min,
die ganze Oberflaeche stand. Live belegt: ein Bundle mit Hunderten
item-logs-Dateien.

Fix: Alle Datei-Zugriffe im Bundle-Build auf fs.promises (nicht-blockierend)
umgestellt, buildSupportBundle ist jetzt async und liefert Promise<Buffer>.
Dateiinhalte werden per readFile gelesen und mit zip.addFile(buffer)
hinzugefuegt (gleiche Zip-Struktur wie vorher). Der Event-Loop bleibt
waehrend der IO-Wartezeiten frei → UI friert nicht mehr ein. Caller
angepasst (app-controller.exportSupportBundle async, main.ts await,
debug-server via .then).

Test support-bundle.test.ts: valides Zip mit Overview + echter Datei;
ein paralleler Timer feuert waehrend des Builds (Event-Loop nicht blockiert).
840/840 gruen, tsc unveraendert (6), Build ok.
This commit is contained in:
Sucukdeluxe 2026-06-17 03:14:01 +02:00
parent 738ae2e2d5
commit 21803f316b
5 changed files with 130 additions and 50 deletions

View File

@ -661,14 +661,14 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
return encryptBackup(JSON.stringify(payloadObj)); return encryptBackup(JSON.stringify(payloadObj));
} }
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
this.audit("INFO", "Support-Bundle exportiert"); this.audit("INFO", "Support-Bundle exportiert");
logTraceEvent("INFO", "support", "Support-Bundle erstellt", { logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
itemCount: Object.keys(this.manager.getSnapshot().session.items).length itemCount: Object.keys(this.manager.getSnapshot().session.items).length
}); });
return { return {
buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }), buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
defaultFileName: getSupportBundleDefaultFileName() defaultFileName: getSupportBundleDefaultFileName()
}; };
} }

View File

@ -837,12 +837,17 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
const fileName = getSupportBundleDefaultFileName(); const fileName = getSupportBundleDefaultFileName();
const body = buildSupportBundle(manager, runtimeBaseDir); buildSupportBundle(manager, runtimeBaseDir)
.then((body) => {
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", { logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
fileName, fileName,
sizeBytes: body.length sizeBytes: body.length
}); });
binaryResponse(res, 200, body, "application/zip", fileName); binaryResponse(res, 200, body, "application/zip", fileName);
})
.catch((error) => {
jsonResponse(res, 500, { error: String((error as { message?: string })?.message || error) });
});
return; return;
} }

View File

@ -592,7 +592,7 @@ function registerIpcHandlers(): void {
if (result.canceled || !result.filePath) { if (result.canceled || !result.filePath) {
return { saved: false }; return { saved: false };
} }
const exported = controller.exportSupportBundle(); const exported = await controller.exportSupportBundle();
await fs.promises.writeFile(result.filePath, exported.buffer); await fs.promises.writeFile(result.filePath, exported.buffer);
return { saved: true, filePath: result.filePath }; return { saved: true, filePath: result.filePath };
}); });

View File

@ -1,4 +1,4 @@
import fs from "node:fs"; import { promises as fsp } from "node:fs";
import path from "node:path"; import path from "node:path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { APP_VERSION } from "./constants"; import { APP_VERSION } from "./constants";
@ -20,9 +20,9 @@ import type { DownloadManager } from "./download-manager";
const AI_MANIFEST_FILE = "debug_ai_manifest.json"; const AI_MANIFEST_FILE = "debug_ai_manifest.json";
function safeReadJson(filePath: string): unknown { async function safeReadJson(filePath: string): Promise<unknown> {
try { try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
} catch { } catch {
return null; return null;
} }
@ -32,42 +32,50 @@ function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")); zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
} }
function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): void { async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
if (!sourcePath || !fs.existsSync(sourcePath)) { if (!sourcePath) {
return; return;
} }
zip.addLocalFile(sourcePath, path.posix.dirname(zipPath), path.posix.basename(zipPath)); try {
const buffer = await fsp.readFile(sourcePath);
zip.addFile(zipPath, buffer);
} catch {
}
} }
function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): void { async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
if (!fs.existsSync(dirPath)) { let entries;
try {
entries = await fsp.readdir(dirPath, { withFileTypes: true });
} catch {
return; return;
} }
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name); const fullPath = path.join(dirPath, entry.name);
const zipPath = path.posix.join(zipRoot, entry.name); const zipPath = path.posix.join(zipRoot, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
addDirectoryIfExists(zip, fullPath, zipPath); await addDirectoryIfExists(zip, fullPath, zipPath);
continue; continue;
} }
zip.addLocalFile(fullPath, path.posix.dirname(zipPath), path.posix.basename(zipPath)); await addFileIfExists(zip, fullPath, zipPath);
} }
} }
function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number { async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
if (!fs.existsSync(dirPath)) { let entries;
try {
entries = await fsp.readdir(dirPath, { withFileTypes: true });
} catch {
return 0; return 0;
} }
const cutoff = Date.now() - maxAgeMs; const cutoff = Date.now() - maxAgeMs;
let added = 0; let added = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
if (!entry.isFile()) continue; if (!entry.isFile()) continue;
const fullPath = path.join(dirPath, entry.name); const fullPath = path.join(dirPath, entry.name);
try { try {
if (fs.statSync(fullPath).mtimeMs >= cutoff) { if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
zip.addLocalFile(fullPath, zipRoot, entry.name); await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
added += 1; added += 1;
} }
} catch { } } catch { }
@ -127,7 +135,7 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
return getWindowsHostDiagnostics(); return getWindowsHostDiagnostics();
} }
export function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Buffer { export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
const zip = new AdmZip(); const zip = new AdmZip();
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full"; const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
const storagePaths = createStoragePaths(baseDir); const storagePaths = createStoragePaths(baseDir);
@ -175,39 +183,39 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
const recentErrors = getRecentErrors(); const recentErrors = getRecentErrors();
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }); addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`); await addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt"); await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json"); await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old"); await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
addFileIfExists(zip, getAuditLogPath(), "logs/audit.log"); await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old"); await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log"); await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old"); await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt"); await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log"); await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old"); await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log"); await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old"); await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000; const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
for (const packageId of packageIds) { for (const packageId of packageIds) {
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`); await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
} }
for (const itemId of itemIds) { for (const itemId of itemIds) {
addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`); await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
} }
const aiManifest = safeReadJson(path.join(baseDir, AI_MANIFEST_FILE)); const aiManifest = await safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
if (aiManifest) { if (aiManifest) {
addJson(zip, "overview/ai-manifest.json", aiManifest); addJson(zip, "overview/ai-manifest.json", aiManifest);
} }

View File

@ -0,0 +1,67 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest";
import { buildSupportBundle } from "../src/main/support-bundle";
import type { DownloadManager } from "../src/main/download-manager";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
}
});
function fakeManager(): DownloadManager {
const snapshot = {
stats: {},
session: { packages: {}, items: {}, packageOrder: [] },
speedText: "",
etaText: "",
canStart: false,
canStop: false,
canPause: false
};
return {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} as unknown as DownloadManager;
}
describe("buildSupportBundle (async, non-blocking)", () => {
it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8");
const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
expect(promise).toBeInstanceOf(Promise);
const buffer = await promise;
expect(Buffer.isBuffer(buffer)).toBe(true);
expect(buffer.length).toBeGreaterThan(0);
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
expect(entries).toContain("overview/meta.json");
expect(entries).toContain("overview/settings.json");
expect(entries).toContain("runtime/debug_host.txt");
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
});
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
let timerFired = false;
const timer = setTimeout(() => { timerFired = true; }, 0);
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(timerFired).toBe(true);
});
});