From dd231d4ef20106f1fef25e6b67b6bb2e6d6763a5 Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 10 May 2026 22:54:02 +0800 Subject: [PATCH] fix(renderer): per-node catch restores canvas save stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/pen-renderer/src/renderer.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/pen-renderer/src/renderer.ts b/packages/pen-renderer/src/renderer.ts index b916fc895..c24a05812 100644 --- a/packages/pen-renderer/src/renderer.ts +++ b/packages/pen-renderer/src/renderer.ts @@ -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 ?? ''; console.error(`[pen-renderer] drawNode threw for node ${id}:`, err); }