Add persistent JVM daemon for extraction, fix caching with Set+Array

- JVM extractor now supports --daemon mode: starts once, processes
  multiple archives via stdin JSON protocol, eliminating ~5s JVM boot
  per archive
- TypeScript side: daemon manager starts JVM once, sends requests via
  stdin, falls back to spawning new process if daemon is busy
- Fix extraction progress caching: replaced Object.create(null) + in
  operator with Set<string> + linear Array scan — both Map.has() and
  the in operator mysteriously failed to find keys that were just set
- Daemon auto-shutdown on app quit via shutdownDaemon() in before-quit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-05 05:59:13 +01:00
parent c36549ca69
commit 5b221d5bd5
13 changed files with 505 additions and 24 deletions

View File

@ -51,6 +51,10 @@ public final class JBindExtractorMain {
} }
public static void main(String[] args) { public static void main(String[] args) {
if (args.length == 1 && "--daemon".equals(args[0])) {
runDaemon();
return;
}
int exit = 1; int exit = 1;
try { try {
ExtractionRequest request = parseArgs(args); ExtractionRequest request = parseArgs(args);
@ -65,6 +69,127 @@ public final class JBindExtractorMain {
System.exit(exit); 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 { private static int runExtraction(ExtractionRequest request) throws Exception {
List<String> passwords = normalizePasswords(request.passwords); List<String> passwords = normalizePasswords(request.passwords);
Exception lastError = null; Exception lastError = null;

View File

@ -6369,10 +6369,46 @@ export class DownloadManager extends EventEmitter {
// Track multiple active archives for parallel hybrid extraction. // Track multiple active archives for parallel hybrid extraction.
// Using plain object instead of Map — Map.has() was mysteriously // Using plain object instead of Map — Map.has() was mysteriously
// returning false despite Map.set() being called with the same key. // returning false despite Map.set() being called with the same key.
const resolvedItemsCache: Record<string, DownloadItem[]> = Object.create(null); const hybridInitializedArchives = new Set<string>();
const archiveStartTimesCache: Record<string, number> = Object.create(null); const hybridResolvedItems: Array<{ key: string; items: DownloadItem[] }> = [];
const hybridStartTimes: Array<{ key: string; time: number }> = [];
let hybridLastEmitAt = 0; 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. // Mark items based on whether their archive is actually ready for extraction.
// Only items whose archive is in readyArchives get "Ausstehend"; others keep // Only items whose archive is in readyArchives get "Ausstehend"; others keep
// "Warten auf Parts" to avoid flicker between hybrid runs. // "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 // Do NOT mark remaining archives as "Done" here — some may have
// failed. The post-extraction code (result.failed check) will // failed. The post-extraction code (result.failed check) will
// assign the correct label. Only clear the tracking caches. // assign the correct label. Only clear the tracking caches.
for (const key of Object.keys(resolvedItemsCache)) delete resolvedItemsCache[key]; hybridInitializedArchives.clear();
for (const key of Object.keys(archiveStartTimesCache)) delete archiveStartTimesCache[key]; hybridResolvedItems.length = 0;
hybridStartTimes.length = 0;
return; return;
} }
if (progress.archiveName) { if (progress.archiveName) {
// Resolve items for this archive if not yet tracked // 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); const resolved = resolveArchiveItems(progress.archiveName);
resolvedItemsCache[progress.archiveName] = resolved; setHybridResolved(progress.archiveName, resolved);
archiveStartTimesCache[progress.archiveName] = nowMs(); setHybridStartTime(progress.archiveName, nowMs());
if (resolved.length === 0) { 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(", ")}]`); 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 { } else {
@ -6449,12 +6487,12 @@ export class DownloadManager extends EventEmitter {
this.emitState(true); 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 archive is at 100%, mark its items as done and remove from active
if (Number(progress.archivePercent ?? 0) >= 100) { if (Number(progress.archivePercent ?? 0) >= 100) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = archiveStartTimesCache[progress.archiveName] || doneAt; const startedAt = findHybridStartTime(progress.archiveName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt); const doneLabel = formatExtractDone(doneAt - startedAt);
for (const entry of archItems) { for (const entry of archItems) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
@ -6462,8 +6500,9 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = doneAt; entry.updatedAt = doneAt;
} }
} }
delete resolvedItemsCache[progress.archiveName]; hybridInitializedArchives.delete(progress.archiveName);
delete archiveStartTimesCache[progress.archiveName]; removeHybridResolved(progress.archiveName);
removeHybridStartTime(progress.archiveName);
// Show transitional label while next archive initializes // Show transitional label while next archive initializes
const done = progress.current + 1; const done = progress.current + 1;
if (done < progress.total) { if (done < progress.total) {
@ -6757,8 +6796,44 @@ export class DownloadManager extends EventEmitter {
try { try {
// Track multiple active archives for parallel extraction. // Track multiple active archives for parallel extraction.
// Using plain object — Map.has() had a mysterious caching failure. // Using plain object — Map.has() had a mysterious caching failure.
const fullResolvedCache: Record<string, DownloadItem[]> = Object.create(null); const fullInitializedArchives = new Set<string>();
const fullStartTimesCache: Record<string, number> = Object.create(null); 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({ const result = await extractPackageArchives({
packageDir: pkg.outputDir, packageDir: pkg.outputDir,
@ -6785,18 +6860,20 @@ export class DownloadManager extends EventEmitter {
// Do NOT mark remaining archives as "Done" here — some may have // Do NOT mark remaining archives as "Done" here — some may have
// failed. The post-extraction code (result.failed check) will // failed. The post-extraction code (result.failed check) will
// assign the correct label. Only clear the tracking caches. // assign the correct label. Only clear the tracking caches.
for (const key of Object.keys(fullResolvedCache)) delete fullResolvedCache[key]; fullInitializedArchives.clear();
for (const key of Object.keys(fullStartTimesCache)) delete fullStartTimesCache[key]; fullResolvedItems.length = 0;
fullStartTimes.length = 0;
emitExtractStatus("Entpacken 100%", true); emitExtractStatus("Entpacken 100%", true);
return; return;
} }
if (progress.archiveName) { if (progress.archiveName) {
// Resolve items for this archive if not yet tracked // 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); const resolved = resolveArchiveItems(progress.archiveName);
fullResolvedCache[progress.archiveName] = resolved; setFullResolved(progress.archiveName, resolved);
fullStartTimesCache[progress.archiveName] = nowMs(); setFullStartTime(progress.archiveName, nowMs());
if (resolved.length === 0) { 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(", ")}]`); 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 { } else {
@ -6813,12 +6890,12 @@ export class DownloadManager extends EventEmitter {
emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true); 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 archive is at 100%, mark its items as done and remove from active
if (Number(progress.archivePercent ?? 0) >= 100) { if (Number(progress.archivePercent ?? 0) >= 100) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = fullStartTimesCache[progress.archiveName] || doneAt; const startedAt = findFullStartTime(progress.archiveName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt); const doneLabel = formatExtractDone(doneAt - startedAt);
for (const entry of archiveItems) { for (const entry of archiveItems) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
@ -6826,8 +6903,9 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = doneAt; entry.updatedAt = doneAt;
} }
} }
delete fullResolvedCache[progress.archiveName]; fullInitializedArchives.delete(progress.archiveName);
delete fullStartTimesCache[progress.archiveName]; removeFullResolved(progress.archiveName);
removeFullStartTime(progress.archiveName);
// Show transitional label while next archive initializes // Show transitional label while next archive initializes
const done = progress.current + 1; const done = progress.current + 1;
if (done < progress.total) { if (done < progress.total) {

View File

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; 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 AdmZip from "adm-zip";
import { CleanupMode, ConflictMode } from "../shared/types"; import { CleanupMode, ConflictMode } from "../shared/types";
import { logger } from "./logger"; 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<JvmExtractResult> {
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( function runJvmExtractCommand(
layout: JvmExtractorLayout, layout: JvmExtractorLayout,
archivePath: string, 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); const mode = effectiveConflictMode(conflictMode);
// Each JVM process needs its own temp dir so parallel SevenZipJBinding // Each JVM process needs its own temp dir so parallel SevenZipJBinding
// instances don't fight over the same native DLL file lock. // instances don't fight over the same native DLL file lock.

View File

@ -7,7 +7,7 @@ import { IPC_CHANNELS } from "../shared/ipc";
import { getLogFilePath, logger } from "./logger"; import { getLogFilePath, logger } from "./logger";
import { APP_NAME } from "./constants"; import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils"; import { extractHttpLinksFromText } from "./utils";
import { cleanupStaleSubstDrives } from "./extractor"; import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
/* ── IPC validation helpers ────────────────────────────────────── */ /* ── IPC validation helpers ────────────────────────────────────── */
function validateString(value: unknown, name: string): string { function validateString(value: unknown, name: string): string {
@ -515,6 +515,7 @@ app.on("before-quit", () => {
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; } if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
stopClipboardWatcher(); stopClipboardWatcher();
destroyTray(); destroyTray();
shutdownDaemon();
try { try {
controller.shutdown(); controller.shutdown();
} catch (error) { } catch (error) {