Compare commits

...
Author SHA1 Message Date
Ludy87 c6daec4938 Update lifecycle.test.ts 2026-08-04 09:24:54 +02:00
Ludy87 ede9b5da76 Improve resource cleanup and lifecycle safety
Centralise and harden blob URL revocation and document/page cleanup, add cancellation for async file processing, and make worker document destruction async.

- Add lifecycle test to verify tracked blob URL revocation.
- FileLifecycleManager: add revokeBlobUrl helper and use it for thumbnail/blob/processed-page thumbnails.
- useEnhancedProcessedFiles: add cancellation flag and avoid setting state after cancellation; update processedFilesRef.
- pdfWorkerManager: make destroyDocument/destroyAllDocuments async and await pdf.destroy; ensure forced destroys call pdf.destroy() without blocking.
- heuristicExtractor: ensure page.cleanup() in finally.

Fixes potential leaks/race conditions and improves robustness.
2026-08-04 09:15:53 +02:00
Ludy87 780d599fcf fix(frontend): prevent PDF preview and worker memory leaks 2026-08-04 08:58:57 +02:00
9 changed files with 213 additions and 74 deletions
@@ -0,0 +1,62 @@
import { MantineProvider } from "@mantine/core";
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import StampSetupSettings from "@app/components/tools/addStamp/StampSetupSettings";
import { defaultParameters } from "@app/components/tools/addStamp/useAddStampParameters";
describe("StampSetupSettings image preview", () => {
const onParameterChange = vi.fn();
const stampImage = new File(["image"], "stamp.png", {
type: "image/png",
});
beforeEach(() => {
vi.clearAllMocks();
});
const renderImageStamp = () =>
render(
<MantineProvider>
<StampSetupSettings
parameters={{
...defaultParameters,
stampType: "image",
stampImage,
}}
onParameterChange={onParameterChange}
/>
</MantineProvider>,
);
it("creates one preview URL per selected image, not per render", () => {
const createObjectURL = vi.mocked(URL.createObjectURL);
const { rerender } = renderImageStamp();
expect(createObjectURL).toHaveBeenCalledTimes(1);
rerender(
<MantineProvider>
<StampSetupSettings
parameters={{
...defaultParameters,
stampType: "image",
stampImage,
pageNumbers: "2",
}}
onParameterChange={onParameterChange}
/>
</MantineProvider>,
);
expect(createObjectURL).toHaveBeenCalledTimes(1);
});
it("revokes the preview URL when the component unmounts", () => {
const revokeObjectURL = vi.mocked(URL.revokeObjectURL);
const { unmount } = renderImageStamp();
unmount();
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
@@ -209,6 +209,17 @@ const StampSetupSettings = ({
filename,
}: StampSetupSettingsProps) => {
const { t } = useTranslation();
const stampImageUrl = useMemo(
() =>
parameters.stampImage ? URL.createObjectURL(parameters.stampImage) : null,
[parameters.stampImage],
);
useEffect(() => {
return () => {
if (stampImageUrl) URL.revokeObjectURL(stampImageUrl);
};
}, [stampImageUrl]);
return (
<Stack gap="md">
@@ -679,10 +690,10 @@ const StampSetupSettings = ({
>
{t("chooseFile", "Choose File")}
</Button>
{parameters.stampImage && (
{parameters.stampImage && stampImageUrl && (
<Stack gap="xs">
<img
src={URL.createObjectURL(parameters.stampImage)}
src={stampImageUrl}
alt="Selected stamp image"
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
/>
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FileLifecycleManager } from "@app/contexts/file/lifecycle";
import type { FileId } from "@app/types/file";
describe("FileLifecycleManager", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("revokes tracked blob URLs during cleanup", () => {
const fileId = "file-1" as FileId;
const filesRef = {
current: new Map<FileId, File>([[fileId, new File(["pdf"], "file.pdf")]]),
};
const dispatch = vi.fn();
const manager = new FileLifecycleManager(filesRef, dispatch);
const blobUrl = "blob:file-1";
manager.trackBlobUrl(blobUrl);
manager.cleanupAllFiles();
expect(URL.revokeObjectURL).toHaveBeenCalledWith(blobUrl);
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
});
});
@@ -34,6 +34,16 @@ export class FileLifecycleManager {
}
};
private revokeBlobUrl = (url: string): void => {
if (!url.startsWith("blob:")) return;
try {
URL.revokeObjectURL(url);
} catch {
// Ignore revocation errors.
}
this.blobUrls.delete(url);
};
/**
* Clean up resources for a specific file (with stateRef access for complete cleanup)
*/
@@ -150,32 +160,14 @@ export class FileLifecycleManager {
const record = stateRef.current.files.byId[fileId];
if (record) {
// Clean up thumbnail blob URLs
if (record.thumbnailUrl && record.thumbnailUrl.startsWith("blob:")) {
try {
URL.revokeObjectURL(record.thumbnailUrl);
} catch {
// Ignore revocation errors
}
}
if (record.thumbnailUrl) this.revokeBlobUrl(record.thumbnailUrl);
if (record.blobUrl && record.blobUrl.startsWith("blob:")) {
try {
URL.revokeObjectURL(record.blobUrl);
} catch {
// Ignore revocation errors
}
}
if (record.blobUrl) this.revokeBlobUrl(record.blobUrl);
// Clean up processed file thumbnails
if (record.processedFile?.pages) {
record.processedFile.pages.forEach((page: ProcessedFilePage) => {
if (page.thumbnail && page.thumbnail.startsWith("blob:")) {
try {
URL.revokeObjectURL(page.thumbnail);
} catch {
// Ignore revocation errors
}
}
if (page.thumbnail) this.revokeBlobUrl(page.thumbnail);
});
}
}
@@ -681,6 +681,8 @@ export const useCompareOperation = (): CompareOperationHook => {
useEffect(() => {
return () => {
cancelledRef.current = true;
pixelSignalRef.current.cancelled = true;
cleanupDownloadUrl();
revokePixelUrls();
if (workerRef.current) {
@@ -47,6 +47,8 @@ export function useEnhancedProcessedFiles(
const [processingStates, setProcessingStates] = useState<
Map<string, ProcessingState>
>(new Map());
const processedFilesRef = useRef(processedFiles);
processedFilesRef.current = processedFiles;
// Subscribe to processing state changes once
useEffect(() => {
@@ -57,6 +59,7 @@ export function useEnhancedProcessedFiles(
// Process files when activeFiles changes
useEffect(() => {
let cancelled = false;
console.log(
"useEnhancedProcessedFiles: activeFiles changed",
activeFiles.length,
@@ -77,6 +80,7 @@ export function useEnhancedProcessedFiles(
const newProcessedFiles = new Map<File, ProcessedFile>();
for (const file of activeFiles) {
if (cancelled) return;
// Generate hash for this file
const fileHash = await FileHasher.generateHybridHash(file);
fileHashMapRef.current.set(file, fileHash);
@@ -120,57 +124,67 @@ export function useEnhancedProcessedFiles(
(file) => !processedFiles.has(file),
);
if (hasChanged) {
if (hasChanged && !cancelled) {
processedFilesRef.current = newProcessedFiles;
setProcessedFiles(newProcessedFiles);
}
};
processFiles();
return () => {
cancelled = true;
};
}, [activeFiles]); // Only depend on activeFiles to avoid infinite loops
// Listen for processing completion
useEffect(() => {
const checkInFlightRef = { current: false };
const checkForCompletedFiles = async () => {
if (checkInFlightRef.current) return;
checkInFlightRef.current = true;
let hasNewFiles = false;
const updatedFiles = new Map(processedFiles);
const updatedFiles = new Map(processedFilesRef.current);
// Generate file keys for all files first
const fileKeyPromises = activeFiles.map(async (file) => ({
file,
key: await FileHasher.generateHybridHash(file),
}));
try {
// Generate file keys for all files first
const fileKeyPromises = activeFiles.map(async (file) => ({
file,
key: await FileHasher.generateHybridHash(file),
}));
const fileKeyPairs = await Promise.all(fileKeyPromises);
const fileKeyPairs = await Promise.all(fileKeyPromises);
for (const { file, key } of fileKeyPairs) {
// Only check files that don't have processed results yet
if (!updatedFiles.has(file)) {
const processingState = processingStates.get(key);
for (const { file, key } of fileKeyPairs) {
// Only check files that don't have processed results yet
if (!updatedFiles.has(file)) {
const processingState = processingStates.get(key);
// Check for both processing and recently completed files
// This ensures we catch completed files before they're cleaned up
if (
processingState?.status === "processing" ||
processingState?.status === "completed"
) {
try {
const processed = await enhancedPDFProcessingService.processFile(
file,
config,
);
if (processed) {
updatedFiles.set(file, processed);
hasNewFiles = true;
// Check for both processing and recently completed files
// This ensures we catch completed files before they're cleaned up
if (
processingState?.status === "processing" ||
processingState?.status === "completed"
) {
try {
const processed =
await enhancedPDFProcessingService.processFile(file, config);
if (processed) {
updatedFiles.set(file, processed);
hasNewFiles = true;
}
} catch {
// Ignore errors in completion check
}
} catch {
// Ignore errors in completion check
}
}
}
}
if (hasNewFiles) {
setProcessedFiles(updatedFiles);
if (hasNewFiles) {
processedFilesRef.current = updatedFiles;
setProcessedFiles(updatedFiles);
}
} finally {
checkInFlightRef.current = false;
}
};
@@ -54,11 +54,12 @@ class PDFWorkerManager {
disableStream?: boolean;
stopAtErrors?: boolean;
verbosity?: number;
signal?: { cancelled: boolean };
} = {},
): Promise<PDFDocumentProxy> {
// Wait if we've hit the worker limit
if (this.activeDocuments.size >= this.maxWorkers) {
await this.waitForAvailableWorker();
await this.waitForAvailableWorker(options.signal);
}
// Normalize input data to PDF.js format
@@ -105,7 +106,7 @@ class PDFWorkerManager {
// If document creation fails, make sure to clean up the loading task
if (loadingTask) {
try {
loadingTask.destroy();
void loadingTask.destroy();
} catch {
// Ignore errors
}
@@ -117,14 +118,13 @@ class PDFWorkerManager {
/**
* Properly destroy a PDF document and clean up resources
*/
destroyDocument(pdf: PDFDocumentProxy): void {
async destroyDocument(pdf: PDFDocumentProxy): Promise<void> {
if (this.activeDocuments.has(pdf)) {
try {
pdf.destroy();
this.activeDocuments.delete(pdf);
this.workerCount = Math.max(0, this.workerCount - 1);
await pdf.destroy();
} catch {
// Still remove from tracking even if destroy failed
// Still remove from tracking if destroy fails.
} finally {
this.activeDocuments.delete(pdf);
this.workerCount = Math.max(0, this.workerCount - 1);
}
@@ -134,26 +134,31 @@ class PDFWorkerManager {
/**
* Destroy all active PDF documents
*/
destroyAllDocuments(): void {
async destroyAllDocuments(): Promise<void> {
const documentsToDestroy = Array.from(this.activeDocuments);
documentsToDestroy.forEach((pdf) => {
this.destroyDocument(pdf);
});
this.activeDocuments.clear();
this.workerCount = 0;
await Promise.all(
documentsToDestroy.map((pdf) => this.destroyDocument(pdf)),
);
}
/**
* Wait for a worker to become available
*/
private async waitForAvailableWorker(): Promise<void> {
return new Promise((resolve) => {
private async waitForAvailableWorker(signal?: {
cancelled: boolean;
}): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null;
const checkAvailability = () => {
if (signal?.cancelled) {
if (timer !== null) clearTimeout(timer);
reject(new Error("CANCELLED"));
return;
}
if (this.activeDocuments.size < this.maxWorkers) {
resolve();
} else {
setTimeout(checkAvailability, 100);
timer = setTimeout(checkAvailability, 100);
}
};
checkAvailability();
@@ -178,7 +183,7 @@ class PDFWorkerManager {
// Force destroy all documents
this.activeDocuments.forEach((pdf) => {
try {
pdf.destroy();
void pdf.destroy();
} catch {
// Ignore errors
}
@@ -89,12 +89,17 @@ export const runPixelCompare = async ({
};
return await new Promise<CompareResultPixelData>((resolve, reject) => {
let settled = false;
let cancellationTimer: ReturnType<typeof setInterval> | null = null;
const handleMessage = (event: MessageEvent<PixelCompareWorkerResponse>) => {
if (settled) return;
const message = event.data;
if (!message) return;
if (signal?.cancelled) {
terminateWorker();
cleanupOnFailure();
settled = true;
reject(new Error("CANCELLED"));
return;
}
@@ -131,6 +136,7 @@ export const runPixelCompare = async ({
case "success": {
pages.sort((a, b) => a.pageNumber - b.pageNumber);
terminateWorker();
settled = true;
resolve({
mode: "pixel",
base: { fileId: baseFileId, fileName: baseFile.name },
@@ -151,6 +157,7 @@ export const runPixelCompare = async ({
case "error": {
terminateWorker();
cleanupOnFailure();
settled = true;
reject(new Error(message.message));
break;
}
@@ -158,14 +165,20 @@ export const runPixelCompare = async ({
};
const handleError = (event: ErrorEvent) => {
if (settled) return;
terminateWorker();
cleanupOnFailure();
settled = true;
reject(
event.error ?? new Error(event.message || "Pixel compare worker error"),
);
};
const terminateWorker = () => {
if (cancellationTimer !== null) {
clearInterval(cancellationTimer);
cancellationTimer = null;
}
worker.removeEventListener("message", handleMessage as EventListener);
worker.removeEventListener("error", handleError as EventListener);
try {
@@ -178,6 +191,17 @@ export const runPixelCompare = async ({
worker.addEventListener("message", handleMessage as EventListener);
worker.addEventListener("error", handleError as EventListener);
if (signal) {
cancellationTimer = setInterval(() => {
if (signal.cancelled && !settled) {
terminateWorker();
cleanupOnFailure();
settled = true;
reject(new Error("CANCELLED"));
}
}, 50);
}
const request: PixelCompareWorkerRequest = {
type: "pixel-compare",
payload: {
@@ -108,7 +108,11 @@ async function pageText(
): Promise<string> {
if (pageNo < 1 || pageNo > pdfDoc.numPages) return "";
const page = await pdfDoc.getPage(pageNo);
return textFromItems(await pageTextItems(page));
try {
return textFromItems(await pageTextItems(page));
} finally {
page.cleanup();
}
}
function textFromItems(items: readonly unknown[]): string {