Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/robust-host-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"ragelayer": patch
---

Clean up failed custom-tool mounts across vanilla, React, Vue, and custom-element integrations.
Isolate host event, toolbar, lifecycle, and telemetry callbacks so one exception cannot prevent
cleanup, stall frame recording, or leave other views stale. Complete disposal even when a custom
tool reset throws. Validate rate-limiter and history numeric boundaries, reject overlapping or
nonfinite scripted strikes, and release gesture/audio state when a tool throws.
4 changes: 3 additions & 1 deletion docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,7 @@ The SDK preserves inferred state types across `onDown`, `onMove`, `onUp`, `tick`
`hasPendingWork`, `backgroundTick`, and `reset`. Pair the two background hooks for a fuse or
projectile that must continue after another tool is selected; return `false` for held-only tools so
their selected idle state can sleep. `createRateLimiter()` prevents a stalled frame from releasing
an unbounded effect burst. Custom procedural models can register measured icon bounds with
an unbounded effect burst. Its rate and maximum burst must be finite; nonfinite configuration throws
`RangeError`. A nonfinite or nonpositive elapsed time returns zero without changing accumulated time.
Custom procedural models can register measured icon bounds with
`registerToolIconBounds()`.
8 changes: 7 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ rageLayer.subscribe((engine) => console.log(engine));
registers the tools and returns a live engine immediately, so call it only in a browser. Construct
`RageLayerEngine` directly when you want to register each tool yourself.

If a custom tool fails during mounting, the helper disposes the partially initialized engine
before rethrowing the error. The React, Vue, and custom-element components use the same cleanup.
Lifecycle and engine event subscribers are isolated: a throwing subscriber is warned about without
interrupting other subscribers or engine cleanup.

## `new RageLayerEngine(options?)`

Creates the overlay and starts capturing the page. Must run in a browser.
Expand Down Expand Up @@ -241,7 +246,8 @@ they always have.
- `engine.strike(x, y, { holdMs? })` — use the active tool at a document point with no pointer
device. Runs the same `onDown`/`onUp` pair a click produces and takes a history checkpoint, so
custom tools need no special handling to be driven this way. Returns `false` when no tool is
selected or the engine is paused.
selected, the engine is paused or disposed, a pointer gesture is already active, or any coordinate
or `holdMs` is nonfinite. Tool exceptions propagate to the caller after gesture and audio cleanup.
- `engine.historyEnabled` — whether undo/redo was turned on, as distinct from whether there is
currently anything to undo. A toolbar needs this to show its undo controls from the start.

Expand Down
8 changes: 8 additions & 0 deletions src/callbacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** Host callbacks must not interrupt engine notifications or resource teardown. */
export function runSafely(callback: () => void, context: string): void {
try {
callback();
} catch (error) {
console.warn(`[RageLayer] ${context}`, error);
}
}
8 changes: 5 additions & 3 deletions src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@

import { RAGELAYER_IGNORE_ATTR } from "./capture";
import { defaultTools } from "./default-tools";
import { RageLayerEngine } from "./engine";
import type { RageLayerEngine } from "./engine";
import { mountRageLayer } from "./mount";
import type { RageLayerStrings } from "./strings";
import { type ToolbarButton, ToolbarModel, type ToolbarState } from "./toolbar";
import { toolbarIconElement } from "./toolbar-icons";
Expand Down Expand Up @@ -267,12 +268,13 @@ export class RageLayerElement extends ElementBase {

const tools = this.config.tools ?? defaultTools;

const engine = new RageLayerEngine({
const engine = mountRageLayer({
soundEnabled: this.hasAttribute("sound"),
history: true,
...this.config,
tools,
initialTool: null,
});
for (const tool of tools) engine.registerTool(tool);
const initialTool = this.getAttribute("initial-tool");
if (initialTool) engine.setTool(initialTool);
this.engine = engine;
Expand Down
31 changes: 23 additions & 8 deletions src/engine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SoundEngine } from "./audio";
import { BugSwarm } from "./bugs-sim";
import { runSafely } from "./callbacks";
import { defaultCaptureFilter } from "./capture";
import { CaptureController, type CaptureHost } from "./capture-controller";
import { type ComboEvent, ComboTracker, type InteractionKind } from "./combos";
Expand Down Expand Up @@ -509,7 +510,11 @@ export class RageLayerEngine implements RageLayerEngineApi {
} finally {
this.applyingCombo = false;
}
for (const callback of this.comboListeners) callback(event);
for (const callback of [...this.comboListeners]) {
if (this.comboListeners.has(callback)) {
runSafely(() => callback(event), "Combo listener failed");
}
}
}
return events;
}
Expand Down Expand Up @@ -544,7 +549,12 @@ export class RageLayerEngine implements RageLayerEngineApi {
if (cause === undefined) console.warn(`[RageLayer] ${message}`);
else console.warn(`[RageLayer] ${message}`, cause);
} else {
for (const callback of this.errorListeners) callback(this.lastError);
const error = this.lastError;
for (const callback of [...this.errorListeners]) {
if (this.errorListeners.has(callback)) {
runSafely(() => callback(error), "Error listener failed");
}
}
}
this.emit("error");
}
Expand Down Expand Up @@ -605,6 +615,7 @@ export class RageLayerEngine implements RageLayerEngineApi {
}

registerTool(tool: Tool) {
if (this.disposed) return;
const previous = this.tools.get(tool.id);
const replacingActive = previous === this.activeTool;

Expand Down Expand Up @@ -915,8 +926,6 @@ export class RageLayerEngine implements RageLayerEngineApi {
this.overlay.container.replaceChildren();
this.sound.dispose();
this.history?.clear();
this.emit("dispose");
this.listeners.clear();
this.monitor.dispose();

// `dispose` must release the expensive state even when application code
Expand Down Expand Up @@ -946,7 +955,9 @@ export class RageLayerEngine implements RageLayerEngineApi {
this.comboListeners.clear();
this.errorListeners.clear();
// Pending tool state must not survive disposal.
for (const tool of this.tools.values()) tool.reset?.(this);
for (const tool of this.tools.values()) {
runSafely(() => tool.reset?.(this), "Tool cleanup failed");
}
this.tools.clear();
this.activeTool = null;
this.fire.dispose();
Expand All @@ -956,6 +967,8 @@ export class RageLayerEngine implements RageLayerEngineApi {
// engine is created later — `sprites()` rebuilds lazily.
liveEngines = Math.max(0, liveEngines - 1);
if (liveEngines === 0) clearSpriteCache();
this.emit("dispose");
this.listeners.clear();
}

// ── Content capture (the "destroy the real page" pipeline) ────────────────
Expand Down Expand Up @@ -1606,9 +1619,11 @@ export class RageLayerEngine implements RageLayerEngineApi {
}

private emit(event: EngineEvent) {
this.listeners.get(event)?.forEach((cb) => {
cb();
});
const listeners = this.listeners.get(event);
if (!listeners) return;
for (const callback of [...listeners]) {
if (listeners.has(callback)) runSafely(callback, `${event} listener failed`);
}
}

private applyQuality(tier: PerformanceQualityTier) {
Expand Down
12 changes: 7 additions & 5 deletions src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ export class DestructionHistory<T extends DestructionHistoryEntry> {
private readonly maxPixels: number;

constructor(options: HistoryOptions = {}) {
this.maxEntries = Math.max(1, Math.min(20, Math.round(options.maxEntries ?? 6)));
this.maxPixels = Math.max(1, Math.round(options.maxPixels ?? 24_000_000));
const entries = options.maxEntries ?? 6;
const pixels = options.maxPixels ?? 24_000_000;
this.maxEntries = Math.max(1, Math.min(20, Math.round(Number.isFinite(entries) ? entries : 6)));
this.maxPixels = Math.max(1, Math.round(Number.isFinite(pixels) ? pixels : 24_000_000));
}

get state(): HistoryState {
Expand All @@ -42,7 +44,7 @@ export class DestructionHistory<T extends DestructionHistoryEntry> {
}

canStore(pixelCost: number): boolean {
return pixelCost <= this.maxPixels;
return Number.isFinite(pixelCost) && pixelCost >= 0 && pixelCost <= this.maxPixels;
}

push(entry: T): boolean {
Expand All @@ -62,7 +64,7 @@ export class DestructionHistory<T extends DestructionHistoryEntry> {
current.dispose();
return null;
}
if (current.pixelCost <= this.maxPixels) this.future.push(current);
if (this.canStore(current.pixelCost)) this.future.push(current);
else current.dispose();
this.enforceBudget();
return previous;
Expand All @@ -74,7 +76,7 @@ export class DestructionHistory<T extends DestructionHistoryEntry> {
current.dispose();
return null;
}
if (current.pixelCost <= this.maxPixels) this.past.push(current);
if (this.canStore(current.pixelCost)) this.past.push(current);
else current.dispose();
this.enforceBudget();
return next;
Expand Down
26 changes: 18 additions & 8 deletions src/mount.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { runSafely } from "./callbacks";
import { defaultTools } from "./default-tools";
import { RageLayerEngine } from "./engine";
import type { RageLayerEngineOptions, Tool } from "./types";
Expand All @@ -24,15 +25,18 @@ export function mountRageLayer(options: MountRageLayerOptions = {}) {
const { tools: explicitTools, initialTool, ...engineOptions } = options;
const tools = explicitTools ?? defaultTools;
const selectedInitialTool = initialTool === undefined ? "hammer" : initialTool;
const engine = new RageLayerEngine(engineOptions);
for (const tool of tools) engine.registerTool(tool);

if (selectedInitialTool !== null && !tools.some((tool) => tool.id === selectedInitialTool)) {
engine.dispose();
throw new RangeError(`Unknown initial RageLayer tool: ${selectedInitialTool}`);
}
engine.setTool(selectedInitialTool);
return engine;
const engine = new RageLayerEngine(engineOptions);
try {
for (const tool of tools) engine.registerTool(tool);
engine.setTool(selectedInitialTool);
return engine;
} catch (error) {
engine.dispose();
throw error;
}
}

export interface RageLayerController {
Expand All @@ -56,7 +60,13 @@ export function createRageLayer(options: MountRageLayerOptions = {}): RageLayerC
const listeners = new Set<(engine: RageLayerEngine | null) => void>();

const notify = () => {
for (const listener of listeners) listener(engine);
const current = engine;
for (const listener of [...listeners]) {
// A listener can synchronously open or close the controller. That newer
// notification must not be followed by the stale state from this loop.
if (engine !== current) break;
if (listeners.has(listener)) runSafely(() => listener(current), "Lifecycle listener failed");
}
};

const controller: RageLayerController = {
Expand Down Expand Up @@ -97,7 +107,7 @@ export function createRageLayer(options: MountRageLayerOptions = {}): RageLayerC
},
subscribe(listener) {
listeners.add(listener);
listener(engine);
runSafely(() => listener(engine), "Lifecycle listener failed");
return () => listeners.delete(listener);
},
};
Expand Down
8 changes: 7 additions & 1 deletion src/performance.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { runSafely } from "./callbacks";
import { measuredUploadCostMs } from "./gl";
import type { PerfCounterName, PerfCounterSink } from "./perf-counters";
import type {
Expand Down Expand Up @@ -498,7 +499,12 @@ export class PerformanceMonitor implements PerfCounterSink {
captureMs: this.captureMs,
memory: memorySnapshot(),
};
for (const callback of this.callbacks) callback(this.latest);
const snapshot = this.latest;
for (const callback of [...this.callbacks]) {
if (this.callbacks.has(callback)) {
runSafely(() => callback(snapshot), "Performance listener failed");
}
}

if (!this.adaptive || this.samples < 12) return null;
const cpuOverload = cpu.p95 > targetBudget * 0.72;
Expand Down
55 changes: 36 additions & 19 deletions src/pointer-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,19 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll
function end(event?: PointerEvent) {
if (!held) return;
if (event && activePointerId !== null && event.pointerId !== activePointerId) return;
flush();
held = false;
artUpAt = host.nowSeconds();
const toolEvent = event ? toToolEvent(event) : { ...pointer, dx: 0, dy: 0, buttons: 0 };
host.getTool()?.onUp?.(host.engine, toolEvent);
host.silenceToolLoops();
releasePointerCapture();
host.requestFrame();
try {
flush();
held = false;
artUpAt = host.nowSeconds();
const toolEvent = event ? toToolEvent(event) : { ...pointer, dx: 0, dy: 0, buttons: 0 };
host.getTool()?.onUp?.(host.engine, toolEvent);
} finally {
held = false;
artUpAt = host.nowSeconds();
host.silenceToolLoops();
releasePointerCapture();
host.requestFrame();
}
}

/**
Expand All @@ -118,7 +123,8 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll
/** Drive the selected tool at a document point without a pointing device. */
function strike(x: number, y: number, holdMs = 0): boolean {
const tool = host.getTool();
if (!tool || host.isBlocked()) return false;
if (!tool || held || host.isBlocked()) return false;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(holdMs)) return false;

flush();
host.checkpoint(tool.id);
Expand All @@ -127,18 +133,29 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll
pointer.y = previous.y = y;
artDownAt = host.nowSeconds();
held = true;
tool.onDown?.(host.engine, event);
try {
tool.onDown?.(host.engine, event);

if (holdMs > 0) {
const dt = 1 / 60;
const steps = Math.min(600, Math.round(holdMs / (dt * 1000)));
for (let i = 0; i < steps; i++) {
if (!held || host.isBlocked() || host.getTool() !== tool) break;
tool.tick?.(host.engine, dt, true, pointer);
}
}

if (holdMs > 0) {
const dt = 1 / 60;
const steps = Math.min(600, Math.round(holdMs / (dt * 1000)));
for (let i = 0; i < steps; i++) tool.tick?.(host.engine, dt, true, pointer);
if (held && !host.isBlocked() && host.getTool() === tool) {
held = false;
artUpAt = host.nowSeconds();
tool.onUp?.(host.engine, { ...event, buttons: 0 });
}
} finally {
held = false;
artUpAt = host.nowSeconds();
host.silenceToolLoops();
host.requestFrame();
}

held = false;
artUpAt = host.nowSeconds();
tool.onUp?.(host.engine, { ...event, buttons: 0 });
host.requestFrame();
return true;
}

Expand Down
8 changes: 5 additions & 3 deletions src/react/RageLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { defaultTools } from "../default-tools";
import { RageLayerEngine } from "../engine";
import type { RageLayerEngine } from "../engine";
import { mountRageLayer } from "../mount";
import type { RageLayerStrings } from "../strings";
import { type ToolbarButton as ButtonState, ToolbarModel, type ToolbarState } from "../toolbar";
import type { RageLayerEngineOptions, Tool, ToolStyle } from "../types";
Expand Down Expand Up @@ -110,12 +111,13 @@ export function RageLayer({
const resolvedToolStyle = engineOptions?.toolStyle ?? toolStyle;

useEffect(() => {
const engine = new RageLayerEngine({
const engine = mountRageLayer({
soundEnabled: soundDefault,
...engineOptions,
toolStyle: resolvedToolStyle,
tools: toolset,
initialTool: null,
});
for (const tool of toolset) engine.registerTool(tool);

const model = new ToolbarModel(engine, {
tools: toolset,
Expand Down
4 changes: 4 additions & 0 deletions src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,15 @@ export interface RateLimiter {

/** Bounded fractional scheduler for particle sprays and other hold-to-use effects. */
export function createRateLimiter(ratePerSecond: number, maxBurst = 8): RateLimiter {
if (!Number.isFinite(ratePerSecond) || !Number.isFinite(maxBurst)) {
throw new RangeError("Rate and maximum burst must be finite");
}
const rate = Math.max(0, ratePerSecond);
const limit = Math.max(1, Math.round(maxBurst));
let debt = 0;
return {
take(dt) {
if (!Number.isFinite(dt) || dt <= 0) return 0;
debt = Math.min(limit, debt + Math.max(0, dt) * rate);
const count = Math.floor(debt);
debt -= count;
Expand Down
Loading
Loading