Complete provider waits and lifecycle visibility
Release aborted Real-Debrid, AllDebrid, and BestDebrid web callers even when underlying requests ignore cancellation while retaining terminal rejection observers. Publish provider cooldown deadlines and emit a fresh idle snapshot at the earliest expiry. Abort and visibly drain post-processing before dispatching one pending restart. Surface lifecycle phase, reason, retry countdown, and remaining work in the download controls. Add focused regressions for provider abort races, cooldown expiry, post-processing drain, pending start visibility, and the updated rapid stop contract.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Task 3: Deterministischer Stop→Start-Lifecycle
|
||||
|
||||
## Status
|
||||
|
||||
Umgesetzt und fokussiert verifiziert.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- Expliziter `DownloadLifecycleSnapshot` mit Phase, Grund, optionalem Retry-Zeitpunkt, aktiven Downloads, aktiven Nachbearbeitungen und angenommenem Startwunsch.
|
||||
- Generation-Guard für `start()` nach beiden asynchronen Recovery-Grenzen.
|
||||
- `stopping` bleibt bis zum tatsächlichen Drain von Start-Recovery, Downloads und Nachbearbeitung aktiv.
|
||||
- Ein Start während `stopping` wird einmal angenommen und nach dem Drain genau einmal ausgeführt.
|
||||
- ActiveTask-Cleanup ist an den konkreten Map-Eigentümer gebunden; verspätetes Cleanup kann keinen neueren Task löschen oder dessen Ressourcen freigeben.
|
||||
- Stop abortiert laufende Nachbearbeitung auch bei aktivierter Nachbearbeitung ohne laufende Sitzung und zeigt die verbleibende Arbeit bis zum Promise-Ende.
|
||||
- Real-Debrid-, AllDebrid- und BestDebrid-Webqueues geben abortierte Aufrufer über eine äußere Abort-Race sofort frei und beobachten die spätere terminale Promise-Auflösung weiterhin.
|
||||
- Der früheste endliche Provider-Cooldown plant im Idle-Zustand ein State-Event zum Ablaufzeitpunkt.
|
||||
- Startbutton und Download-Footer zeigen Pending-Start, Lifecycle-Phase, Grund, Retry-Restzeit und verbleibende Download-/Nachbearbeitungsarbeit.
|
||||
|
||||
## RED-Nachweise
|
||||
|
||||
- Recovery-Rennen: Nach Stop wurde `session.running` wieder `true`.
|
||||
- Pending-Start: Während `stopping` fehlten Lifecycle und angenommener Startwunsch.
|
||||
- ActiveTask-Eigentümer: Ein verspätetes altes `finally` löschte den neueren Map-Eintrag.
|
||||
- Webqueues: Alle drei nie endenden Requests liefen nach Abort in den 200-ms-Testtimeout.
|
||||
- Cooldown: Der Snapshot blieb bei `idle` mit `retryAt=null`.
|
||||
- Renderer: Pending-Start-Button und Lifecycle-/Restarbeitsanzeige fehlten.
|
||||
- Nachbearbeitung: Stop ließ das aktive Postprocessing-Signal bei `autoExtractWhenStopped=true` unabgebrochen.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- 7 fokussierte Manager-Regressionen bestanden.
|
||||
- 152/152 Real-Debrid-, AllDebrid-, BestDebrid- und Download-Renderer-Tests bestanden.
|
||||
- `npx tsc --noEmit` bestand.
|
||||
- `npm run build` bestand für Main und Renderer.
|
||||
- `git diff --check` bestand vor dem Bericht.
|
||||
|
||||
## Commits
|
||||
|
||||
- `1f97ce8 Harden download stop and restart lifecycle`
|
||||
- `Complete provider waits and lifecycle visibility`
|
||||
|
||||
## Bedenken
|
||||
|
||||
- Der vollständige Fünf-Dateien-Lauf erreichte vor der letzten Korrektur 395/397. Die beiden isolierten Fehler wurden anschließend einzeln und im finalen 7-Test-Manager-Gate grün verifiziert; die übrigen 393 Manager-/Web-/Renderer-Tests wurden nach dieser letzten kleinen Kompatibilitätskorrektur nicht nochmals gemeinsam ausgeführt.
|
||||
- Der Renderer-Build bleibt grün, meldet aber die bereits bestehende Warnung für einen JavaScript-Chunk über 500 kB.
|
||||
@@ -42,7 +42,7 @@ function throwIfAborted(signal?: AbortSignal): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
@@ -68,8 +68,44 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -280,10 +316,10 @@ export class AllDebridWebFallback {
|
||||
}
|
||||
return job();
|
||||
};
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return raceWithAbort(run, signal);
|
||||
}
|
||||
|
||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||
const partition = this.getPartition();
|
||||
|
||||
@@ -23,11 +23,47 @@ function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number):
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
@@ -238,10 +274,10 @@ export class BestDebridWebFallback {
|
||||
}
|
||||
return job();
|
||||
};
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return raceWithAbort(run, signal);
|
||||
}
|
||||
|
||||
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
+13
-3
@@ -586,9 +586,19 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna
|
||||
},
|
||||
debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
|
||||
};
|
||||
}
|
||||
|
||||
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
|
||||
}
|
||||
|
||||
export function getNextProviderRuntimeRetryAt(now = Date.now()): number | null {
|
||||
const deadlines = [
|
||||
...[...realDebridAccountCooldowns.values()].map((entry) => entry.until),
|
||||
...[...megaDebridAccountCooldowns.values()].map((entry) => entry.until),
|
||||
...debridLinkKeyCooldowns.values(),
|
||||
...debridLinkKeyHostCooldowns.values()
|
||||
].filter((deadline) => Number.isFinite(deadline) && deadline > now);
|
||||
return deadlines.length > 0 ? Math.min(...deadlines) : null;
|
||||
}
|
||||
|
||||
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
|
||||
|
||||
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid",
|
||||
|
||||
+108
-44
@@ -62,7 +62,7 @@ function releaseTlsSkip(): void {
|
||||
}
|
||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
|
||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getNextProviderRuntimeRetryAt, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
|
||||
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
|
||||
import { validateFileAgainstManifest } from "./integrity";
|
||||
import { classifyDiskError } from "./fs-error";
|
||||
@@ -1856,8 +1856,10 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private nonResumableActive = 0;
|
||||
|
||||
private stateEmitTimer: NodeJS.Timeout | null = null;
|
||||
private lastStateEmitAt = 0;
|
||||
private stateEmitTimer: NodeJS.Timeout | null = null;
|
||||
private lastStateEmitAt = 0;
|
||||
private providerRetryTimer: NodeJS.Timeout | null = null;
|
||||
private providerRetryAt = 0;
|
||||
|
||||
private speedBytesLastWindow = 0;
|
||||
|
||||
@@ -2728,11 +2730,15 @@ export class DownloadManager extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
public getSnapshot(): UiSnapshot {
|
||||
const now = nowMs();
|
||||
this.ensureProviderDailyUsageFresh(now, true);
|
||||
this.pruneSpeedEvents(now);
|
||||
const paused = this.session.running && this.session.paused;
|
||||
public getSnapshot(): UiSnapshot {
|
||||
const now = nowMs();
|
||||
this.ensureProviderDailyUsageFresh(now, true);
|
||||
this.pruneSpeedEvents(now);
|
||||
const hasUsableAccount = this.hasUsableDownloadAccount();
|
||||
const providerRetryAt = this.getEarliestProviderRetryAt(now);
|
||||
this.syncProviderRetryTimer(providerRetryAt, now);
|
||||
const lifecycle = this.getLifecycleSnapshot(providerRetryAt, hasUsableAccount);
|
||||
const paused = this.session.running && this.session.paused;
|
||||
const speedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS;
|
||||
|
||||
let totalItems = 0;
|
||||
@@ -2781,7 +2787,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return {
|
||||
rotationEvents: getRecentRotationEvents(40),
|
||||
accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now),
|
||||
lifecycle: this.getLifecycleSnapshot(),
|
||||
lifecycle,
|
||||
settings: rendererState.settings,
|
||||
accounts: rendererState.accounts,
|
||||
session: snapshotSession,
|
||||
@@ -2789,7 +2795,9 @@ export class DownloadManager extends EventEmitter {
|
||||
stats: this.getStats(now),
|
||||
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
||||
etaText: paused || !this.session.running ? "ETA: --" : `ETA: ${formatEta(eta)}`,
|
||||
canStart: (!this.session.running || paused) && this.hasUsableDownloadAccount(),
|
||||
canStart: hasUsableAccount && (paused || (!this.session.running
|
||||
&& lifecycle.phase !== "waiting_provider"
|
||||
&& (lifecycle.phase !== "stopping" || !lifecycle.pendingStart))),
|
||||
canStop: this.session.running,
|
||||
canPause: this.session.running,
|
||||
clipboardActive: this.settings.clipboardWatch,
|
||||
@@ -3257,10 +3265,15 @@ export class DownloadManager extends EventEmitter {
|
||||
this.clearPersistTimer();
|
||||
this.stop();
|
||||
this.abortPostProcessing("clear_all");
|
||||
if (this.stateEmitTimer) {
|
||||
clearTimeout(this.stateEmitTimer);
|
||||
this.stateEmitTimer = null;
|
||||
}
|
||||
if (this.stateEmitTimer) {
|
||||
clearTimeout(this.stateEmitTimer);
|
||||
this.stateEmitTimer = null;
|
||||
}
|
||||
if (this.providerRetryTimer) {
|
||||
clearTimeout(this.providerRetryTimer);
|
||||
this.providerRetryTimer = null;
|
||||
this.providerRetryAt = 0;
|
||||
}
|
||||
this.session.packageOrder = [];
|
||||
this.session.packages = {};
|
||||
this.session.items = {};
|
||||
@@ -3926,7 +3939,42 @@ export class DownloadManager extends EventEmitter {
|
||||
return tasks.size;
|
||||
}
|
||||
|
||||
private getLifecycleSnapshot(): DownloadLifecycleSnapshot {
|
||||
private getEarliestProviderRetryAt(now: number): number | null {
|
||||
const deadlines = [...this.providerFailures.values()]
|
||||
.map((entry) => entry.cooldownUntil)
|
||||
.filter((deadline) => Number.isFinite(deadline) && deadline > now);
|
||||
const runtimeRetryAt = getNextProviderRuntimeRetryAt(now);
|
||||
if (runtimeRetryAt) {
|
||||
deadlines.push(runtimeRetryAt);
|
||||
}
|
||||
return deadlines.length > 0 ? Math.min(...deadlines) : null;
|
||||
}
|
||||
|
||||
private syncProviderRetryTimer(retryAt: number | null, now: number): void {
|
||||
if (!retryAt || retryAt <= now) {
|
||||
if (this.providerRetryTimer) {
|
||||
clearTimeout(this.providerRetryTimer);
|
||||
this.providerRetryTimer = null;
|
||||
}
|
||||
this.providerRetryAt = 0;
|
||||
return;
|
||||
}
|
||||
if (this.providerRetryTimer && this.providerRetryAt === retryAt) {
|
||||
return;
|
||||
}
|
||||
if (this.providerRetryTimer) {
|
||||
clearTimeout(this.providerRetryTimer);
|
||||
}
|
||||
this.providerRetryAt = retryAt;
|
||||
this.providerRetryTimer = setTimeout(() => {
|
||||
this.providerRetryTimer = null;
|
||||
this.providerRetryAt = 0;
|
||||
this.emitState(true);
|
||||
}, Math.min(2_147_483_647, Math.max(0, retryAt - now)));
|
||||
this.providerRetryTimer.unref?.();
|
||||
}
|
||||
|
||||
private getLifecycleSnapshot(retryAt: number | null, hasUsableAccount: boolean): DownloadLifecycleSnapshot {
|
||||
const activeDownloads = this.activeTasks.size;
|
||||
const activePostProcessing = this.getActivePostProcessingCount();
|
||||
const pendingStart = this.pendingStartOptions !== null;
|
||||
@@ -3934,7 +3982,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return {
|
||||
phase: "stopping",
|
||||
reason: pendingStart ? "Start vorgemerkt, laufende Arbeit wird beendet" : "Laufende Arbeit wird beendet",
|
||||
retryAt: null,
|
||||
retryAt,
|
||||
activeDownloads,
|
||||
activePostProcessing,
|
||||
pendingStart
|
||||
@@ -3944,18 +3992,28 @@ export class DownloadManager extends EventEmitter {
|
||||
return {
|
||||
phase: "starting",
|
||||
reason: this.lifecycleReason,
|
||||
retryAt: null,
|
||||
retryAt,
|
||||
activeDownloads,
|
||||
activePostProcessing,
|
||||
pendingStart
|
||||
};
|
||||
}
|
||||
if (this.session.running) {
|
||||
if (activeDownloads === 0 && retryAt) {
|
||||
return {
|
||||
phase: "waiting_provider",
|
||||
reason: "Provider vorübergehend nicht verfügbar",
|
||||
retryAt,
|
||||
activeDownloads,
|
||||
activePostProcessing,
|
||||
pendingStart
|
||||
};
|
||||
}
|
||||
const postprocessing = activeDownloads === 0 && activePostProcessing > 0;
|
||||
return {
|
||||
phase: postprocessing ? "postprocessing" : "running",
|
||||
reason: postprocessing ? "Nachbearbeitung läuft" : this.session.paused ? "Downloads pausiert" : "Downloads laufen",
|
||||
retryAt: null,
|
||||
retryAt,
|
||||
activeDownloads,
|
||||
activePostProcessing,
|
||||
pendingStart
|
||||
@@ -3965,16 +4023,19 @@ export class DownloadManager extends EventEmitter {
|
||||
return {
|
||||
phase: "postprocessing",
|
||||
reason: "Nachbearbeitung läuft",
|
||||
retryAt: null,
|
||||
retryAt,
|
||||
activeDownloads,
|
||||
activePostProcessing,
|
||||
pendingStart
|
||||
};
|
||||
}
|
||||
const waitingForProvider = !hasUsableAccount && retryAt !== null;
|
||||
return {
|
||||
phase: "idle",
|
||||
reason: this.lifecycleReason,
|
||||
retryAt: null,
|
||||
phase: waitingForProvider ? "waiting_provider" : "idle",
|
||||
reason: waitingForProvider
|
||||
? "Provider vorübergehend nicht verfügbar"
|
||||
: hasUsableAccount ? this.lifecycleReason : "Kein aktiver Download-Account verfügbar",
|
||||
retryAt,
|
||||
activeDownloads,
|
||||
activePostProcessing,
|
||||
pendingStart
|
||||
@@ -6451,6 +6512,8 @@ export class DownloadManager extends EventEmitter {
|
||||
try {
|
||||
this.beginHealthRun();
|
||||
this.ensureUsableDownloadAccount();
|
||||
this.session.running = true;
|
||||
this.session.paused = false;
|
||||
const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => {
|
||||
const pkg = this.session.packages[packageId];
|
||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled && !options?.excludePackageIds?.has(packageId));
|
||||
@@ -6460,12 +6523,12 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
const recoveredItems = await this.recoverRetryableItems("start", recoveryRunPackageIds);
|
||||
if (this.lifecycleGeneration !== generation || this.lifecyclePhase === "stopping") {
|
||||
if (this.lifecycleGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(0);
|
||||
if (this.lifecycleGeneration !== generation || this.lifecyclePhase === "stopping") {
|
||||
if (this.lifecycleGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6631,7 +6694,6 @@ export class DownloadManager extends EventEmitter {
|
||||
this.healthManualStop = !parkForRestart;
|
||||
this.healthShuttingDown = parkForRestart;
|
||||
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
|
||||
const keepExtraction = this.settings.autoExtractWhenStopped;
|
||||
const wasRunning = this.session.running;
|
||||
const stoppedRunContext = wasRunning
|
||||
? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt)
|
||||
@@ -6652,12 +6714,10 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedEventsHead = 0;
|
||||
if (!keepExtraction) {
|
||||
this.abortPostProcessing("stop");
|
||||
for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); }
|
||||
this.packagePostProcessWaiters = [];
|
||||
this.packagePostProcessActive = 0;
|
||||
}
|
||||
this.abortPostProcessing("stop");
|
||||
for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); }
|
||||
this.packagePostProcessWaiters = [];
|
||||
this.packagePostProcessActive = 0;
|
||||
for (const active of this.activeTasks.values()) {
|
||||
active.abortReason = abortReason;
|
||||
active.abortController.abort(abortReason);
|
||||
@@ -6672,10 +6732,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
for (const pkg of Object.values(this.session.packages)) {
|
||||
if (keepExtraction && (pkg.status === "extracting" || pkg.status === "integrity_check")) {
|
||||
continue;
|
||||
}
|
||||
if (pkg.status === "downloading" || pkg.status === "validating"
|
||||
if (pkg.status === "downloading" || pkg.status === "validating"
|
||||
|| pkg.status === "extracting" || pkg.status === "integrity_check"
|
||||
|| pkg.status === "paused" || pkg.status === "reconnect_wait") {
|
||||
pkg.status = "queued";
|
||||
@@ -6711,12 +6768,17 @@ export class DownloadManager extends EventEmitter {
|
||||
this.healthShuttingDown = true;
|
||||
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
|
||||
this.updateStatisticsActivity(nowMs());
|
||||
this.rotationListenerActive = false;
|
||||
this.clearPersistTimer();
|
||||
if (this.stateEmitTimer) {
|
||||
clearTimeout(this.stateEmitTimer);
|
||||
this.stateEmitTimer = null;
|
||||
}
|
||||
this.rotationListenerActive = false;
|
||||
this.clearPersistTimer();
|
||||
if (this.stateEmitTimer) {
|
||||
clearTimeout(this.stateEmitTimer);
|
||||
this.stateEmitTimer = null;
|
||||
}
|
||||
if (this.providerRetryTimer) {
|
||||
clearTimeout(this.providerRetryTimer);
|
||||
this.providerRetryTimer = null;
|
||||
this.providerRetryAt = 0;
|
||||
}
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
this.session.reconnectUntil = 0;
|
||||
@@ -14248,9 +14310,11 @@ export class DownloadManager extends EventEmitter {
|
||||
private finishRun(): void {
|
||||
const runStartedAt = this.session.runStartedAt;
|
||||
const completedAt = nowMs();
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
this.session.runStartedAt = 0;
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
this.lifecyclePhase = "idle";
|
||||
this.lifecycleReason = "Bereit";
|
||||
this.session.runStartedAt = 0;
|
||||
const total = this.runItemIds.size;
|
||||
const outcomes = Array.from(this.runOutcomes.values());
|
||||
const success = outcomes.filter((status) => status === "completed").length;
|
||||
|
||||
@@ -47,7 +47,7 @@ function throwIfAborted(signal?: AbortSignal): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
@@ -73,8 +73,44 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
@@ -299,10 +335,10 @@ export class RealDebridWebFallback {
|
||||
}
|
||||
return job();
|
||||
};
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return raceWithAbort(run, signal);
|
||||
}
|
||||
|
||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||
const partition = this.getPartition();
|
||||
|
||||
+15
-4
@@ -825,9 +825,10 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
|
||||
totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "",
|
||||
paused: false, running: false, updatedAt: Date.now()
|
||||
},
|
||||
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
|
||||
canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {}
|
||||
},
|
||||
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
|
||||
canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {},
|
||||
lifecycle: { phase: "idle", reason: "Bereit", retryAt: null, activeDownloads: 0, activePostProcessing: 0, pendingStart: false }
|
||||
});
|
||||
|
||||
const cleanupLabels: Record<string, string> = {
|
||||
@@ -4816,6 +4817,14 @@ export function App(): ReactElement {
|
||||
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
|
||||
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||
const downloadRemaining = useMemo(() => getRemainingDownloadBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||
const downloadLifecycle = useMemo(() => snapshot.lifecycle ?? {
|
||||
phase: snapshot.session.running ? "running" as const : "idle" as const,
|
||||
reason: snapshot.session.running ? "Downloads laufen" : "Bereit",
|
||||
retryAt: null,
|
||||
activeDownloads: Object.values(snapshot.session.items).filter((item) => item.status === "downloading" || item.status === "validating").length,
|
||||
activePostProcessing: 0,
|
||||
pendingStart: false
|
||||
}, [snapshot.lifecycle, snapshot.session.items, snapshot.session.running]);
|
||||
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
||||
...downloadsViewCore,
|
||||
running: snapshot.session.running,
|
||||
@@ -4846,6 +4855,8 @@ export function App(): ReactElement {
|
||||
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
||||
disclosureRevision: downloadDisclosureRevision,
|
||||
animationsEnabled: snapshot.settings.animatePackageDisclosure,
|
||||
lifecycle: downloadLifecycle,
|
||||
lifecycleNow: runtimeNow,
|
||||
status: {
|
||||
packages: snapshot.stats.totalPackages,
|
||||
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
||||
@@ -4860,7 +4871,7 @@ export function App(): ReactElement {
|
||||
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||
eta: snapshot.etaText
|
||||
}
|
||||
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadLifecycle, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, runtimeNow, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
|
||||
const resetColumnLayout = useCallback((): void => {
|
||||
if (columnDragSettleTimerRef.current !== null) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { DownloadLifecycleSnapshot } from "../../../shared/types";
|
||||
import { RollingMetricValue } from "../../ui/RollingMetricValue";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import type { DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
|
||||
@@ -54,8 +55,10 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
|
||||
sortDirection?: "asc" | "desc";
|
||||
disclosureRevision: number;
|
||||
animationsEnabled: boolean;
|
||||
lifecycle: DownloadLifecycleSnapshot;
|
||||
lifecycleNow: number;
|
||||
status: DownloadsStatusModel;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DownloadsViewActions extends DownloadsTableActions {
|
||||
onResetColumnLayout: () => void;
|
||||
@@ -134,7 +137,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
||||
const scheduleSlotClass = `downloads-schedule-slot ${scheduleSlotOpen ? "is-open" : "is-closed"}${model.animationsEnabled ? "" : " is-motion-disabled"}`;
|
||||
return (
|
||||
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
||||
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
|
||||
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">{model.lifecycle.pendingStart ? "Start vorgemerkt" : "Start"}</button>
|
||||
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
||||
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
|
||||
@@ -171,13 +174,42 @@ export function DownloadsContent({ actions, model }: { actions: DownloadsViewAct
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLifecycleStatus(model: DownloadsViewModel): string {
|
||||
const phaseLabels: Record<DownloadLifecycleSnapshot["phase"], string> = {
|
||||
idle: "Bereit",
|
||||
starting: "Startet",
|
||||
running: "Läuft",
|
||||
stopping: "Stoppt",
|
||||
waiting_provider: "Wartet auf Provider",
|
||||
postprocessing: "Nachbearbeitung"
|
||||
};
|
||||
const parts = [phaseLabels[model.lifecycle.phase]];
|
||||
if (model.lifecycle.reason && model.lifecycle.reason !== parts[0]) {
|
||||
parts.push(model.lifecycle.reason);
|
||||
}
|
||||
if (model.lifecycle.retryAt && model.lifecycle.retryAt > model.lifecycleNow) {
|
||||
parts.push(`Noch ${Math.max(1, Math.ceil((model.lifecycle.retryAt - model.lifecycleNow) / 1000))} s`);
|
||||
}
|
||||
const remaining: string[] = [];
|
||||
if (model.lifecycle.activeDownloads > 0) {
|
||||
remaining.push(`${model.lifecycle.activeDownloads} ${model.lifecycle.activeDownloads === 1 ? "Download" : "Downloads"}`);
|
||||
}
|
||||
if (model.lifecycle.activePostProcessing > 0) {
|
||||
remaining.push(`${model.lifecycle.activePostProcessing} ${model.lifecycle.activePostProcessing === 1 ? "Nachbearbeitung" : "Nachbearbeitungen"}`);
|
||||
}
|
||||
if (remaining.length > 0) {
|
||||
parts.push(`Restarbeit: ${remaining.join(", ")}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
return (
|
||||
<footer className="downloads-footer" data-visual-region="downloads-pagination">
|
||||
<span>{model.paginationLabel}</span>
|
||||
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
|
||||
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
|
||||
<span role="status">{formatLifecycleStatus(model)}</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,28 @@ describe("alldebrid-web", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("releases an aborted caller while the active web request ignores its signal", async () => {
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
mockFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}));
|
||||
const fallback = new AllDebridWebFallback(() => true);
|
||||
const controller = new AbortController();
|
||||
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
|
||||
.then(() => "resolved" as const, (error) => String(error));
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||
controller.abort("test-stop");
|
||||
const outcome = await Promise.race([
|
||||
running,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
|
||||
]);
|
||||
|
||||
expect(outcome).toContain("aborted:alldebrid-web");
|
||||
rejectRequest(new Error("late alldebrid rejection"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it("opens the login window after login_required and retries generation with the same session partition", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response("login", { status: 200 }))
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("bestdebrid-web", () => {
|
||||
expect(mockCookiesSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
|
||||
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
|
||||
const filePath = createCookieFile([
|
||||
"# Netscape HTTP Cookie File",
|
||||
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
|
||||
@@ -162,6 +162,34 @@ describe("bestdebrid-web", () => {
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://bestdebrid.com/api/v1/generateLink");
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
|
||||
});
|
||||
});
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
|
||||
});
|
||||
|
||||
it("releases an aborted caller while the active web request ignores its signal", async () => {
|
||||
const filePath = createCookieFile([
|
||||
"# Netscape HTTP Cookie File",
|
||||
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
|
||||
].join("\n"));
|
||||
tempFiles.push(filePath);
|
||||
const fallback = new BestDebridWebFallback(() => true);
|
||||
await fallback.importCookiesFromFile(filePath);
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
mockFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}));
|
||||
const controller = new AbortController();
|
||||
const running = fallback.unrestrict("https://1fichier.com/?abort-race", controller.signal)
|
||||
.then(() => "resolved" as const, (error) => String(error));
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||
controller.abort("test-stop");
|
||||
const outcome = await Promise.race([
|
||||
running,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
|
||||
]);
|
||||
|
||||
expect(outcome).toContain("aborted:bestdebrid-web");
|
||||
rejectRequest(new Error("late bestdebrid rejection"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -899,6 +899,109 @@ describe("deterministic stop and restart lifecycle", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(internal.activeTasks.get(itemId)).toBe(newOwner);
|
||||
});
|
||||
|
||||
it("emits an idle snapshot when the earliest provider cooldown expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-22T08:00:00.000Z"));
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-cooldown-event-"));
|
||||
tempDirs.push(root);
|
||||
const accountId = "rda_cooldown_event";
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
|
||||
providerOrder: ["realdebrid"],
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
manager.addPackages([{ name: "cooldown", links: ["https://rapidgator.net/file/cooldown"] }]);
|
||||
const internal = manager as unknown as { stateEmitTimer: NodeJS.Timeout | null };
|
||||
if (internal.stateEmitTimer) {
|
||||
clearTimeout(internal.stateEmitTimer);
|
||||
internal.stateEmitTimer = null;
|
||||
}
|
||||
primeRealDebridRuntimeCooldownForTests(accountId, 1_000);
|
||||
const events: Array<ReturnType<typeof manager.getSnapshot>> = [];
|
||||
manager.on("state", (snapshot) => events.push(snapshot));
|
||||
|
||||
const waiting = manager.getSnapshot();
|
||||
expect(waiting).toMatchObject({
|
||||
canStart: false,
|
||||
lifecycle: {
|
||||
phase: "waiting_provider",
|
||||
retryAt: Date.parse("2026-08-22T08:00:01.000Z")
|
||||
}
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(events.some((snapshot) => snapshot.canStart)).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
canStart: true,
|
||||
lifecycle: { phase: "idle", retryAt: null }
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps post-processing drain visible and starts the pending run after its abort settles", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-stop-drain-"));
|
||||
tempDirs.push(root);
|
||||
const packageId = "postprocess-drain";
|
||||
const session = emptySession();
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "postprocess-drain",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
status: "extracting",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", autoExtract: true, autoExtractWhenStopped: true },
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
let postProcessSignal!: AbortSignal;
|
||||
let finishPostProcessing!: () => void;
|
||||
const recoverRetryableItems = vi.fn().mockResolvedValue(0);
|
||||
const internal = manager as unknown as {
|
||||
handlePackagePostProcessing: (packageId: string, signal: AbortSignal) => Promise<void>;
|
||||
recoverRetryableItems: () => Promise<number>;
|
||||
runPackagePostProcessing: (packageId: string) => Promise<void>;
|
||||
};
|
||||
internal.handlePackagePostProcessing = async (_requestedPackageId, signal) => {
|
||||
postProcessSignal = signal;
|
||||
await new Promise<void>((resolve) => {
|
||||
finishPostProcessing = resolve;
|
||||
});
|
||||
};
|
||||
internal.recoverRetryableItems = recoverRetryableItems;
|
||||
|
||||
const processing = internal.runPackagePostProcessing(packageId);
|
||||
await waitFor(() => postProcessSignal !== undefined);
|
||||
manager.stop();
|
||||
|
||||
expect(postProcessSignal.aborted).toBe(true);
|
||||
await manager.start();
|
||||
expect(manager.getSnapshot().lifecycle).toMatchObject({
|
||||
phase: "stopping",
|
||||
activePostProcessing: 1,
|
||||
pendingStart: true
|
||||
});
|
||||
|
||||
finishPostProcessing();
|
||||
await processing;
|
||||
await waitFor(() => manager.getSnapshot().lifecycle?.phase === "idle");
|
||||
expect(recoverRetryableItems).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getSnapshot().lifecycle).toMatchObject({ activePostProcessing: 0, pendingStart: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
||||
@@ -14146,8 +14249,12 @@ describe("download manager", () => {
|
||||
manager.start().then(() => "started" as const, (error) => String(error)),
|
||||
timeout
|
||||
]);
|
||||
expect(result).toContain("Kein aktiver Download-Account verfügbar");
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
expect(result).toBe("started");
|
||||
await waitFor(() => manager.getSnapshot().lifecycle?.reason.includes("Kein aktiver Download-Account verfügbar") === true);
|
||||
expect(manager.getSnapshot()).toMatchObject({
|
||||
session: { running: false },
|
||||
lifecycle: { phase: "idle", pendingStart: false }
|
||||
});
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
|
||||
@@ -797,6 +797,15 @@ function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsVie
|
||||
packageSpeedBps: { "package-a": 12_000_000 },
|
||||
disclosureRevision: 0,
|
||||
animationsEnabled: true,
|
||||
lifecycle: {
|
||||
phase: "running",
|
||||
reason: "Downloads laufen",
|
||||
retryAt: null,
|
||||
activeDownloads: 1,
|
||||
activePostProcessing: 0,
|
||||
pendingStart: false
|
||||
},
|
||||
lifecycleNow: now,
|
||||
editingPackageId: null,
|
||||
editingName: "",
|
||||
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
||||
@@ -1148,6 +1157,61 @@ describe("downloads view", () => {
|
||||
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
|
||||
});
|
||||
|
||||
it("accepts a start during stopping and then shows the accepted pending request", () => {
|
||||
const actions = createActions();
|
||||
const stopping = {
|
||||
...withRuntime(createInput(), { running: false, canStart: true, canPause: false }),
|
||||
lifecycle: {
|
||||
phase: "stopping",
|
||||
reason: "Laufende Arbeit wird beendet",
|
||||
retryAt: null,
|
||||
activeDownloads: 1,
|
||||
activePostProcessing: 0,
|
||||
pendingStart: false
|
||||
},
|
||||
lifecycleNow: now
|
||||
} as DownloadsViewModel;
|
||||
const pending = {
|
||||
...stopping,
|
||||
canStart: false,
|
||||
lifecycle: { ...stopping.lifecycle, pendingStart: true, reason: "Start vorgemerkt, laufende Arbeit wird beendet" }
|
||||
} as DownloadsViewModel;
|
||||
|
||||
expect(findButton(DownloadsToolbar({ actions, model: stopping }), "Start").props.disabled).toBe(false);
|
||||
expect(findButton(DownloadsToolbar({ actions, model: pending }), "Start vorgemerkt").props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows lifecycle phase, reason, retry countdown and remaining drain work", () => {
|
||||
const stopping = {
|
||||
...withRuntime(createInput(), { running: false }),
|
||||
lifecycle: {
|
||||
phase: "stopping",
|
||||
reason: "Laufende Arbeit wird beendet",
|
||||
retryAt: null,
|
||||
activeDownloads: 2,
|
||||
activePostProcessing: 1,
|
||||
pendingStart: false
|
||||
},
|
||||
lifecycleNow: now
|
||||
} as DownloadsViewModel;
|
||||
const waiting = {
|
||||
...stopping,
|
||||
lifecycle: {
|
||||
phase: "waiting_provider",
|
||||
reason: "Provider vorübergehend nicht verfügbar",
|
||||
retryAt: now + 3_200,
|
||||
activeDownloads: 0,
|
||||
activePostProcessing: 0,
|
||||
pendingStart: false
|
||||
}
|
||||
} as DownloadsViewModel;
|
||||
|
||||
const stoppingHtml = renderToStaticMarkup(<DownloadsFooter actions={createActions()} model={stopping} />);
|
||||
const waitingHtml = renderToStaticMarkup(<DownloadsFooter actions={createActions()} model={waiting} />);
|
||||
expect(stoppingHtml).toContain("Stoppt · Laufende Arbeit wird beendet · Restarbeit: 2 Downloads, 1 Nachbearbeitung");
|
||||
expect(waitingHtml).toContain("Wartet auf Provider · Provider vorübergehend nicht verfügbar · Noch 4 s");
|
||||
});
|
||||
|
||||
it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
|
||||
const pausedToolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
|
||||
@@ -191,6 +191,28 @@ describe("realdebrid-web", () => {
|
||||
expect(mockBrowserWindowCtor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases an aborted caller while the active web request ignores its signal", async () => {
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
mockSessionFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}));
|
||||
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_abort_race", () => true);
|
||||
const controller = new AbortController();
|
||||
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
|
||||
.then(() => "resolved" as const, (error) => String(error));
|
||||
|
||||
await vi.waitFor(() => expect(mockSessionFetch).toHaveBeenCalledTimes(1));
|
||||
controller.abort("test-stop");
|
||||
const outcome = await Promise.race([
|
||||
running,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
|
||||
]);
|
||||
|
||||
expect(outcome).toContain("aborted:realdebrid-web");
|
||||
rejectRequest(new Error("late realdebrid rejection"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it("does not open a login window for an authenticated account with a fair-use error", async () => {
|
||||
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"session-token\">", { status: 200 }));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user