fix(extraction): coordinate jobs leases and shutdown

Route full, hybrid, top-level nested, and deferred nested archive work through the single global coordinator and remove the package-level extraction slot layer. Build each operation reservation from deduplicated multipart members, retain it through real child close and package output-scope finalization, and preserve package/run cancellation ownership while clearing queued waiters. Delay native and JVM abort/timeout completion until child close, retain daemon requests until exit, drain extraction before session persistence, and stop the daemon last. Add focused integration, process lifecycle, lease, and shutdown ordering regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-22 16:54:41 +02:00
parent 36eaafd5b9
commit 648e7fed3a
10 changed files with 667 additions and 382 deletions
+2
View File
@@ -1360,6 +1360,8 @@ export class AppController {
abortActiveUpdateDownload();
cancelPendingAsyncSaves();
this.manager.prepareForShutdown();
await this.manager.shutdownAndDrain?.(deadlineAt);
this.manager.persistForShutdown?.();
if (this.downloadHealthEvaluation) {
await this.waitForShutdownTask(this.downloadHealthEvaluation, deadlineAt);
}
+143 -138
View File
@@ -83,6 +83,7 @@ import { mergeKnownTotalBytes } from "./download-size";
import { DiskCapacityError, DiskReservationCoordinator, type DiskReservationLease } from "./disk-space";
import { createRendererState } from "./renderer-state";
import { PackageOutputScope } from "./package-output-scope";
import { ExtractionCoordinator, type ExtractionArchiveMember } from "./extraction-coordinator";
import {
RollingAccountStatisticsAccumulator,
addStatisticsActiveIntervalInPlace,
@@ -1940,14 +1941,10 @@ export class DownloadManager extends EventEmitter {
private cleanupQueue: Promise<void> = Promise.resolve();
private packageOutputScopes = new Map<string, PackageOutputScope>();
private extractionCoordinator: ExtractionCoordinator;
private packagePostProcessQueue: Promise<void> = Promise.resolve();
private packagePostProcessActive = 0;
private packagePostProcessWaiters: Array<{ packageId: string; runOwnerId: string | null; resolve: (acquired: boolean) => void }> = [];
private packagePostProcessTasks = new Map<string, Promise<void>>();
private packagePostProcessTasks = new Map<string, Promise<void>>();
private packagePostProcessAbortControllers = new Map<string, AbortController>();
@@ -2088,6 +2085,7 @@ export class DownloadManager extends EventEmitter {
this.session = session;
this.itemCount = Object.keys(this.session.items).length;
this.storagePaths = storagePaths;
this.extractionCoordinator = new ExtractionCoordinator(settings.maxParallelExtract || 1);
this.statisticsLedger = seedStatisticsDayProviderBytes(
loadStatisticsLedger(storagePaths.statisticsFile, startedAt),
settings.providerDailyUsageBytes,
@@ -2424,7 +2422,8 @@ export class DownloadManager extends EventEmitter {
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
const now = nowMs();
next.totalRuntimeAllTimeMs = Math.max(next.totalRuntimeAllTimeMs || 0, this.getLiveTotalRuntimeMs(now));
this.settings = next;
this.settings = next;
this.extractionCoordinator.resize(next.maxParallelExtract || 1);
this.invalidateSettingsSnapshotCache();
this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0;
this.runtimePersistedAt = now;
@@ -2670,8 +2669,7 @@ export class DownloadManager extends EventEmitter {
public abortAllPostProcessing(): void {
this.abortPostProcessing("external");
this.cancelPostProcessWaiters();
}
}
public triggerIdleExtractions(): void {
if (this.session.running || !this.settings.autoExtract || !this.settings.autoExtractWhenStopped) {
@@ -2987,6 +2985,7 @@ export class DownloadManager extends EventEmitter {
private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): Promise<void>[] {
const tasks: Promise<void>[] = [];
void this.extractionCoordinator.cancelPackage(packageId, reason);
if (invalidateDeferred) {
this.bumpPackagePostProcessVersion(packageId);
}
@@ -3355,8 +3354,6 @@ export class DownloadManager extends EventEmitter {
this.hybridExtractedPaths.clear();
this.hybridFailedArchives.clear();
this.providerFailures.clear();
this.packagePostProcessQueue = Promise.resolve();
this.cancelPostProcessWaiters();
this.summary = null;
this.nonResumableActive = 0;
this.resetSessionTotalsIfQueueEmpty(true);
@@ -4664,6 +4661,86 @@ export class DownloadManager extends EventEmitter {
}
}
private async extractionArchiveMembers(archivePaths: readonly string[]): Promise<ExtractionArchiveMember[]> {
const archiveRoots = new Map<string, string>();
for (const archivePath of archivePaths) {
const resolved = path.resolve(archivePath);
const key = process.platform === "win32" ? resolved.toLocaleLowerCase("en-US") : resolved;
if (!archiveRoots.has(key)) {
archiveRoots.set(key, resolved);
}
}
const directoryFiles = new Map<string, string[]>();
const memberPaths = new Map<string, string>();
for (const archivePath of archiveRoots.values()) {
const directory = path.dirname(archivePath);
const directoryKey = process.platform === "win32" ? directory.toLocaleLowerCase("en-US") : directory;
let files = directoryFiles.get(directoryKey);
if (!files) {
try {
files = (await fs.promises.readdir(directory, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => entry.name);
} catch {
files = [];
}
directoryFiles.set(directoryKey, files);
}
for (const memberPath of collectArchiveCleanupTargets(archivePath, files)) {
const resolved = path.resolve(memberPath);
const key = process.platform === "win32" ? resolved.toLocaleLowerCase("en-US") : resolved;
if (!memberPaths.has(key)) {
memberPaths.set(key, resolved);
}
}
}
return Promise.all([...memberPaths.values()].map(async (memberPath) => {
try {
return { path: memberPath, size: (await fs.promises.stat(memberPath)).size };
} catch {
return { path: memberPath, size: null };
}
}));
}
private async runCoordinatedExtraction<T>(
pkg: PackageEntry,
archivePaths: readonly string[],
signal: AbortSignal | undefined,
operation: (
targetDir: string,
scope: PackageOutputScope,
scheduleArchive: <R>(archivePath: string, execute: (signal: AbortSignal) => Promise<R>) => Promise<R>
) => Promise<T>
): Promise<T> {
const members = await this.extractionArchiveMembers(archivePaths);
const extraction = await this.extractionCoordinator.beginOperation({
context: {
operationId: uuidv4(),
packageId: pkg.id,
generation: this.getPackageResultGeneration(pkg.id),
runOwnerId: this.getPackageResultRunOwner(pkg.id) || ""
},
targetPath: pkg.extractDir,
members,
acquireLease: (request) => this.diskReservations.reserve({
phase: request.phase,
ownerId: request.ownerId,
targetPath: request.targetPath,
requiredBytes: request.requiredBytes
})
});
const scheduleArchive = <R>(archivePath: string, execute: (jobSignal: AbortSignal) => Promise<R>): Promise<R> =>
this.extractionCoordinator.scheduleArchive(extraction, archivePath, (jobSignal) => execute(
signal ? AbortSignal.any([signal, jobSignal]) : jobSignal
));
try {
return await this.runWithPackageOutputProvenance(pkg, (targetDir, scope) => operation(targetDir, scope, scheduleArchive));
} finally {
await extraction.finalize();
}
}
private async removeEmptyDirectoryTree(rootDir: string): Promise<number> {
if (!rootDir) {
return 0;
@@ -7093,7 +7170,9 @@ export class DownloadManager extends EventEmitter {
this.speedBytesPerPackage.clear();
this.speedEventsHead = 0;
this.abortPostProcessing("stop", stoppedRunContext?.id);
this.cancelPostProcessWaiters(stoppedRunContext?.id);
if (stoppedRunContext) {
void this.extractionCoordinator.cancelRun(stoppedRunContext.id, "stop");
}
for (const active of this.activeTasks.values()) {
active.abortReason = abortReason;
active.abortController.abort(abortReason);
@@ -7217,9 +7296,18 @@ export class DownloadManager extends EventEmitter {
this.pacedStartReservationByItem.clear();
this.nonResumableActive = 0;
this.session.summaryText = "";
if (!this.skipShutdownPersist) {
const pkgCount = Object.keys(this.session.packages).length;
const itemCount = Object.keys(this.session.items).length;
this.emitState(true);
logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`);
}
public async shutdownAndDrain(deadlineAt: number): Promise<void> {
await this.extractionCoordinator.shutdownAndDrain(deadlineAt);
}
public persistForShutdown(): void {
if (!this.skipShutdownPersist) {
const pkgCount = Object.keys(this.session.packages).length;
const itemCount = Object.keys(this.session.items).length;
logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`);
this.foldRuntimeIntoSettings(nowMs());
if (!this.guardBlocksSessionSave()) {
@@ -7227,12 +7315,10 @@ export class DownloadManager extends EventEmitter {
}
saveSettings(this.storagePaths, this.settings);
saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger);
} else {
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
}
this.emitState(true);
logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`);
}
} else {
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
}
}
public togglePause(): boolean {
if (!this.session.running) {
@@ -8672,6 +8758,7 @@ export class DownloadManager extends EventEmitter {
if (runContextId !== undefined && owner !== runContextId) {
continue;
}
void this.extractionCoordinator.cancelPackage(packageId, reason);
if (!controller.signal.aborted) {
controller.abort(reason);
}
@@ -8701,21 +8788,23 @@ export class DownloadManager extends EventEmitter {
}
}
for (const controller of this.packageDeferredPostProcessAbortControllers.values()) {
for (const [packageId, controller] of this.packageDeferredPostProcessAbortControllers.entries()) {
const owner = this.packageDeferredRunOwnerByController.get(controller);
if (runContextId !== undefined && owner !== runContextId) {
continue;
}
void this.extractionCoordinator.cancelPackage(packageId, reason);
if (!controller.signal.aborted) {
controller.abort(reason);
}
}
for (const hybridSet of this.packageHybridPostProcessControllers.values()) {
for (const [packageId, hybridSet] of this.packageHybridPostProcessControllers.entries()) {
for (const controller of hybridSet) {
const owner = this.packageHybridRunOwnerByController.get(controller);
if (runContextId !== undefined && owner !== runContextId) {
continue;
}
void this.extractionCoordinator.cancelPackage(packageId, reason);
if (!controller.signal.aborted) {
controller.abort(reason);
}
@@ -8723,55 +8812,6 @@ export class DownloadManager extends EventEmitter {
}
}
private cancelPostProcessWaiters(runOwnerId?: string): void {
const retained: typeof this.packagePostProcessWaiters = [];
for (const waiter of this.packagePostProcessWaiters) {
if (runOwnerId !== undefined && waiter.runOwnerId !== runOwnerId) {
retained.push(waiter);
} else {
waiter.resolve(false);
}
}
this.packagePostProcessWaiters = retained;
}
private async acquirePostProcessSlot(packageId: string, runOwnerId: string | null = this.getPackageResultRunOwner(packageId)): Promise<boolean> {
const maxConcurrent = Math.max(1, Math.min(8, this.settings.maxParallelExtract || 1));
if (this.packagePostProcessActive < maxConcurrent) {
this.packagePostProcessActive += 1;
return true;
}
return new Promise<boolean>((resolve) => {
this.packagePostProcessWaiters.push({ packageId, runOwnerId, resolve });
});
}
private releasePostProcessSlot(): void {
if (this.packagePostProcessActive <= 0) {
this.packagePostProcessActive = 0;
return;
}
const maxConcurrent = Math.max(1, Math.min(8, this.settings.maxParallelExtract || 1));
if (this.packagePostProcessWaiters.length === 0 || this.packagePostProcessActive > maxConcurrent) {
this.packagePostProcessActive -= 1;
return;
}
const order = this.session.packageOrder;
let bestIdx = 0;
let bestOrder = order.indexOf(this.packagePostProcessWaiters[0].packageId);
if (bestOrder === -1) bestOrder = Infinity;
for (let i = 1; i < this.packagePostProcessWaiters.length; i++) {
let pos = order.indexOf(this.packagePostProcessWaiters[i].packageId);
if (pos === -1) pos = Infinity;
if (pos < bestOrder) {
bestOrder = pos;
bestIdx = i;
}
}
const [next] = this.packagePostProcessWaiters.splice(bestIdx, 1);
next.resolve(true);
}
private runPackagePostProcessing(packageId: string): Promise<void> {
this.trackPackagePostProcessResult(packageId);
const existing = this.packagePostProcessTasks.get(packageId);
@@ -8793,31 +8833,12 @@ export class DownloadManager extends EventEmitter {
// cannot reference its own const inside its initializer). Assigned right after.
const handle: { task?: Promise<void> } = {};
const task = (async () => {
const slotWaitStart = nowMs();
let slotAcquired = false;
try {
slotAcquired = await this.acquirePostProcessSlot(
packageId,
this.packagePostProcessRunOwnerByController.get(abortController) ?? null
);
if (!slotAcquired) {
return;
}
const startedPackage = this.session.packages[packageId];
if (startedPackage) {
startedPackage.postProcessStartedAt = startedPackage.postProcessStartedAt || nowMs();
startedPackage.updatedAt = nowMs();
}
const slotWaitMs = nowMs() - slotWaitStart;
if (slotWaitMs > 100) {
logger.info(`Post-Process Slot erhalten nach ${(slotWaitMs / 1000).toFixed(1)}s Wartezeit: pkg=${packageId.slice(0, 8)}`);
const pkg = this.session.packages[packageId];
if (pkg) {
this.logPackageForPackage(pkg, "INFO", "Post-Process-Slot erhalten", {
slotWaitMs
});
}
}
let round = 0;
do {
round += 1;
@@ -8847,12 +8868,9 @@ export class DownloadManager extends EventEmitter {
this.hybridExtractRequeue.delete(packageId);
}
}
} while (this.hybridExtractRequeue.has(packageId));
} while (this.hybridExtractRequeue.has(packageId));
} finally {
if (slotAcquired) {
this.releasePostProcessSlot();
}
// Identity guard: only clear the map entries if they still point to THIS
// Identity guard: only clear the map entries if they still point to THIS
// task/controller. After an abort deletes our handle a new run can install
// a fresh task+controller for the same packageId; a blind delete here would
// orphan that newer task (uncancellable) and allow a duplicate concurrent run.
@@ -13598,7 +13616,7 @@ export class DownloadManager extends EventEmitter {
return 0;
}
const result = await this.runWithPackageOutputProvenance(pkg, (targetDir, scope) => extractPackageArchives({
const result = await this.runCoordinatedExtraction(pkg, [...readyArchives], signal, (targetDir, scope, scheduleArchive) => extractPackageArchives({
packageDir: pkg.outputDir,
targetDir,
cleanupMode: this.settings.cleanupMode,
@@ -13611,7 +13629,7 @@ export class DownloadManager extends EventEmitter {
skipPostCleanup: true,
packageId,
hybridMode: true,
maxParallel: this.settings.maxParallelExtract || 2,
scheduleArchive,
extractCpuPriority: "high",
onLog: (level, message) => this.logExtractionForItems(pkg, items, "Hybrid-Extractor", level, message),
onOutput: (event) => scope.add(event),
@@ -14136,40 +14154,6 @@ export class DownloadManager extends EventEmitter {
fullExtractItemIds.add(entry.id);
}
}
const archiveSizes = await Promise.all([...fullArchiveSet].map(async (archivePath) => {
try {
return (await fs.promises.stat(archivePath)).size;
} catch {
return null;
}
}));
try {
const diskLease = await this.diskReservations.reserve({
phase: "extract",
ownerId: packageId,
targetPath: pkg.extractDir,
requiredBytes: archiveSizes.every((size) => size === null) ? null : archiveSizes.reduce<number>((total, size) => total + Math.max(0, size || 0), 0)
});
diskLease.release();
} catch (error) {
if (error instanceof DiskCapacityError) {
this.diskWaitEvents = [{ ...error.event, packageId }];
const retryAt = error.event.retryAt;
this.packageDiskRetryAfterByPackage.set(packageId, retryAt);
for (const entry of completedItems) {
entry.fullStatus = "Warte auf Festplatte";
entry.lastError = "Zu wenig Speicherplatz";
entry.updatedAt = nowMs();
}
pkg.postProcessLabel = undefined;
pkg.status = "queued";
pkg.updatedAt = nowMs();
this.persistSoon();
this.emitState();
return;
}
throw error;
}
const pendingAt = nowMs();
for (const entry of completedItems) {
if (!fullExtractItemIds.has(entry.id) || isExtractedLabel(entry.fullStatus)) {
@@ -14180,7 +14164,9 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = pendingAt;
}
this.emitState();
const result = await this.runWithPackageOutputProvenance(pkg, (targetDir, scope) => extractPackageArchives({
let result;
try {
result = await this.runCoordinatedExtraction(pkg, [...fullArchiveSet], extractAbortController.signal, (targetDir, scope, scheduleArchive) => extractPackageArchives({
packageDir: pkg.outputDir,
targetDir,
cleanupMode: this.settings.cleanupMode,
@@ -14192,7 +14178,7 @@ export class DownloadManager extends EventEmitter {
packageId,
onlyArchives: fullArchiveSet,
skipPostCleanup: true,
maxParallel: this.settings.maxParallelExtract || 2,
scheduleArchive,
extractCpuPriority: "high",
onLog: (level, message) => this.logExtractionForItems(pkg, completedItems, "Extractor", level, message),
onOutput: (event) => scope.add(event),
@@ -14322,7 +14308,26 @@ export class DownloadManager extends EventEmitter {
}
emitExtractStatus(overallLabel);
}
}));
}));
} catch (error) {
if (error instanceof DiskCapacityError) {
this.diskWaitEvents = [{ ...error.event, packageId }];
const retryAt = error.event.retryAt;
this.packageDiskRetryAfterByPackage.set(packageId, retryAt);
for (const entry of completedItems) {
entry.fullStatus = "Warte auf Festplatte";
entry.lastError = "Zu wenig Speicherplatz";
entry.updatedAt = nowMs();
}
pkg.postProcessLabel = undefined;
pkg.status = "queued";
pkg.updatedAt = nowMs();
this.persistSoon();
this.emitState();
return;
}
throw error;
}
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", {
extracted: result.extracted,
@@ -14532,7 +14537,7 @@ export class DownloadManager extends EventEmitter {
});
const nestedFailureCategories = new Map<string, string>();
const nestedItems = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[];
const nestedResult = await this.runWithPackageOutputProvenance(pkg, (targetDir, scope) => extractPackageArchives({
const nestedResult = await this.runCoordinatedExtraction(pkg, nestedCandidates, deferredController.signal, (targetDir, scope, scheduleArchive) => extractPackageArchives({
packageDir: pkg.extractDir,
targetDir,
cleanupMode: this.settings.cleanupMode,
@@ -14543,7 +14548,7 @@ export class DownloadManager extends EventEmitter {
signal: deferredController.signal,
packageId,
onlyArchives: new Set(nestedCandidates.map((p) => process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p))),
maxParallel: this.settings.maxParallelExtract || 2,
scheduleArchive,
extractCpuPriority: this.settings.extractCpuPriority,
onLog: (level, message) => this.logPackageForPackage(pkg, level, `Nested-Extractor: ${message}`),
onOutput: (event) => scope.add(event),
+19 -5
View File
@@ -162,7 +162,7 @@ export class ExtractionCoordinator {
const members = deduplicateMembers(options.members || []);
state.lease = await options.acquireLease({
phase: "extract",
ownerId: context.operationId,
ownerId: context.packageId,
targetPath: String(options.targetPath || ""),
requiredBytes: reservationBytes(members),
memberPaths: Object.freeze(members.map((member) => member.path))
@@ -233,10 +233,7 @@ export class ExtractionCoordinator {
this.resolveDrainIfIdle(state);
}
await this.waitUntilDeadline(Promise.all(states.map((state) => state.drain.promise)), deadlineAt);
const finalizers = states.map((state) => state.finalizePromise).filter((value): value is Promise<void> => Boolean(value));
if (finalizers.length > 0) {
await this.waitUntilDeadline(Promise.allSettled(finalizers), deadlineAt);
}
await this.waitForFinalization(states, deadlineAt);
for (const state of states) {
this.releaseLease(state);
}
@@ -402,4 +399,21 @@ export class ExtractionCoordinator {
clearTimeout(timeout);
}
}
private async waitForFinalization(states: readonly OperationState[], deadlineAt: number): Promise<void> {
while (Date.now() < deadlineAt) {
const pending = states.filter((state) => this.operations.get(state.context.operationId) === state);
if (pending.length === 0) {
return;
}
const finalizers = pending
.map((state) => state.finalizePromise)
.filter((value): value is Promise<void> => Boolean(value));
const waitMs = Math.max(1, Math.min(10, deadlineAt - Date.now()));
await Promise.race([
...finalizers.map((finalizer) => finalizer.then(() => undefined, () => undefined)),
new Promise<void>((resolve) => setTimeout(resolve, waitMs))
]);
}
}
}
+146 -129
View File
@@ -60,11 +60,11 @@ export interface ExtractOptions {
skipPostCleanup?: boolean;
packageId?: string;
hybridMode?: boolean;
maxParallel?: number;
extractCpuPriority?: string;
onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void;
onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void;
onOutput?: (event: ExtractOutputEvent) => void;
scheduleArchive?: <T>(archivePath: string, execute: (signal: AbortSignal) => Promise<T>) => Promise<T>;
}
export interface ExtractProgressUpdate {
@@ -215,6 +215,9 @@ interface DaemonRequest {
passwordCount: number;
onOutput?: (event: ExtractOutputEvent) => void;
targetDir: string;
aborted: boolean;
timedOut: boolean;
terminationStarted: boolean;
}
const activeSubstDrives = new Set<string>();
@@ -1489,26 +1492,18 @@ function runExtractCommand(
resolve(result);
};
if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => {
timedOutByWatchdog = true;
killProcessTree(child);
finish({
ok: false,
missingCommand: false,
aborted: false,
timedOut: true,
errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`
});
}, timeoutMs);
}
if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => {
timedOutByWatchdog = true;
killProcessTree(child);
}, timeoutMs);
}
const onAbort = signal
? (): void => {
abortedBySignal = true;
killProcessTree(child);
finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" });
}
? (): void => {
abortedBySignal = true;
killProcessTree(child);
}
: null;
if (signal && onAbort) {
signal.addEventListener("abort", onAbort, { once: true });
@@ -1525,8 +1520,11 @@ function runExtractCommand(
onChunk?.(text);
});
child.on("error", (error) => {
const text = cleanErrorText(String(error));
child.on("error", (error) => {
if (abortedBySignal || timedOutByWatchdog) {
return;
}
const text = cleanErrorText(String(error));
finish({
ok: false,
missingCommand: text.toLowerCase().includes("enoent"),
@@ -1926,8 +1924,11 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
}
});
child.on("error", () => {
if (daemonCurrentRequest) {
child.on("error", () => {
if (daemonCurrentRequest?.terminationStarted) {
return;
}
if (daemonCurrentRequest) {
finishDaemonRequest({
ok: false, missingCommand: true, missingRuntime: true,
aborted: false, timedOut: false, errorText: "Daemon process error",
@@ -1937,16 +1938,31 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
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
});
}
child.on("close", () => {
if (daemonCurrentRequest) {
const req = daemonCurrentRequest;
if (req.aborted) {
finishDaemonRequest({
ok: false, missingCommand: false, missingRuntime: false,
aborted: true, timedOut: false, errorText: "aborted:extract",
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
});
} else if (req.timedOut) {
finishDaemonRequest({
ok: false, missingCommand: false, missingRuntime: false,
aborted: false, timedOut: true,
errorText: `Entpacken Timeout nach ${Math.ceil((req.timeoutMs || 0) / 1000)}s`,
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
});
} else {
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
});
}
}
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
daemonProcess = null;
daemonReady = false;
@@ -2010,37 +2026,39 @@ function sendDaemonRequest(
startedAt: Date.now(),
passwordCount: passwordCandidates.length,
onOutput,
targetDir
targetDir,
aborted: false,
timedOut: false,
terminationStarted: false
};
logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`);
if (timeoutMs && timeoutMs > 0) {
daemonTimeoutId = setTimeout(() => {
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);
daemonTimeoutId = setTimeout(() => {
const req = daemonCurrentRequest;
if (req && !req.terminationStarted) {
req.timedOut = true;
req.terminationStarted = true;
try { daemonProcess?.stdin?.end(); } catch { }
if (daemonProcess) {
killProcessTree(daemonProcess);
}
}
}, timeoutMs);
}
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
});
}
shutdownDaemon();
};
daemonAbortHandler = () => {
const req = daemonCurrentRequest;
if (req && !req.terminationStarted) {
req.aborted = true;
req.terminationStarted = true;
try { daemonProcess?.stdin?.end(); } catch { }
if (daemonProcess) {
killProcessTree(daemonProcess);
}
}
};
signal.addEventListener("abort", daemonAbortHandler, { once: true });
}
@@ -2207,29 +2225,18 @@ async function runJvmExtractCommand(
resolve(finalResult);
};
if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => {
timedOutByWatchdog = true;
killProcessTree(child);
finish({
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
});
}, timeoutMs);
}
if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => {
timedOutByWatchdog = true;
killProcessTree(child);
}, timeoutMs);
}
onAbort = signal
? (): void => {
abortedBySignal = true;
killProcessTree(child);
finish({
ok: false, missingCommand: false, missingRuntime: false,
aborted: true, timedOut: false, errorText: "aborted:extract",
usedPassword: parseState.usedPassword, backend: parseState.backend
});
}
? (): void => {
abortedBySignal = true;
killProcessTree(child);
}
: null;
if (signal && onAbort) {
@@ -2243,8 +2250,11 @@ async function runJvmExtractCommand(
flushLines(String(chunk || ""), true);
});
child.on("error", (error) => {
const text = cleanErrorText(String(error));
child.on("error", (error) => {
if (abortedBySignal || timedOutByWatchdog) {
return;
}
const text = cleanErrorText(String(error));
finish({
ok: false, missingCommand: text.toLowerCase().includes("enoent"),
missingRuntime: true, aborted: false, timedOut: false,
@@ -2555,21 +2565,14 @@ export function parseNativeExtractOutput(
}
function failDaemonOutputCallback(req: DaemonRequest): void {
if (daemonCurrentRequest !== req || !req.parseState.outputError) {
if (daemonCurrentRequest !== req || !req.parseState.outputError || req.terminationStarted) {
return;
}
const message = cleanErrorText(req.parseState.outputError.message || String(req.parseState.outputError));
finishDaemonRequest({
ok: false,
missingCommand: false,
missingRuntime: false,
aborted: false,
timedOut: false,
errorText: message,
usedPassword: req.parseState.usedPassword,
backend: req.parseState.backend
});
shutdownDaemon();
req.terminationStarted = true;
try { daemonProcess?.stdin?.end(); } catch { }
if (daemonProcess) {
killProcessTree(daemonProcess);
}
}
function createNativeOutputCollector(
@@ -3793,12 +3796,24 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
}
}
const maxParallel = Math.max(1, options.maxParallel || 1);
let noExtractorEncountered = false;
let lastArchiveFinishedAt: number | null = null;
const extractSingleArchive = async (archivePath: string): Promise<void> => {
if (options.signal?.aborted) {
const fallbackSignal = options.signal || new AbortController().signal;
let localScheduleQueue = Promise.resolve();
const scheduleArchive = options.scheduleArchive
? <T>(archivePath: string, execute: (signal: AbortSignal) => Promise<T>): Promise<T> => options.scheduleArchive!(
archivePath,
(jobSignal) => execute(options.signal ? AbortSignal.any([options.signal, jobSignal]) : jobSignal)
)
: <T>(_archivePath: string, execute: (signal: AbortSignal) => Promise<T>): Promise<T> => {
const scheduled = localScheduleQueue.then(() => execute(fallbackSignal));
localScheduleQueue = scheduled.then(() => undefined, () => undefined);
return scheduled;
};
const archiveWorkerCount = Math.max(1, pendingCandidates.length);
let noExtractorEncountered = false;
let lastArchiveFinishedAt: number | null = null;
const extractSingleArchive = async (archivePath: string, signal: AbortSignal): Promise<void> => {
if (signal.aborted) {
throw new Error("aborted:extract");
}
if (noExtractorEncountered) {
@@ -3876,18 +3891,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
try {
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
reportArchiveProgress(value);
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
}, signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPassword);
} catch (error) {
if (isNoExtractorError(String(error))) {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, signal, emitOutput, validateOutputTarget);
} else {
throw error;
}
}
} else {
try {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, signal, emitOutput, validateOutputTarget);
archivePercent = 100;
} catch (error) {
if (!shouldFallbackToExternalZip(error)) {
@@ -3896,7 +3911,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
try {
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
reportArchiveProgress(value);
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
}, signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPassword);
} catch (externalError) {
throw selectZipFallbackError(error, externalError);
@@ -3907,7 +3922,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const flatResult = { needed: false };
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
reportArchiveProgress(value);
}, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog, emitOutput);
}, signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog, emitOutput);
rememberLearnedPassword(usedPassword);
if (flatResult.needed) packageNeedsFlatMode = true;
}
@@ -3974,10 +3989,10 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
}
};
if (maxParallel <= 1) {
if (archiveWorkerCount <= 1) {
for (const archivePath of pendingCandidates) {
if (options.signal?.aborted || noExtractorEncountered) break;
await extractSingleArchive(archivePath);
await scheduleArchive(archivePath, (signal) => extractSingleArchive(archivePath, signal));
}
if (noExtractorEncountered) {
const remaining = candidates.length - (extracted + failed);
@@ -3993,7 +4008,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
options.onLog?.("INFO", `Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
const first = pendingCandidates[0];
try {
await extractSingleArchive(first);
await scheduleArchive(first, (signal) => extractSingleArchive(first, signal));
} catch (err) {
const errText = String(err);
if (/aborted:extract/i.test(errText)) throw err;
@@ -4015,7 +4030,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const idx = nextIdx;
nextIdx += 1;
try {
await extractSingleArchive(queue[idx]);
await scheduleArchive(queue[idx], (signal) => extractSingleArchive(queue[idx], signal));
} catch (error) {
const errText = String(error);
if (errText.includes("noextractor:skipped")) {
@@ -4029,7 +4044,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
}
};
const workerCount = Math.min(maxParallel, parallelQueue.length);
const workerCount = parallelQueue.length;
logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`);
const frozenPasswords = [...passwordCandidates];
await Promise.all(Array.from({ length: workerCount }, () => worker()));
@@ -4051,7 +4066,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
if (options.signal?.aborted || noExtractorEncountered) break;
try {
failed -= 1;
await extractSingleArchive(archivePath);
await scheduleArchive(archivePath, (signal) => extractSingleArchive(archivePath, signal));
retryRecovered += 1;
} catch (retryError) {
const errText = String(retryError);
@@ -4073,7 +4088,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
if (options.signal?.aborted || noExtractorEncountered) break;
try {
failed -= 1;
await extractSingleArchive(archivePath);
await scheduleArchive(archivePath, (signal) => extractSingleArchive(archivePath, signal));
retryRecovered += 1;
} catch (retryError) {
const errText = String(retryError);
@@ -4142,24 +4157,26 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const nestedPulse = setInterval(() => {
emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, Date.now() - nestedStartedAt, undefined, undefined, nestedArchive);
}, 1100);
const hybrid = Boolean(options.hybridMode);
logger.info(`Nested-Entpacke: ${nestedName} -> ${options.targetDir}${hybrid ? " (hybrid)" : ""}`);
try {
const ext = path.extname(nestedArchive).toLowerCase();
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
try {
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
nestedPercent = 100;
} catch (zipErr) {
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPw);
}
} else {
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPw);
}
extracted += 1;
const hybrid = Boolean(options.hybridMode);
logger.info(`Nested-Entpacke: ${nestedName} -> ${options.targetDir}${hybrid ? " (hybrid)" : ""}`);
try {
await scheduleArchive(nestedArchive, async (signal) => {
const ext = path.extname(nestedArchive).toLowerCase();
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
try {
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, signal, emitOutput, validateOutputTarget);
nestedPercent = 100;
} catch (zipErr) {
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPw);
}
} else {
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPw);
}
});
extracted += 1;
nestedExtracted += 1;
extractedArchives.add(nestedArchive);
const nestedResume = await buildResumeArchive(nestedArchive, options.packageDir, options.targetDir, outputScope.records());
+6 -3
View File
@@ -1117,11 +1117,14 @@ app.on("before-quit", createBeforeQuitHandler({
powerMonitor.removeListener("resume", handlePowerResume);
stopClipboardWatcher();
destroyTray();
shutdownDaemon();
},
shutdown: async () => {
if (controller) {
await controller.shutdown();
try {
if (controller) {
await controller.shutdown();
}
} finally {
shutdownDaemon();
}
},
continueQuit: () => app.quit(),