From aa4aa12fd5a49eae03940aac927823edcc484113 Mon Sep 17 00:00:00 2001 From: TKman <102001532+greekr4@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:01:09 +0900 Subject: [PATCH] Rethrow dirtied function exceptions after the Wasm call returns A dirtied function runs while Yoga marks nodes dirty, from style setters, insertChild, removeChild, markDirty, copyStyle and setIsReferenceBaseline. If it throws, the exception unwinds the WebAssembly frames: the stack pointer is not restored, so repeated throws eventually break the module, and dirty propagation stops, so the ancestors stay clean and the next layout misses the change. A throw from insertChild or removeChild also leaves the JS children list out of sync with Yoga's tree. The dirtied function bridge now holds the exception so the Wasm call can finish, and the call that marked the nodes dirty rethrows it once it returns. A Yoga call made from inside a dirtied function keeps its own error, and a WebAssembly.RuntimeError takes precedence over an earlier error. The calls are wrapped only while a dirtied function is set. insertChild and removeChild keep the JS tree in sync when a dirtied function throws, and leave it unchanged when Yoga aborts. --- javascript/src/wrapAssembly.ts | 123 ++++++++++++-- javascript/tests/YGDirtiedTest.test.ts | 215 +++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 9 deletions(-) diff --git a/javascript/src/wrapAssembly.ts b/javascript/src/wrapAssembly.ts index f6689a2ca9..4538424f0d 100644 --- a/javascript/src/wrapAssembly.ts +++ b/javascript/src/wrapAssembly.ts @@ -281,6 +281,81 @@ export default function wrapAssembly(lib: any): Yoga { lib._yogaMeasureFuncs = new Map(); lib._yogaDirtiedFuncs = new Map(); + // A dirtied function runs while Yoga marks nodes dirty, deep inside + // WebAssembly. An exception must not unwind those frames: the stack pointer + // would not be restored and the nodes above would not be marked dirty. So + // the bridge holds the first exception, and the call that marked the nodes + // dirty rethrows it once it returns. These calls are wrapped only while at + // least one dirtied function is set. + let pendingDirtiedError: {error: unknown} | null = null; + + // Set by a wrapped call that returned and then rethrew a dirtied function's + // error, so the caller knows Yoga finished the change. insertChild and + // removeChild reset it before each call and read it when the call throws. + let dirtiedErrorAfterReturn = false; + + const dirtyingCalls = [ + '_YGNodeInsertChild', + '_YGNodeRemoveChild', + '_YGNodeMarkDirty', + '_YGNodeCopyStyle', + '_YGNodeSetIsReferenceBaseline', + ...Object.keys(lib).filter(name => name.startsWith('_YGNodeStyleSet')), + ]; + const unwrappedCalls = new Map unknown>(); + + function rethrowDirtiedErrorAfter(call: (...args: unknown[]) => unknown) { + return (...args: unknown[]) => { + // A call made from inside a dirtied function keeps its own error apart + // from the one held for the call that is still running. + const outerError = pendingDirtiedError; + pendingDirtiedError = null; + let result: unknown; + try { + result = call(...args); + } catch (e) { + pendingDirtiedError = outerError; + dirtiedErrorAfterReturn = false; + throw e; + } + const ownError = pendingDirtiedError; + pendingDirtiedError = outerError; + if (ownError !== null) { + dirtiedErrorAfterReturn = true; + throw ownError.error; + } + return result; + }; + } + + function updateDirtyingCallWrappers(): void { + const wrapped = unwrappedCalls.size > 0; + const needed = lib._yogaDirtiedFuncs.size > 0; + if (needed === wrapped) return; + if (needed) { + for (const name of dirtyingCalls) { + unwrappedCalls.set(name, lib[name]); + lib[name] = rethrowDirtiedErrorAfter(lib[name]); + } + } else { + for (const [name, call] of unwrappedCalls) lib[name] = call; + unwrappedCalls.clear(); + } + } + + function attachChild(parent: NodeImpl, child: NodeImpl, index: number) { + parent._children.splice(index, 0, child); + child._parent = parent; + } + + function detachChild(parent: NodeImpl, child: NodeImpl) { + const idx = parent._children.indexOf(child); + if (idx !== -1) { + parent._children.splice(idx, 1); + } + child._parent = null; + } + function readYGValue(): Value { return { value: lib.HEAPF32[valueBufIdx], @@ -371,6 +446,9 @@ export default function wrapAssembly(lib: any): Yoga { lib._yogaMeasureFuncs.delete(ptr); lib._yogaDirtiedFuncs.delete(ptr); lib._YGNodeFinalize(ptr); + // Finalization callbacks run as their own task, never in the middle of a + // wrapped call, so swapping the wrappers here is safe. + updateDirtyingCallWrappers(); }); // --- Config class --- @@ -433,18 +511,27 @@ export default function wrapAssembly(lib: any): Yoga { // --- Tree hierarchy --- insertChild(child: NodeImpl, index: number): void { - lib._YGNodeInsertChild(this._ptr, child._ptr, index); - this._children.splice(index, 0, child); - child._parent = this; + dirtiedErrorAfterReturn = false; + try { + lib._YGNodeInsertChild(this._ptr, child._ptr, index); + } catch (e) { + // Yoga inserted the child if the error came from a dirtied function. + if (dirtiedErrorAfterReturn) attachChild(this, child, index); + throw e; + } + attachChild(this, child, index); } removeChild(child: NodeImpl): void { - lib._YGNodeRemoveChild(this._ptr, child._ptr); - const idx = this._children.indexOf(child); - if (idx !== -1) { - this._children.splice(idx, 1); + dirtiedErrorAfterReturn = false; + try { + lib._YGNodeRemoveChild(this._ptr, child._ptr); + } catch (e) { + // Yoga removed the child if the error came from a dirtied function. + if (dirtiedErrorAfterReturn) detachChild(this, child); + throw e; } - child._parent = null; + detachChild(this, child); } getChildCount(): number { @@ -466,6 +553,7 @@ export default function wrapAssembly(lib: any): Yoga { this._children = []; this._parent = null; lib._YGNodeReset(this._ptr); + updateDirtyingCallWrappers(); } // --- Style setters --- @@ -904,9 +992,25 @@ export default function wrapAssembly(lib: any): Yoga { const nodeWeakRef = new WeakRef(this); lib._yogaDirtiedFuncs.set(this._ptr, () => { const node = nodeWeakRef.deref(); - if (node) dirtiedFunc(node); + if (!node) return; + try { + dirtiedFunc(node); + } catch (error) { + // Keep the first error, but let a trap or abort replace it: it + // means the module is broken, which matters more. + if ( + pendingDirtiedError === null || + (error instanceof WebAssembly.RuntimeError && + !( + pendingDirtiedError.error instanceof WebAssembly.RuntimeError + )) + ) { + pendingDirtiedError = {error}; + } + } }); lib._jswrap_YGNodeSetDirtiedFunc(this._ptr); + updateDirtyingCallWrappers(); } else { this.unsetDirtiedFunc(); } @@ -915,6 +1019,7 @@ export default function wrapAssembly(lib: any): Yoga { unsetDirtiedFunc(): void { lib._yogaDirtiedFuncs.delete(this._ptr); lib._jswrap_YGNodeUnsetDirtiedFunc(this._ptr); + updateDirtyingCallWrappers(); } // --- Dirty / Layout --- diff --git a/javascript/tests/YGDirtiedTest.test.ts b/javascript/tests/YGDirtiedTest.test.ts index 8a47291af0..a8d906b991 100644 --- a/javascript/tests/YGDirtiedTest.test.ts +++ b/javascript/tests/YGDirtiedTest.test.ts @@ -147,3 +147,218 @@ test('dirtied_reset', () => { root.markDirty(); expect(dirtied).toBe(1); }); + +test('dirtied_func_exception_propagates_to_caller', () => { + const root = Yoga.Node.create(); + root.setWidth(100); + root.setHeight(100); + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + + root.setDirtiedFunc(() => { + throw new Error('dirtied failed'); + }); + + expect(() => root.setWidth(50)).toThrow('dirtied failed'); +}); + +test('layout_works_after_repeated_dirtied_func_exceptions', () => { + // Without holding the error in the bridge, this breaks after about 4,000 + // throws. + for (let i = 0; i < 5000; i++) { + const root = Yoga.Node.create(); + const root_child0 = Yoga.Node.create(); + root_child0.setWidth(10); + root_child0.setHeight(10); + root.insertChild(root_child0, 0); + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + + root.setDirtiedFunc(() => { + throw new Error('dirtied failed'); + }); + expect(() => root_child0.setWidth(20)).toThrow('dirtied failed'); + } + + const root = Yoga.Node.create(); + root.setWidth(100); + root.setHeight(100); + root.setAlignItems(Yoga.ALIGN_FLEX_START); + + const root_child0 = Yoga.Node.create(); + root_child0.setWidth(10); + root_child0.setHeight(10); + root.insertChild(root_child0, 0); + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + + expect(root_child0.getComputedWidth()).toBe(10); + expect(root_child0.getComputedHeight()).toBe(10); +}); + +test('dirtied_func_exception_still_marks_ancestors_dirty', () => { + const root = Yoga.Node.create(); + root.setAlignItems(Yoga.ALIGN_FLEX_START); + root.setWidth(100); + root.setHeight(100); + + const root_child0 = Yoga.Node.create(); + root_child0.setAlignItems(Yoga.ALIGN_FLEX_START); + root.insertChild(root_child0, 0); + + const root_child0_child0 = Yoga.Node.create(); + root_child0_child0.setWidth(10); + root_child0_child0.setHeight(10); + root_child0.insertChild(root_child0_child0, 0); + + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + expect(root_child0.getComputedWidth()).toBe(10); + + root_child0.setDirtiedFunc(() => { + throw new Error('dirtied failed'); + }); + + expect(() => root_child0_child0.setWidth(30)).toThrow('dirtied failed'); + expect(root_child0_child0.isDirty()).toBe(true); + expect(root_child0.isDirty()).toBe(true); + expect(root.isDirty()).toBe(true); + + root_child0.setDirtiedFunc(null); + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + + expect(root_child0_child0.getComputedWidth()).toBe(30); + expect(root_child0.getComputedWidth()).toBe(30); +}); + +test('dirtied_func_exception_keeps_children_in_sync', () => { + const root = Yoga.Node.create(); + root.setFlexDirection(Yoga.FLEX_DIRECTION_ROW); + root.setWidth(100); + root.setHeight(100); + + const root_child0 = Yoga.Node.create(); + root_child0.setWidth(10); + root.insertChild(root_child0, 0); + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + + root.setDirtiedFunc(() => { + throw new Error('dirtied failed'); + }); + + const root_child1 = Yoga.Node.create(); + root_child1.setWidth(20); + expect(() => root.insertChild(root_child1, 1)).toThrow('dirtied failed'); + expect(root.getChildCount()).toBe(2); + expect(root.getChild(1)).toBe(root_child1); + expect(root_child1.getParent()).toBe(root); + + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + expect(root_child1.getComputedLeft()).toBe(10); + + expect(() => root.removeChild(root_child0)).toThrow('dirtied failed'); + expect(root.getChildCount()).toBe(1); + expect(root.getChild(0)).toBe(root_child1); + expect(root_child0.getParent()).toBe(null); +}); + +test('dirtied_func_exception_is_rethrown_by_the_outermost_call', () => { + const other = Yoga.Node.create(); + + const root = Yoga.Node.create(); + root.setAlignItems(Yoga.ALIGN_FLEX_START); + root.setWidth(100); + root.setHeight(100); + + const root_child0 = Yoga.Node.create(); + root_child0.setAlignItems(Yoga.ALIGN_FLEX_START); + root.insertChild(root_child0, 0); + + const root_child0_child0 = Yoga.Node.create(); + root_child0_child0.setWidth(10); + root_child0_child0.setHeight(10); + root_child0.insertChild(root_child0_child0, 0); + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + + // Runs first, while root_child0_child0 is marked dirty. + root_child0_child0.setDirtiedFunc(() => { + throw new Error('dirtied failed'); + }); + // Runs next, and calls into Yoga from inside the dirtied function. + let innerError: unknown = null; + root_child0.setDirtiedFunc(() => { + try { + other.setWidth(5); + } catch (e) { + innerError = e; + } + }); + + expect(() => root_child0_child0.setWidth(30)).toThrow('dirtied failed'); + expect(innerError).toBe(null); + expect(root.isDirty()).toBe(true); +}); + +test('dirtied_func_exception_is_thrown_by_the_call_that_caused_it', () => { + const other = Yoga.Node.create(); + other.setWidth(10); + other.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + other.setDirtiedFunc(() => { + throw new Error('other failed'); + }); + + const root = Yoga.Node.create(); + root.setWidth(100); + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + + let innerError: unknown = null; + root.setDirtiedFunc(() => { + try { + other.setWidth(20); + } catch (e) { + innerError = e; + } + }); + + root.setWidth(50); + expect(innerError).toEqual(new Error('other failed')); +}); + +// These throw a WebAssembly.RuntimeError from JS to stand in for a trap or +// abort; a real abort ends the Jest run. +test('dirtied_func_runtime_error_takes_precedence', () => { + const root = Yoga.Node.create(); + root.setAlignItems(Yoga.ALIGN_FLEX_START); + + const root_child0 = Yoga.Node.create(); + root_child0.setWidth(10); + root_child0.setHeight(10); + root.insertChild(root_child0, 0); + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + + root_child0.setDirtiedFunc(() => { + throw new Error('dirtied failed'); + }); + root.setDirtiedFunc(() => { + throw new WebAssembly.RuntimeError('aborted'); + }); + + expect(() => root_child0.setWidth(20)).toThrow(WebAssembly.RuntimeError); +}); + +test('dirtied_func_runtime_error_keeps_children_in_sync', () => { + const root = Yoga.Node.create(); + root.setFlexDirection(Yoga.FLEX_DIRECTION_ROW); + + const root_child0 = Yoga.Node.create(); + root_child0.setWidth(10); + root.insertChild(root_child0, 0); + root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + + root.setDirtiedFunc(() => { + throw new WebAssembly.RuntimeError('dirtied failed'); + }); + + const root_child1 = Yoga.Node.create(); + expect(() => root.insertChild(root_child1, 1)).toThrow( + WebAssembly.RuntimeError, + ); + expect(root.getChildCount()).toBe(2); + expect(root_child1.getParent()).toBe(root); +});