Skip to content
Merged
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
136 changes: 120 additions & 16 deletions docs/guide/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Rezi includes a debug trace system for diagnosing rendering, events, and perform
Create a debug controller to capture and analyze runtime behavior:

```typescript
import { createDebugController, categoriesToMask } from "@rezi-ui/core";
import { createDebugController, categoriesToMask, perfPhaseFromNum } from "@rezi-ui/core";

const debug = createDebugController({ maxFrames: 1000 });

Expand All @@ -22,12 +22,114 @@ debug.on("error", (err) => console.error(err.message));

// Subscribe to all records
debug.on("record", (record) => {
if (record.category === "perf") {
console.log(`${record.phase}: ${record.durationMs}ms`);
if (record.header.category === "perf" && record.payload && "usElapsed" in record.payload) {
const phase = perfPhaseFromNum(record.payload.phase) ?? `phase_${record.payload.phase}`;
console.log(`${phase}: ${(record.payload.usElapsed / 1000).toFixed(2)}ms`);
}
});
```

## Enable Inspector Overlay

Use `createAppWithInspectorOverlay` to auto-install the overlay and runtime toggle.
Default hotkey is `ctrl+shift+i`.

```typescript
import { createAppWithInspectorOverlay, createDebugController } from "@rezi-ui/core";
import { createNodeBackend } from "@rezi-ui/node";

const backend = createNodeBackend();

const debug = createDebugController({
backend: backend.debug,
terminalCapsProvider: () => backend.getCaps(),
});

await debug.enable({
minSeverity: "info",
captureRawEvents: false,
captureDrawlistBytes: false,
});

const app = createAppWithInspectorOverlay({
backend,
initialState: { ready: true },
inspector: {
hotkey: "ctrl+shift+i",
debug, // auto-populates drawlist/diff/us_* timing rows from frame snapshots
},
});
```

Overlay sections include:
- Focus summary (`focusedId`, active zone, active trap)
- Cursor target summary (position, shape, blink intent when cursor v2 is active)
- Damage + frame summary (`mode`, rect/cell counts, commit/layout/incremental state)
- Frame timing rows (`drawlistBytes`, `diffBytesEmitted`, `usDrawlist`, `usDiff`, `usWrite`)
- Event routing breadcrumbs (last event kind, keybindings vs widget routing path, last action)

## Export Debug Bundle

Exporting a debug bundle is deterministic for the same debug state and options.
The API never writes to disk; it returns either a JSON object or UTF-8 bytes.

```typescript
import { createDebugController } from "@rezi-ui/core/debug";
import { createNodeBackend } from "@rezi-ui/node";

const backend = createNodeBackend();
const debug = createDebugController({
backend: backend.debug,
terminalCapsProvider: () => backend.getCaps(), // Optional
});

await debug.enable({
minSeverity: "info",
captureRawEvents: false,
captureDrawlistBytes: false,
});

const bundle = await debug.exportBundle({
maxRecords: 2000,
maxPayloadBytes: 4096,
maxTotalPayloadBytes: 262_144,
maxRecentFrames: 32,
});

console.log(bundle.schema); // "rezi-debug-bundle-v1"

// If you want raw bytes (for transport or persistence):
const bundleBytes = await debug.exportBundleBytes();
```

### Bundle Contents

- `schema`: Versioned identifier. Current value: `rezi-debug-bundle-v1`.
- `captureFlags`: Capture state used during export (`captureRawEvents`, `captureDrawlistBytes`).
- `bounds`: Export bounds (`maxRecords`, per-record payload cap, total payload cap, frame summary cap).
- `terminalCaps`: Terminal capability snapshot (or `null` if unavailable).
- `stats`: Debug stats snapshot (`totalRecords`, `totalDropped`, counts, ring usage/capacity).
- `queryWindow`: Trace query window metadata (`recordsReturned`, `recordsAvailable`, oldest/newest IDs).
- `trace`: Deterministically ordered trace records (`header` + bounded payload snapshot).
- `recentFrameSummaries`: Optional recent frame summaries when frame snapshots are available.

Notes:
- `u64` fields are serialized as decimal strings for JSON safety.
- Payload snapshots are hex-encoded and truncated by configured bounds.

### Privacy Note

Raw event payloads and raw drawlist byte payloads can contain sensitive data
(for example typed input or rendered text bytes).

By default, bundle export follows debug capture flags:
- Event payloads are omitted when `captureRawEvents` is `false`.
- Raw drawlist byte payloads are omitted when `captureDrawlistBytes` is `false`.

To minimize risk in production:
- Keep `captureRawEvents` and `captureDrawlistBytes` disabled unless needed.
- Share bundles only with trusted recipients.

## Debug Categories

| Category | Description |
Expand Down Expand Up @@ -70,23 +172,26 @@ const inspector = debug.frameInspector;
// Get recent frame snapshots
const frames = inspector.getSnapshots(10);
for (const frame of frames) {
console.log(`Frame ${frame.frameId}: ${frame.renderMs}ms render, ${frame.drawCommands} commands`);
const totalUs = frame.usDrawlist + frame.usDiff + frame.usWrite;
console.log(`Frame ${frame.frameId}: ${(totalUs / 1000).toFixed(2)}ms total, ${frame.drawlistCmds} commands`);
}

// Compare two frames for changes
const diff = inspector.compareFrames(frameA, frameB);
for (const change of diff.changes) {
console.log(`${change.field}: ${change.oldValue} -> ${change.newValue}`);
if (diff) {
for (const change of diff.changed) {
console.log(`${change.field}: ${change.before} -> ${change.after}`);
}
}
```

Frame snapshots include:

- Frame ID and timestamp
- Render duration
- Draw command count
- Widget tree depth
- Visible widget count
- `frameId` and `timestamp`
- Terminal dimensions (`cols`, `rows`)
- Drawlist metrics (`drawlistBytes`, `drawlistCmds`)
- Diff/damage metrics (`diffBytesEmitted`, `dirtyLines`, `dirtyCells`, `damageRects`)
- Timing metrics in microseconds (`usDrawlist`, `usDiff`, `usWrite`)

## Event Trace

Expand All @@ -98,12 +203,11 @@ const trace = debug.eventTrace;
// Query recent events
const keyEvents = trace.query({
eventTypes: ["key"],
since: Date.now() - 5000,
limit: 100,
minFrameId: 100n,
});

for (const ev of keyEvents) {
console.log(`Key: ${ev.key} at ${ev.timestamp}`);
for (const ev of keyEvents.slice(-100)) {
console.log(`${ev.eventType}: ${ev.parseResult} at ${ev.timestamp}ms`);
}
```

Expand Down Expand Up @@ -193,7 +297,7 @@ Configure debug behavior at creation:
const debug = createDebugController({
maxFrames: 500, // Frame history limit
maxEvents: 1000, // Event trace limit
maxErrors: 100, // Error aggregation limit
maxStateChanges: 500, // State timeline limit
});
```

Expand Down
119 changes: 119 additions & 0 deletions packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { assert, test } from "@rezi-ui/testkit";
import { ZR_MOD_CTRL, ZR_MOD_SHIFT, charToKeyCode } from "../../keybindings/keyCodes.js";
import { ui } from "../../widgets/ui.js";
import { createAppWithInspectorOverlay } from "../inspectorOverlayHelper.js";
import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js";
import { StubBackend } from "./stubBackend.js";

function u32(bytes: Uint8Array, off: number): number {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return dv.getUint32(off, true);
}

function parseInternedStrings(bytes: Uint8Array): readonly string[] {
const spanOffset = u32(bytes, 28);
const count = u32(bytes, 32);
const bytesOffset = u32(bytes, 36);
const bytesLen = u32(bytes, 40);
if (count === 0) return Object.freeze([]);

const tableEnd = bytesOffset + bytesLen;
assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds");
const out: string[] = [];
const decoder = new TextDecoder();
for (let i = 0; i < count; i++) {
const span = spanOffset + i * 8;
const start = bytesOffset + u32(bytes, span);
const end = start + u32(bytes, span + 4);
assert.ok(end <= tableEnd, "string span must be in bounds");
out.push(decoder.decode(bytes.subarray(start, end)));
}
return Object.freeze(out);
}

async function pushEvents(
backend: StubBackend,
events: NonNullable<Parameters<typeof encodeZrevBatchV1>[0]["events"]>,
): Promise<void> {
backend.pushBatch(
makeBackendBatch({
bytes: encodeZrevBatchV1({ events }),
}),
);
await flushMicrotasks(20);
}

async function settleNextFrame(backend: StubBackend): Promise<void> {
backend.resolveNextFrame();
await flushMicrotasks(20);
}

test("createAppWithInspectorOverlay toggles overlay/hotkey and captures only while enabled", async () => {
const backend = new StubBackend();
const app = createAppWithInspectorOverlay({
backend,
initialState: 0,
inspector: {
title: "Inspector",
hotkey: "ctrl+shift+i",
},
});

app.view((state) => ui.text(`main-${String(state)}`));

await app.start();
await pushEvents(backend, [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }]);

assert.equal(backend.requestedFrames.length, 1);
assert.equal(app.inspectorOverlay.isEnabled(), false);
assert.equal(app.inspectorOverlay.getSnapshot(), null);
const firstFrameStrings = parseInternedStrings(backend.requestedFrames[0] ?? new Uint8Array());
assert.equal(firstFrameStrings.includes("inspector overlay"), false);

await settleNextFrame(backend);

const keyI = charToKeyCode("i");
assert.notEqual(keyI, null);
await pushEvents(backend, [
{ kind: "key", timeMs: 2, key: keyI ?? 0, mods: ZR_MOD_CTRL | ZR_MOD_SHIFT, action: "down" },
]);

assert.equal(app.inspectorOverlay.isEnabled(), true);
assert.equal(backend.requestedFrames.length, 2);
const secondFrameStrings = parseInternedStrings(backend.requestedFrames[1] ?? new Uint8Array());
assert.equal(secondFrameStrings.includes("inspector overlay"), true);
assert.equal(secondFrameStrings.includes("event: kind=<none> path=<none>"), true);
assert.equal(secondFrameStrings.includes("event: kind=resize path=<none>"), false);

await settleNextFrame(backend);

await pushEvents(backend, [{ kind: "resize", timeMs: 3, cols: 81, rows: 24 }]);
assert.equal(backend.requestedFrames.length, 3);
const thirdFrameStrings = parseInternedStrings(backend.requestedFrames[2] ?? new Uint8Array());
assert.equal(thirdFrameStrings.includes("event: kind=<none> path=<none>"), true);

await settleNextFrame(backend);

const latestSnapshot = app.inspectorOverlay.getSnapshot();
assert.equal(latestSnapshot?.event.kind, "resize");
assert.equal(latestSnapshot?.event.path, null);

app.update(1);
await flushMicrotasks(20);
assert.equal(backend.requestedFrames.length, 4);
const fourthFrameStrings = parseInternedStrings(backend.requestedFrames[3] ?? new Uint8Array());
assert.equal(fourthFrameStrings.includes("event: kind=resize path=<none>"), true);

await settleNextFrame(backend);

await pushEvents(backend, [
{ kind: "key", timeMs: 6, key: keyI ?? 0, mods: ZR_MOD_CTRL | ZR_MOD_SHIFT, action: "down" },
]);

assert.equal(app.inspectorOverlay.isEnabled(), false);
assert.equal(backend.requestedFrames.length, 5);
const fifthFrameStrings = parseInternedStrings(backend.requestedFrames[4] ?? new Uint8Array());
assert.equal(fifthFrameStrings.includes("inspector overlay"), false);

await settleNextFrame(backend);
});
Loading
Loading