diff --git a/server/package.json b/server/package.json index a800ad4..3468f84 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,8 @@ "dev": "tsx src/index.ts", "build": "tsc -p tsconfig.json", "start": "node dist/index.js", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/server/src/lib/DownloadManager.ts b/server/src/lib/DownloadManager.ts index ac5781d..12e897c 100644 --- a/server/src/lib/DownloadManager.ts +++ b/server/src/lib/DownloadManager.ts @@ -23,12 +23,22 @@ export type DownloadTask = { }; // Map to store active task metadata in memory (for speed) -// Ideally this would be persisted to JSON in the session folder for crash recovery +// Ideally this would be persisted to JSON in the session folder for crash recovery. +// As tasks de uma sessão são removidas quando ela expira (ver construtor), então o +// mapa não cresce sem limite num servidor de longa duração. const tasks = new Map(); export class DownloadManager { - constructor(private globalAgent: Dispatcher) { } + constructor(private globalAgent: Dispatcher) { + sessionManager.onDestroy((sessionId) => this.dropSession(sessionId)); + } + + private dropSession(sessionId: string) { + for (const [id, task] of tasks) { + if (task.sessionId === sessionId) tasks.delete(id); + } + } public getTasks(sessionId: string): DownloadTask[] { return Array.from(tasks.values()).filter(t => t.sessionId === sessionId); diff --git a/server/src/lib/SessionManager.ts b/server/src/lib/SessionManager.ts index 93bf5a8..2c02b78 100644 --- a/server/src/lib/SessionManager.ts +++ b/server/src/lib/SessionManager.ts @@ -21,6 +21,7 @@ type SessionData = { export class SessionManager { private sessions: Map = new Map(); + private destroyListeners: Array<(sessionId: string) => void> = []; constructor() { // Ensure storage root exists @@ -29,7 +30,8 @@ export class SessionManager { } // Start Garbage Collector - setInterval(() => this.cleanup(), 60 * 1000); // Check every minute + // unref: o coletor não deve impedir o processo de encerrar (nem os testes). + setInterval(() => this.cleanup(), 60 * 1000).unref(); // Check every minute console.log("[SessionManager] Garbage Collector started."); } @@ -89,6 +91,14 @@ export class SessionManager { } } + /** + * Registra um callback chamado quando uma sessão é destruída (expiração), + * para quem guarda estado por sessão poder descartá-lo. + */ + public onDestroy(listener: (sessionId: string) => void) { + this.destroyListeners.push(listener); + } + /** * Removes expired sessions and deletes their files. */ @@ -128,6 +138,7 @@ export class SessionManager { private destroySession(id: string) { this.sessions.delete(id); + for (const listener of this.destroyListeners) listener(id); const path = this.getSessionPath(id); if (existsSync(path)) { try { diff --git a/server/test/helpers.ts b/server/test/helpers.ts new file mode 100644 index 0000000..03e3eb7 --- /dev/null +++ b/server/test/helpers.ts @@ -0,0 +1,18 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { vi } from "vitest"; + +/** + * Carrega os módulos do servidor com um STORAGE_ROOT temporário. + * O SessionManager lê a variável e cria o singleton no import, então o módulo + * precisa ser reimportado a cada teste. + */ +export async function loadModules() { + const storageRoot = mkdtempSync(join(tmpdir(), "downloader-test-")); + process.env.STORAGE_ROOT = storageRoot; + vi.resetModules(); + const { sessionManager } = await import("../src/lib/SessionManager.js"); + const { DownloadManager } = await import("../src/lib/DownloadManager.js"); + return { storageRoot, sessionManager, DownloadManager }; +} diff --git a/server/test/sessions.test.ts b/server/test/sessions.test.ts new file mode 100644 index 0000000..a93ca00 --- /dev/null +++ b/server/test/sessions.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { existsSync, readdirSync, rmSync } from "node:fs"; +import { Agent } from "undici"; +import { loadModules } from "./helpers.js"; + +const HOUR = 60 * 60 * 1000; + +let origin: Server; +let originUrl: string; + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ["setInterval", "Date"] }); + origin = createServer((_req, res) => { + res.setHeader("content-length", "5"); + res.end("hello"); + }); + await new Promise((done) => origin.listen(0, "127.0.0.1", done)); + originUrl = `http://127.0.0.1:${(origin.address() as AddressInfo).port}/file.txt`; +}); + +afterEach(async () => { + vi.useRealTimers(); + await new Promise((done) => origin.close(done)); +}); + +async function waitForStatus( + read: () => string | undefined, + wanted: string, +) { + for (let i = 0; i < 500 && read() !== wanted; i++) { + await new Promise((done) => setImmediate(done)); + } + expect(read()).toBe(wanted); +} + +describe("tasks e sessões", () => { + it("descarta as tasks e os arquivos quando a sessão expira", async () => { + const { sessionManager, DownloadManager, storageRoot } = await loadModules(); + const manager = new DownloadManager(new Agent()); + + const session = sessionManager.getSession(); + const task = await manager.queueDownload(session.id, originUrl); + await waitForStatus(() => manager.getTask(task.id)?.status, "completed"); + expect(readdirSync(`${storageRoot}/${session.id}`)).toHaveLength(1); + + vi.setSystemTime(Date.now() + HOUR + 1000); + vi.advanceTimersByTime(60_000); // dispara o coletor + + expect(manager.getTask(task.id)).toBeUndefined(); + expect(manager.getTasks(session.id)).toEqual([]); + expect(existsSync(`${storageRoot}/${session.id}`)).toBe(false); + rmSync(storageRoot, { recursive: true, force: true }); + }); + + it("mantém as tasks de outras sessões ainda ativas", async () => { + const { sessionManager, DownloadManager, storageRoot } = await loadModules(); + const manager = new DownloadManager(new Agent()); + + const expiring = sessionManager.getSession(); + await manager.queueDownload(expiring.id, originUrl); + + vi.setSystemTime(Date.now() + HOUR - 1000); + const alive = sessionManager.getSession(); // criada perto do fim da outra + const kept = await manager.queueDownload(alive.id, originUrl); + vi.setSystemTime(Date.now() + 2000); + vi.advanceTimersByTime(60_000); + + expect(manager.getTasks(expiring.id)).toEqual([]); + expect(manager.getTask(kept.id)).toBeDefined(); + rmSync(storageRoot, { recursive: true, force: true }); + }); +});