From 92dfdfb1065270ffb1595e7ce99b7322f597eb73 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:29:25 +0400 Subject: [PATCH 1/2] fix(core): defer fatal handlers outside event dispatch --- .../src/app/__tests__/onEventHandlers.test.ts | 39 +++++++++++++++++++ packages/core/src/app/createApp.ts | 4 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/core/src/app/__tests__/onEventHandlers.test.ts b/packages/core/src/app/__tests__/onEventHandlers.test.ts index fb81274d..9cfd57fa 100644 --- a/packages/core/src/app/__tests__/onEventHandlers.test.ts +++ b/packages/core/src/app/__tests__/onEventHandlers.test.ts @@ -103,3 +103,42 @@ test("onEvent handler failure aborts the current batch before later events commi assert.equal(backend.stopCalls, 1); assert.equal(backend.disposeCalls, 1); }); + +test("fatal handlers run after onEvent handler depth unwinds", async () => { + const backend = new StubBackend(); + const app = createApp({ backend, initialState: 0 }); + app.draw((g) => g.clear()); + + app.onEvent((ev) => { + if (ev.kind === "engine" && ev.event.kind === "text") { + throw new Error("boom"); + } + }); + + let fatalHandlerError: unknown = null; + let sawFatal = false; + app.onEvent((ev) => { + if (ev.kind !== "fatal") return; + sawFatal = true; + try { + app.dispose(); + } catch (error: unknown) { + fatalHandlerError = error; + } + }); + + await app.start(); + backend.pushBatch( + makeBackendBatch({ + bytes: encodeZrevBatchV1({ + events: [{ kind: "text", timeMs: 1, codepoint: 65 }], + }), + }), + ); + + await flushMicrotasks(20); + + assert.equal(sawFatal, true); + assert.equal(fatalHandlerError, null); + assert.equal(backend.disposeCalls >= 1, true); +}); diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index 0ba78092..e627c75b 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -882,7 +882,9 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl } function fatalNowOrEnqueue(code: ZrUiErrorCode, detail: string): void { - if (scheduler.isExecuting) { + const canFailFastInline = + scheduler.isExecuting && !inRender && !inCommit && inEventHandlerDepth === 0; + if (canFailFastInline) { doFatal(code, detail); return; } From eeb8e16fd40aa330cb1502b9acd4d2962c936f1e Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:44:15 +0400 Subject: [PATCH 2/2] fix(core): preserve fail-fast fatal handling --- .../src/app/__tests__/onEventHandlers.test.ts | 38 +++++++++++++++++++ packages/core/src/app/createApp.ts | 18 ++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/core/src/app/__tests__/onEventHandlers.test.ts b/packages/core/src/app/__tests__/onEventHandlers.test.ts index 9cfd57fa..1a9845a7 100644 --- a/packages/core/src/app/__tests__/onEventHandlers.test.ts +++ b/packages/core/src/app/__tests__/onEventHandlers.test.ts @@ -104,6 +104,44 @@ test("onEvent handler failure aborts the current batch before later events commi assert.equal(backend.disposeCalls, 1); }); +test("onEvent handler failure faults before same-turn queued updates commit", async () => { + const backend = new StubBackend(); + const app = createApp({ backend, initialState: 0 }); + + app.draw((g) => g.clear()); + + let updaterRan = false; + app.onEvent((ev) => { + if (ev.kind !== "engine" || ev.event.kind !== "text") return; + app.update((state) => { + updaterRan = true; + return state + 1; + }); + throw new Error("boom"); + }); + + const fatals: string[] = []; + app.onEvent((ev) => { + if (ev.kind === "fatal") fatals.push(ev.detail); + }); + + await app.start(); + backend.pushBatch( + makeBackendBatch({ + bytes: encodeZrevBatchV1({ + events: [{ kind: "text", timeMs: 1, codepoint: 65 }], + }), + }), + ); + + await flushMicrotasks(20); + + assert.equal(updaterRan, false); + assert.equal(fatals.length, 1); + assert.equal(backend.stopCalls, 1); + assert.equal(backend.disposeCalls, 1); +}); + test("fatal handlers run after onEvent handler depth unwinds", async () => { const backend = new StubBackend(); const app = createApp({ backend, initialState: 0 }); diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index e627c75b..d3002802 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -719,6 +719,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl let breadcrumbLastConsumptionPath: RuntimeBreadcrumbConsumptionPath | null = null; let breadcrumbLastAction: RuntimeBreadcrumbAction | null = null; let breadcrumbEventTracked = false; + let deferredInlineFatal: Readonly<{ code: ZrUiErrorCode; detail: string }> | null = null; function recomputeRuntimeBreadcrumbCollection(): void { const next = @@ -881,9 +882,21 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl scheduler.enqueue({ kind: "fatal", code, detail }); } + function flushDeferredInlineFatal(): void { + if (deferredInlineFatal === null || inEventHandlerDepth !== 0) return; + const fatal = deferredInlineFatal; + deferredInlineFatal = null; + doFatal(fatal.code, fatal.detail); + } + function fatalNowOrEnqueue(code: ZrUiErrorCode, detail: string): void { - const canFailFastInline = - scheduler.isExecuting && !inRender && !inCommit && inEventHandlerDepth === 0; + const canFailFastInline = scheduler.isExecuting && !inRender && !inCommit; + if (canFailFastInline && inEventHandlerDepth > 0) { + if (deferredInlineFatal === null) { + deferredInlineFatal = Object.freeze({ code, detail }); + } + return; + } if (canFailFastInline) { doFatal(code, detail); return; @@ -936,6 +949,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl } } finally { inEventHandlerDepth--; + flushDeferredInlineFatal(); } return true; }