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:
parent
c36549ca69
commit
5b221d5bd5
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user