diff --git a/server/src/nest/storage/drivers/local.driver.ts b/server/src/nest/storage/drivers/local.driver.ts index 35ae0e333..4c474927d 100644 --- a/server/src/nest/storage/drivers/local.driver.ts +++ b/server/src/nest/storage/drivers/local.driver.ts @@ -16,6 +16,8 @@ import { } from '../storage.types'; const SPOOL_DIR_NAME = '.tmp'; +/** Boot spool-reap age gate: entries younger than this survive the sweep. */ +const SPOOL_REAP_AGE_MS = 60 * 60 * 1000; function errnoCode(err: unknown): string | undefined { return (err as NodeJS.ErrnoException).code; @@ -65,7 +67,18 @@ export class LocalDriver implements StorageDriver { } if (opts.cleanSpool) { for (const entry of fs.readdirSync(this.spoolDir())) { - fs.rmSync(path.join(this.spoolDir(), entry), { recursive: true, force: true }); + const entryPath = path.join(this.spoolDir(), entry); + // Age gate: only reap entries older than the threshold. Crash leftovers + // are always old by the next boot; a fresh entry belongs to another + // process spooling into the same tree right now (the vitest integration + // workers share uploads/, and a second worker booting mid-upload must + // not delete the first one's in-flight spool file). + try { + if (Date.now() - fs.statSync(entryPath).mtimeMs < SPOOL_REAP_AGE_MS) continue; + } catch { + continue; // raced away already — nothing to reap + } + fs.rmSync(entryPath, { recursive: true, force: true }); } } } diff --git a/server/tests/unit/nest/storage/local.driver.test.ts b/server/tests/unit/nest/storage/local.driver.test.ts index 776d3e0aa..c61f77b6d 100644 --- a/server/tests/unit/nest/storage/local.driver.test.ts +++ b/server/tests/unit/nest/storage/local.driver.test.ts @@ -63,11 +63,14 @@ describe('LocalDriver specifics', () => { expect(() => driver.getLocalPath('../escape')).toThrow(StorageInvalidKeyError); }); - it('cleans spool leftovers only at boot, never on reload-style init', () => { + it('cleans aged spool leftovers only at boot, never on reload-style init', () => { const driver = new LocalDriver({ id: 'spool', root: tmp() }); driver.init(); const stray = path.join(driver.getSpoolDir(), 'leftover.part'); fs.writeFileSync(stray, 'in-flight upload'); + // Crash leftover: old enough to clear the reap age gate. + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); + fs.utimesSync(stray, old, old); driver.init(); // reload(): must NOT delete an in-flight upload's spool file expect(fs.existsSync(stray)).toBe(true); @@ -76,6 +79,16 @@ describe('LocalDriver specifics', () => { expect(fs.existsSync(stray)).toBe(false); }); + it('the boot spool sweep spares fresh entries (another process may be mid-upload)', () => { + const driver = new LocalDriver({ id: 'spool-fresh', root: tmp() }); + driver.init(); + const inflight = path.join(driver.getSpoolDir(), 'inflight.part'); + fs.writeFileSync(inflight, 'spooling right now'); + + driver.init({ cleanSpool: true }); + expect(fs.existsSync(inflight)).toBe(true); + }); + it('creates its root, spool, and category prefix dirs at init', () => { const root = path.join(tmp(), 'nested', 'uploads'); const driver = new LocalDriver({ id: 'pfx', root }); diff --git a/server/tests/unit/nest/storage/storage-registry.service.test.ts b/server/tests/unit/nest/storage/storage-registry.service.test.ts index 806057a3c..592f8cb69 100644 --- a/server/tests/unit/nest/storage/storage-registry.service.test.ts +++ b/server/tests/unit/nest/storage/storage-registry.service.test.ts @@ -237,6 +237,10 @@ describe('StorageRegistryService reload', () => { fs.mkdirSync(path.dirname(stray), { recursive: true }); fs.writeFileSync(stray, 'crash leftover'); + // Aged past the reap gate: the boot sweep spares fresh entries (another + // process may be spooling into the same tree — see LocalDriver.init). + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); + fs.utimesSync(stray, old, old); const { registry } = makeRegistry({ uploadsDir: uploadsRoot }); expect(fs.existsSync(stray)).toBe(false); // boot reclaimed it