fix(extraction): harden daemon termination and drain deadlines
Prevent aborted daemon waiters and pre-aborted requests from spawning one-shot JVM children. Ignore buffered request completion after abort or timeout so permits and disk leases remain owned until real daemon close. Keep incomplete shutdown operations and leases alive past the shared deadline while returning explicit drain status, and make multipart reservations fail closed whenever any deduplicated member size is unknown. Add real daemon boot, busy, abort, timeout, DONE race, deadline, and reservation regression coverage.
This commit is contained in:
@@ -83,7 +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 { ExtractionCoordinator, type ExtractionArchiveMember, type ExtractionShutdownResult } from "./extraction-coordinator";
|
||||
import {
|
||||
RollingAccountStatisticsAccumulator,
|
||||
addStatisticsActiveIntervalInPlace,
|
||||
@@ -7300,8 +7300,8 @@ export class DownloadManager extends EventEmitter {
|
||||
logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`);
|
||||
}
|
||||
|
||||
public async shutdownAndDrain(deadlineAt: number): Promise<void> {
|
||||
await this.extractionCoordinator.shutdownAndDrain(deadlineAt);
|
||||
public async shutdownAndDrain(deadlineAt: number): Promise<ExtractionShutdownResult> {
|
||||
return this.extractionCoordinator.shutdownAndDrain(deadlineAt);
|
||||
}
|
||||
|
||||
public persistForShutdown(): void {
|
||||
|
||||
@@ -35,6 +35,14 @@ export type ExtractionOperation = {
|
||||
finalize(finalizeScope?: () => void | Promise<void>): Promise<void>;
|
||||
};
|
||||
|
||||
export type ExtractionShutdownResult = Readonly<{
|
||||
completed: boolean;
|
||||
timedOut: boolean;
|
||||
activeJobs: number;
|
||||
pendingOperations: number;
|
||||
queuedJobs: number;
|
||||
}>;
|
||||
|
||||
type ArchiveJob = {
|
||||
archiveId: string;
|
||||
execute: (signal: AbortSignal) => Promise<unknown>;
|
||||
@@ -97,10 +105,10 @@ function deduplicateMembers(members: readonly ExtractionArchiveMember[]): Extrac
|
||||
}
|
||||
|
||||
function reservationBytes(members: readonly ExtractionArchiveMember[]): number | null {
|
||||
const known = members
|
||||
.map((member) => member.size)
|
||||
.filter((size): size is number => typeof size === "number" && Number.isFinite(size));
|
||||
return known.length > 0 ? known.reduce((total, size) => total + Math.max(0, Math.floor(size)), 0) : null;
|
||||
if (members.length === 0 || members.some((member) => member.size === null)) {
|
||||
return null;
|
||||
}
|
||||
return members.reduce((total, member) => total + Math.max(0, Math.floor(member.size as number)), 0);
|
||||
}
|
||||
|
||||
export class ExtractionCancelledError extends Error {
|
||||
@@ -216,7 +224,7 @@ export class ExtractionCoordinator {
|
||||
await this.cancelMatching((state) => state.context.packageId === packageId, reason);
|
||||
}
|
||||
|
||||
public async shutdownAndDrain(deadlineAt: number): Promise<void> {
|
||||
public async shutdownAndDrain(deadlineAt: number): Promise<ExtractionShutdownResult> {
|
||||
this.closed = true;
|
||||
const states = [...this.operations.values()];
|
||||
for (const state of states) {
|
||||
@@ -234,9 +242,16 @@ export class ExtractionCoordinator {
|
||||
}
|
||||
await this.waitUntilDeadline(Promise.all(states.map((state) => state.drain.promise)), deadlineAt);
|
||||
await this.waitForFinalization(states, deadlineAt);
|
||||
for (const state of states) {
|
||||
this.releaseLease(state);
|
||||
}
|
||||
const pendingOperations = states.filter((state) => this.operations.get(state.context.operationId) === state).length;
|
||||
const queuedJobs = states.reduce((total, state) => total + state.queued.length, 0);
|
||||
const completed = this.activeCount === 0 && pendingOperations === 0 && queuedJobs === 0;
|
||||
return Object.freeze({
|
||||
completed,
|
||||
timedOut: !completed && Date.now() >= deadlineAt,
|
||||
activeJobs: this.activeCount,
|
||||
pendingOperations,
|
||||
queuedJobs
|
||||
});
|
||||
}
|
||||
|
||||
private async finalizeOperation(state: OperationState, finalizeScope?: () => void | Promise<void>): Promise<void> {
|
||||
|
||||
+91
-47
@@ -1815,14 +1815,14 @@ function handleDaemonLine(line: string): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("RD_REQUEST_DONE ")) {
|
||||
const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10);
|
||||
const req = daemonCurrentRequest;
|
||||
if (!req) return;
|
||||
const finalize = (): void => {
|
||||
if (daemonCurrentRequest !== req) {
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("RD_REQUEST_DONE ")) {
|
||||
const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10);
|
||||
const req = daemonCurrentRequest;
|
||||
if (!req || req.terminationStarted) return;
|
||||
const finalize = (): void => {
|
||||
if (daemonCurrentRequest !== req || req.terminationStarted) {
|
||||
return;
|
||||
}
|
||||
flushDaemonParseBuffers(req);
|
||||
if (req.parseState.outputError) {
|
||||
failDaemonOutputCallback(req);
|
||||
@@ -1978,26 +1978,59 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
|
||||
function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
|
||||
if (!daemonProcess || !daemonReady) {
|
||||
startDaemon(layout);
|
||||
}
|
||||
return Boolean(daemonProcess && daemonReady && !daemonBusy);
|
||||
}
|
||||
|
||||
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const check = () => {
|
||||
if (signal?.aborted) { resolve(false); return; }
|
||||
if (daemonProcess && daemonReady && !daemonBusy) { resolve(true); return; }
|
||||
if (!daemonProcess) { resolve(false); return; }
|
||||
if (Date.now() - start >= maxWaitMs) { resolve(false); return; }
|
||||
setTimeout(check, 50);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
return Boolean(daemonProcess && daemonReady && !daemonBusy);
|
||||
}
|
||||
|
||||
function abortedJvmExtractResult(): JvmExtractResult {
|
||||
return {
|
||||
ok: false,
|
||||
missingCommand: false,
|
||||
missingRuntime: false,
|
||||
aborted: true,
|
||||
timedOut: false,
|
||||
errorText: "aborted:extract",
|
||||
usedPassword: "",
|
||||
backend: ""
|
||||
};
|
||||
}
|
||||
|
||||
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const finish = (ready: boolean): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(ready);
|
||||
};
|
||||
const onAbort = (): void => finish(false);
|
||||
const check = () => {
|
||||
if (signal?.aborted) { finish(false); return; }
|
||||
if (daemonProcess && daemonReady && !daemonBusy) { finish(true); return; }
|
||||
if (!daemonProcess) { finish(false); return; }
|
||||
if (Date.now() - start >= maxWaitMs) { finish(false); return; }
|
||||
timer = setTimeout(check, 50);
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function sendDaemonRequest(
|
||||
archivePath: string,
|
||||
@@ -2008,8 +2041,11 @@ function sendDaemonRequest(
|
||||
signal?: AbortSignal,
|
||||
timeoutMs?: number,
|
||||
onOutput?: (event: ExtractOutputEvent) => void
|
||||
): Promise<JvmExtractResult> {
|
||||
return new Promise((resolve) => {
|
||||
): Promise<JvmExtractResult> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve(abortedJvmExtractResult());
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
|
||||
const archiveName = path.basename(archivePath);
|
||||
@@ -2094,13 +2130,9 @@ async function runJvmExtractCommand(
|
||||
signal?: AbortSignal,
|
||||
timeoutMs?: number,
|
||||
onOutput?: (event: ExtractOutputEvent) => void
|
||||
): Promise<JvmExtractResult> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve({
|
||||
ok: false, missingCommand: false, missingRuntime: false,
|
||||
aborted: true, timedOut: false, errorText: "aborted:extract",
|
||||
usedPassword: "", backend: ""
|
||||
});
|
||||
): Promise<JvmExtractResult> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve(abortedJvmExtractResult());
|
||||
}
|
||||
|
||||
if (isDaemonAvailable(layout)) {
|
||||
@@ -2112,18 +2144,25 @@ async function runJvmExtractCommand(
|
||||
if (daemonProcess) {
|
||||
const reason = !daemonReady ? "booting" : "busy";
|
||||
const waitStartedAt = Date.now();
|
||||
logger.info(`JVM Daemon: Warte auf ${reason} Daemon für ${path.basename(archivePath)}...`);
|
||||
const ready = await waitForDaemonReady(15_000, signal);
|
||||
const waitedMs = Date.now() - waitStartedAt;
|
||||
if (ready) {
|
||||
logger.info(`JVM Daemon: Warte auf ${reason} Daemon für ${path.basename(archivePath)}...`);
|
||||
const ready = await waitForDaemonReady(15_000, signal);
|
||||
const waitedMs = Date.now() - waitStartedAt;
|
||||
if (signal?.aborted) {
|
||||
return abortedJvmExtractResult();
|
||||
}
|
||||
if (ready) {
|
||||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||||
logger.info(`JVM Daemon: Bereit nach ${waitedMs}ms — sende Request für ${path.basename(archivePath)}`);
|
||||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput);
|
||||
}
|
||||
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
|
||||
}
|
||||
|
||||
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
|
||||
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
return abortedJvmExtractResult();
|
||||
}
|
||||
|
||||
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
|
||||
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
const jvmTmpDir = path.join(os.tmpdir(), `rd-extract-${crypto.randomUUID()}`);
|
||||
@@ -2147,11 +2186,16 @@ async function runJvmExtractCommand(
|
||||
"--backend",
|
||||
"auto"
|
||||
];
|
||||
for (const password of passwordCandidates) {
|
||||
args.push("--password", password);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
for (const password of passwordCandidates) {
|
||||
args.push("--password", password);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
|
||||
return abortedJvmExtractResult();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let output = "";
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
Reference in New Issue
Block a user