fix: isolate cursor telemetry samples per recording session
Previously, the main process kept two module-scope arrays — activeCursorSamples and pendingCursorSamples — and set-recording-state on a new recording wiped BOTH. When a user stopped recording and immediately started a new one before store-recorded-session fired, the previous recording's pending samples were discarded or later overwritten with the new session's data, producing empty or mismatched .cursor.json files. Replace the two arrays with a small FIFO buffer (createCursorTelemetryBuffer) that: - Keeps pending batches per completed recording, never wiping them on a new session start. - Yields batches in arrival order to storeRecordedSessionFiles. - Caps pending batches (default 8) so a never-stored sequence cannot leak unbounded memory. Unit-tested directly in src/lib/cursorTelemetryBuffer.test.ts, including the rapid-restart race that motivated the change.
This commit is contained in:
+13
-20
@@ -11,6 +11,10 @@ import {
|
||||
shell,
|
||||
systemPreferences,
|
||||
} from "electron";
|
||||
import {
|
||||
type CursorTelemetryPoint,
|
||||
createCursorTelemetryBuffer,
|
||||
} from "../../src/lib/cursorTelemetryBuffer";
|
||||
import {
|
||||
normalizeProjectMedia,
|
||||
normalizeRecordingSession,
|
||||
@@ -275,14 +279,14 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
|
||||
currentProjectPath = null;
|
||||
|
||||
const telemetryPath = `${screenVideoPath}.cursor.json`;
|
||||
if (pendingCursorSamples.length > 0) {
|
||||
const pendingSamples: CursorTelemetryPoint[] = cursorTelemetryBuffer.takeNextBatch();
|
||||
if (pendingSamples.length > 0) {
|
||||
await fs.writeFile(
|
||||
telemetryPath,
|
||||
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
|
||||
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingSamples }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
pendingCursorSamples = [];
|
||||
|
||||
const sessionManifestPath = path.join(
|
||||
RECORDINGS_DIR,
|
||||
@@ -302,16 +306,11 @@ const CURSOR_TELEMETRY_VERSION = 1;
|
||||
const CURSOR_SAMPLE_INTERVAL_MS = 100;
|
||||
const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
let cursorCaptureInterval: NodeJS.Timeout | null = null;
|
||||
let cursorCaptureStartTimeMs = 0;
|
||||
let activeCursorSamples: CursorTelemetryPoint[] = [];
|
||||
let pendingCursorSamples: CursorTelemetryPoint[] = [];
|
||||
const cursorTelemetryBuffer = createCursorTelemetryBuffer({
|
||||
maxActiveSamples: MAX_CURSOR_SAMPLES,
|
||||
});
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@@ -338,15 +337,11 @@ function sampleCursorPoint() {
|
||||
const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
|
||||
const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
|
||||
|
||||
activeCursorSamples.push({
|
||||
cursorTelemetryBuffer.push({
|
||||
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
|
||||
cx,
|
||||
cy,
|
||||
});
|
||||
|
||||
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
|
||||
activeCursorSamples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
@@ -534,15 +529,13 @@ export function registerIpcHandlers(
|
||||
ipcMain.handle("set-recording-state", (_, recording: boolean) => {
|
||||
if (recording) {
|
||||
stopCursorCapture();
|
||||
activeCursorSamples = [];
|
||||
pendingCursorSamples = [];
|
||||
cursorTelemetryBuffer.startSession();
|
||||
cursorCaptureStartTimeMs = Date.now();
|
||||
sampleCursorPoint();
|
||||
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
|
||||
} else {
|
||||
stopCursorCapture();
|
||||
pendingCursorSamples = [...activeCursorSamples];
|
||||
activeCursorSamples = [];
|
||||
cursorTelemetryBuffer.endSession();
|
||||
}
|
||||
|
||||
const source = selectedSource || { name: "Screen" };
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type CursorTelemetryPoint, createCursorTelemetryBuffer } from "./cursorTelemetryBuffer";
|
||||
|
||||
function sample(tag: number): CursorTelemetryPoint {
|
||||
return { timeMs: tag, cx: tag / 10, cy: tag / 10 };
|
||||
}
|
||||
|
||||
describe("createCursorTelemetryBuffer", () => {
|
||||
it("stores samples captured during an active session", () => {
|
||||
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
|
||||
buf.startSession();
|
||||
for (let i = 0; i < 3; i++) buf.push(sample(i));
|
||||
buf.endSession();
|
||||
|
||||
const batch = buf.takeNextBatch();
|
||||
expect(batch).toHaveLength(3);
|
||||
expect(batch[0]?.timeMs).toBe(0);
|
||||
});
|
||||
|
||||
it("trims active samples past maxActiveSamples (ring behaviour)", () => {
|
||||
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 2 });
|
||||
buf.startSession();
|
||||
buf.push(sample(1));
|
||||
buf.push(sample(2));
|
||||
buf.push(sample(3));
|
||||
buf.endSession();
|
||||
|
||||
const batch = buf.takeNextBatch();
|
||||
expect(batch).toEqual([sample(2), sample(3)]);
|
||||
});
|
||||
|
||||
it("preserves earlier pending batches when a new session starts before store", () => {
|
||||
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
|
||||
|
||||
// Recording 1
|
||||
buf.startSession();
|
||||
buf.push(sample(101));
|
||||
buf.push(sample(102));
|
||||
buf.endSession();
|
||||
|
||||
// Recording 2 starts before recording 1's batch has been consumed
|
||||
buf.startSession();
|
||||
buf.push(sample(201));
|
||||
buf.endSession();
|
||||
|
||||
const batch1 = buf.takeNextBatch();
|
||||
const batch2 = buf.takeNextBatch();
|
||||
expect(batch1.map((s) => s.timeMs)).toEqual([101, 102]);
|
||||
expect(batch2.map((s) => s.timeMs)).toEqual([201]);
|
||||
});
|
||||
|
||||
it("returns an empty batch when nothing is pending", () => {
|
||||
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
|
||||
expect(buf.takeNextBatch()).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops empty sessions instead of queuing empty batches", () => {
|
||||
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
|
||||
buf.startSession();
|
||||
buf.endSession();
|
||||
expect(buf.pendingCount).toBe(0);
|
||||
expect(buf.takeNextBatch()).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps the pending queue at maxPendingBatches to bound memory", () => {
|
||||
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10, maxPendingBatches: 3 });
|
||||
|
||||
for (let round = 1; round <= 5; round++) {
|
||||
buf.startSession();
|
||||
buf.push(sample(round));
|
||||
buf.endSession();
|
||||
}
|
||||
|
||||
expect(buf.pendingCount).toBe(3);
|
||||
// Oldest two batches (rounds 1 and 2) should have been dropped
|
||||
expect(buf.takeNextBatch().map((s) => s.timeMs)).toEqual([3]);
|
||||
expect(buf.takeNextBatch().map((s) => s.timeMs)).toEqual([4]);
|
||||
expect(buf.takeNextBatch().map((s) => s.timeMs)).toEqual([5]);
|
||||
});
|
||||
|
||||
it("starting a new session clears in-progress samples but keeps pending batches", () => {
|
||||
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
|
||||
|
||||
buf.startSession();
|
||||
buf.push(sample(1));
|
||||
buf.endSession();
|
||||
|
||||
buf.startSession();
|
||||
buf.push(sample(99));
|
||||
// Simulate another startSession before endSession (e.g. rapid restart)
|
||||
buf.startSession();
|
||||
expect(buf.activeCount).toBe(0);
|
||||
expect(buf.pendingCount).toBe(1);
|
||||
|
||||
const batch = buf.takeNextBatch();
|
||||
expect(batch.map((s) => s.timeMs)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("reset() clears both active and pending state", () => {
|
||||
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });
|
||||
buf.startSession();
|
||||
buf.push(sample(1));
|
||||
buf.endSession();
|
||||
buf.startSession();
|
||||
buf.push(sample(2));
|
||||
|
||||
buf.reset();
|
||||
|
||||
expect(buf.activeCount).toBe(0);
|
||||
expect(buf.pendingCount).toBe(0);
|
||||
expect(buf.takeNextBatch()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
export interface CursorTelemetryBuffer {
|
||||
startSession(): void;
|
||||
push(point: CursorTelemetryPoint): void;
|
||||
endSession(): void;
|
||||
takeNextBatch(): CursorTelemetryPoint[];
|
||||
reset(): void;
|
||||
readonly activeCount: number;
|
||||
readonly pendingCount: number;
|
||||
}
|
||||
|
||||
export interface CursorTelemetryBufferOptions {
|
||||
maxActiveSamples: number;
|
||||
maxPendingBatches?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_PENDING_BATCHES = 8;
|
||||
|
||||
export function createCursorTelemetryBuffer(
|
||||
options: CursorTelemetryBufferOptions,
|
||||
): CursorTelemetryBuffer {
|
||||
const maxActive = options.maxActiveSamples;
|
||||
const maxPending = options.maxPendingBatches ?? DEFAULT_MAX_PENDING_BATCHES;
|
||||
|
||||
let active: CursorTelemetryPoint[] = [];
|
||||
let pending: CursorTelemetryPoint[][] = [];
|
||||
|
||||
return {
|
||||
startSession() {
|
||||
active = [];
|
||||
},
|
||||
push(point) {
|
||||
active.push(point);
|
||||
if (active.length > maxActive) {
|
||||
active.shift();
|
||||
}
|
||||
},
|
||||
endSession() {
|
||||
if (active.length > 0) {
|
||||
pending.push(active);
|
||||
while (pending.length > maxPending) {
|
||||
pending.shift();
|
||||
}
|
||||
}
|
||||
active = [];
|
||||
},
|
||||
takeNextBatch() {
|
||||
return pending.shift() ?? [];
|
||||
},
|
||||
reset() {
|
||||
active = [];
|
||||
pending = [];
|
||||
},
|
||||
get activeCount() {
|
||||
return active.length;
|
||||
},
|
||||
get pendingCount() {
|
||||
return pending.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user