Fix lifecycle review findings

Advance each serialized web-provider queue when an aborted caller is released while retaining terminal observation of the underlying request. Preserve an accepted pending start across repeated stop requests and dispatch it exactly once after drain. Derive provider retry deadlines from eligible queued items, configured fallback chains, enabled accounts and keys, and matching provider or hoster cooldowns. Add RED-to-GREEN coverage for queue progress, repeated stop behavior, disabled accounts, alternative providers, unrelated hosters, and post-processing-only state.
This commit is contained in:
Sucukdeluxe
2026-08-22 10:53:00 +02:00
parent ad29239661
commit ab31d04410
10 changed files with 326 additions and 38 deletions
+20 -3
View File
@@ -144,9 +144,15 @@ 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;
}));
mockFetch
.mockReturnValueOnce(new Promise<Response>((_resolve, reject) => {
rejectRequest = reject;
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
link: "https://alldebrid.direct/second.bin",
filename: "second.bin",
filesize: 333
}), { status: 200 }));
const fallback = new AllDebridWebFallback(() => true);
const controller = new AbortController();
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
@@ -160,6 +166,17 @@ describe("alldebrid-web", () => {
]);
expect(outcome).toContain("aborted:alldebrid-web");
const secondOutcome = await Promise.race([
fallback.unrestrict("https://rapidgator.net/file/second"),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
]);
expect(secondOutcome).toEqual({
directUrl: "https://alldebrid.direct/second.bin",
fileName: "second.bin",
fileSize: 333,
retriesUsed: 0
});
expect(mockFetch).toHaveBeenCalledTimes(2);
rejectRequest(new Error("late alldebrid rejection"));
await Promise.resolve();
});
+21 -3
View File
@@ -174,9 +174,16 @@ describe("bestdebrid-web", () => {
const fallback = new BestDebridWebFallback(() => true);
await fallback.importCookiesFromFile(filePath);
let rejectRequest!: (error: Error) => void;
mockFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
rejectRequest = reject;
}));
mockFetch
.mockReturnValueOnce(new Promise<Response>((_resolve, reject) => {
rejectRequest = reject;
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
error: 0,
link: "https://bestdebrid.direct/second.bin",
filename: "second.bin",
size: "444 B"
}), { status: 200 }));
const controller = new AbortController();
const running = fallback.unrestrict("https://1fichier.com/?abort-race", controller.signal)
.then(() => "resolved" as const, (error) => String(error));
@@ -189,6 +196,17 @@ describe("bestdebrid-web", () => {
]);
expect(outcome).toContain("aborted:bestdebrid-web");
const secondOutcome = await Promise.race([
fallback.unrestrict("https://1fichier.com/?second"),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
]);
expect(secondOutcome).toEqual({
directUrl: "https://bestdebrid.direct/second.bin",
fileName: "second.bin",
fileSize: 444,
retriesUsed: 0
});
expect(mockFetch).toHaveBeenCalledTimes(2);
rejectRequest(new Error("late bestdebrid rejection"));
await Promise.resolve();
});
+87
View File
@@ -847,6 +847,8 @@ describe("deterministic stop and restart lifecycle", () => {
manager.stop();
await manager.start();
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true });
manager.stop();
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true });
finishFirstAbort();
await waitFor(() => attempts.length === 2);
@@ -854,6 +856,8 @@ describe("deterministic stop and restart lifecycle", () => {
session: { running: true },
lifecycle: { phase: "running", pendingStart: false }
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(attempts).toHaveLength(2);
manager.stop();
});
@@ -945,6 +949,89 @@ describe("deterministic stop and restart lifecycle", () => {
vi.useRealTimers();
});
it("ignores cooldowns from disabled accounts when projecting provider wait state", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-cooldown-context-"));
tempDirs.push(root);
const accountId = "rda_disabled_cooldown";
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
realDebridDisabledAccountIds: [accountId],
providerOrder: ["realdebrid"],
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "disabled", links: ["https://rapidgator.net/file/disabled"] }]);
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: false,
lifecycle: { phase: "idle", retryAt: null }
});
});
it("ignores a primary provider cooldown when an alternative provider can start the queue", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alternative-provider-context-"));
tempDirs.push(root);
const accountId = "rda_alternative_provider";
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
allDebridToken: "all-debrid-token",
providerOrder: ["realdebrid", "alldebrid"],
autoProviderFallback: true,
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "alternative", links: ["https://rapidgator.net/file/alternative"] }]);
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: true,
lifecycle: { phase: "idle", retryAt: null }
});
});
it("ignores cooldowns for another provider or hoster and for pure post-processing", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-irrelevant-cooldown-context-"));
tempDirs.push(root);
const manager = new DownloadManager(
{
...defaultSettings(),
oneFichierApiKey: "onefichier-key",
providerOrder: ["onefichier"],
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "other-hoster", links: ["https://1fichier.com/?other-hoster"] }]);
const internal = manager as unknown as {
packagePostProcessTasks: Map<string, Promise<void>>;
providerFailures: Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>;
session: ReturnType<typeof manager.getSession>;
};
internal.providerFailures.set("alldebrid:rapidgator", {
count: 20,
lastFailAt: Date.now(),
cooldownUntil: Date.now() + 60_000
});
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "idle", retryAt: null });
const itemId = Object.keys(internal.session.items)[0];
internal.session.items[itemId].status = "completed";
internal.packagePostProcessTasks.set("postprocess-only", new Promise<void>(() => {}));
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "postprocessing", retryAt: null });
});
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);
+21 -3
View File
@@ -193,9 +193,16 @@ describe("realdebrid-web", () => {
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;
}));
mockSessionFetch
.mockReturnValueOnce(new Promise<Response>((_resolve, reject) => {
rejectRequest = reject;
}))
.mockResolvedValueOnce(new Response("<input name=\"private_token\" value=\"second-token\">", { status: 200 }));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
download: "https://cdn.real-debrid.example/second.bin",
filename: "second.bin",
filesize: 222
}), { status: 200 })));
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)
@@ -209,6 +216,17 @@ describe("realdebrid-web", () => {
]);
expect(outcome).toContain("aborted:realdebrid-web");
const secondOutcome = await Promise.race([
fallback.unrestrict("https://rapidgator.net/file/second"),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
]);
expect(secondOutcome).toEqual({
directUrl: "https://cdn.real-debrid.example/second.bin",
fileName: "second.bin",
fileSize: 222,
retriesUsed: 0
});
expect(mockSessionFetch).toHaveBeenCalledTimes(2);
rejectRequest(new Error("late realdebrid rejection"));
await Promise.resolve();
});