diff --git a/electron/ipc/project/atomicSave.test.ts b/electron/ipc/project/atomicSave.test.ts index 32784671..3ad1671d 100644 --- a/electron/ipc/project/atomicSave.test.ts +++ b/electron/ipc/project/atomicSave.test.ts @@ -22,6 +22,7 @@ describe("writeProjectFileAtomically", () => { }); afterEach(async () => { + vi.useRealTimers(); vi.restoreAllMocks(); await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -426,4 +427,86 @@ describe("writeProjectFileAtomically", () => { await Promise.allSettled([first, second]); } }); + it("releases unrelated admissions when a filesystem identity never resolves", async () => { + const other = path.join(tempDir, "other.recordly"); + await writeProjectFileAtomically(projectPath, '{"revision":1}'); + await writeProjectFileAtomically(other, '{"revision":1}'); + const canonicalParent = await fs.realpath(tempDir); + const parentStats = await fs.stat(tempDir); + vi.useFakeTimers(); + vi.spyOn(fs, "realpath") + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValue(canonicalParent); + vi.spyOn(fs, "stat").mockResolvedValue(parentStats); + let firstError: unknown; + void writeProjectFileAtomically(projectPath, '{"revision":2}').catch((error) => { + firstError = error; + }); + const second = writeProjectFileAtomically(other, '{"revision":9}'); + void second.catch(() => undefined); + await vi.advanceTimersByTimeAsync(0); + expect(firstError).toBeUndefined(); + await vi.advanceTimersByTimeAsync(5000); + expect(firstError).toBeInstanceOf(Error); + expect((firstError as Error).message).toContain("Project location did not respond"); + await second; + expect(await fs.readFile(other, "utf-8")).toBe('{"revision":9}'); + expect(await fs.readFile(projectPath, "utf-8")).toBe('{"revision":1}'); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([ + "resolve", + "reject", + ])("never writes after a timed-out identity later %ss", async (completion) => { + const other = path.join(tempDir, "other.recordly"); + await writeProjectFileAtomically(projectPath, '{"revision":1}'); + await writeProjectFileAtomically(other, '{"revision":1}'); + const backup = await getProjectBackupPath(projectPath); + const canonicalParent = await fs.realpath(tempDir); + const parentStats = await fs.stat(tempDir); + let resolveIdentity!: (value: string) => void; + let rejectIdentity!: (error: Error) => void; + const lateIdentity = new Promise((resolve, reject) => { + resolveIdentity = resolve; + rejectIdentity = reject; + }); + vi.useFakeTimers(); + vi.spyOn(fs, "realpath") + .mockReturnValueOnce(lateIdentity) + .mockResolvedValue(canonicalParent); + vi.spyOn(fs, "stat").mockResolvedValue(parentStats); + let firstError: unknown; + void writeProjectFileAtomically(projectPath, '{"revision":2}').catch((error) => { + firstError = error; + }); + const second = writeProjectFileAtomically(other, '{"revision":9}'); + void second.catch(() => undefined); + await vi.advanceTimersByTimeAsync(5000); + expect(firstError).toBeInstanceOf(Error); + if (completion === "resolve") resolveIdentity(canonicalParent); + else rejectIdentity(new Error("Late filesystem failure")); + await vi.advanceTimersByTimeAsync(0); + await second; + // A same-key read joins after any wrongly admitted late write, so it also + // detects a timeout that rejected only the caller while leaving work queued. + const read = await readProjectFileWithRecovery(projectPath, JSON.parse); + expect(read.project).toEqual({ revision: 1 }); + await expect(fs.access(backup)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await fs.readFile(other, "utf-8")).toBe('{"revision":9}'); + expect(vi.getTimerCount()).toBe(0); + }); + + it("clears identity deadlines after successful resolution and immediate failure", async () => { + vi.useFakeTimers(); + await writeProjectFileAtomically(projectPath, '{"revision":1}'); + expect(vi.getTimerCount()).toBe(0); + const denied = Object.assign(new Error("Identity access denied"), { code: "EACCES" }); + vi.spyOn(fs, "realpath").mockRejectedValueOnce(denied); + await expect(writeProjectFileAtomically(projectPath, '{"revision":2}')).rejects.toBe( + denied, + ); + expect(vi.getTimerCount()).toBe(0); + expect(await fs.readFile(projectPath, "utf-8")).toBe('{"revision":1}'); + }); }); diff --git a/electron/ipc/project/atomicSave.ts b/electron/ipc/project/atomicSave.ts index 2ed2f5a1..f386b569 100644 --- a/electron/ipc/project/atomicSave.ts +++ b/electron/ipc/project/atomicSave.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; const pendingWrites = new Map>(); +const PROJECT_IDENTITY_TIMEOUT_MS = 5000; // Reserve admission before identity I/O so aliases retain call-invocation order. let pendingIdentityAdmissions: Promise = Promise.resolve(); let recoveryDirectory: string | undefined; @@ -386,9 +387,32 @@ export async function updateProjectFileAtomically( async function queueProjectWrite(projectPath: string, write: () => Promise): Promise { // Start identity resolution in parallel, and handle rejection immediately even // when an earlier admission is still waiting for its filesystem result. - const identity = getProjectStorageKey(projectPath).then( - (key) => ({ key }), - (error: unknown) => ({ error }), + let identityTimeout: ReturnType | undefined; + // Race the identity itself, not just the returned save promise: a timed-out + // lookup must release admission and must never enqueue a late write. + const identity = Promise.race([ + getProjectStorageKey(projectPath), + new Promise((_, reject) => { + identityTimeout = setTimeout( + () => + reject( + new Error( + "Project location did not respond while resolving its filesystem identity.", + ), + ), + PROJECT_IDENTITY_TIMEOUT_MS, + ); + identityTimeout.unref?.(); + }), + ]).then( + (key) => { + clearTimeout(identityTimeout); + return { key }; + }, + (error: unknown) => { + clearTimeout(identityTimeout); + return { error }; + }, ); const admission = pendingIdentityAdmissions.then(async () => { const resolved = await identity; diff --git a/services/recordly-share/worker/web/src/pages/privacy.astro b/services/recordly-share/worker/web/src/pages/privacy.astro index 6bdf527b..432e6111 100644 --- a/services/recordly-share/worker/web/src/pages/privacy.astro +++ b/services/recordly-share/worker/web/src/pages/privacy.astro @@ -41,7 +41,7 @@ import { legalDetails } from "../legal/config";

We use access controls and authenticated server checks to protect accounts and uploads. No storage or transmission system is completely secure, and a sharing link or password can be redistributed by its recipient.

How long information remains

    -
  • Local files: remain on your device until you remove them. Removing a cloud copy does not remove your local project or another person’s downloaded copy.
  • +
  • Local files: remain on your device until you remove them. The app also keeps project recovery copies and preview thumbnails in its local application-data folder. Moving or deleting a project outside Recordly may leave these copies behind. Deleting a project in Recordly also attempts to remove its associated recovery copies and preview thumbnails. Removing a cloud copy does not remove your local project or another person’s downloaded copy.
  • Free hosted recordings: normally expire after 7 days and become unavailable. Cleanup removes expired media and associated recording records. Incomplete uploads may be cleaned up after one day.
  • Paid retention: completed recordings granted Cloud retention have no scheduled expiry while Cloud is active. Upgrading also protects eligible, still-stored completed recordings. When Cloud access ends, those recordings expire after a seven-day grace period. Deleted media cannot be restored by upgrading.
  • Deletion: deleting a cloud recording removes its media, thumbnail and associated comments, reactions and recording metadata through the service. Cached copies and provider recovery copies may take additional time to disappear; copies held by recipients are outside our control.