diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class index fcefae8..aa67331 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class index 41ca622..54d4c8f 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class index 098d94e..5cf9f85 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class index f2ccd33..f570152 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class index 8c1da8f..1c0c20a 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class index 603dad7..050bbd2 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class index 10566c8..9013963 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class index 6826c5e..e4c49b0 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index ef9eef2..d4c29ce 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class differ diff --git a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java index 33f1fef..b60b275 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -51,6 +51,10 @@ public final class JBindExtractorMain { } public static void main(String[] args) { + if (args.length == 1 && "--daemon".equals(args[0])) { + runDaemon(); + return; + } int exit = 1; try { ExtractionRequest request = parseArgs(args); @@ -65,6 +69,127 @@ public final class JBindExtractorMain { System.exit(exit); } + private static void runDaemon() { + System.out.println("RD_DAEMON_READY"); + System.out.flush(); + java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(System.in, StandardCharsets.UTF_8)); + try { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + int exitCode = 1; + try { + ExtractionRequest request = parseDaemonRequest(line); + exitCode = runExtraction(request); + } catch (IllegalArgumentException error) { + emitError("Argumentfehler: " + safeMessage(error)); + exitCode = 2; + } catch (Throwable error) { + emitError(safeMessage(error)); + exitCode = 1; + } + System.out.println("RD_REQUEST_DONE " + exitCode); + System.out.flush(); + } + } catch (IOException ignored) { + // stdin closed — parent process exited + } + } + + private static ExtractionRequest parseDaemonRequest(String jsonLine) { + // Minimal JSON parsing without external dependencies. + // Expected format: {"archive":"...","target":"...","conflict":"...","backend":"...","passwords":["...","..."]} + ExtractionRequest request = new ExtractionRequest(); + request.archiveFile = new File(extractJsonString(jsonLine, "archive")); + request.targetDir = new File(extractJsonString(jsonLine, "target")); + String conflict = extractJsonString(jsonLine, "conflict"); + if (conflict.length() > 0) { + request.conflictMode = ConflictMode.fromValue(conflict); + } + String backend = extractJsonString(jsonLine, "backend"); + if (backend.length() > 0) { + request.backend = Backend.fromValue(backend); + } + // Parse passwords array + int pwStart = jsonLine.indexOf("\"passwords\""); + if (pwStart >= 0) { + int arrStart = jsonLine.indexOf('[', pwStart); + int arrEnd = jsonLine.indexOf(']', arrStart); + if (arrStart >= 0 && arrEnd > arrStart) { + String arrContent = jsonLine.substring(arrStart + 1, arrEnd); + int idx = 0; + while (idx < arrContent.length()) { + int qStart = arrContent.indexOf('"', idx); + if (qStart < 0) break; + int qEnd = findClosingQuote(arrContent, qStart + 1); + if (qEnd < 0) break; + request.passwords.add(unescapeJsonString(arrContent.substring(qStart + 1, qEnd))); + idx = qEnd + 1; + } + } + } + if (request.archiveFile == null || !request.archiveFile.exists() || !request.archiveFile.isFile()) { + throw new IllegalArgumentException("Archiv nicht gefunden: " + + (request.archiveFile == null ? "null" : request.archiveFile.getAbsolutePath())); + } + if (request.targetDir == null) { + throw new IllegalArgumentException("--target fehlt"); + } + return request; + } + + private static String extractJsonString(String json, String key) { + String search = "\"" + key + "\""; + int keyIdx = json.indexOf(search); + if (keyIdx < 0) return ""; + int colonIdx = json.indexOf(':', keyIdx + search.length()); + if (colonIdx < 0) return ""; + int qStart = json.indexOf('"', colonIdx + 1); + if (qStart < 0) return ""; + int qEnd = findClosingQuote(json, qStart + 1); + if (qEnd < 0) return ""; + return unescapeJsonString(json.substring(qStart + 1, qEnd)); + } + + private static int findClosingQuote(String s, int from) { + for (int i = from; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\') { + i++; // skip escaped character + continue; + } + if (c == '"') return i; + } + return -1; + } + + private static String unescapeJsonString(String s) { + if (s.indexOf('\\') < 0) return s; + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\' && i + 1 < s.length()) { + char next = s.charAt(i + 1); + switch (next) { + case '"': sb.append('"'); i++; break; + case '\\': sb.append('\\'); i++; break; + case '/': sb.append('/'); i++; break; + case 'n': sb.append('\n'); i++; break; + case 'r': sb.append('\r'); i++; break; + case 't': sb.append('\t'); i++; break; + default: sb.append(c); break; + } + } else { + sb.append(c); + } + } + return sb.toString(); + } + private static int runExtraction(ExtractionRequest request) throws Exception { List passwords = normalizePasswords(request.passwords); Exception lastError = null; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index a6a33d3..29158dd 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -6369,10 +6369,46 @@ export class DownloadManager extends EventEmitter { // Track multiple active archives for parallel hybrid extraction. // Using plain object instead of Map — Map.has() was mysteriously // returning false despite Map.set() being called with the same key. - const resolvedItemsCache: Record = Object.create(null); - const archiveStartTimesCache: Record = Object.create(null); + const hybridInitializedArchives = new Set(); + const hybridResolvedItems: Array<{ key: string; items: DownloadItem[] }> = []; + const hybridStartTimes: Array<{ key: string; time: number }> = []; let hybridLastEmitAt = 0; + const findHybridResolved = (key: string): DownloadItem[] | undefined => { + for (let i = 0; i < hybridResolvedItems.length; i++) { + if (hybridResolvedItems[i].key === key) return hybridResolvedItems[i].items; + } + return undefined; + }; + const setHybridResolved = (key: string, items: DownloadItem[]): void => { + for (let i = 0; i < hybridResolvedItems.length; i++) { + if (hybridResolvedItems[i].key === key) { hybridResolvedItems[i].items = items; return; } + } + hybridResolvedItems.push({ key, items }); + }; + const removeHybridResolved = (key: string): void => { + for (let i = hybridResolvedItems.length - 1; i >= 0; i--) { + if (hybridResolvedItems[i].key === key) { hybridResolvedItems.splice(i, 1); return; } + } + }; + const findHybridStartTime = (key: string): number | undefined => { + for (let i = 0; i < hybridStartTimes.length; i++) { + if (hybridStartTimes[i].key === key) return hybridStartTimes[i].time; + } + return undefined; + }; + const setHybridStartTime = (key: string, time: number): void => { + for (let i = 0; i < hybridStartTimes.length; i++) { + if (hybridStartTimes[i].key === key) { hybridStartTimes[i].time = time; return; } + } + hybridStartTimes.push({ key, time }); + }; + const removeHybridStartTime = (key: string): void => { + for (let i = hybridStartTimes.length - 1; i >= 0; i--) { + if (hybridStartTimes[i].key === key) { hybridStartTimes.splice(i, 1); return; } + } + }; + // Mark items based on whether their archive is actually ready for extraction. // Only items whose archive is in readyArchives get "Ausstehend"; others keep // "Warten auf Parts" to avoid flicker between hybrid runs. @@ -6420,17 +6456,19 @@ export class DownloadManager extends EventEmitter { // Do NOT mark remaining archives as "Done" here — some may have // failed. The post-extraction code (result.failed check) will // assign the correct label. Only clear the tracking caches. - for (const key of Object.keys(resolvedItemsCache)) delete resolvedItemsCache[key]; - for (const key of Object.keys(archiveStartTimesCache)) delete archiveStartTimesCache[key]; + hybridInitializedArchives.clear(); + hybridResolvedItems.length = 0; + hybridStartTimes.length = 0; return; } if (progress.archiveName) { // Resolve items for this archive if not yet tracked - if (!(progress.archiveName in resolvedItemsCache)) { + if (!hybridInitializedArchives.has(progress.archiveName)) { + hybridInitializedArchives.add(progress.archiveName); const resolved = resolveArchiveItems(progress.archiveName); - resolvedItemsCache[progress.archiveName] = resolved; - archiveStartTimesCache[progress.archiveName] = nowMs(); + setHybridResolved(progress.archiveName, resolved); + setHybridStartTime(progress.archiveName, nowMs()); if (resolved.length === 0) { logger.warn(`resolveArchiveItems (hybrid): KEINE Items gefunden für archiveName="${progress.archiveName}", items.length=${items.length}, itemNames=[${items.slice(0, 5).map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`); } else { @@ -6449,12 +6487,12 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } } - const archItems = resolvedItemsCache[progress.archiveName] || []; + const archItems = findHybridResolved(progress.archiveName) || []; // If archive is at 100%, mark its items as done and remove from active if (Number(progress.archivePercent ?? 0) >= 100) { const doneAt = nowMs(); - const startedAt = archiveStartTimesCache[progress.archiveName] || doneAt; + const startedAt = findHybridStartTime(progress.archiveName) || doneAt; const doneLabel = formatExtractDone(doneAt - startedAt); for (const entry of archItems) { if (!isExtractedLabel(entry.fullStatus)) { @@ -6462,8 +6500,9 @@ export class DownloadManager extends EventEmitter { entry.updatedAt = doneAt; } } - delete resolvedItemsCache[progress.archiveName]; - delete archiveStartTimesCache[progress.archiveName]; + hybridInitializedArchives.delete(progress.archiveName); + removeHybridResolved(progress.archiveName); + removeHybridStartTime(progress.archiveName); // Show transitional label while next archive initializes const done = progress.current + 1; if (done < progress.total) { @@ -6757,8 +6796,44 @@ export class DownloadManager extends EventEmitter { try { // Track multiple active archives for parallel extraction. // Using plain object — Map.has() had a mysterious caching failure. - const fullResolvedCache: Record = Object.create(null); - const fullStartTimesCache: Record = Object.create(null); + const fullInitializedArchives = new Set(); + const fullResolvedItems: Array<{ key: string; items: DownloadItem[] }> = []; + const fullStartTimes: Array<{ key: string; time: number }> = []; + + const findFullResolved = (key: string): DownloadItem[] | undefined => { + for (let i = 0; i < fullResolvedItems.length; i++) { + if (fullResolvedItems[i].key === key) return fullResolvedItems[i].items; + } + return undefined; + }; + const setFullResolved = (key: string, items: DownloadItem[]): void => { + for (let i = 0; i < fullResolvedItems.length; i++) { + if (fullResolvedItems[i].key === key) { fullResolvedItems[i].items = items; return; } + } + fullResolvedItems.push({ key, items }); + }; + const removeFullResolved = (key: string): void => { + for (let i = fullResolvedItems.length - 1; i >= 0; i--) { + if (fullResolvedItems[i].key === key) { fullResolvedItems.splice(i, 1); return; } + } + }; + const findFullStartTime = (key: string): number | undefined => { + for (let i = 0; i < fullStartTimes.length; i++) { + if (fullStartTimes[i].key === key) return fullStartTimes[i].time; + } + return undefined; + }; + const setFullStartTime = (key: string, time: number): void => { + for (let i = 0; i < fullStartTimes.length; i++) { + if (fullStartTimes[i].key === key) { fullStartTimes[i].time = time; return; } + } + fullStartTimes.push({ key, time }); + }; + const removeFullStartTime = (key: string): void => { + for (let i = fullStartTimes.length - 1; i >= 0; i--) { + if (fullStartTimes[i].key === key) { fullStartTimes.splice(i, 1); return; } + } + }; const result = await extractPackageArchives({ packageDir: pkg.outputDir, @@ -6785,18 +6860,20 @@ export class DownloadManager extends EventEmitter { // Do NOT mark remaining archives as "Done" here — some may have // failed. The post-extraction code (result.failed check) will // assign the correct label. Only clear the tracking caches. - for (const key of Object.keys(fullResolvedCache)) delete fullResolvedCache[key]; - for (const key of Object.keys(fullStartTimesCache)) delete fullStartTimesCache[key]; + fullInitializedArchives.clear(); + fullResolvedItems.length = 0; + fullStartTimes.length = 0; emitExtractStatus("Entpacken 100%", true); return; } if (progress.archiveName) { // Resolve items for this archive if not yet tracked - if (!(progress.archiveName in fullResolvedCache)) { + if (!fullInitializedArchives.has(progress.archiveName)) { + fullInitializedArchives.add(progress.archiveName); const resolved = resolveArchiveItems(progress.archiveName); - fullResolvedCache[progress.archiveName] = resolved; - fullStartTimesCache[progress.archiveName] = nowMs(); + setFullResolved(progress.archiveName, resolved); + setFullStartTime(progress.archiveName, nowMs()); if (resolved.length === 0) { logger.warn(`resolveArchiveItems (full): KEINE Items für archiveName="${progress.archiveName}", completedItems=${completedItems.length}, names=[${completedItems.slice(0, 5).map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`); } else { @@ -6813,12 +6890,12 @@ export class DownloadManager extends EventEmitter { emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true); } } - const archiveItems = fullResolvedCache[progress.archiveName] || []; + const archiveItems = findFullResolved(progress.archiveName) || []; // If archive is at 100%, mark its items as done and remove from active if (Number(progress.archivePercent ?? 0) >= 100) { const doneAt = nowMs(); - const startedAt = fullStartTimesCache[progress.archiveName] || doneAt; + const startedAt = findFullStartTime(progress.archiveName) || doneAt; const doneLabel = formatExtractDone(doneAt - startedAt); for (const entry of archiveItems) { if (!isExtractedLabel(entry.fullStatus)) { @@ -6826,8 +6903,9 @@ export class DownloadManager extends EventEmitter { entry.updatedAt = doneAt; } } - delete fullResolvedCache[progress.archiveName]; - delete fullStartTimesCache[progress.archiveName]; + fullInitializedArchives.delete(progress.archiveName); + removeFullResolved(progress.archiveName); + removeFullStartTime(progress.archiveName); // Show transitional label while next archive initializes const done = progress.current + 1; if (done < progress.total) { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index d975881..1962bfe 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -import { spawn, spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import AdmZip from "adm-zip"; import { CleanupMode, ConflictMode } from "../shared/types"; import { logger } from "./logger"; @@ -988,6 +988,274 @@ function parseJvmLine( } } +// ── Persistent JVM Daemon ── +// Keeps a single JVM process alive across multiple extraction requests, +// eliminating the ~5s JVM boot overhead per archive. + +interface DaemonRequest { + resolve: (result: JvmExtractResult) => void; + onArchiveProgress?: (percent: number) => void; + signal?: AbortSignal; + timeoutMs?: number; + parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }; +} + +let daemonProcess: ChildProcess | null = null; +let daemonReady = false; +let daemonBusy = false; +let daemonCurrentRequest: DaemonRequest | null = null; +let daemonStdoutBuffer = ""; +let daemonStderrBuffer = ""; +let daemonOutput = ""; +let daemonTimeoutId: NodeJS.Timeout | null = null; +let daemonAbortHandler: (() => void) | null = null; +let daemonLayout: JvmExtractorLayout | null = null; + +export function shutdownDaemon(): void { + if (daemonProcess) { + try { daemonProcess.stdin?.end(); } catch { /* ignore */ } + try { killProcessTree(daemonProcess); } catch { /* ignore */ } + daemonProcess = null; + } + daemonReady = false; + daemonBusy = false; + daemonCurrentRequest = null; + daemonStdoutBuffer = ""; + daemonStderrBuffer = ""; + daemonOutput = ""; + if (daemonTimeoutId) { clearTimeout(daemonTimeoutId); daemonTimeoutId = null; } + if (daemonAbortHandler) { daemonAbortHandler = null; } + daemonLayout = null; +} + +function finishDaemonRequest(result: JvmExtractResult): void { + const req = daemonCurrentRequest; + if (!req) return; + daemonCurrentRequest = null; + daemonBusy = false; + daemonStdoutBuffer = ""; + daemonStderrBuffer = ""; + daemonOutput = ""; + if (daemonTimeoutId) { clearTimeout(daemonTimeoutId); daemonTimeoutId = null; } + if (req.signal && daemonAbortHandler) { + req.signal.removeEventListener("abort", daemonAbortHandler); + daemonAbortHandler = null; + } + req.resolve(result); +} + +function handleDaemonLine(line: string): void { + const trimmed = String(line || "").trim(); + if (!trimmed) return; + + // Check for daemon ready signal + if (trimmed === "RD_DAEMON_READY") { + daemonReady = true; + logger.info("JVM Daemon bereit (persistent)"); + return; + } + + // Check for request completion + if (trimmed.startsWith("RD_REQUEST_DONE ")) { + const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10); + const req = daemonCurrentRequest; + if (!req) return; + + if (code === 0) { + req.onArchiveProgress?.(100); + finishDaemonRequest({ + ok: true, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: false, errorText: "", + usedPassword: req.parseState.usedPassword, backend: req.parseState.backend + }); + } else { + const message = cleanErrorText(req.parseState.reportedError || daemonOutput) || `Exit Code ${code}`; + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: isJvmRuntimeMissingError(message), + aborted: false, timedOut: false, errorText: message, + usedPassword: req.parseState.usedPassword, backend: req.parseState.backend + }); + } + return; + } + + // Regular progress/status lines — delegate to parseJvmLine + if (daemonCurrentRequest) { + parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState); + } +} + +function startDaemon(layout: JvmExtractorLayout): boolean { + if (daemonProcess && daemonReady) return true; + shutdownDaemon(); + + const jvmTmpDir = path.join(os.tmpdir(), `rd-extract-daemon-${crypto.randomUUID()}`); + fs.mkdirSync(jvmTmpDir, { recursive: true }); + + const args = [ + "-Dfile.encoding=UTF-8", + `-Djava.io.tmpdir=${jvmTmpDir}`, + "-Xms512m", + "-Xmx8g", + "-XX:+UseSerialGC", + "-cp", + layout.classPath, + JVM_EXTRACTOR_MAIN_CLASS, + "--daemon" + ]; + + try { + const child = spawn(layout.javaCommand, args, { + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"] + }); + lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); + daemonProcess = child; + daemonLayout = layout; + + child.stdout!.on("data", (chunk) => { + const raw = String(chunk || ""); + daemonOutput = appendLimited(daemonOutput, raw); + daemonStdoutBuffer += raw; + const lines = daemonStdoutBuffer.split(/\r?\n/); + daemonStdoutBuffer = lines.pop() || ""; + for (const line of lines) { + handleDaemonLine(line); + } + }); + + child.stderr!.on("data", (chunk) => { + const raw = String(chunk || ""); + daemonOutput = appendLimited(daemonOutput, raw); + daemonStderrBuffer += raw; + const lines = daemonStderrBuffer.split(/\r?\n/); + daemonStderrBuffer = lines.pop() || ""; + for (const line of lines) { + if (daemonCurrentRequest) { + parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState); + } + } + }); + + child.on("error", () => { + if (daemonCurrentRequest) { + finishDaemonRequest({ + ok: false, missingCommand: true, missingRuntime: true, + aborted: false, timedOut: false, errorText: "Daemon process error", + usedPassword: "", backend: "" + }); + } + shutdownDaemon(); + }); + + child.on("close", () => { + if (daemonCurrentRequest) { + const req = daemonCurrentRequest; + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: false, + errorText: cleanErrorText(req.parseState.reportedError || daemonOutput) || "Daemon process exited unexpectedly", + usedPassword: req.parseState.usedPassword, backend: req.parseState.backend + }); + } + // Clean up tmp dir + fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {}); + daemonProcess = null; + daemonReady = false; + daemonBusy = false; + daemonLayout = null; + }); + + logger.info(`JVM Daemon gestartet (PID ${child.pid})`); + return true; + } catch (error) { + logger.warn(`JVM Daemon Start fehlgeschlagen: ${String(error)}`); + return false; + } +} + +function isDaemonAvailable(layout: JvmExtractorLayout): boolean { + // Start daemon if not running yet + if (!daemonProcess || !daemonReady) { + startDaemon(layout); + } + return Boolean(daemonProcess && daemonReady && !daemonBusy); +} + +function sendDaemonRequest( + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + passwordCandidates: string[], + onArchiveProgress?: (percent: number) => void, + signal?: AbortSignal, + timeoutMs?: number +): Promise { + return new Promise((resolve) => { + const mode = effectiveConflictMode(conflictMode); + const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" }; + + daemonBusy = true; + daemonOutput = ""; + daemonCurrentRequest = { resolve, onArchiveProgress, signal, timeoutMs, parseState }; + + // Set up timeout + if (timeoutMs && timeoutMs > 0) { + daemonTimeoutId = setTimeout(() => { + // Timeout — kill the daemon and restart fresh for next request + const req = daemonCurrentRequest; + if (req) { + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: true, + errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`, + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + } + shutdownDaemon(); + }, timeoutMs); + } + + // Set up abort handler + if (signal) { + daemonAbortHandler = () => { + const req = daemonCurrentRequest; + if (req) { + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: true, timedOut: false, errorText: "aborted:extract", + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + } + // Kill daemon on abort — cleaner than trying to interrupt mid-extraction + shutdownDaemon(); + }; + signal.addEventListener("abort", daemonAbortHandler, { once: true }); + } + + // Build and send JSON request + const jsonRequest = JSON.stringify({ + archive: archivePath, + target: targetDir, + conflict: mode, + backend: "auto", + passwords: passwordCandidates + }); + + try { + daemonProcess!.stdin!.write(jsonRequest + "\n"); + } catch (error) { + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: false, + errorText: `Daemon stdin write failed: ${String(error)}`, + usedPassword: "", backend: "" + }); + shutdownDaemon(); + } + }); +} + function runJvmExtractCommand( layout: JvmExtractorLayout, archivePath: string, @@ -1011,6 +1279,15 @@ function runJvmExtractCommand( }); } + // Try persistent daemon first — saves ~5s JVM boot per archive + if (isDaemonAvailable(layout)) { + logger.info(`JVM Daemon: Sende Request für ${path.basename(archivePath)}`); + return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs); + } + + // Fallback: spawn a new JVM process (daemon busy or not available) + logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}${daemonBusy ? " (Daemon busy)" : ""}`); + const mode = effectiveConflictMode(conflictMode); // Each JVM process needs its own temp dir so parallel SevenZipJBinding // instances don't fight over the same native DLL file lock. diff --git a/src/main/main.ts b/src/main/main.ts index 053f413..562e407 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -7,7 +7,7 @@ import { IPC_CHANNELS } from "../shared/ipc"; import { getLogFilePath, logger } from "./logger"; import { APP_NAME } from "./constants"; import { extractHttpLinksFromText } from "./utils"; -import { cleanupStaleSubstDrives } from "./extractor"; +import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor"; /* ── IPC validation helpers ────────────────────────────────────── */ function validateString(value: unknown, name: string): string { @@ -515,6 +515,7 @@ app.on("before-quit", () => { if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; } stopClipboardWatcher(); destroyTray(); + shutdownDaemon(); try { controller.shutdown(); } catch (error) {