Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-0017: Prove the struct-by-value upcall from C, and make it cheap

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3.1, §5, ADR-0010

Context

Yoga measures a leaf node by calling back into the host:

typedef YGSize (*YGMeasureFunc)(
    YGNodeConstRef node,
    float width, YGMeasureMode widthMode,
    float height, YGMeasureMode heightMode);

YGSize is two floats, and it comes back by value. That is the fiddliest thing Goldberry asks of the Foreign Function & Memory API, and it sits on the layout hot path: once per measured node, per layout pass, per frame, for every piece of text on screen.

It is also the one crossing the layout table cannot check. ADR-0010 accepts hand-written bindings because libgoldberry reports its own sizeof, offsetof and _Alignof, and a test asserts the Java declarations agree. That argument covers memory. It says nothing about registers. YGSize is eight bytes with no padding on every target we ship, so its row in the layout table is identical everywhere — and yet the return convention differs on each: packed into XMM0 on SysV x86-64, a homogeneous float aggregate in s0/s1 on AArch64, folded into RAX on Win64. A FunctionDescriptor that is wrong about this produces an upcall stub the JVM builds without complaint and C reads as garbage. Yoga would take that garbage as a measurement and lay out around it.

Nothing on the Java side can catch that. Asserting what the callback returns proves only that Java can read back what Java just wrote.

Two smaller forces, both consequences of this being a hot path and a callback:

An upcall’s return value has to live somewhere. The obvious implementation allocates a segment per call — putting an allocation in the inner loop of layout to hold a value the linker copies out microseconds later.

And an exception thrown inside an upcall has nowhere to go. There is no Java frame beneath it, only Yoga’s C++. The JVM’s answer is to terminate the process. A measure function calls into text shaping, which loads fonts, which can fail.

Decision

The check comes from C. libgoldberry exports goldberry_probe_measure, which takes a YGMeasureFunc, calls it, and reports what arrived through out-parameters. It is compiled by the target’s own C compiler against Yoga’s own header, so what it receives is what Yoga would receive. MeasureUpcallTest calls it through an upcall stub with two distinct, exactly representable values and asserts both survive. That test runs on every target in CI, which is the only place the question is actually answered.

Out-parameters rather than a returned YGSize, deliberately: returning one would put a struct-by-value downcall return in the same test, and a failure could then be either mechanism.

MeasureCallback allocates its return segment once, in the arena it owns, and hands the same segment back on every call. The callback is synchronous — the linker has copied the result before Yoga can call again — so one segment per callback is enough, and the hot path allocates nothing.

And a measure function that throws does not reach C. MeasureCallback catches everything, reports zero to Yoga, holds the first exception, and rethrows it from throwIfFailed() once control is back in Java. One node is laid out wrongly; the alternative is losing the process.

Alternatives considered

Prove it by binding Yoga’s node API instead. YGNodeSetMeasureFunc plus a real YGNodeCalculateLayout would exercise the callback the way production will. It is the stronger end-to-end test and it should exist — but it answers this question no better, because the ABI is the risk and both callers use the same one, and it needs six more exported symbols and an opaque-handle design that is not written yet. Proving the mechanism first is what M0 asked for; the node binding follows.

Trust the layout table. It is already the safety argument for everything else, and extending it here would cost nothing. It would also be worthless: YGSize has the same size, alignment and offsets on all six targets, so the row passes whether or not the return convention is right. A check that cannot fail is worse than no check, because it reads like coverage.

Allocate the returned segment per call, from a confined arena closed immediately. Simple, obviously correct, and it puts an allocation and an arena close on the path that runs once per text node per frame. Rejected on cost, not on correctness — but the reuse it replaces is only safe while the callback is synchronous, which is now a documented assumption rather than an obvious truth.

Let exceptions propagate. Honest, in that a broken measure function is a serious bug and a hard crash is unambiguous. It is also unrecoverable and untestable: MeasureUpcallTest could not assert on a failing callback at all, because the JVM running the assertion would be gone.

jextract. It generates upcall stubs and would have got the descriptor right without anyone reasoning about XMM0. ADR-0006 chose it and ADR-0010 replaced it; this is the class of bug that decision took on, and the answer is not to re-litigate it but to make the check specific enough to catch this.

Consequences

The YGSize return is proven on real hardware for every target, and the proof runs on every push rather than being asserted once and assumed after.

goldberry_probe_measure is a test-only symbol in a shipped library. It is four lines and it is on the export list, which is the honest place for it — the alternative is a second artifact built with different flags, which is a worse thing to have to trust.

Measure functions may not assume the process dies when they fail. Every native call that can invoke a callback must be followed by throwIfFailed(), and forgetting it makes a failure look like a measurement of zero. This is the one sharp edge the design adds, and it is currently a documentation obligation rather than something the compiler enforces.

MeasureCallback is confined to its creating thread and must be closed, and closing it while a Yoga node still holds the pointer leaves that node calling freed memory. Node lifetime and callback lifetime are now coupled, and nothing yet enforces the coupling — the Yoga node binding will have to.

The reuse of the return segment is safe only while the callback is synchronous and non-reentrant. Yoga’s measure functions are leaf calls, so this holds today. If a future engine measures in parallel, the segment becomes per-thread or the design goes back to allocating.