[AUTOMATED] fix(p9): branchflip armswap - an else-if collapse must not steal a nested if's brace - #282
Merged
Merged
Conversation
…t steal a nested if's brace
A statement escaped its `else` arm and executed on the then-path too, so kuna
shipped semantically wrong C on a default-ON pass. Two independent witnesses:
* cleanflight `serialUART` @0x800ba0c (ARM Thumb) called
`IOInit(rxIO)`/`IOConfigGPIOAF(rxIO)` on the SERIAL_BIDIR path. The machine
code never takes it: 800bab6 branches to the BIDIR body at 800bb06, which
falls straight through to the join at 800bb22 and never reaches the RX guard
at 800bada.
* iproute2 `ip netns_add` @0x281bb (x86-64) read `argv[1]` and ran `get_s32` on
the create path; `2821d: jg 282e5` jumps past that block.
The filed root cause was wrong, and this matters for the guard rail. The
`branchflip` arm swap does NOT corrupt the block tree - dumping sblocks before
and after `Funcdata::block_if_flip_negated_guard` on serialUART shows a clean
swap of components 1 and 2 with every child intact, and no pass between the
swap and emission re-parents anything. The defect was entirely in the printer.
Mechanism. S8 folds a run of sibling guards into ONE `BlockIf` whose
*condition component* is a `BlockList` leading with a whole `if` statement.
When `branchflip` swaps the arms, that statement-carrying `BlockIf` lands in
the else slot and `PrintC::emit_block_if` takes the `else if` collapse path.
The collapse is a lazy brace: the clause frame registers a brace that opens at
the first line break. Upstream decides whether to cancel it with
`emit->hasPendingPrint(&pendingBrace)` - a pointer-identity test against the
frame's OWN `PendingBrace`. kuna's port asked the emitter "is any brace
pending", so the first nested `if` inside the clause's condition block grabbed
the ancestor's brace, cancelled it, rendered ITSELF as the `else if`, and left
the real clause's `if` header on a fresh line at the parent's indent - hoisting
its body out of the arm.
Fix: only the frame whose registration is still the active one (generation
stamp match, kuna's existing stand-in for C++ pointer identity) may cancel and
collapse. Strict bug fix per docs/agents.md, no option; `branchflip` stays
default-ON, and `branchflip off` was only ever a workaround.
Guard rails (both debug-only, zero cost in release):
* `PrintC::emit_block_if` now asserts that every registered pending brace is
resolved by its own frame - fired or self-cancelled. That is the assertion
that would have caught this bug.
* `Funcdata::block_if_flip_negated_guard` now asserts that the multiset of leaf
components reachable under the `BlockIf` is unchanged across the flip, since
the flip is a pure arm swap. It polices the whole arm-rearranging family
(it does not fire here - the tree was never the problem).
Corpus sweep, old vs new binary, `decompile-all` over 155 decbench binaries /
86,634 functions (x86-64 ELF at O0/O2/O2-noinline, ARM Cortex-M firmware,
i386 PE): 441 functions changed (0.51%) - 324 x86-64, 117 ARM, 0 PE. Every one
of the 441 is byte-identical after stripping all whitespace and all braces, so
no statement was added, removed, reordered or altered; 440 carry the exact
expected signature (an `else if` becoming a braced `else {`) and every changed
function nests strictly deeper, never shallower. The 441st was a
`--max-fn-seconds` watchdog artifact under load and is identical on re-run.
Brace balance is unchanged in all 441.
Speed (interleaved min-of-11, old vs new binary): serialUART +1.14%,
netns_add +1.91%, `decompile-all` coreutils sort +0.59%, crazyflie CMSIS_DAP
-0.29%. Within the 5% budget.
Gates: make test 675/675 PARITY OK; make test-stages 411/411 PARITY OK; make
rust-test 4537 passed / 0 failed; make check-spec OK.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8UQbPqALzdUQ3cLLjUeKH
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
A statement escaped its
elsearm and executed on the then-path too, so kunashipped semantically wrong C on a default-ON pass. Two independent witnesses,
both checked against the disassembly:
cleanflight
serialUART@0x800ba0c (ARM Thumb, O0). kuna calledIOInit(rxIO)/IOConfigGPIOAF(rxIO)on theSERIAL_BIDIRpath. The machinecode never takes it:
if (a3 & 8) { // branch-flip <- options & SERIAL_BIDIR ...BIDIR body... } - else if ((a2 & 2) && (v2)) { ...TX... } - if ((a2 & 1) && (v3)) { ...RX... } <- ESCAPED the else arm + else { + if ((a2 & 2) && (v2)) { ...TX... } + if ((a2 & 1) && (v3)) { ...RX... } + }iproute2
ip netns_add@0x281bb (x86-64, O0). kuna readargv[1]and ranget_s32on the create path;2821d: jg 282e5jumps straight past that block.Same fix re-nests it.
Both witnesses were re-derived on the rebased base (
11f40f46, i.e. with#280
funcboundflowmerged).funcboundflowbounds fall-through at a knownfunction entry and so could have moved a stripped-ARM boundary, but it did not
touch this one: the
serialUARTbody is byte-identical between the pre-#280build and the
11f40f46base (bothsha1[:10] = 4343611222, 68 lines, oneelse if), so the disassembly correspondence above still holds verbatim. Thefixed build is the only one that differs (70 lines, zero
else if).The filed root cause was wrong
The triage record named
Funcdata::block_if_flip_negated_guard'sswap_blocks(sif, 1, 2)as the site, on the theory that an arm which was aBlockListof twoBlockIfs lost its tail child. It did not. Dumping thesblockstree before and after the flip onserialUARTshows a clean swap ofcomponents 1 and 2 with every child intact, and nothing between the swap and
emission re-parents anything. The block tree is never corrupted; the defect is
entirely in the printer. It is also not architecture-specific (the record
reported three x86-64 reproducers that did not corrupt): a 12-line
gcc -O0x86-64 function reproduces it, and that is the stage test shipped here.
The real mechanism
S8 folds a run of sibling guards into ONE
BlockIfwhose conditioncomponent is a
BlockListthat leads with a wholeifstatement — thestandard Ghidra "condition block carries statements" shape. When
branchflipswaps the arms, that statement-carrying
BlockIflands in the else slot andPrintC::emit_block_iftakes theelse ifcollapse path.That collapse is a lazy brace: the clause frame registers a brace that opens
at the first line break, and cancels it if nothing forced one. Upstream decides
with
emit->hasPendingPrint(&pendingBrace)— a pointer-identity testagainst the frame's own
PendingBrace. kuna's port asked the emitter "is anybrace pending", so the first nested
ifinside the clause's condition blockgrabbed the ancestor's brace, cancelled it, rendered itself as the
else if,and left the real clause's
ifheader on a fresh line at the parent's indent —hoisting its body out of the arm.
The fix
Only the frame whose registration is still the active one (generation-stamp
match, kuna's existing stand-in for C++ pointer identity) may cancel and
collapse. Strict bug fix per
docs/agents.md, no option:branchflipstaysdefault-ON, and
branchflip offwas only ever a workaround. No default changes,so no DIV row (and no catalog counters move: 96 settables on both sides).
Guard rails (debug-only, zero cost in release) — and what they caught
PrintC::emit_block_ifasserts that every registered pending brace isresolved by its own frame — fired or self-cancelled.
Funcdata::block_if_flip_negated_guardasserts that the multiset of leafcomponents reachable under the
BlockIfis unchanged across the flip, sincethe flip is a pure arm swap. This is the guard rail the triage asked for; it
polices the whole arm-rearranging family. It does not fire here, which is
itself the evidence that the tree was never the problem.
Both were verified rather than assumed. Applying only the printer assertion
(not the fix) to the unfixed
11f40f46base and running every corpus filethrough it, the assertion fires on 3 of the 195 files — and two of them are
pre-existing instances already in kuna's own corpus that no assertion covered:
tests/stages/switchsharedcase-b2sum.xmlgoto label_401403/goto label_401572chains escape theelsearmtests/stages/ghangr-x8664-cvs-863633.xmllabel_4059c4:block and its body escape theelsearmtests/stages/ghdec-branchflip-armswap.xmlOn the fixed tree the same corpus (112 stages + 83 datatests = 195 files,
413 + 675 assertions) runs under both assertions with 0 firings, and the
whole debug workspace suite (4,540 tests) passes with them live.
Corpus sweep (standing requirement 8)
Re-run against a base binary built at
11f40f46so the A/B isolates this fixon the current base rather than conflating it with #280.
decompile-all, base vsbranch, over 155 decbench binaries / 86,634 functions — x86-64 ELF at
O0/O2/O2-noinline, ARM Cortex-M firmware, i386 PE:
Every one of the 435 was re-decompiled with both binaries and classified:
statement added, removed, reordered or altered. The only change is bracing and
indentation.
else ifbecoming a bracedelse {).(The earlier sweep against the pre-#280 base gave 441 changed with the same
verdict; the 6-function difference is #280's own effect on which bodies exist.)
Speed
Interleaved min-of-13 on an idle box (load 3.6), base
11f40f46vs branch — nooption to flip, so this is
scripts.pipeline.timeit's binary-flip analogue:decompile-allcoreutils sortdecompile-allcrazyflie CMSIS_DAPWithin the 5% budget — the change is two integer comparisons per
BlockIf.Ships with
tests/stages/ghdec-branchflip-armswap.xml— two-pass end-to-end testcase on a12-line x86-64 reproducer. 4 of its 6 asserts fail on the pre-fix binary and
all 6 pass after. Corpus file count in
kuna-base/src/xml.rsre-derived from thetree (83 datatests + 112 stages = 195, not by arithmetic) and
docs/baseline-stages.jsonre-recorded (407 -> 413, additions only — no existingkey moved).
docs/spec/09-emission.mdgains a Pending-brace ownershipparagraph (the normative rule);
docs/spec/08-structuring.mdrecords thearm-swap leaf-multiset invariant and points at it.
Gates (verbatim, on this exact tree, rebased onto
11f40f46)Note for whoever merges the sibling option PRs (#281
guardarm, #283loopcondhoist): each bumps settables 96 -> 97 and the corpus file count, sothis PR will conflict with them on
kuna-base/src/xml.rsanddocs/baseline-stages.json. Resolve by re-deriving both from the merged tree,not by hand-merging.
🤖 Generated with Claude Code
https://claude.ai/code/session_01C8UQbPqALzdUQ3cLLjUeKH