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); +});