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:
shaun0927
2026-04-16 10:27:20 +09:00
parent a6ae0e6d98
commit 84ec5a7e68
3 changed files with 192 additions and 20 deletions
+13 -20
View File
@@ -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" };
+113
View File
@@ -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([]);
});
});
+66
View File
@@ -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;
},
};
}