Bound project location checks and finish Recordly 2.0 part 7 review fixes

This commit is contained in:
webadderall committed 2026-11-08 21:29:32 +11:00
commit ea600a86f6
3 files changed
+111 -4

No files matched your search

+83
View File
@@ -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<string>(() => {}))
.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<string>((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}');
});
});
+27 -3
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
const pendingWrites = new Map<string, Promise<unknown>>();
const PROJECT_IDENTITY_TIMEOUT_MS = 5000;
// Reserve admission before identity I/O so aliases retain call-invocation order.
let pendingIdentityAdmissions: Promise<unknown> = Promise.resolve();
let recoveryDirectory: string | undefined;
@@ -386,9 +387,32 @@ export async function updateProjectFileAtomically(
async function queueProjectWrite<T>(projectPath: string, write: () => Promise<T>): Promise<T> {
// 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<typeof setTimeout> | 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<never>((_, 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;
@@ -41,7 +41,7 @@ import { legalDetails } from "../legal/config";
<p>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.</p>
<h2>How long information remains</h2>
<ul>
<li><strong>Local files:</strong> remain on your device until you remove them. Removing a cloud copy does not remove your local project or another person’s downloaded copy.</li>
<li><strong>Local files:</strong> 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.</li>
<li><strong>Free hosted recordings:</strong> 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.</li>
<li><strong>Paid retention:</strong> 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.</li>
<li><strong>Deletion:</strong> 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.</li>