Skip to content

Decode payloads against a schema - #7

Merged
mgravell merged 4 commits into
mainfrom
schema-aware-decode
Aug 24, 2026
Merged

mgravell merged 4 commits into
mainfrom
schema-aware-decode

Conversation

@mgravell

@mgravell mgravell commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

The site has had both halves of this since it was written: a parser that turns .proto into a descriptor, and a walker that turns bytes into a tree. They had never met. The decode view now takes an optional schema, works out which message the payload is, and uses it.

With one, every field carries its name and declared type, sub-messages are decomposed as the type they actually are, enum values are named, packed repeated fields are read as their element type, and map entries come through as key and value. No more ranking a string reading against a sub-message reading and hoping.

#1 name   : string                  = Ada Lovelace
#2 id     : int32                   = 1815
#4 phones : repeated Person.PhoneNumber
   #1 number : string               = 555-4816
   #2 type   : Person.PhoneType     = PHONE_TYPE_MOBILE = 1
#9 (not in the schema)              = abc

Without one, nothing changes: the panel is shut by default and the existing heuristics are untouched.

Working out which message it is

Nothing in a payload names its type, so the tool reads it once as every message the schema declares and compares the results. The scoring rule is the part that is easy to get wrong:

Only agreement counts. A message scores on how many fields it recognises, and fields it has never heard of are not held against it. Unknown fields are the normal condition of protobuf — they are what a service that has moved on from the schema you have to hand sends, and they are meant to survive a round trip untouched. Scoring on the share of fields recognised would punish the right message for exactly that, and would invert on nesting: a message that decomposes a sub-message which is itself newer than the schema would rank below one that writes the same bytes off as opaque bytes.

Contradictions are close to disqualifying. A schema declaring field 3 a string cannot be what wrote a varint there — reusing a field number with an incompatible type is the one thing the compatibility rules forbid, which is what reserved exists to prevent. But a message that contradicts nothing because it declares nothing the payload contains is the worst answer available, not the safest, so recognising something and disagreeing about a corner still beats recognising nothing.

Ties go to declaration order, which is why the candidate list is now in declaration order with your own file ahead of its imports rather than alphabetised — a .proto's first message is usually its point. A tie settled that way is then said out loud: ⚠ in the panel header, a warning above the tree naming what else fits, picker one click away.

Everything inferred is labelled rootGuessed all the way to the screen. An inferred type that happens to be wrong is the most misleading thing this view can produce, so it never reads like a choice someone made — and only an explicit selection pins it, so editing the schema is free to arrive at a different answer.

Ranking is skipped above 48 messages or 128 KB, where it costs more than the guess is worth; the note says that happened rather than implying anything was measured.

The rest of the shape

WireWalker still knows nothing about .proto text and the parser still knows nothing about payloads; Decoder.cs is the only file that knows both. SchemaIndex flattens the descriptor into the two lookups a walk needs — field number to declared type, enum number to name — and SchemaSource remembers the last schema it parsed, so typing in the payload box does not reparse the schema beside it.

On the WASM boundary, Decode takes JSON alongside the bytes rather than a bool (omitting the root type is what asks for inference), and a new SchemaTypes lists a schema's messages for the picker. Both go through that same remembered parse.

Tests

The walker had none, which was tolerable while it only ever guessed and is not now that it will be trusted. src/ProtoGen.Wasm.Tests covers naming, enums, maps, groups, extensions, packed and unpacked repeated fields, every way the bytes and the schema can disagree, and the ranking rules above — including the skew case that the share-of-fields scoring got backwards, and the vacuous-non-contradiction ordering, which took a failing test to notice. It compiles the engine sources in rather than referencing the project, because a browser-targeted assembly full of [JSExport] will not load in a test host, and it hand-writes its payloads, because a serializer would refuse to produce most of what needs covering. dotnet test runs as its own CI step, before the build that produces web/dist.

Verified end to end by hosting the published runtime under Node and calling the exports directly, since the interesting failure mode here is the JS boundary rather than the C#.

The site has had both halves of this since it was written: a parser that turns
.proto into a descriptor, and a walker that turns bytes into a tree. They had
never met. The walker guessed - a length-delimited field could be a string, a
sub-message or packed scalars, and the wire format has nothing to say about
which - so it ranked the readings and offered the alternatives. That is the
best answer available without a schema, and a poor one next to the schema the
user usually has sitting in the other tab.

Decode now takes an optional schema and the message the payload is an instance
of. With them, every field carries its name and declared type, sub-messages are
decomposed as the type they actually are, enum values are named, packed
repeated fields are read as their element type, and map entries come through as
key and value. The guessing is gone rather than merely better-informed.

Three decisions are worth recording:

The root type is asked for, never guessed. Nothing in a payload says which
message it is, and decoding as the wrong one produces confident nonsense rather
than an error. The engine reports why it did not apply a schema instead of
picking one.

Where the bytes and the schema disagree, the bytes win and the disagreement is
reported: a wire type that does not match the declared one, a string field
holding invalid UTF-8, a sub-message that will not parse. Rendering a mismatch
as the declared type would hide the exact bug someone opened this tool to find,
so the field is flagged and then read as if there were no schema at all. Fields
the schema does not declare are marked and counted for the same reason - a
payload from a service that has moved on is a thing worth seeing.

A schema that fails to parse still helps. Its diagnostics travel with the
decode and the fields it did understand are still named.

The walker itself knows nothing about .proto text and the parser knows nothing
about payloads; Decoder.cs is the only file that knows about both. SchemaIndex
flattens the descriptor into the two lookups the walk actually needs, and
SchemaSource remembers the last schema parsed, so typing in the payload box does
not reparse the schema beside it.

On the boundary, Decode takes JSON alongside the bytes rather than a bool, and
SchemaTypes lists the messages a schema declares for the picker to offer. Both
go through the same remembered parse.

The walker had no tests, which was tolerable while it only ever guessed and is
not now that it will be trusted. ProtoGen.Wasm.Tests compiles the engine sources
in rather than referencing the project, because a browser-targeted assembly full
of [JSExport] will not load in a test host, and hand-writes its payloads,
because most of what needs covering is what happens when the bytes and the
schema disagree - which a serializer would refuse to produce.
The decode view gains a collapsible schema panel: a .proto editor, and a picker
for the message the payload is an instance of, filled from what that schema
declares. Both are optional and the panel is shut by default, so pasting bytes
and reading the tree works exactly as it did.

The picker chooses for the user only when the schema declares exactly one
message. Anything else is a guess, and the wrong guess decodes to something that
looks right, so it asks. The state of that choice is summarised in the panel's
own header - the message being decoded against, or that none has been chosen,
plus a count of fields the schema does not declare - because the panel spends
most of its life shut and that is the one thing worth seeing from outside it.

Rows in the tree lead with the field's name and declared type when there is one,
and the readings behind them shrink to the one the schema asked for. The type
tag that used to follow every value goes away in that case, since the row now
says it once. Fields the schema does not declare, fields that are not what it
says they are, oneof membership and extensions are all marked; the full
explanation of a mismatch sits in the body, where there is room for it.

Two examples come with the feature, because a payload demonstrates nothing on
its own and neither does a schema. One is a Person with nested messages, an enum
and a map; the other is a payload carrying two fields its schema has never heard
of, which is the version-skew case this is most useful for.

The button beside the picker copies whatever the Schema -> code tab is holding,
which is where the schema usually already is.
The three decisions that will not be obvious from the code six months from now:
the root type is asked for rather than guessed, the bytes win over the schema
where they disagree, and a schema that fails to parse still names the fields it
understood.

Also records why the tests compile the engine sources rather than referencing
the project, and why they run as their own CI step instead of joining the build
that produces web/dist.
Requiring the root type up front was defensible - nothing in a payload names its
type - but it made the common case worse for no benefit. Most schemas pasted
here have one obvious answer, and the tool was in a better position to find it
than the user was: it can read the payload as every message the schema declares
and see which one the bytes agree with.

So it does, whenever nobody has said. The result is labelled a guess everywhere
it surfaces - rootGuessed on the wire, "guessed" in the panel header, a note
above the tree saying how it was arrived at - because an inferred type that
happens to be wrong is the most misleading thing this view can produce, and it
must never read like a choice someone made.

The scoring rule matters more than the mechanism.

Only agreement counts. A message scores on how many fields it recognises, and
fields it has never heard of are not held against it. Unknown fields are the
normal condition of protobuf rather than a symptom of the wrong message; they
are what a service that has moved on sends, and they are meant to survive a
round trip untouched. Ranking on the share of fields recognised would punish the
right message for exactly that, and would invert on nesting: a message that
decomposes a sub-message which is itself newer than the schema would score below
one that writes the same bytes off as opaque bytes, which is backwards.

Contradictions are the other half. A schema that declares field 3 a string
cannot be the one that wrote a varint there - reusing a field number with an
incompatible type is the one thing the compatibility rules forbid outright.
Those lose to any message that fits cleanly. They do not lose to a message that
contradicts nothing only because it declares nothing the payload contains, which
is the worst answer available rather than the safest; that ordering took a
failing test to notice.

Ties go to declaration order, which is why RootCandidates is now in declaration
order with the user's own file ahead of its imports rather than alphabetised - a
.proto's first message is usually its point, and its imports never are. A tie
settled that way is then said out loud: the header carries a warning sign, the
note names what else fits, and the picker stays one click away.

The picker now shows what was inferred without recording it as a choice. Only an
explicit selection sets a root type on the request, and sending none is what
asks for inference, so an edit to the schema is free to arrive at a different
answer instead of being pinned to the last guess.
@mgravell
mgravell merged commit ffd4ce8 into main Aug 24, 2026
2 checks passed
mgravell added a commit that referenced this pull request Aug 24, 2026
* Cache-bust the one runtime file the SDK does not hash

Merging #7 left the site stuck on the loading overlay for anyone who had visited
it before. The deploy was fine and a fresh browser loaded it without complaint;
the fault was four hours old and sitting in everyone else's cache.

Every file the .NET runtime loads is content-hashed by the SDK except dotnet.js
itself, and dotnet.js is the file that names all the hashed ones. Pages serves
it with max-age=14400 and has no way to say otherwise for one path, so a
returning visitor booted a four-hour-old dotnet.js, asked for an assembly hash
that deploy had replaced, took a 404 partway through boot and never got the
overlay removed.

It only bites when the .NET side changes, which is why it hid through three
web-only deploys and surfaced on the first one to touch C#. It has been latent
since the site was written.

vite.config.ts now hashes dotnet.js into __FRAMEWORK_ID__ and wasm.ts loads it
as dotnet.js?v=<id>. Hashing that particular file is the exact invalidation key:
the hashed names are embedded in it, so the id changes when, and only when,
something it loads does. The query is a cache key rather than a parameter -
everything dotnet.js goes on to fetch resolves relative to itself, without the
query, and is content-hashed already. Verified booting three times each under
vite preview, a plain static server and the dev server, since a plain static
server is what Pages is.

That needed @types/node, because the config now reads a file.

Separately: dotnet publish copies into its output without clearing it first, so
every rebuild that changed the engine left the previous build's hashed files
beside the current ones, and they shipped. Three copies of ProtoGen.Wasm had
accumulated locally. Harmless bytes rather than a fault - the boot manifest names
exactly one of them - but it grows without limit and makes the output a poor
guide to what is actually loaded. build-wasm.mjs clears the publish directory
first now.

* Say something when the engine takes too long

The overlay had one thing to say and said it regardless: still loading, failed,
and never coming all looked identical from the outside. The middle one is already
handled - main.ts has always caught a rejected boot - but the third is not, and
the third is what shipped last week. A missing runtime file does not reject: the
loader waits for something that will never arrive, so no catch anywhere runs.

Confirmed by serving a build with the main assembly deleted. The page produced no
error at all and sat on the spinner until the browser gave up.

So after ten seconds a line is added under the spinner - added, not substituted,
because the spinner may still be telling the truth. It says the engine is large
and compiles on the first visit, and that if it never finishes this browser may
be holding an out-of-date copy, with a link that fetches a fresh one.

The link carries a cache-busting query rather than calling reload(), which is
free to serve back the same cached files that caused the problem; the argument
that used to force otherwise has not done anything for years. A fresh index.html
is enough on its own, because it names the current bundle, which asks for the
current runtime.

Deliberately not a diagnosis. From inside the page a slow connection and a loader
waiting on a file that will never arrive are indistinguishable, and telling them
apart would mean interposing on fetch and deciding which failed requests matter -
for a message that is the same either way. If it is only a slow connection,
nothing has been taken off the screen and the load carries on.

Verified in headless Chrome against a healthy build and one with the main
assembly removed: the first boots without ever showing the line, the second shows
it.
@mgravell
mgravell deleted the schema-aware-decode branch August 25, 2026 09:15
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