mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 07:23:14 +00:00
Bound project location checks and finish Recordly 2.0 part 7 review fixes
This commit is contained in:
commit
ea600a86f6
3 files changed
+111
-4
No files matched your search
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user