Skip to content

Keep code metadata annotations attached when folding - #2833

Open
Nishuuzz wants to merge 1 commit into
WebAssembly:mainfrom
Nishuuzz:fix/fold-code-metadata-annotations
Open

Keep code metadata annotations attached when folding#2833
Nishuuzz wants to merge 1 commit into
WebAssembly:mainfrom
Nishuuzz:fix/fold-code-metadata-annotations

Conversation

@Nishuuzz

@Nishuuzz Nishuuzz commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

wasm2wat --fold-exprs produces output it cannot read back for any module carrying code metadata, and corrupts the instruction the metadata belongs to on the way.

For test/dump/code-metadata.txt, where the annotation decorates i32.add:

(i32.const 1234)
(local.get 0)
((@metadata.code.test "aa\01a"))
(return
  (i32.add))

The annotation has been wrapped in parentheses of its own, which does not parse — error: unexpected token "metadata.code.test", expected an instr. — and i32.add has lost both operands. Branch hints are code metadata too, so test/parse/branch-hints.txt breaks the same way.

The cause is that a CodeMetadataExpr has arity {0, 0}, so WriteFoldedExpr hands it to PushExpr like an instruction. That gives it its own node, hence the parentheses, and since result_count == 0 it also flushes the expression stack, which detaches the operands of the instruction it was supposed to decorate.

The output form

Annotations are now held until the instruction they decorate is pushed and carried on that instruction's tree node, so they are written immediately before its folded expression:

(return
  (@metadata.code.test "aa\01a") (i32.add
    (i32.const 1234)
    (local.get 0)))

This is what binaryen and wasm-tools both emit and round-trip, as @keithw showed above; test/lit/branch-hinting.wast in binaryen has the same shape with RTRIP checks over it. Thanks to both of you for pushing back on the first version of this, which de-folded the annotated instruction instead. I had described the flat form as a limitation of the text format, and that was wrong — it is a limitation of wabt's parser, which is a different thing and fixable.

The parser change

wabt rejected an annotation in that position, so it learns to accept one. The rule is that an annotation decorates the operator of the expression that follows it, and a folded expression expands to its operands before that operator, so the annotation is spliced between the two. Get that wrong and the output still parses but the offset recorded in the metadata section moves to a different instruction, which is the quiet failure rather than the loud one.

It has to happen in two places: in an operand list, and at statement level, where block-like instructions are written. The second is the case branch hints actually hit, since they decorate an if. Both paths share AppendAnnotatedInstr.

Testing

test/roundtrip/fold-code-metadata.txt covers an annotation on an instruction with operands, one on an instruction without, and one on an if, that last being the branch-hint shape. run-roundtrip re-assembles and compares against the original binary, so it catches a moved offset as well as a parse failure.

Both reproducers above now round-trip to byte-identical binaries.

I ran every module in test/ through wasm2wat in each of its writer variants — default, --fold-exprs, --inline-exports, --inline-imports, --generate-names, --no-debug-names — and assembled each result again. --fold-exprs was 1102 of 1108 before this change and is 1108 now, with the other five unchanged.

Suites: roundtrip 95, dump 148, parse 758, typecheck 119, desugar 6, no failures, and unit tests 135. No existing expectation changed.

@sbc100

sbc100 commented Aug 22, 2026

Copy link
Copy Markdown
Member

It sounds like you are saying that code annotation are fundamentally incompatible with the folded wat format, is that right?

If so, I wonder if this is something that we should fix in the spec? Perhaps you could file an upstream issue in the spec repo just to check that this is indeed a known issue and not something that we should fix?

Can you also confirm what binaryen does in this case? I know it likes to output the folded form.

@keithw

keithw commented Aug 22, 2026

Copy link
Copy Markdown
Member

I believe we (now) have round-trippable annotations in the folded format in wasm-tools (recent bugfix here: bytecodealliance/wasm-tools#2578), so I know it's possible to do this. :-) I think the upstream spec is okay.

@sbc100

sbc100 commented Aug 22, 2026

Copy link
Copy Markdown
Member

@keithw, how does it handle the above example?

@keithw

keithw commented Aug 22, 2026

Copy link
Copy Markdown
Member

Well, good question. I'm realizing that the wast crate ignores annotations other than @metadata.code.branch_hint, but for the input:

(module
  (func $f (param i32) (result i32)
    i32.const 1234
    local.get 0
    (@metadata.code.branch_hint "\01") i32.add
    return))

(well-formed but invalid because a branch hint can't go on "i32.add"), wasm-tools prints it folded as:

(module
  (type (;0;) (func (param i32) (result i32)))
  (func $f (;0;) (type 0) (param i32) (result i32)
    (return
      (@metadata.code.branch_hint "\01")
      (i32.add
        (i32.const 1234)
        (local.get 0)))
  )
)

and then parses that back to flat format as:

(module
  (type (;0;) (func (param i32) (result i32)))
  (func $f (;0;) (type 0) (param i32) (result i32)
    i32.const 1234
    local.get 0
    (@metadata.code.branch_hint "\01")
    i32.add
    return
  )
)

@sbc100

sbc100 commented Aug 22, 2026

Copy link
Copy Markdown
Member

@Nishuuzz do you think that form looks correct? Could we find a way to do this rather than give up on folding:

(module
  (type (;0;) (func (param i32) (result i32)))
  (func $f (;0;) (type 0) (param i32) (result i32)
    (return
      (@metadata.code.branch_hint "\01")
      (i32.add
        (i32.const 1234)
        (local.get 0)))
  )
)

@Nishuuzz

Copy link
Copy Markdown
Contributor Author

Yes, that form is right — and I was wrong to describe this as the text format not allowing it. It's wabt's parser that doesn't, which is a different thing, and my description overstated it.

The rule that makes it work is that the annotation binds to the operator of the folded expression that follows it, rather than to the first instruction that expression expands to. So in @keithw's example the annotation sits before (i32.add ...) but lands after both operands and immediately before i32.add once flattened, which is where the offset recorded in the metadata section needs it.

On binaryen: it does handle these, and it prints the same shape. From test/lit/branch-hinting.wast, with its RTRIP checks confirming it survives a round trip:

(block $out
  (@metadata.code.branch_hint "\00")
  (br_if $out
   (local.get $x)
  )
  (@metadata.code.branch_hint "\01")
  (br_if $out
   (local.get $x)
  )
)

So binaryen and wasm-tools agree on the convention and wabt is the odd one out. That being the case I don't think there's anything to file upstream.

wabt rejects it on the way in today:

(return (@metadata.code.branch_hint "\01") (i32.add (i32.const 1234) (local.get 0)))
error: unexpected token metadata.code.branch_hint, expected ).

so this needs a parser change as well as a writer one. In ParseExpr a folded plain instruction parses its operands with ParseExprList and pushes the operator last, so ParseExprList would need to accept an annotation and insert it immediately before the operator of the child that follows it — that is the last element of that child's expansion, which behaves the same way for block, loop and if children.

Happy to do it that way rather than land the de-folding version, so I'll rework this branch instead.

A code metadata annotation has arity {0, 0}, so the folded writer treated
it as an instruction. That wrapped it in its own parentheses, and because
it produces no results it also flushed the operands of the instruction it
was meant to decorate:

  ((@metadata.code.test "aa\01a"))
  (return
    (i32.add))

The result could not be parsed back, and i32.add had lost both operands.
Branch hints are code metadata too, so they broke the same way.

Annotations are now held until the instruction they decorate is pushed
and carried on its tree node, so they are written immediately before that
instruction's folded expression:

  (return
    (@metadata.code.test "aa\01a") (i32.add
      (i32.const 1234)
      (local.get 0)))

which is the form binaryen and wasm-tools both emit and round-trip.

The parser did not accept an annotation there, so it learns to. An
annotation decorates the operator of the expression that follows it, and
a folded expression expands to its operands before that operator, so the
annotation is spliced between the two. This has to happen both in an
operand list and at statement level, where block-like instructions such
as the if a branch hint decorates are written, so both paths share
AppendAnnotatedInstr.
@Nishuuzz
Nishuuzz force-pushed the fix/fold-code-metadata-annotations branch from d8b4269 to 9167864 Compare August 23, 2026 14:49
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.

3 participants