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:
parent
738ae2e2d5
commit
21803f316b
@ -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()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
|
.then((body) => {
|
||||||
fileName,
|
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
|
||||||
sizeBytes: body.length
|
fileName,
|
||||||
});
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
67
tests/support-bundle.test.ts
Normal file
67
tests/support-bundle.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user