diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index ad39d2d57..4c4f0d9a4 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -2492,6 +2492,34 @@ describe('FSService copy', () => { expect(await readBack(overwritten)).toBe('source'); }); + it('does not see a phantom collision after the occupant is renamed', async () => { + const destination = (await entryAt(user, '/Desktop'))!; + const source = await writeFile( + user, + `${user.home}/Documents/phantom.txt`, + 'src', + ); + + // First copy occupies Desktop/phantom.txt (and primes the path cache). + const first = await fs.copy(user.userId, { + source, + destinationParent: destination, + }); + expect(first.path).toBe(`${user.home}/Desktop/phantom.txt`); + + // Renaming the occupant frees the path... + await fs.rename(first, 'phantom-renamed.txt'); + + // ...so an immediate re-copy must succeed. A stale path-cache entry + // for the old name used to surface a phantom conflict here — and a + // Replace against it would have deleted the renamed file. + const second = await fs.copy(user.userId, { + source, + destinationParent: destination, + }); + expect(second.path).toBe(`${user.home}/Desktop/phantom.txt`); + }); + it('cleans up and reports 404 when the source object has vanished', async () => { const source = await writeFile( user, diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index a04bf2356..d56a625ae 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -2485,8 +2485,7 @@ export class FSEntryStore extends PuterStore { } = {}, ): Promise<{ entries: FSEntry[]; cursor?: string }> { const payload = decodeCursor(options.cursor) as - | { v: unknown; id: number; s?: string; o?: string } - | undefined; + { v: unknown; id: number; s?: string; o?: string } | undefined; const requestedSort = options.sortBy ?? null; const requestedOrder = options.sortOrder ?? null; @@ -2669,8 +2668,7 @@ export class FSEntryStore extends PuterStore { const limit = normalizeLimit(options.limit, { cap: 10_000 }) ?? 1000; const payload = decodeCursor(options.cursor) as - | { p: string } - | undefined; + { p: string } | undefined; const seek = payload ? 'AND path > ?' : ''; const params: unknown[] = payload ? [userId, likePattern, maxSlashes, payload.p, limit + 1] @@ -2834,6 +2832,17 @@ export class FSEntryStore extends PuterStore { return existing; } + // A path-changing patch (rename, move) must also drop the OLD path's + // cache key — invalidating only the updated entry leaves + // `prodfsv2:fsentry:path:any:` serving the pre-update entry + // for the full TTL. That stale hit made a paste right after renaming + // the occupant report a phantom name conflict, and accepting the + // Replace it offered deleted the renamed file (the stale entry + // carries its uuid). Read the pre-update entry so its keys can be + // invalidated alongside the new ones. + const previous = + patch.path !== undefined ? await this.getEntryByUuid(uuid) : null; + await this.clients.db.write( `UPDATE fsentries SET ${assignments.join(', ')} WHERE uuid = ?`, [...values, uuid], @@ -2850,6 +2859,9 @@ export class FSEntryStore extends PuterStore { }); } const updated = this.#mapFSEntryRow(row); + if (previous && previous.path !== updated.path) { + await this.#invalidateEntryCache(previous); + } await this.#invalidateEntryCache(updated); await this.#writeEntryToCache(updated); return updated;