Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 114 additions & 9 deletions javascript/src/wrapAssembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (...args: unknown[]) => 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],
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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 {
Expand All @@ -466,6 +553,7 @@ export default function wrapAssembly(lib: any): Yoga {
this._children = [];
this._parent = null;
lib._YGNodeReset(this._ptr);
updateDirtyingCallWrappers();
}

// --- Style setters ---
Expand Down Expand Up @@ -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();
}
Expand All @@ -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 ---
Expand Down
215 changes: 215 additions & 0 deletions javascript/tests/YGDirtiedTest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading