96765e483d
Two follow-up fixes for CodeRabbit feedback on the docs commit: - CursorTelemetryPoint JSDoc previously described cx/cy as 'device-pixel positions'. The producer sampleCursorPoint() in electron/ipc/handlers.ts clamps them to the [0, 1] range after dividing by the source display's width/height, so they are normalised ratios, not pixel values. Correct the doc comment accordingly. - createCursorTelemetryBuffer now sanitizes maxActiveSamples and maxPendingBatches: non-finite, zero, or negative values fall back to safe positive-integer defaults. Without this, a caller passing Infinity or NaN would hang the trim loops. New test covers the sanitisation path for both options.
185 lines
5.4 KiB
TypeScript
185 lines
5.4 KiB
TypeScript
/**
|
|
* A single cursor telemetry sample captured during a recording session.
|
|
*
|
|
* Coordinates (`cx`, `cy`) are clamped ratios in the `[0, 1]` range,
|
|
* normalised against the captured surface's width and height by the
|
|
* main-process `sampleCursorPoint()` before being pushed. `timeMs` is the
|
|
* offset (in milliseconds) from the recording's start.
|
|
*/
|
|
export interface CursorTelemetryPoint {
|
|
timeMs: number;
|
|
cx: number;
|
|
cy: number;
|
|
}
|
|
|
|
/**
|
|
* Per-session cursor telemetry buffer with bounded memory.
|
|
*
|
|
* Flow: `startSession()` → `push(point)` N times → `endSession()` enqueues
|
|
* the collected samples as a completed batch. The main process later
|
|
* drains batches in FIFO order via `takeNextBatch()` to persist them to
|
|
* disk, and can `prependBatch()` on write failure to retry without losing
|
|
* order.
|
|
*
|
|
* Memory is bounded by `maxActiveSamples` (ring buffer on the in-progress
|
|
* batch) and `maxPendingBatches` (FIFO cap across completed batches).
|
|
*/
|
|
export interface CursorTelemetryBuffer {
|
|
/**
|
|
* Begin a new recording session. Clears any in-progress active samples
|
|
* (without touching already-completed pending batches). Safe to call
|
|
* repeatedly — e.g. a rapid Stop → Record sequence.
|
|
*/
|
|
startSession(): void;
|
|
|
|
/**
|
|
* Append a telemetry sample to the current active session. When the
|
|
* active buffer exceeds `maxActiveSamples`, the oldest sample is
|
|
* dropped (ring behaviour).
|
|
*/
|
|
push(point: CursorTelemetryPoint): void;
|
|
|
|
/**
|
|
* Finalize the active session, moving its samples into the pending
|
|
* queue as a single batch. Empty sessions are dropped (no empty batch
|
|
* is enqueued).
|
|
*
|
|
* If the pending queue would exceed `maxPendingBatches`, the oldest
|
|
* batches are evicted to bound memory. A `console.warn` is emitted
|
|
* whenever at least one batch is dropped so that pathological rapid-
|
|
* restart scenarios are observable.
|
|
*
|
|
* @returns the number of pending batches dropped by this call (0 under
|
|
* normal operation).
|
|
*/
|
|
endSession(): number;
|
|
|
|
/**
|
|
* Remove and return the oldest pending batch, or an empty array if
|
|
* the queue is empty.
|
|
*/
|
|
takeNextBatch(): CursorTelemetryPoint[];
|
|
|
|
/**
|
|
* Re-insert a batch at the front of the queue, preserving FIFO order
|
|
* on retry paths (e.g. when persisting the batch failed and the
|
|
* caller wants the next `takeNextBatch()` to yield it again).
|
|
*
|
|
* Empty batches are ignored. The pending cap is enforced defensively
|
|
* — if prepending would push the queue past `maxPendingBatches`, the
|
|
* oldest entries are evicted and a `console.warn` is emitted. In
|
|
* normal retry usage this trim is a no-op because the caller has just
|
|
* removed the batch via `takeNextBatch()`.
|
|
*/
|
|
prependBatch(batch: CursorTelemetryPoint[]): void;
|
|
|
|
/**
|
|
* Drop the most recently enqueued pending batch. Used when a recording
|
|
* is discarded after `endSession()` but before it has been persisted.
|
|
* No-op on an empty queue.
|
|
*/
|
|
discardLatestPending(): void;
|
|
|
|
/**
|
|
* Clear both the active and pending state. Intended for tests and
|
|
* full teardown paths.
|
|
*/
|
|
reset(): void;
|
|
|
|
readonly activeCount: number;
|
|
readonly pendingCount: number;
|
|
}
|
|
|
|
export interface CursorTelemetryBufferOptions {
|
|
maxActiveSamples: number;
|
|
maxPendingBatches?: number;
|
|
}
|
|
|
|
const DEFAULT_MAX_PENDING_BATCHES = 8;
|
|
const DEFAULT_MAX_ACTIVE_SAMPLES = 10_000;
|
|
|
|
/** Coerce a numeric option into a safe, finite, positive integer. */
|
|
function sanitizeLimit(value: number | undefined, fallback: number): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
const floored = Math.floor(value);
|
|
return floored >= 1 ? floored : fallback;
|
|
}
|
|
|
|
/**
|
|
* Create a cursor telemetry buffer.
|
|
*
|
|
* Numeric options are sanitized: non-finite, negative, or zero values fall
|
|
* back to safe defaults so a bad caller cannot disable the memory bounds
|
|
* (which would turn the trim loops into infinite loops).
|
|
*
|
|
* @see CursorTelemetryBuffer for the full lifecycle contract.
|
|
*/
|
|
export function createCursorTelemetryBuffer(
|
|
options: CursorTelemetryBufferOptions,
|
|
): CursorTelemetryBuffer {
|
|
const maxActive = sanitizeLimit(options.maxActiveSamples, DEFAULT_MAX_ACTIVE_SAMPLES);
|
|
const maxPending = sanitizeLimit(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() {
|
|
let dropped = 0;
|
|
if (active.length > 0) {
|
|
pending.push(active);
|
|
while (pending.length > maxPending) {
|
|
pending.shift();
|
|
dropped++;
|
|
}
|
|
}
|
|
active = [];
|
|
if (dropped > 0) {
|
|
console.warn(
|
|
`[cursorTelemetryBuffer] dropped ${dropped} pending batch(es) to stay within maxPendingBatches=${maxPending}`,
|
|
);
|
|
}
|
|
return dropped;
|
|
},
|
|
takeNextBatch() {
|
|
return pending.shift() ?? [];
|
|
},
|
|
prependBatch(batch) {
|
|
if (batch.length === 0) return;
|
|
pending.unshift(batch);
|
|
let dropped = 0;
|
|
while (pending.length > maxPending) {
|
|
pending.pop();
|
|
dropped++;
|
|
}
|
|
if (dropped > 0) {
|
|
console.warn(
|
|
`[cursorTelemetryBuffer] prependBatch trimmed ${dropped} trailing batch(es) to stay within maxPendingBatches=${maxPending}`,
|
|
);
|
|
}
|
|
},
|
|
discardLatestPending() {
|
|
pending.pop();
|
|
},
|
|
reset() {
|
|
active = [];
|
|
pending = [];
|
|
},
|
|
get activeCount() {
|
|
return active.length;
|
|
},
|
|
get pendingCount() {
|
|
return pending.length;
|
|
},
|
|
};
|
|
}
|