Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion templates/clips/actions/finalize-recording.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const mockState = vi.hoisted(() => ({
hasAudio: true,
hasCamera: false,
title: "Test recording",
uploadGenerationId: null as string | null,
},
uploadState: null as Record<string, unknown> | null,
chunkRows: [] as Array<{ key: string }>,
Expand Down Expand Up @@ -106,6 +107,7 @@ vi.mock("../server/db/index.js", () => ({
id: "recordings.id",
ownerEmail: "recordings.ownerEmail",
status: "recordings.status",
uploadGenerationId: "recordings.uploadGenerationId",
videoUrl: "recordings.videoUrl",
trashedAt: "recordings.trashedAt",
},
Expand Down Expand Up @@ -188,6 +190,8 @@ describe("finalize-recording chunk completeness", () => {
};
mockState.chunkRows = [];
mockState.selectRows = [];
mockState.existingRecording.status = "uploading";
mockState.existingRecording.uploadGenerationId = null;
mockReadAppState.mockImplementation(async (key: string) => {
if (key === "recording-upload-rec_1") return mockState.uploadState;
return null;
Expand All @@ -198,6 +202,36 @@ describe("finalize-recording chunk completeness", () => {
}));
});

it("does not finalize after reset wins the generation claim race", async () => {
mockState.existingRecording = {
...mockState.existingRecording,
status: "uploading",
uploadGenerationId: "generation-a",
};
// The generation/status CAS returns no row: reset installed generation B
// after finalize read A but before it could claim processing.
mockUpdateReturning.mockResolvedValueOnce([]);

await expect(
finalizeRecording.run({
id: "rec_1",
uploadGenerationId: "generation-a",
}),
).rejects.toThrow("Upload changed before finalization could claim it");

expect(mockUploadFile).not.toHaveBeenCalled();
});

it("rejects an unfenced finalizer after reset installs a generation", async () => {
mockState.existingRecording.uploadGenerationId = "generation-b";

await expect(finalizeRecording.run({ id: "rec_1" })).rejects.toThrow(
"Upload generation changed before finalization",
);

expect(mockUploadFile).not.toHaveBeenCalled();
});

it("fails before upload when persisted chunk indices have a gap", async () => {
mockState.chunkRows = [
{ key: "recording-chunks-rec_1-000000" },
Expand Down Expand Up @@ -786,6 +820,6 @@ describe("finalize-recording resumable recovery", () => {
videoUrl: "https://cdn.example.com/rec_1",
}),
);
expect(deleteResumableSession).toHaveBeenCalledWith("rec_1");
expect(deleteResumableSession).toHaveBeenCalledWith("rec_1", null);
});
});
48 changes: 42 additions & 6 deletions templates/clips/actions/finalize-recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,12 @@ export default defineAction({
"Whether the uploaded video bytes were already locally transcoded/compressed before upload",
),
mediaVerificationRetryAttempt: z.number().int().min(1).max(10).optional(),
uploadGenerationId: z
.string()
.min(1)
.max(128)
.optional()
.describe("Upload generation that owns the scratch data being finalized"),
}),
run: async (args) => {
const db = getDb();
Expand All @@ -921,7 +927,7 @@ export default defineAction({
// never retain scratch video blobs.
let chunkKeysToPurge: string[] = [];
try {
const [existing] = await db
let [existing] = await db
.select()
.from(schema.recordings)
.where(
Expand All @@ -938,6 +944,32 @@ export default defineAction({
throw new Error(`Recording not found: ${id}`);
}

const generationId = args.uploadGenerationId ?? null;
if ((existing.uploadGenerationId ?? null) !== generationId) {
throw new Error("Upload generation changed before finalization");
}
// Claim finalization before touching provider/scratch state. Reset only
// admits uploading/failed rows, so once this CAS succeeds it cannot
// replace the generation underneath a delayed final chunk.
if (generationId !== null && existing.status === "uploading") {
const claimed = await db
.update(schema.recordings)
.set({ status: "processing", updatedAt: new Date().toISOString() })
.where(
and(
eq(schema.recordings.id, id),
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
eq(schema.recordings.status, "uploading"),
eq(schema.recordings.uploadGenerationId, generationId),
),
)
.returning();
if (claimed.length !== 1) {
throw new Error("Upload changed before finalization could claim it");
}
existing = claimed[0]!;
}

// Idempotency guard: finalize can be re-invoked when a client retries the
// final chunk after a lost response. If already 'ready' return the existing
// result instead of re-running the complete/assembly path (session and
Expand All @@ -947,7 +979,7 @@ export default defineAction({
// A prior attempt may have persisted the ready row and then failed
// before deleting its resumable-session handle. The provider upload is
// complete at this point, so retire only the local retry state.
await deleteResumableSession(id).catch((err) =>
await deleteResumableSession(id, generationId).catch((err) =>
console.warn("[finalize] failed to delete resumable session:", err),
);
await deleteAppState(mediaVerificationStateKey(id)).catch(() => {});
Expand Down Expand Up @@ -1038,7 +1070,7 @@ export default defineAction({

// Resumable path: create-recording initialized a session and chunk.post.ts
// forwarded all chunks to the provider. Complete the session to get the CDN URL.
const resumableSession = await getResumableSession(id);
const resumableSession = await getResumableSession(id, generationId);
if (resumableSession && isStreamingUploadDisabled()) {
console.warn(
`[finalize] streaming uploads are disabled, but completing existing resumable session for in-flight recording: ${id}`,
Expand Down Expand Up @@ -1147,7 +1179,7 @@ export default defineAction({
locallyTranscoded: args.locallyTranscoded === true,
},
});
await deleteResumableSession(id).catch((deleteErr) =>
await deleteResumableSession(id, generationId).catch((deleteErr) =>
console.warn(
"[finalize] failed to retire pending resumable session:",
deleteErr,
Expand Down Expand Up @@ -1178,7 +1210,7 @@ export default defineAction({
}
// Delete only after durable state is written — so a retry before
// this point can still find the session and re-enter this path.
deleteResumableSession(id).catch((err) =>
deleteResumableSession(id, generationId).catch((err) =>
console.warn("[finalize] failed to delete resumable session:", err),
);
return result;
Expand Down Expand Up @@ -1262,7 +1294,11 @@ export default defineAction({
// Pull chunk keys first, then fetch values one at a time. A single
// SELECT key,value over many base64 chunks can exceed Neon's 8s op
// timeout before we even start assembling the recording.
const chunkKeys = await listRecordingChunkKeys(ownerEmail, id);
const chunkKeys = await listRecordingChunkKeys(
ownerEmail,
id,
generationId,
);
const expectedDataChunks = stateNumber(uploadState, "expectedDataChunks");
debugLog("[finalize] chunks found", {
id,
Expand Down
24 changes: 23 additions & 1 deletion templates/clips/app/components/recorder/recorder-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ export class RecorderEngine {
*/
private uploadAbort: AbortController | null = null;
private uploadMode: UploadMode = "buffered";
private uploadGenerationId: string | null = null;
/**
* Streaming-path buffer. MediaRecorder blobs accumulate here until at least
* STREAM_CHUNK_BYTES is available, then a 256 KiB-aligned slice is PUT to API
Expand Down Expand Up @@ -1102,6 +1103,7 @@ export class RecorderEngine {
this.opts.uploadUrl = target.uploadUrl;
this.opts.abortUrl = target.abortUrl;
this.opts.uploadMode = target.uploadMode ?? "buffered";
this.uploadGenerationId = null;
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -1178,6 +1180,7 @@ export class RecorderEngine {
this.lastFinalizeMeta = null;
this.uploadAbort = new AbortController();
this.uploadMode = this.opts.uploadMode ?? "buffered";
this.uploadGenerationId = null;
this.pendingStreamBlobs = [];
this.pendingStreamBytes = 0;
this.cameraDisconnectNotified = false;
Expand Down Expand Up @@ -1540,6 +1543,10 @@ export class RecorderEngine {
compression,
requestStreaming: this.uploadMode === "streaming",
mimeType: uploadMimeType,
useGenerationFence: true,
...(this.uploadGenerationId
? { uploadGenerationId: this.uploadGenerationId }
: {}),
}),
signal,
});
Expand All @@ -1566,6 +1573,7 @@ export class RecorderEngine {
}
const reset = (await resetRes.json().catch(() => null)) as {
uploadMode?: unknown;
uploadGenerationId?: unknown;
} | null;
if (reset?.uploadMode !== "streaming" && reset?.uploadMode !== "buffered") {
throw new Error(
Expand All @@ -1574,6 +1582,11 @@ export class RecorderEngine {
}
const uploadMode = reset.uploadMode;
this.uploadMode = uploadMode;
this.uploadGenerationId =
typeof reset.uploadGenerationId === "string" &&
reset.uploadGenerationId.length > 0
? reset.uploadGenerationId
: null;
return uploadMode;
}

Expand Down Expand Up @@ -1752,8 +1765,10 @@ export class RecorderEngine {
this.uploadAbort = null;
}
this.cleanupTracks();
const uploadGenerationId = this.uploadGenerationId;
this.chunkIndex = 0;
this.uploadFailure = null;
this.uploadGenerationId = null;
this.startedAtMs = null;
this.pausedAccumMs = 0;
this.pausedStartedMs = null;
Expand All @@ -1764,7 +1779,13 @@ export class RecorderEngine {

if (this.opts.abortUrl) {
try {
await fetch(this.opts.abortUrl, { method: "POST" });
await fetch(this.opts.abortUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(uploadGenerationId ? { uploadGenerationId } : {}),
}),
});
} catch {
// ignore — best effort
}
Expand Down Expand Up @@ -2136,6 +2157,7 @@ export class RecorderEngine {
height: extra.height,
hasAudio: extra.hasAudio,
hasCamera: extra.hasCamera,
uploadGenerationId: this.uploadGenerationId ?? undefined,
});

const body = await blob.arrayBuffer();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { RecorderEngine } from "./recorder-engine";

describe("RecorderEngine upload generation fencing", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("carries each reset generation through chunks and the next reset", async () => {
vi.stubGlobal("window", {
setTimeout,
clearTimeout,
location: { pathname: "/" },
});
const requests: Array<{ url: string; body: unknown }> = [];
let resetCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/reset-chunks")) {
resetCount += 1;
requests.push({
url,
body: JSON.parse(String(init?.body)),
});
return Response.json({
uploadMode: "buffered",
uploadGenerationId: `generation-${resetCount}`,
});
}
requests.push({ url, body: init?.body });
return Response.json({ ok: true });
}),
);

const engine = new RecorderEngine({
recordingId: "rec-1",
mode: "screen",
uploadUrl: "/api/uploads/rec-1/chunk",
abortUrl: "/api/uploads/rec-1/abort",
});
const internals = engine as unknown as {
resetUploadedChunks: (compression: null) => Promise<"buffered">;
uploadChunk: (blob: Blob, index: number) => Promise<unknown>;
};

await internals.resetUploadedChunks(null);
await internals.uploadChunk(new Blob(["first"]), 0);
await internals.resetUploadedChunks(null);
await internals.uploadChunk(new Blob(["second"]), 0);
await engine.cancel();

expect(requests[0]?.body).toMatchObject({ useGenerationFence: true });
expect(requests[1]?.url).toContain("uploadGenerationId=generation-1");
expect(requests[2]?.body).toMatchObject({
useGenerationFence: true,
uploadGenerationId: "generation-1",
});
expect(requests[3]?.url).toContain("uploadGenerationId=generation-2");
expect(requests[4]).toMatchObject({
url: "/api/uploads/rec-1/abort",
body: JSON.stringify({ uploadGenerationId: "generation-2" }),
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: fixed
date: 2026-07-27
---

Saved Clips now resume from the last uploaded byte after an interruption and promptly show when another retry is needed.
Loading
Loading