Files
openscreen/src/lib/cursorTelemetryBuffer.ts
T
shaun0927 96765e483d docs: correct cx/cy units and sanitize buffer option limits
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.
2026-04-21 18:12:28 +09:00

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;
},
};
}