Harden download stop and restart lifecycle
Publish an explicit lifecycle snapshot with active download and post-processing counts. Guard asynchronous start recovery with a dedicated generation so a stop cannot revive an invalidated run. Keep starts requested during stopping pending until old work drains, then dispatch the accepted request once. Protect active task ownership and post-processing drain cleanup from stale finalizers. Cover recovery invalidation, pending restart dispatch, and late task cleanup with focused regression tests.
This commit is contained in:
+202
-45
@@ -9,8 +9,9 @@ import {
|
|||||||
AppSettings,
|
AppSettings,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
AudioStripSummary,
|
AudioStripSummary,
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
DownloadStats,
|
DownloadLifecycleSnapshot,
|
||||||
|
DownloadStats,
|
||||||
DownloadSummary,
|
DownloadSummary,
|
||||||
DownloadStatus,
|
DownloadStatus,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
@@ -126,6 +127,7 @@ type ActiveTask = {
|
|||||||
phase?: "validating" | "downloading" | "integrity_check";
|
phase?: "validating" | "downloading" | "integrity_check";
|
||||||
phaseStartedAt?: number;
|
phaseStartedAt?: number;
|
||||||
phaseDeadlineAt?: number;
|
phaseDeadlineAt?: number;
|
||||||
|
generation: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOWNLOAD_ACCOUNT_PROVIDERS: readonly DebridProvider[] = [
|
const DOWNLOAD_ACCOUNT_PROVIDERS: readonly DebridProvider[] = [
|
||||||
@@ -1836,10 +1838,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private invalidateMegaSessionFn?: () => void;
|
private invalidateMegaSessionFn?: () => void;
|
||||||
|
|
||||||
private activeTasks = new Map<string, ActiveTask>();
|
private activeTasks = new Map<string, ActiveTask>();
|
||||||
|
|
||||||
private scheduleRunning = false;
|
private scheduleRunning = false;
|
||||||
private schedulerGeneration = 0;
|
private schedulerGeneration = 0;
|
||||||
|
private lifecycleGeneration = 0;
|
||||||
|
private lifecyclePhase: DownloadLifecycleSnapshot["phase"] = "idle";
|
||||||
|
private lifecycleReason = "Bereit";
|
||||||
|
private pendingStartOptions: { excludePackageIds?: ReadonlySet<string> } | null = null;
|
||||||
|
private startOperations = new Set<number>();
|
||||||
|
|
||||||
private persistTimer: NodeJS.Timeout | null = null;
|
private persistTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
@@ -2624,9 +2631,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.packagePostProcessActive = 0;
|
this.packagePostProcessActive = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public triggerIdleExtractions(): void {
|
public triggerIdleExtractions(): void {
|
||||||
if (this.session.running || !this.settings.autoExtract || !this.settings.autoExtractWhenStopped) {
|
if (this.session.running || !this.settings.autoExtract || !this.settings.autoExtractWhenStopped) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.recoverPostProcessingOnStartup();
|
this.recoverPostProcessingOnStartup();
|
||||||
this.triggerPendingExtractions();
|
this.triggerPendingExtractions();
|
||||||
@@ -2774,6 +2781,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return {
|
return {
|
||||||
rotationEvents: getRecentRotationEvents(40),
|
rotationEvents: getRecentRotationEvents(40),
|
||||||
accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now),
|
accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now),
|
||||||
|
lifecycle: this.getLifecycleSnapshot(),
|
||||||
settings: rendererState.settings,
|
settings: rendererState.settings,
|
||||||
accounts: rendererState.accounts,
|
accounts: rendererState.accounts,
|
||||||
session: snapshotSession,
|
session: snapshotSession,
|
||||||
@@ -3900,6 +3908,100 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getActivePostProcessingCount(): number {
|
||||||
|
const tasks = new Set<Promise<void>>();
|
||||||
|
for (const task of this.packagePostProcessTasks.values()) {
|
||||||
|
tasks.add(task);
|
||||||
|
}
|
||||||
|
for (const group of this.packageDeferredPostProcessTasks.values()) {
|
||||||
|
for (const task of group) {
|
||||||
|
tasks.add(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const group of this.packageHybridPostProcessTasks.values()) {
|
||||||
|
for (const task of group) {
|
||||||
|
tasks.add(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tasks.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLifecycleSnapshot(): DownloadLifecycleSnapshot {
|
||||||
|
const activeDownloads = this.activeTasks.size;
|
||||||
|
const activePostProcessing = this.getActivePostProcessingCount();
|
||||||
|
const pendingStart = this.pendingStartOptions !== null;
|
||||||
|
if (this.lifecyclePhase === "stopping") {
|
||||||
|
return {
|
||||||
|
phase: "stopping",
|
||||||
|
reason: pendingStart ? "Start vorgemerkt, laufende Arbeit wird beendet" : "Laufende Arbeit wird beendet",
|
||||||
|
retryAt: null,
|
||||||
|
activeDownloads,
|
||||||
|
activePostProcessing,
|
||||||
|
pendingStart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this.lifecyclePhase === "starting") {
|
||||||
|
return {
|
||||||
|
phase: "starting",
|
||||||
|
reason: this.lifecycleReason,
|
||||||
|
retryAt: null,
|
||||||
|
activeDownloads,
|
||||||
|
activePostProcessing,
|
||||||
|
pendingStart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this.session.running) {
|
||||||
|
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,
|
||||||
|
activeDownloads,
|
||||||
|
activePostProcessing,
|
||||||
|
pendingStart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (activePostProcessing > 0) {
|
||||||
|
return {
|
||||||
|
phase: "postprocessing",
|
||||||
|
reason: "Nachbearbeitung läuft",
|
||||||
|
retryAt: null,
|
||||||
|
activeDownloads,
|
||||||
|
activePostProcessing,
|
||||||
|
pendingStart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
phase: "idle",
|
||||||
|
reason: this.lifecycleReason,
|
||||||
|
retryAt: null,
|
||||||
|
activeDownloads,
|
||||||
|
activePostProcessing,
|
||||||
|
pendingStart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeStopIfDrained(): void {
|
||||||
|
if (this.lifecyclePhase !== "stopping"
|
||||||
|
|| this.startOperations.size > 0
|
||||||
|
|| this.activeTasks.size > 0
|
||||||
|
|| this.getActivePostProcessingCount() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pendingOptions = this.pendingStartOptions;
|
||||||
|
this.pendingStartOptions = null;
|
||||||
|
this.lifecyclePhase = "idle";
|
||||||
|
this.lifecycleReason = "Bereit";
|
||||||
|
this.emitState(true);
|
||||||
|
if (pendingOptions) {
|
||||||
|
void this.start(pendingOptions).catch((error) => {
|
||||||
|
this.lifecyclePhase = "idle";
|
||||||
|
this.lifecycleReason = compactErrorText(error);
|
||||||
|
this.emitState(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private applyOneFichierCheckResult(item: DownloadItem, result: OneFichierCheckResult | null): void {
|
private applyOneFichierCheckResult(item: DownloadItem, result: OneFichierCheckResult | null): void {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
if (item.onlineStatus === "checking") {
|
if (item.onlineStatus === "checking") {
|
||||||
@@ -6324,14 +6426,31 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
|
public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
|
||||||
|
if (this.lifecyclePhase === "stopping") {
|
||||||
|
if (!this.pendingStartOptions) {
|
||||||
|
this.pendingStartOptions = options?.excludePackageIds
|
||||||
|
? { excludePackageIds: new Set(options.excludePackageIds) }
|
||||||
|
: {};
|
||||||
|
this.lifecycleReason = "Start vorgemerkt";
|
||||||
|
this.emitState(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.session.running) {
|
if (this.session.running) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.beginHealthRun();
|
if (this.lifecyclePhase === "starting") {
|
||||||
this.ensureUsableDownloadAccount();
|
return;
|
||||||
this.schedulerGeneration += 1;
|
}
|
||||||
|
const generation = this.lifecycleGeneration + 1;
|
||||||
this.session.running = true;
|
this.lifecycleGeneration = generation;
|
||||||
|
this.lifecyclePhase = "starting";
|
||||||
|
this.lifecycleReason = "Warteschlange wird vorbereitet";
|
||||||
|
this.startOperations.add(generation);
|
||||||
|
this.emitState(true);
|
||||||
|
try {
|
||||||
|
this.beginHealthRun();
|
||||||
|
this.ensureUsableDownloadAccount();
|
||||||
const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => {
|
const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => {
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled && !options?.excludePackageIds?.has(packageId));
|
return Boolean(pkg && !pkg.cancelled && pkg.enabled && !options?.excludePackageIds?.has(packageId));
|
||||||
@@ -6341,8 +6460,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recoveredItems = await this.recoverRetryableItems("start", recoveryRunPackageIds);
|
const recoveredItems = await this.recoverRetryableItems("start", recoveryRunPackageIds);
|
||||||
|
if (this.lifecycleGeneration !== generation || this.lifecyclePhase === "stopping") {
|
||||||
await sleep(0);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(0);
|
||||||
|
if (this.lifecycleGeneration !== generation || this.lifecyclePhase === "stopping") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let recoveredStoppedItems = 0;
|
let recoveredStoppedItems = 0;
|
||||||
for (const item of Object.values(this.session.items)) {
|
for (const item of Object.values(this.session.items)) {
|
||||||
@@ -6380,13 +6505,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
||||||
});
|
});
|
||||||
if (runItems.length === 0) {
|
if (runItems.length === 0) {
|
||||||
if (this.packagePostProcessTasks.size > 0) {
|
if (this.packagePostProcessTasks.size > 0) {
|
||||||
this.runItemIds.clear();
|
this.runItemIds.clear();
|
||||||
this.runPackageIds.clear();
|
this.runPackageIds.clear();
|
||||||
this.runOutcomes.clear();
|
this.runOutcomes.clear();
|
||||||
this.runCompletedPackages.clear();
|
this.runCompletedPackages.clear();
|
||||||
this.session.running = true;
|
this.schedulerGeneration += 1;
|
||||||
this.session.paused = false;
|
this.session.running = true;
|
||||||
|
this.session.paused = false;
|
||||||
|
this.lifecyclePhase = "postprocessing";
|
||||||
|
this.lifecycleReason = "Nachbearbeitung läuft";
|
||||||
this.session.runStartedAt = this.session.runStartedAt || nowMs();
|
this.session.runStartedAt = this.session.runStartedAt || nowMs();
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
@@ -6424,8 +6552,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.lastGlobalProgressBytes = 0;
|
this.lastGlobalProgressBytes = 0;
|
||||||
this.lastGlobalProgressAt = nowMs();
|
this.lastGlobalProgressAt = nowMs();
|
||||||
this.summary = null;
|
this.summary = null;
|
||||||
this.nonResumableActive = 0;
|
this.nonResumableActive = 0;
|
||||||
this.persistSoon();
|
this.lifecyclePhase = "idle";
|
||||||
|
this.lifecycleReason = "Bereit";
|
||||||
|
this.persistSoon();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6447,8 +6577,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.schedulerGeneration += 1;
|
||||||
this.session.running = true;
|
this.session.running = true;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
|
this.lifecyclePhase = "running";
|
||||||
|
this.lifecycleReason = "Downloads laufen";
|
||||||
this.session.runStartedAt = nowMs();
|
this.session.runStartedAt = nowMs();
|
||||||
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
|
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
|
||||||
this.session.totalDownloadedBytes = 0;
|
this.session.totalDownloadedBytes = 0;
|
||||||
@@ -6469,17 +6602,32 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.nonResumableActive = 0;
|
this.nonResumableActive = 0;
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
void this.ensureScheduler().catch((error) => {
|
void this.ensureScheduler().catch((error) => {
|
||||||
logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`);
|
logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`);
|
||||||
this.session.running = false;
|
this.session.running = false;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
});
|
});
|
||||||
}
|
} catch (error) {
|
||||||
|
if (this.lifecycleGeneration === generation && this.lifecyclePhase === "starting") {
|
||||||
|
this.lifecyclePhase = "idle";
|
||||||
|
this.lifecycleReason = compactErrorText(error);
|
||||||
|
this.emitState(true);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.startOperations.delete(generation);
|
||||||
|
this.completeStopIfDrained();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public stop(options?: { parkForRestart?: boolean }): void {
|
public stop(options?: { parkForRestart?: boolean }): void {
|
||||||
const parkForRestart = options?.parkForRestart === true;
|
const parkForRestart = options?.parkForRestart === true;
|
||||||
|
this.lifecycleGeneration += 1;
|
||||||
|
this.lifecyclePhase = "stopping";
|
||||||
|
this.lifecycleReason = "Laufende Arbeit wird beendet";
|
||||||
|
this.pendingStartOptions = null;
|
||||||
this.healthManualStop = !parkForRestart;
|
this.healthManualStop = !parkForRestart;
|
||||||
this.healthShuttingDown = parkForRestart;
|
this.healthShuttingDown = parkForRestart;
|
||||||
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
|
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
|
||||||
@@ -6554,9 +6702,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runPackageIds.clear();
|
this.runPackageIds.clear();
|
||||||
this.runOutcomes.clear();
|
this.runOutcomes.clear();
|
||||||
this.runCompletedPackages.clear();
|
this.runCompletedPackages.clear();
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
}
|
this.completeStopIfDrained();
|
||||||
|
}
|
||||||
|
|
||||||
public prepareForShutdown(): void {
|
public prepareForShutdown(): void {
|
||||||
this.healthShuttingDown = true;
|
this.healthShuttingDown = true;
|
||||||
@@ -8243,13 +8392,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
if (this.hybridExtractRequeue.delete(packageId)) {
|
if (this.lifecyclePhase !== "stopping" && this.hybridExtractRequeue.delete(packageId)) {
|
||||||
void this.runPackagePostProcessing(packageId).catch((err) =>
|
void this.runPackagePostProcessing(packageId).catch((err) =>
|
||||||
logger.warn(`runPackagePostProcessing Fehler (hybridRequeue): ${compactErrorText(err)}`)
|
logger.warn(`runPackagePostProcessing Fehler (hybridRequeue): ${compactErrorText(err)}`)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
this.hybridExtractRequeue.delete(packageId);
|
||||||
this.tryFinalizePackageResult(packageId);
|
this.tryFinalizePackageResult(packageId);
|
||||||
}
|
}
|
||||||
|
this.completeStopIfDrained();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -9725,28 +9876,32 @@ export class DownloadManager extends EventEmitter {
|
|||||||
blockedOnThrottleUntil: 0,
|
blockedOnThrottleUntil: 0,
|
||||||
phase: "validating",
|
phase: "validating",
|
||||||
phaseStartedAt: item.updatedAt,
|
phaseStartedAt: item.updatedAt,
|
||||||
phaseDeadlineAt: item.updatedAt + getUnrestrictTimeoutMs() + 15_000
|
phaseDeadlineAt: item.updatedAt + getUnrestrictTimeoutMs() + 15_000,
|
||||||
|
generation: this.lifecycleGeneration
|
||||||
};
|
};
|
||||||
this.activeTasks.set(itemId, active);
|
this.activeTasks.set(itemId, active);
|
||||||
this.notePacedStartForItem(item, nowMs());
|
this.notePacedStartForItem(item, nowMs());
|
||||||
this.emitState();
|
this.emitState();
|
||||||
|
|
||||||
void this.processItem(active).catch((err) => {
|
void this.processItem(active).catch((err) => {
|
||||||
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
|
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.diskLeasesByOwner.get(itemId)?.release();
|
if (active.nonResumableCounted) {
|
||||||
this.diskLeasesByOwner.delete(itemId);
|
this.nonResumableActive = Math.max(0, this.nonResumableActive - 1);
|
||||||
if (!this.retryAfterByItem.has(item.id)) {
|
}
|
||||||
this.releaseTargetPath(item.id);
|
if (this.activeTasks.get(itemId) === active) {
|
||||||
}
|
this.diskLeasesByOwner.get(itemId)?.release();
|
||||||
if (active.nonResumableCounted) {
|
this.diskLeasesByOwner.delete(itemId);
|
||||||
this.nonResumableActive = Math.max(0, this.nonResumableActive - 1);
|
if (!this.retryAfterByItem.has(item.id)) {
|
||||||
}
|
this.releaseTargetPath(item.id);
|
||||||
this.activeTasks.delete(itemId);
|
}
|
||||||
this.tryFinalizePackageResult(packageId);
|
this.activeTasks.delete(itemId);
|
||||||
this.persistSoon();
|
this.tryFinalizePackageResult(packageId);
|
||||||
this.emitState();
|
this.persistSoon();
|
||||||
});
|
this.emitState();
|
||||||
|
}
|
||||||
|
this.completeStopIfDrained();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processItem(active: ActiveTask): Promise<void> {
|
private async processItem(active: ActiveTask): Promise<void> {
|
||||||
@@ -13112,6 +13267,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.packageHybridPostProcessTasks.delete(packageId);
|
this.packageHybridPostProcessTasks.delete(packageId);
|
||||||
}
|
}
|
||||||
this.tryFinalizePackageResult(packageId);
|
this.tryFinalizePackageResult(packageId);
|
||||||
|
this.completeStopIfDrained();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
hybridHandle.task = hybridTask;
|
hybridHandle.task = hybridTask;
|
||||||
@@ -13764,6 +13920,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.tryFinalizePackageResult(packageId);
|
this.tryFinalizePackageResult(packageId);
|
||||||
this.applyPackageDoneCleanup(packageId);
|
this.applyPackageDoneCleanup(packageId);
|
||||||
|
this.completeStopIfDrained();
|
||||||
});
|
});
|
||||||
const tasks = this.packageDeferredPostProcessTasks.get(packageId) || new Set<Promise<void>>();
|
const tasks = this.packageDeferredPostProcessTasks.get(packageId) || new Set<Promise<void>>();
|
||||||
tasks.add(task);
|
tasks.add(task);
|
||||||
|
|||||||
@@ -655,6 +655,17 @@ export interface AccountRuntimeEntry {
|
|||||||
dailyUsageBytes: number;
|
dailyUsageBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DownloadLifecyclePhase = "idle" | "starting" | "running" | "stopping" | "waiting_provider" | "postprocessing";
|
||||||
|
|
||||||
|
export interface DownloadLifecycleSnapshot {
|
||||||
|
phase: DownloadLifecyclePhase;
|
||||||
|
reason: string;
|
||||||
|
retryAt: number | null;
|
||||||
|
activeDownloads: number;
|
||||||
|
activePostProcessing: number;
|
||||||
|
pendingStart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UiSnapshot {
|
export interface UiSnapshot {
|
||||||
settings: RendererSettings;
|
settings: RendererSettings;
|
||||||
accounts: RendererAccount[];
|
accounts: RendererAccount[];
|
||||||
@@ -685,6 +696,7 @@ export interface UiSnapshot {
|
|||||||
removedPackageIds?: string[];
|
removedPackageIds?: string[];
|
||||||
rotationEvents?: RotationEvent[];
|
rotationEvents?: RotationEvent[];
|
||||||
accountRuntime?: AccountRuntimeEntry[];
|
accountRuntime?: AccountRuntimeEntry[];
|
||||||
|
lifecycle?: DownloadLifecycleSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddLinksPayload {
|
export interface AddLinksPayload {
|
||||||
|
|||||||
@@ -770,8 +770,138 @@ describe("download start account gate", () => {
|
|||||||
expect(manager.getSnapshot().session.paused).toBe(true);
|
expect(manager.getSnapshot().session.paused).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
describe("deterministic stop and restart lifecycle", () => {
|
||||||
|
it("does not let a start recovery revive a run after stop invalidated its generation", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-start-recovery-generation-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{ ...defaultSettings(), token: "rd-token", autoExtract: false },
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
manager.addPackages([{ name: "generation", links: ["https://rapidgator.net/file/generation"] }]);
|
||||||
|
|
||||||
|
let finishRecovery!: (recovered: number) => void;
|
||||||
|
const internal = manager as unknown as {
|
||||||
|
recoverRetryableItems: () => Promise<number>;
|
||||||
|
};
|
||||||
|
internal.recoverRetryableItems = () => new Promise<number>((resolve) => {
|
||||||
|
finishRecovery = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const starting = manager.start();
|
||||||
|
expect(finishRecovery).toBeTypeOf("function");
|
||||||
|
manager.stop();
|
||||||
|
finishRecovery(0);
|
||||||
|
await starting;
|
||||||
|
|
||||||
|
expect(manager.getSnapshot()).toMatchObject({
|
||||||
|
session: { running: false },
|
||||||
|
lifecycle: { phase: "idle", pendingStart: false }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts one pending start during stopping and dispatches it after the old download drains", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pending-start-drain-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const accountId = "rdw_pending_start";
|
||||||
|
const attempts: AbortSignal[] = [];
|
||||||
|
let finishFirstAbort!: () => void;
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
realDebridUseWebLogin: true,
|
||||||
|
realDebridWebAccountIds: [accountId],
|
||||||
|
providerOrder: ["realdebrid"],
|
||||||
|
autoExtract: false,
|
||||||
|
maxParallel: 1
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state")),
|
||||||
|
{
|
||||||
|
realDebridWebUnrestrict: async (_requestedAccountId, _link, signal) => {
|
||||||
|
if (!signal) {
|
||||||
|
throw new Error("missing abort signal");
|
||||||
|
}
|
||||||
|
attempts.push(signal);
|
||||||
|
return new Promise<UnrestrictedLink | null>((_resolve, reject) => {
|
||||||
|
const rejectAborted = () => {
|
||||||
|
if (attempts.length === 1) {
|
||||||
|
finishFirstAbort = () => reject(new Error("aborted:test-web"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (signal.aborted) {
|
||||||
|
rejectAborted();
|
||||||
|
} else {
|
||||||
|
signal.addEventListener("abort", rejectAborted, { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
manager.addPackages([{ name: "pending", links: ["https://rapidgator.net/file/pending"] }]);
|
||||||
|
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => attempts.length === 1);
|
||||||
|
manager.stop();
|
||||||
|
await manager.start();
|
||||||
|
|
||||||
|
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true });
|
||||||
|
finishFirstAbort();
|
||||||
|
await waitFor(() => attempts.length === 2);
|
||||||
|
expect(manager.getSnapshot()).toMatchObject({
|
||||||
|
session: { running: true },
|
||||||
|
lifecycle: { phase: "running", pendingStart: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a newer task owner when cleanup from the previous generation arrives late", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-owner-generation-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const accountId = "rdw_owner_generation";
|
||||||
|
let rejectUnrestrict!: (error: Error) => void;
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
realDebridUseWebLogin: true,
|
||||||
|
realDebridWebAccountIds: [accountId],
|
||||||
|
providerOrder: ["realdebrid"],
|
||||||
|
autoExtract: false,
|
||||||
|
maxParallel: 1
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state")),
|
||||||
|
{
|
||||||
|
realDebridWebUnrestrict: async () => new Promise<UnrestrictedLink | null>((_resolve, reject) => {
|
||||||
|
rejectUnrestrict = reject;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
manager.addPackages([{ name: "owner", links: ["https://rapidgator.net/file/owner"] }]);
|
||||||
|
const snapshot = manager.getSnapshot();
|
||||||
|
const packageId = snapshot.session.packageOrder[0];
|
||||||
|
const itemId = snapshot.session.packages[packageId].itemIds[0];
|
||||||
|
const internal = manager as unknown as {
|
||||||
|
activeTasks: Map<string, { abortController: AbortController; abortReason: string }>;
|
||||||
|
startItem: (packageId: string, itemId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
internal.startItem(packageId, itemId);
|
||||||
|
await waitFor(() => rejectUnrestrict !== undefined);
|
||||||
|
const oldOwner = internal.activeTasks.get(itemId)!;
|
||||||
|
const newOwner = { ...oldOwner, abortController: new AbortController(), abortReason: "none" };
|
||||||
|
internal.activeTasks.set(itemId, newOwner);
|
||||||
|
rejectUnrestrict(new Error("aborted:late-owner"));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
expect(internal.activeTasks.get(itemId)).toBe(newOwner);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
||||||
it("detects archive names from extractor log variants", () => {
|
it("detects archive names from extractor log variants", () => {
|
||||||
expect(extractArchiveNameFromExtractorLogMessage("Extract-Backend Start: archive=scn-dhanbs7-S02E008.rar, mode=legacy")).toBe("scn-dhanbs7-S02E008.rar");
|
expect(extractArchiveNameFromExtractorLogMessage("Extract-Backend Start: archive=scn-dhanbs7-S02E008.rar, mode=legacy")).toBe("scn-dhanbs7-S02E008.rar");
|
||||||
expect(extractArchiveNameFromExtractorLogMessage("Entpacke Archiv: scn-dhanbs7-S02E008.rar -> C:\\target")).toBe("scn-dhanbs7-S02E008.rar");
|
expect(extractArchiveNameFromExtractorLogMessage("Entpacke Archiv: scn-dhanbs7-S02E008.rar -> C:\\target")).toBe("scn-dhanbs7-S02E008.rar");
|
||||||
|
|||||||
Reference in New Issue
Block a user