Skip to content

fix(codegen): descend through record fields when sorting machine layouts - #2880

Open
gertybotbot wants to merge 2 commits into
hew-lang:mainfrom
gertybotbot:fix/machine-layout-record-descent
Open

fix(codegen): descend through record fields when sorting machine layouts#2880
gertybotbot wants to merge 2 commits into
hew-lang:mainfrom
gertybotbot:fix/machine-layout-record-descent

Conversation

@gertybotbot

Copy link
Copy Markdown
Contributor

Closes the record-wrapped half ("Gap 1") of #2864. #2879 took Gap 2 (machine-in-machine); this is the other half, and the issue can close once both land.

⚠️ Stacked on #2879. The fix lives inside the dependency sort that #2879 introduces, so this branch contains that commit as its parent. Review/merge #2879 first; this diff is the 43-line descent on top of it.

The gap

register_machine_layouts sorts machines so a container is never sized against a still-opaque embedded machine. That walk looks for payload field types whose name resolves to a machine — but a record is not a machine, so it missed the lookup and stopped. A machine reached only through a record field was never registered as a dependency:

machine OuterG { state HoldingG { w: WrapG } ... }
type WrapG { inner: InnerG; }
machine InnerG { state ReadyG { payload: string } ... }

It fails SILENTLY — worse than Gap 2

Gap 2 segfaulted. This one produces no diagnostic, no output, and exit 1. Two programs with byte-for-byte identical token multisets, differing only in declaration order:

$ diff <(tr -s ' \n' '\n\n' <gap1.hew|sort) <(tr -s ' \n' '\n\n' <gap1_ctl.hew|sort)
IDENTICAL-TOKENS

$ hew run gap1_ctl.hew    # embedded declared first
gap1-payload              # rc=0

$ hew run gap1.hew        # container declared first
                          # rc=1, no stdout, no diagnostic

This is worth noting against #2864's claim that both gaps "fail closed ... do not miscompile". A silent empty exit-1 is arguably harder to diagnose than the segfault, since nothing points at layout at all.

Fix

collect_named_machine_deps now descends into a named type's record field types, keyed off pipeline.record_layouts (already threaded into codegen). A visiting stack guards recursion rather than trusting acyclicity — a malformed or indirect layout terminates instead of overflowing the stack.

Evidence

⚠️ Load-bearing: the tests are revert-verified to discriminate. With the record descent disabled and the tests kept, the container-first test FAILS while embedded-first still passes — so they test the fix, not merely its presence.

Both declaration orders are covered, each asserting the exact payload string so a zero-sized or misaligned payload fails rather than reading back an accidental value.

Green bare (exit codes read directly, not through a pipe):

gertybotbot and others added 2 commits August 9, 2026 23:56
Machine layouts were registered in SOURCE order while enum layouts were
already topologically sorted. `build_tagged_union_layout` sizes each state
payload via `TargetData::get_abi_size`, so a state holding another machine
(`state Holding { inner: Inner }`) was sized against a still-opaque struct
when the container was declared first, yielding a zero-sized payload array.

This is NOT fail-closed, contrary to how the gap is described in hew-lang#2864: the
reversed declaration order compiles clean, passes the opaque-member layout
guard, and SEGFAULTS at runtime. Two token-identical programs differing only
in declaration order behaved differently — `Inner` first printed correctly,
`Outer` first crashed with SIGSEGV.

Apply the same stable Kahn's topological sort `register_enum_layouts` uses,
over dependencies collected from both state and event payloads (the `<Name>Event`
companion is built in the same pass and needs the same guarantee). A layout
cycle now fails closed with a named diagnostic rather than falling back to
input order, which would silently size a participant against an opaque member.

Closes the machine-in-machine half (Gap 2) of hew-lang#2864. Gap 1 (enum payload
containing a record containing a machine) is untouched.

Tests cover BOTH declaration orders; a fix handling only one is half a fix.
Verified discriminating: with the sort reverted, the container-first test
fails while the embedded-first test still passes.
Closes the record-wrapped half ("Gap 1") of hew-lang#2864, on top of the
machine-in-machine sort from hew-lang#2879.

`register_machine_layouts` dependency-sorts machines so a container is
never sized against a still-opaque embedded machine. That walk looked for
payload field types whose name resolves to a machine — but a RECORD is not
a machine, so it missed the lookup and stopped. A machine reached only
THROUGH a record field was therefore never registered as a dependency:

    state HoldingG { w: WrapG }   +   type WrapG { inner: InnerG }

`collect_named_machine_deps` now descends into a named type's record field
types, keyed off `pipeline.record_layouts`. Recursion is guarded by a
`visiting` stack rather than trusted: a malformed or indirect layout
terminates instead of overflowing the stack.

Unlike the direct machine-in-machine case, which segfaulted, this one
failed SILENTLY — the container-first program compiled clean, emitted no
diagnostic, produced no output, and exited 1. Token-identical programs
differing only in declaration order disagreed.

Tests cover both declaration orders and are revert-verified to
discriminate: with the record descent disabled, the container-first test
FAILS while the embedded-first test still passes.

Green: hew-codegen-rs 755 tests, all 159 tests/hew/ files,
cargo fmt --all --check, clippy --all-targets -D warnings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant