Files
Stirling-PDF/frontend/src/contexts/file/lifecycle.ts
T
James BruntonandGitHub e142af2863 V2 Make FileId type opaque and use consistently throughout project (#4307)
# Description of Changes
The `FileId` type in V2 currently is just defined to be a string. This
makes it really easy to accidentally pass strings into things accepting
file IDs (such as file names). This PR makes the `FileId` type [an
opaque
type](https://www.geeksforgeeks.org/typescript/opaque-types-in-typescript/),
so it is compatible with things accepting strings (arguably not ideal
for this...) but strings are not compatible with it without explicit
conversion.

The PR also includes changes to use `FileId` consistently throughout the
project (everywhere I could find uses of `fileId: string`), so that we
have the maximum benefit from the type safety.

> [!note]
> I've marked quite a few things as `FIX ME` where we're passing names
in as IDs. If that is intended behaviour, I'm happy to remove the fix me
and insert a cast instead, but they probably need comments explaining
why we're using a file name as an ID.
2025-08-28 09:56:07 +00:00

192 lines
5.8 KiB
TypeScript

/**
* File lifecycle management - Resource cleanup and memory management
*/
import { FileId } from '../../types/file';
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
const DEBUG = process.env.NODE_ENV === 'development';
/**
* Resource tracking and cleanup utilities
*/
export class FileLifecycleManager {
private cleanupTimers = new Map<string, number>();
private blobUrls = new Set<string>();
private fileGenerations = new Map<string, number>(); // Generation tokens to prevent stale cleanup
constructor(
private filesRef: React.MutableRefObject<Map<FileId, File>>,
private dispatch: React.Dispatch<FileContextAction>
) {}
/**
* Track blob URLs for cleanup
*/
trackBlobUrl = (url: string): void => {
// Only track actual blob URLs to avoid trying to revoke other schemes
if (url.startsWith('blob:')) {
this.blobUrls.add(url);
}
};
/**
* Clean up resources for a specific file (with stateRef access for complete cleanup)
*/
cleanupFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
// Use comprehensive cleanup (same as removeFiles)
this.cleanupAllResourcesForFile(fileId, stateRef);
// Remove file from state
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } });
};
/**
* Clean up all files and resources
*/
cleanupAllFiles = (): void => {
// Revoke all blob URLs
this.blobUrls.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
// Ignore revocation errors
}
});
this.blobUrls.clear();
// Clear all cleanup timers and generations
this.cleanupTimers.forEach(timer => clearTimeout(timer));
this.cleanupTimers.clear();
this.fileGenerations.clear();
// Clear files ref
this.filesRef.current.clear();
};
/**
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup
*/
scheduleCleanup = (fileId: FileId, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
// Cancel existing timer
const existingTimer = this.cleanupTimers.get(fileId);
if (existingTimer) {
clearTimeout(existingTimer);
this.cleanupTimers.delete(fileId);
}
// If delay is negative, just cancel (don't reschedule)
if (delay < 0) {
return;
}
// Increment generation for this file to invalidate any pending cleanup
const currentGen = (this.fileGenerations.get(fileId) || 0) + 1;
this.fileGenerations.set(fileId, currentGen);
// Schedule new cleanup with generation token
const timer = window.setTimeout(() => {
// Check if this cleanup is still valid (file hasn't been re-added)
if (this.fileGenerations.get(fileId) === currentGen) {
this.cleanupFile(fileId, stateRef);
} else {
if (DEBUG) console.log(`🗂️ Skipped stale cleanup for file ${fileId} (generation mismatch)`);
}
}, delay);
this.cleanupTimers.set(fileId, timer);
};
/**
* Remove a file immediately with complete resource cleanup
*/
removeFiles = (fileIds: FileId[], stateRef?: React.MutableRefObject<any>): void => {
fileIds.forEach(fileId => {
// Clean up all resources for this file
this.cleanupAllResourcesForFile(fileId, stateRef);
});
// Dispatch removal action once for all files (reducer only updates state)
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
};
/**
* Complete resource cleanup for a single file
*/
private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
// Remove from files ref
this.filesRef.current.delete(fileId);
// Cancel cleanup timer and generation
const timer = this.cleanupTimers.get(fileId);
if (timer) {
clearTimeout(timer);
this.cleanupTimers.delete(fileId);
}
this.fileGenerations.delete(fileId);
// Clean up blob URLs from file record if we have access to state
if (stateRef) {
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 (error) {
// Ignore revocation errors
}
}
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.blobUrl);
} catch (error) {
// Ignore revocation errors
}
}
// Clean up processed file thumbnails
if (record.processedFile?.pages) {
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
try {
URL.revokeObjectURL(page.thumbnail);
} catch (error) {
// Ignore revocation errors
}
}
});
}
}
}
};
/**
* Update file record with race condition guards
*/
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
// Guard against updating removed files (race condition protection)
if (!this.filesRef.current.has(fileId)) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
return;
}
// Additional state guard for rare race conditions
if (stateRef && !stateRef.current.files.byId[fileId]) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
return;
}
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
};
/**
* Cleanup on unmount
*/
destroy = (): void => {
this.cleanupAllFiles();
};
}