fix(renderer): per-node catch restores canvas save stack

Codex stop-hook on the prior per-node try/catch caught a leak: the
catch logged but didn't roll back canvas state. drawNode pushes
canvas.save() once per ancestor clipStack entry (node-renderer.ts:548)
plus more for rotation / flip (574, 583) and per-shape sub-paths
(701, 1094, 1102). If drawNode throws mid-loop, every save() between
its entry and the throw stays on the stack — the next node's draw
operates inside a leaked clip / leaked transform, and the canvas
either renders nothing or renders to the wrong region.

Snapshot canvas.getSaveCount() before each drawNode call; on catch,
canvas.restoreToCount(saveCount) pops everything back to the
baseline. Wrap the restoreToCount itself in a no-op catch since it
can throw if the snapshot count is somehow above the current depth
(shouldn't happen but guarded so the error reporter still runs).

Net effect: per-node failures are now genuinely isolated. The
canvas state at the start of each iteration is identical to where
the previous iteration left it; one bad node can't smear its leaked
state across the rest of the frame.
This commit is contained in:
Fini 2026-05-10 22:54:02 +08:00
parent 138df33309
commit dd231d4ef2

View file

@ -330,10 +330,28 @@ export class PenRenderer {
// shadow omitted `spread` — the throw escaped the loop and every
// sibling stopped rendering. The shadow path now coerces missing
// numeric fields to 0; this catch is the structural backstop.
//
// Snapshot the canvas save count before each node so a throw mid-
// way through drawNode (which pushes save() / clip() per ancestor
// clipStack entry, plus more for rotation / flip — see node-
// renderer.ts:548, 574, 583, 701, 1094, 1102) can roll the canvas
// state back to a clean baseline. Without this, the leaked clip /
// rotation state corrupts every subsequent node's draw — Codex
// stop-hook 2026-05-10 caught the bare-catch leak.
for (const rn of this.renderNodes) {
const saveCount = canvas.getSaveCount();
try {
this.nodeRenderer.drawNode(canvas, rn);
} catch (err) {
try {
canvas.restoreToCount(saveCount);
} catch {
// restoreToCount itself can throw if the snapshot count is
// somehow above the current depth (shouldn't happen in
// practice — drawNode only pushes saves, never pops below
// its entry depth — but guard so the error reporter still
// logs).
}
const id = rn.node.id ?? '<no-id>';
console.error(`[pen-renderer] drawNode threw for node ${id}:`, err);
}