fix: handle recording discard and write-failure in cursor telemetry buffer

Address two issues raised during review:

P1 – When a recording is cancelled or restarted, setRecordingState(false)
enqueues its cursor batch but store-recorded-session is never called,
leaving a stale batch that contaminates the next recording's telemetry.
Add discardLatestPending() to the buffer and a discard-cursor-telemetry
IPC handler; the renderer now calls it on the discard path.

P2 – takeNextBatch() dequeued the batch before fs.writeFile, so a write
failure would permanently lose the telemetry. Wrap the write in
try/catch and re-insert the batch via prependBatch() on failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
JunghwanNA
2026-04-16 11:58:16 +09:00
parent 84ec5a7e68
commit fac0b405d3
6 changed files with 73 additions and 5 deletions
+1
View File
@@ -64,6 +64,7 @@ interface Window {
error?: string;
}>;
setRecordingState: (recording: boolean) => Promise<void>;
discardCursorTelemetry: () => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
+14 -5
View File
@@ -281,11 +281,16 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
const telemetryPath = `${screenVideoPath}.cursor.json`;
const pendingSamples: CursorTelemetryPoint[] = cursorTelemetryBuffer.takeNextBatch();
if (pendingSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingSamples }, null, 2),
"utf-8",
);
try {
await fs.writeFile(
telemetryPath,
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingSamples }, null, 2),
"utf-8",
);
} catch (err) {
cursorTelemetryBuffer.prependBatch(pendingSamples);
throw err;
}
}
const sessionManifestPath = path.join(
@@ -544,6 +549,10 @@ export function registerIpcHandlers(
}
});
ipcMain.handle("discard-cursor-telemetry", () => {
cursorTelemetryBuffer.discardLatestPending();
});
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
const targetVideoPath = normalizeVideoSourcePath(
videoPath ?? currentRecordingSession?.screenVideoPath,
+3
View File
@@ -53,6 +53,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
},
discardCursorTelemetry: () => {
return ipcRenderer.invoke("discard-cursor-telemetry");
},
onStopRecordingFromTray: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("stop-recording-from-tray", listener);
+1
View File
@@ -225,6 +225,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
if (discardRecordingId.current === activeRecordingId) {
window.electronAPI?.discardCursorTelemetry();
return;
}
if (screenBlob.size === 0) {
+44
View File
@@ -96,6 +96,50 @@ describe("createCursorTelemetryBuffer", () => {
expect(batch.map((s) => s.timeMs)).toEqual([1]);
});
it("discardLatestPending() drops the most recently enqueued batch", () => {
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
buf.startSession();
buf.push(sample(1));
buf.endSession();
buf.startSession();
buf.push(sample(2));
buf.endSession();
expect(buf.pendingCount).toBe(2);
buf.discardLatestPending();
expect(buf.pendingCount).toBe(1);
expect(buf.takeNextBatch().map((s) => s.timeMs)).toEqual([1]);
});
it("discardLatestPending() is safe to call on an empty queue", () => {
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
buf.discardLatestPending();
expect(buf.pendingCount).toBe(0);
});
it("prependBatch() re-inserts a batch at the front of the queue", () => {
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
buf.startSession();
buf.push(sample(1));
buf.endSession();
const batch = buf.takeNextBatch();
expect(buf.pendingCount).toBe(0);
buf.prependBatch(batch);
expect(buf.pendingCount).toBe(1);
expect(buf.takeNextBatch().map((s) => s.timeMs)).toEqual([1]);
});
it("prependBatch() ignores empty batches", () => {
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
buf.prependBatch([]);
expect(buf.pendingCount).toBe(0);
});
it("reset() clears both active and pending state", () => {
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
buf.startSession();
+10
View File
@@ -9,6 +9,8 @@ export interface CursorTelemetryBuffer {
push(point: CursorTelemetryPoint): void;
endSession(): void;
takeNextBatch(): CursorTelemetryPoint[];
prependBatch(batch: CursorTelemetryPoint[]): void;
discardLatestPending(): void;
reset(): void;
readonly activeCount: number;
readonly pendingCount: number;
@@ -52,6 +54,14 @@ export function createCursorTelemetryBuffer(
takeNextBatch() {
return pending.shift() ?? [];
},
prependBatch(batch) {
if (batch.length > 0) {
pending.unshift(batch);
}
},
discardLatestPending() {
pending.pop();
},
reset() {
active = [];
pending = [];