From b1511e1f72490eb9f8b1ea71b09ddb91c0b117db Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sun, 25 Oct 2026 00:23:41 -0400 Subject: [PATCH 1/3] fix(sdk-recorder): release capture before uploading --- .github/workflows/ci.yml | 3 + .../__tests__/capture-release.test.ts | 141 ++++++++++++++++++ packages/sdk-recorder/src/index.ts | 4 + 3 files changed, 148 insertions(+) create mode 100644 packages/sdk-recorder/__tests__/capture-release.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b172fa4851..dbfadd702f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,9 @@ jobs: - name: Test Chrome extension run: bun run --cwd apps/chrome-extension test + - name: Test recorder SDK + run: bun --no-install test packages/sdk-recorder + - name: Test render farm orchestration run: | bun test apps/render-farm diff --git a/packages/sdk-recorder/__tests__/capture-release.test.ts b/packages/sdk-recorder/__tests__/capture-release.test.ts new file mode 100644 index 00000000000..5f270915b87 --- /dev/null +++ b/packages/sdk-recorder/__tests__/capture-release.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { CapRecorder } from "../src/index"; + +class Track { + readyState: MediaStreamTrackState = "live"; + constructor(readonly kind: "video" | "audio") {} + stop() { + this.readyState = "ended"; + } +} + +class Stream { + constructor(private readonly tracks: Track[]) {} + getTracks() { + return this.tracks; + } + getVideoTracks() { + return this.tracks.filter((track) => track.kind === "video"); + } + getAudioTracks() { + return this.tracks.filter((track) => track.kind === "audio"); + } +} + +class Encoder { + state: RecordingState = "inactive"; + ondataavailable: ((event: { data: Blob }) => void) | null = null; + onstop: (() => void) | null = null; + static isTypeSupported() { + return true; + } + start() { + this.state = "recording"; + this.ondataavailable?.({ data: new Blob(["recording bytes"]) }); + } + pause() { + this.state = "paused"; + } + stop() { + if (this.state === "inactive") return; + this.state = "inactive"; + queueMicrotask(() => { + this.ondataavailable?.({ data: new Blob(["final recording bytes"]) }); + this.onstop?.(); + }); + } +} + +const originals = ["navigator", "MediaStream", "MediaRecorder", "fetch"].map( + (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, +); + +beforeEach(() => { + for (const [name, value] of Object.entries({ + MediaStream: Stream, + MediaRecorder: Encoder, + })) { + Object.defineProperty(globalThis, name, { configurable: true, value }); + } +}); + +afterEach(() => { + for (const [name, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } +}); + +test.each(["recording", "paused", "upload failure"] as const)( + "releases capture before an upload settles from %s", + async (scenario) => { + const camera = new Track("video"); + const microphone = new Track("audio"); + const started = Promise.withResolvers(); + const gate = Promise.withResolvers(); + let uploaded = ""; + const globals = { + navigator: { + mediaDevices: { + getUserMedia: async (constraints: MediaStreamConstraints) => + new Stream([constraints.video ? camera : microphone]), + }, + }, + fetch: async (input: string, init?: RequestInit) => { + if (input.endsWith("/videos/create")) { + started.resolve(); + await gate.promise; + if (scenario === "upload failure") { + return new Response("Fixture unavailable", { status: 503 }); + } + return Response.json({ + videoId: "fixture", + shareUrl: "https://fixture.invalid/s/fixture", + embedUrl: "https://fixture.invalid/embed/fixture", + }); + } + if (input.endsWith("/initiate")) { + return Response.json({ uploadId: "upload" }); + } + if (input.endsWith("/presign-part")) { + return Response.json({ presignedUrl: "https://fixture.invalid/part" }); + } + if (input === "https://fixture.invalid/part") { + uploaded = await new Response(init?.body).text(); + return new Response(null, { headers: { ETag: "fixture-etag" } }); + } + if (input.endsWith("/complete")) { + return Response.json({ success: true }); + } + throw new Error(`Unexpected request ${input}`); + }, + }; + for (const [name, value] of Object.entries(globals)) { + Object.defineProperty(globalThis, name, { configurable: true, value }); + } + const recorder = new CapRecorder({ publicKey: "fixture", mode: "camera" }); + let stopping: ReturnType | undefined; + try { + await recorder.start(); + if (scenario === "paused") recorder.pause(); + stopping = recorder.stop(); + await started.promise; + expect(recorder.phase).toBe("uploading"); + expect(camera.readyState).toBe("ended"); + expect(microphone.readyState).toBe("ended"); + gate.resolve(); + if (scenario === "upload failure") { + await expect(stopping).rejects.toThrow("SDK API error (503)"); + expect(recorder.phase).toBe("error"); + } else { + await expect(stopping).resolves.toMatchObject({ videoId: "fixture" }); + expect(uploaded).toBe("recording bytesfinal recording bytes"); + expect(recorder.phase).toBe("complete"); + } + } finally { + gate.resolve(); + await stopping?.catch(() => {}); + recorder.destroy(); + } + }, +); diff --git a/packages/sdk-recorder/src/index.ts b/packages/sdk-recorder/src/index.ts index edd71d91394..a08ea957174 100644 --- a/packages/sdk-recorder/src/index.ts +++ b/packages/sdk-recorder/src/index.ts @@ -222,6 +222,10 @@ export class CapRecorder { const stopped = this.recordingStopped; if (recorder.state !== "inactive") recorder.stop(); await stopped; + if (this.streams) { + releaseStreams(this.streams); + this.streams = null; + } return this.uploadRecording(); } From 98efad89a609edba348c28ca4965e02887ed5d1e Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sun, 25 Oct 2026 00:37:51 -0400 Subject: [PATCH 2/3] style(sdk-recorder): format capture release regression --- packages/sdk-recorder/__tests__/capture-release.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk-recorder/__tests__/capture-release.test.ts b/packages/sdk-recorder/__tests__/capture-release.test.ts index 5f270915b87..1e2a5834550 100644 --- a/packages/sdk-recorder/__tests__/capture-release.test.ts +++ b/packages/sdk-recorder/__tests__/capture-release.test.ts @@ -98,7 +98,9 @@ test.each(["recording", "paused", "upload failure"] as const)( return Response.json({ uploadId: "upload" }); } if (input.endsWith("/presign-part")) { - return Response.json({ presignedUrl: "https://fixture.invalid/part" }); + return Response.json({ + presignedUrl: "https://fixture.invalid/part", + }); } if (input === "https://fixture.invalid/part") { uploaded = await new Response(init?.body).text(); From d937565cf3bc8983f3b9a1daded6df5eb18c4563 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sun, 25 Oct 2026 00:53:04 -0400 Subject: [PATCH 3/3] fix(sdk-recorder): isolate canceled recording stops --- .../__tests__/capture-release.test.ts | 128 +++++++++++++++++- packages/sdk-recorder/src/index.ts | 32 +++-- 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/packages/sdk-recorder/__tests__/capture-release.test.ts b/packages/sdk-recorder/__tests__/capture-release.test.ts index 1e2a5834550..58486ac9af8 100644 --- a/packages/sdk-recorder/__tests__/capture-release.test.ts +++ b/packages/sdk-recorder/__tests__/capture-release.test.ts @@ -3,6 +3,7 @@ import { CapRecorder } from "../src/index"; class Track { readyState: MediaStreamTrackState = "live"; + onended: (() => void) | null = null; constructor(readonly kind: "video" | "audio") {} stop() { this.readyState = "ended"; @@ -23,9 +24,14 @@ class Stream { } class Encoder { + static latest: Encoder; state: RecordingState = "inactive"; + stopGate: Promise | null = null; ondataavailable: ((event: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; + constructor() { + Encoder.latest = this; + } static isTypeSupported() { return true; } @@ -39,10 +45,12 @@ class Encoder { stop() { if (this.state === "inactive") return; this.state = "inactive"; - queueMicrotask(() => { + const finish = () => { this.ondataavailable?.({ data: new Blob(["final recording bytes"]) }); this.onstop?.(); - }); + }; + if (this.stopGate) this.stopGate.then(finish); + else queueMicrotask(finish); } } @@ -141,3 +149,119 @@ test.each(["recording", "paused", "upload failure"] as const)( } }, ); + +test.each(["explicit replacement", "source-ended replacement", "destroy only"])( + "destroy cancels a pending encoder stop: %s", + async (scenario) => { + const oldDisplay = new Track("video"); + const oldMicrophone = new Track("audio"); + const display = new Track("video"); + const microphone = new Track("audio"); + const gate = Promise.withResolvers(); + const requests: string[] = []; + const uploaded: string[] = []; + const phases: string[] = []; + const errors: Error[] = []; + const completed: string[] = []; + let displayRequests = 0; + let microphoneRequests = 0; + for (const [name, value] of Object.entries({ + navigator: { + mediaDevices: { + getDisplayMedia: async () => + new Stream([displayRequests++ === 0 ? oldDisplay : display]), + getUserMedia: async () => + new Stream([ + microphoneRequests++ === 0 ? oldMicrophone : microphone, + ]), + }, + }, + fetch: async (input: string, init?: RequestInit) => { + requests.push(input); + if (input.endsWith("/videos/create")) { + return Response.json({ + videoId: "replacement", + shareUrl: "https://fixture.invalid/s/replacement", + embedUrl: "https://fixture.invalid/embed/replacement", + }); + } + if (input.endsWith("/initiate")) { + return Response.json({ uploadId: "upload" }); + } + if (input.endsWith("/presign-part")) { + return Response.json({ + presignedUrl: "https://fixture.invalid/part", + }); + } + if (input === "https://fixture.invalid/part") { + uploaded.push(await new Response(init?.body).text()); + return new Response(null, { headers: { ETag: "fixture-etag" } }); + } + if (input.endsWith("/complete")) { + return Response.json({ success: true }); + } + throw new Error(`Unexpected request ${input}`); + }, + })) { + Object.defineProperty(globalThis, name, { configurable: true, value }); + } + const recorder = new CapRecorder({ + publicKey: "fixture", + camera: { enabled: false }, + }); + recorder.on("phasechange", ({ phase }) => phases.push(phase)); + recorder.on("error", ({ error }) => errors.push(error)); + recorder.on("complete", ({ videoId }) => completed.push(videoId)); + let stopping: Promise | undefined; + try { + await recorder.start(); + Encoder.latest.stopGate = gate.promise; + if (scenario === "source-ended replacement") oldDisplay.onended?.(); + else stopping = recorder.stop().catch((error: unknown) => error); + expect(recorder.phase).toBe("stopping"); + expect(requests).toEqual([]); + recorder.destroy(); + expect(oldDisplay.readyState).toBe("ended"); + expect(oldMicrophone.readyState).toBe("ended"); + if (scenario !== "destroy only") await recorder.start(); + const phasesBeforeFinalData = [...phases]; + gate.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + if (scenario !== "destroy only") { + expect(display.readyState).toBe("live"); + expect(microphone.readyState).toBe("live"); + } + expect(recorder.phase).toBe( + scenario === "destroy only" ? "idle" : "recording", + ); + expect(phases).toEqual(phasesBeforeFinalData); + expect(requests).toEqual([]); + expect(errors).toEqual([]); + expect(completed).toEqual([]); + if (stopping) { + expect(await stopping).toMatchObject({ + name: "AbortError", + message: "Recording was canceled", + }); + } + if (scenario === "destroy only") return; + oldDisplay.onended?.(); + await new Promise((resolve) => setImmediate(resolve)); + expect(recorder.phase).toBe("recording"); + expect(requests).toEqual([]); + stopping = recorder.stop(); + await expect(stopping).resolves.toMatchObject({ videoId: "replacement" }); + expect(uploaded).toEqual(["recording bytesfinal recording bytes"]); + expect(completed).toEqual(["replacement"]); + expect(errors).toEqual([]); + expect(display.readyState).toBe("ended"); + expect(microphone.readyState).toBe("ended"); + expect(recorder.phase).toBe("complete"); + } finally { + gate.resolve(); + await stopping?.catch(() => {}); + recorder.destroy(); + await new Promise((resolve) => setImmediate(resolve)); + } + }, +); diff --git a/packages/sdk-recorder/src/index.ts b/packages/sdk-recorder/src/index.ts index a08ea957174..83f44f3c0f8 100644 --- a/packages/sdk-recorder/src/index.ts +++ b/packages/sdk-recorder/src/index.ts @@ -155,17 +155,25 @@ export class CapRecorder { this.chunks = []; this.recorder.ondataavailable = (e) => { - if (e.data.size > 0) { + if (this.recorder === recorder && e.data.size > 0) { this.chunks.push(e.data); } }; + const stop = () => { + if (this.recorder !== recorder) return; + this.stop().catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + console.error("CapRecorder: failed to stop recording:", error); + }); + }; + if (this.streams.display) { const videoTrack = this.streams.display.getVideoTracks()[0]; if (videoTrack) { - videoTrack.onended = () => { - this.stop(); - }; + videoTrack.onended = stop; } } @@ -173,9 +181,7 @@ export class CapRecorder { this.accumulatedMs = 0; this.lastResumeTime = Date.now(); this.startDurationTimer(); - this.maxDurationTimeout = setTimeout(() => { - this.stop(); - }, maxDurationMs); + this.maxDurationTimeout = setTimeout(stop, maxDurationMs); this.setPhase("recording"); } catch (error) { if (startup.signal.aborted) return; @@ -212,18 +218,22 @@ export class CapRecorder { throw new Error(`Cannot stop from phase "${this._phase}"`); } + const recorder = this.recorder; + const streams = this.streams; + const stopped = this.recordingStopped; this._durationMs = this.accumulatedMs + (this._phase === "recording" ? Date.now() - this.lastResumeTime : 0); this.setPhase("stopping"); this.stopDurationTimer(); - const recorder = this.recorder; - const stopped = this.recordingStopped; if (recorder.state !== "inactive") recorder.stop(); await stopped; - if (this.streams) { - releaseStreams(this.streams); + if (this.recorder !== recorder) { + throw new DOMException("Recording was canceled", "AbortError"); + } + if (streams) { + releaseStreams(streams); this.streams = null; } return this.uploadRecording();