Codecs and protocol glue generated from one plan
Status: under discussion · part of Shared runtime core · stage 3
The question: codecs are the largest per-language cost, and they work on each host's own typed values, so they can't sit behind the core boundary without converting every value on every call. How do ten or more languages get codecs that are as fast as the host allows and provably identical to each other, without ten hand-written codecs per wire format?
The answer proposed here: the shared codegen kernel computes one wire layout plan per type and format, and each language's generator renders it into native, specialized encode and decode functions over a small set of primitives. The per-protocol glue every generator re-derives today is planned the same way. Every option is held to the constraint: correctness, conformance and performance together, with nothing traded away.
Where things stand
No generator emits per-type encode or decode code today. Three different mechanisms do the byte work:
| Where | What does the bytes | Lines on main |
|---|---|---|
Rust runtime, runtime/core/src/format/packed.rs |
a serde Serializer and Deserializer for packed@1, no_std, no alloc |
1,015 (737 code, 278 tests) |
Rust runtime, format/json.rs, format/msgpack.rs |
thin wrappers over serde_json and rmp-serde, both std-gated |
56, 89 |
TypeScript runtime, runtime/src/packed.ts |
PackedCodec, a generic codec that walks a Layout at run time |
534 |
TypeScript runtime, runtime/src/json.ts, codec.ts |
an exact JSON parser to a JsonNode tree, then a walk driven by a Shape |
272, 36 |
Simulator, src/packed.rs |
the Rust runtime's Packed, driven from a run-time TypeDef over serde_json::Value |
791 (~520 code, the rest tests) |
Rust
packed@1is a serde data format. Its rules are in the module doc:- fixed-width little-endian numbers, and
boolas00or01; - strings and bytes as a varint length, then the bytes;
- sequences as a varint count; tuples and fixed arrays with no count;
- an option as
00, or01and the value; - struct fields in declaration order, with no names;
- an enum as its variant index as a varint.
- Decoding is strict. A varint must be in its shortest form, at most 10
bytes, with the tenth byte holding bit 63 alone. A length larger than the
bytes left is refused before anything is allocated. Bad
booland option tags and invalid UTF-8 are refused, and so is a value that doesn't use every byte (decode_seed). - Unknown enum variants are refused by serde's derive, not by the format:
Packedhands the index to the derived visitor, which rejects one out of range. - The generator emits serde derives (
comline-rust/codegen/src/generator.rs):#[derive(Serialize, Deserialize)]on structs, errors, C-like enums and params structs, withserde_renamefor wire names,#[serde(borrow)]on borrowed params, andserde_big_arrayfor fixed arrays longer than 32. Unions are refused before rendering, by the kernel'sfeatures::refuse_unsupportedand Rust'sSUPPORTtable.
TypeScript
- The generator emits a
Layoutconstant beside each type (<Name>Layout, fromfields_layoutandTypeMapper::layoutincomline-typescript/codegen/src/generator.rs). A named type is referenced lazily,() => MessageLayout, so types can refer to each other in any order. PackedCodecwalks thatLayoutfor every value. For each node it:- resolves thunks in a loop (
resolve); - switches on a string tag or probes
"array" in l,"struct" in land so on; - looks integer widths up in a table;
- for a union, scans members with
holds()to find the first that can hold the value by its JavaScript kind; - reads every varint, including every string length, as a
bigint; - builds each decoded struct with
Object.fromEntriesand afilter. - JSON goes through two passes.
parseJsonbuilds aJsonNodetree with aMapper object, thendecodeJsonwalks it against aShape.JsonCodecrebuilds thatShapefrom theLayoutwithjsonShape()on every decode. The exact parser exists becauseJSON.parserounds integers above 2^53. - JSON encoding doesn't see the schema.
stringifyJsonwrites the object's own keys. A field the object lacks is left out where Rust writesnull, and a property the schema doesn't declare goes onto the wire. - JSON decoding doesn't check widths or variants: comline-typescript#57 is
open. There is no MessagePack codec at all (
codec.ts: "a later addition").
The simulator
- It has no generated types. Values are JSON, so
src/packed.rswraps them in aTypedSerializeand aSeedDeserializeSeedthat follow the project'sTypeDefs, and hands both to the runtime's ownPacked. - Its bytes are the reference's by construction, because it calls the reference serializer. It's an interpreter of the schema, which suits a simulator and is not what an application should run.
The shared kernel and its precedent
comline-codegen(generation/codegen/src) is 3,737 lines in 12 files onmaster. The accepted resolution kernel put symbols, modules, naming and collisions there.validation.rs(467 lines) is the "plan once, render per language" precedent. It follows alias chains across schemas, binds every validator property to a value, and returns a language-neutralPlan { base, checks, elements, nested }.comline-rust/codegen/src/validate.rs(383 lines) lowers it into animpl Validateper type and the shadow typevalidate_on_decodeneeds. TypeScript hasn't lowered it yet.connections.rs(443 lines) already centralizes part of the glue: framing resolution (framing, protocol@framingover the package'swire.framingoverdatagram@2),on_connection, JSON-RPC method names, protocol numbers and the connection IR hash.
The glue each generator still re-derives
Each generator's protocol() rebuilds the same per-protocol decisions from
frozen IR:
| Rust | TypeScript | |
|---|---|---|
| File | generator.rs, 2,184 lines (tests from line 2,075) |
generator.rs, 1,635 lines |
| Protocol glue | protocol() from line 1,097, plus async_dispatcher, async_sender, dispatch_arms and fn_info: about 800 lines |
protocol(), lines 1,032 to about 1,395 |
Its own FnInfo |
yes | yes |
UnknownError{o} fallback for an ordinal with no error type |
in fn_info |
inline in protocol() |
| Undecodable params: one-way drops, request/response faults | dispatch_arms |
the try/catch in the dispatcher |
Client outcome: ok, fault, err by ordinal, Remote{id} |
Envelope match |
"ok" in env, "fault" in env, switch (env.err.id) |
| JSON-RPC carries JSON whatever the package format is | format_of |
default_codec |
| Framing name to runtime type | framing_for |
framing_for |
The copies already differ: Rust's dispatcher runs param checks when
validate_on_decode asks for it, and TypeScript's has no such step.
The proposal
1. A wire layout plan in the kernel
A new comline-codegen module, layout.rs, computes a LayoutPlan for a
generation request and a wire format. The plan is a small tree per named
type, with every decision a codec needs already made:
- field order: declaration order, with wire names;
- fixed widths and endianness: each scalar's width, little-endian;
- varint lengths and enum indexes: where a varint goes, and which ones are counts that must not exceed the bytes left;
- option presence: a presence byte in
packed@1,null-or-missing in JSON,nilin MessagePack; - union tags: the member index;
- fixed arrays vs sequences:
T[N]has no count,T[]has one; - aliases resolved: a
typealias is its target on the wire, followed across schemas the wayvalidation.rsfollows it; - strictness for decode: refuse overlong or oversized varints,
booland presence bytes other than00and01, union tags and enum indexes out of range, unknown variants, integers out of range for their type, invalid UTF-8, lengths past the end, and trailing bytes.
A sketch:
// generation/codegen/src/layout.rs
/// The wire formats a plan can be computed for.
pub enum Format { Packed1, Json1, MsgPack1 }
/// Every type a request's bodies reach, for one format.
pub struct LayoutPlan {
pub format: Format,
pub rules: Rules,
/// One entry per named type, alias chains already followed, so an
/// alias never appears here.
pub types: Vec<TypePlan>,
/// Each function's params, return value and error bodies, by `TypeId`.
pub bodies: Vec<BodyPlan>,
}
pub struct TypePlan {
pub id: TypeId,
/// Namespace and declared name, from the symbol table.
pub origin: Origin,
pub node: Node,
/// Bytes every value of this type takes, when that is fixed
/// (no strings, sequences, options or unions anywhere below).
pub fixed_size: Option<u32>,
}
pub enum Node {
Unit,
Scalar(Scalar),
Str,
/// A varint count, then the elements. `u8` elements are one copy.
Seq(Box<Node>),
/// Exactly `len` elements, no count.
Fixed { element: Box<Node>, len: u32 },
/// Presence, then the value.
Option(Box<Node>),
Struct { fields: Vec<Field> },
/// Variants in declaration order, with their wire names.
Enum { variants: Vec<String> },
/// The member index, then the member.
Union { members: Vec<Node> },
/// A named struct, error or enum: a call to its own function, which is
/// what lets types be recursive.
Ref(TypeId),
}
pub enum Scalar { Bool, U8, U16, U32, U64, U128, S8, S16, S32, S64, S128, F32, F64 }
pub struct Field { pub wire_name: String, pub node: Node }
/// What the format decides, as the conformance profile records it.
pub struct Rules {
pub fields: ByPosition | ByName,
pub variants: ByIndex | ByName,
pub integers: FixedLe | JsonDigits | MsgPackByValue,
pub absent: PresenceByte | WriteNullAcceptMissing | Nil,
pub unknown_field: Reject | Skip,
pub trailing_bytes: Reject,
pub varints: ShortestLeb128Max10,
}
(Rules is written loosely; each field would be its own small enum.)
- A tree, not a flat op list. A tree keeps recursion (
Ref) and nesting explicit, which every renderer needs to produce one function per named type. The renderer walks it once at generate time, so the tree costs nothing at run time. A backend that wants a flat op list, a C backend say, can flatten it locally. Rulesare the conformance profile's facts.profiles/packed@1.json,json@1.jsonandmsgpack@1.jsonalready recordstruct_fields,unknown_field,missing_optional,enum_variants,unknown_variantandinteger_width, each proven by named vectors. A kernel test checks thatRulesfor each format agrees with its profile, so the plan can't drift from what the corpus proves.fixed_sizelets a renderer check bounds once for a run of fixed-width fields, instead of once per field.
2. Each language renders native code from the plan
A backend turns each TypePlan into an encode and a decode function in its
own language: straight-line code specialized to that type, with no reflection,
no schema walk and no Layout object at run time.
For struct Player { id: u64, name: str, class: Class, pos: f32[3], guild?: u32 }
under packed@1, the TypeScript backend would render something like:
export function encodePlayer(w: Writer, v: Player): void {
w.u64(v.id);
w.str(v.name);
w.varint(encodeClass(v.class));
const pos = v.pos;
if (pos.length !== 3) refuse();
w.f32(pos[0]!); w.f32(pos[1]!); w.f32(pos[2]!);
if (v.guild === undefined) w.u8(0);
else { w.u8(1); w.u32(v.guild); }
}
export function decodePlayer(r: Reader): Player {
const id = r.u64();
const name = r.str();
const class_ = decodeClass(r.index(CLASS_VARIANTS));
r.need(12);
const pos: [number, number, number] = [r.f32Unchecked(), r.f32Unchecked(), r.f32Unchecked()];
const guild = r.presence() ? r.u32() : undefined;
return guild === undefined ? { id, name, class: class_, pos } : { id, name, class: class_, pos, guild };
}
- Host-width checks are rendered too. Where the host type is wider than
the wire type (a JavaScript
numberfor au8, a Pythonintfor au32), the encoder refuses a value out of range, asPackedCodecdoes today. The plan gives the wire type; the backend knows its own host type. - Unions still need a host rule. The plan fixes the wire tag. How a host value picks its member is the backend's mapping, and it must be unambiguous (see Open questions).
- Validation can join decode. The same backend that lowers
validation::Plancan run a field's checks right after decoding it, which is whatvalidate_on_decodeasks for.
3. A small primitive set per language
Each language's runtime keeps only the primitives the rendered code calls.
For the binary formats (packed@1, and the integer and length encodings
MessagePack needs) the set is:
| Writer | Reader |
|---|---|
u8, u16, u32, u64, u128, little-endian; signed as two's complement |
u8 … u128, little-endian, each bounds-checked |
f32, f64, the bits as given, no NaN canonicalization |
f32, f64, the bits as given |
varint(u64), shortest LEB128 |
varint(): at most 10 bytes, tenth byte ≤ 01, no trailing 00 byte |
bytes(raw), no length |
len(): a varint no larger than the bytes left |
str(s): varint byte length, then UTF-8 |
str(): len(), then strict UTF-8 |
reserve(n), for a fixed-size run |
need(n) and unchecked reads after it, for a fixed-size run |
bool() and presence(): 00 or 01, else refuse |
|
index(n): a varint below n, else refuse |
|
finish(): refuse unless every byte was used |
- One error kind, as Rust's
RuntimeError::Serialization, with a reason kept for tests. - About 150 lines in most languages. TypeScript's
WriterandReaderclasses inpacked.tsare this set already, at about 200 lines with comments. What goes is theLayoutwalk around them. - The set is where hosts differ, so it's where stage 1's differential fuzzing aims hardest: varint edge cases, 128-bit values, NaN bit patterns, invalid UTF-8.
4. JSON and MessagePack
JSON is self-describing, and each host has its own parsers. The plan still decides everything that isn't syntax:
- Field names are the plan's wire names. A rendered encoder writes each
"name":prefix as a constant. A rendered decoder matches keys with a generatedswitch, which is what makesunknown_field: ignoredcheap. - 64-bit and 128-bit integers.
json@1writes them as bare numbers with all their digits: the vectorsformat-json/u64-above-2-53andu64-maxpin it. Writing them as strings would be a different wire format, ajson@2, not something a backend may choose. A decoder must parse the digits exactly into the field's width and refuse a value out of range (decode-wider-integer-overflow). That's whyjson.tsexists:JSON.parserounds above 2^53. - Absent optionals.
json@1writesnull(struct-optional-none) and accepts eithernullor a missing member (missing_optional: absent). Optional-field omission was one of the five TypeScript interop bugs, andstringifyJsonstill omits a member the host object lacks. A rendered encoder writes exactly the plan's fields, so the bytes no longer depend on how the host object was built. - Equality is RFC 8259 equality. Vectors compare JSON by value
(
"compare": "json"), numbers exactly, member order ignored. That stays the rule for vectors and for fuzzing;jsonEqualinjson.tsis TypeScript's copy of it. - The JSON primitive set must guarantee:
- string escaping and unescaping per RFC 8259, with lone surrogates refused;
- exact integer parsing into a given width, with a range check;
- float writing that reads back to the same bits, and correctly rounded float parsing;
- skipping one well-formed value, for an ignored member;
- refusing trailing text after the value;
- one pull tokenizer, so a rendered decoder reads straight into the host type with no intermediate tree.
- A host's own JSON parser may be used only where it meets that list, and
where stage 1's fuzzing proves it equal to the reference. Python's
jsonkeeps integers exact; JavaScript'sJSON.parsedoes not, so TypeScript keeps an exact tokenizer. Which is faster on a given host is for the benchmark gates to say.
MessagePack is the same shape with different rules. Per its profile and
rmp-serde's behaviour, structs are positional arrays, enum variants go by
name, and integers are read by value, not by declared width. TypeScript has no
MessagePack codec today, so rendering one from the plan is how it gets one.
5. Rust: keep serde, and prove the plan against it
Two choices for the Rust backend:
| Keep serde derives | Render Rust from the plan | |
|---|---|---|
| Privileged path | yes: Rust's bytes don't come from the plan | no: Rust is one more consumer |
| An independent check of the plan | yes: serde and the plan are two separate derivations of the rules | no: a plan bug would appear identically in Rust and every other language, and differential fuzzing against Rust could no longer see it |
| Speed | already fast; monomorphized and no_std |
possibly faster in places (bulk u8 copies, one bounds check per fixed run, no &mut dyn BufMut call per byte); unmeasured |
| Zero-copy decode | &'a str borrowed from the input today |
would have to be rendered again |
| Other users | Packed::decode_seed drives the simulator |
none |
Recommendation: keep serde derives as Rust's production path, and as the reference. The deciding reason is the constraint itself. The whole stage 1 design rests on fuzzing every language against an independent reference. If Rust also rendered from the plan, the plan would have no independent check, and a mistake in it would be "conformant" everywhere at once.
The plan still gets proven in Rust first:
- A CI-only Rust renderer. The kernel's tests render the plan to Rust and
run differential fuzzing between the plan-rendered code
and serde with
Packed,JsonandMsgPack, over the conformance types and generated random ones. Random values must encode to identical bytes, and the same malformed inputs must be refused by both. - This proves the plan before any other language renders it, in the language where both sides are cheapest to run. A failure there is a plan bug, not a backend bug.
- Switching Rust's production path is a measurement, not a preference. If the gates show plan-rendered Rust clearly faster than serde, it can become the production path, and serde stays as the reference in tests. Until then nothing changes for Rust users.
6. Why this is the fast option
- Specialized code beats a generic walker. Rendered code does none of the
work
PackedCodecdoes per value: no thunk resolution, no string switches orinprobes, no table lookups, noholds()scans, nobigintvarints for lengths that fit anumber, noObject.fromEntries. The JSON path loses theJsonNodetree and the per-decodejsonShape(). - Native code beats a boundary crossing. A codec in the core would first convert the host object into something the core can read, which is a full walk of the value plus a copy, and then encode it: strictly more work than one native walk that writes bytes. See Alternatives rejected.
- No number is claimed here. There is no benchmark anywhere in the org
today. The benchmark gates must prove, per language and
format, over a corpus of type shapes (flat scalars, strings, deep nesting,
large
u8sequences, unions): - rendered code is at least as fast as the codec it replaces
(
PackedCodec,JsonCodec) before that codec is deleted; - each language meets its performance budget afterwards;
- generated code size, for browser bundles, where a generic walker is smaller. Rendering one function per named type, and loops instead of unrolling past a small fixed length, keeps that bounded.
Protocol glue, planned once
The per-protocol decisions in the glue table
are facts about the schema, not about a language. Following the accepted
resolution kernel direction,
and next to connections.rs, which already holds part of it, the kernel
computes a ProtocolPlan that backends render:
// generation/codegen/src/protocol.rs
pub struct ProtocolPlan {
pub origin: Origin,
pub provider: Provider, // from connections.rs
pub framing: FramingValue, // resolved by connections::framing
pub on_connection: bool,
pub number: Option<u16>, // connections::protocol_number
pub handshake: Handshake, // comline@1 or none, and @unverified
/// JSON-RPC carries JSON whatever the package's format is.
pub body_format: BodyFormat, // Package | Json
/// The call table, in schema order: index = datagram call id.
pub calls: Vec<CallPlan>,
/// Every `!` across the protocol, by ordinal, deduplicated.
pub errors: Vec<ErrorRef>,
}
pub struct CallPlan {
pub index: u16,
pub name: String,
pub method: String, // bare or qualified, from connections.rs
pub params: Option<BodyId>, // into the LayoutPlan
pub ret: Return, // OneWay | Unit | Value(BodyId)
pub errors: Vec<ErrorRef>, // empty for a one-way call
pub timeout_ms: Option<u64>,
pub validate_params: bool,
}
pub enum ErrorRef {
Known { ordinal: u16, body: BodyId },
/// No error type found for the ordinal: today's `UnknownError{o}`.
Unknown { ordinal: u16 },
}
The policies become rules in one place instead of code in each generator:
- Server side.
- Params that don't decode, or fail their checks: a one-way call stops with no reply; a request/response call answers with a fault.
- The handler returns a value: reply
okwith it encoded. - It returns a declared error: reply
errwith that ordinal and the error encoded. - Anything else: a fault. A one-way call never replies.
- Client side.
okdecodes the return value;faultis a provider fault;errwith a declared ordinal decodes that error; any other ordinal isRemote { id }. - Body format. JSON for a JSON-RPC protocol, the package's format otherwise.
A backend renders those rules in its own idiom: Result and Fault<E> in
Rust, throw and error classes in TypeScript, exceptions or result types
elsewhere. The plan says what happens, and the backend decides only how it's
spelled. That retires both FnInfos, both UnknownError{o} fallbacks, and
the format_of / default_codec and framing_for pairs, and closes the
validate_params gap between the two dispatchers.
What a new language then costs
| Today | With stages 2 and 3 | |
|---|---|---|
| Runtime | about 2,350 lines in TypeScript: codecs, framings, handshake, connection, envelopes | the primitive set (about 150 lines binary, plus a JSON set) and a thin binding to the core (core interface) |
| Generator | 1,635 lines in TypeScript, about 2,075 non-test in Rust | type mapping, naming, manifests, plus a renderer for LayoutPlan and ProtocolPlan |
| Proof | the vendored vectors | vectors, differential fuzzing and benchmark gates (verification) |
The renderer's size is unknown until a second backend renders the plan; it should be measured on TypeScript in the rollout below, not estimated here. What is certain is what it no longer contains: no format rules, no strictness rules, no fault policy and no error mapping, which are the parts that drifted.
Alternatives rejected
- A schema-driven codec in the core. Every call would convert host values into a form the core reads, then encode: two walks and a copy where native code does one walk. That fails performance in every language. It also needs the schema at run time in every process. The simulator does exactly this, correctly, because it has no generated types. It's a fine tool there and the wrong main path.
- A generic codec per language walking a schema at run time. This is TypeScript today. It's correct where it's been tested, but it is slower than specialized code by construction: it decides at run time, for every value, what the generator already knew. It also still needs a hand-written codec per format per language. It fails performance, and it doesn't solve the cost.
- Hand-written codecs per language, the status quo. Ten languages times three formats is thirty codecs whose agreement rests on vectors someone wrote down. Five interop bugs on the corpus's first day, and #57 still open, show what that costs in correctness and conformance.
Open questions
- Unions on the host. The plan fixes the wire tag. TypeScript picks the
first member that can hold a value by its JavaScript kind, so in
u8 | u16the value5always goes tou8. Rust refuses unions; the simulator takes the index. Should the kernel refuse unions whose members a host can't tell apart, or should generated hosts carry an explicit tag? - Duplicate JSON keys. serde's derive refuses a repeated field;
TypeScript's
parseJsonkeeps the last one. No vector covers it. - Non-finite floats in JSON. JSON has no NaN or infinity. What
json@1does with one should be written down and vectored, not left to each host's writer. - Third-party encodings. Can an encoding defined the
extensibility way declare its rules for the plan,
instead of shipping a codec per language?
Ruleswould then have to be data, not a closed set of enums. - The plan's form. It's an in-process Rust value while generators are Rust crates. If generators ever run out of process (Proposal D), the plan needs a stable serialized form.
- Validation inside decode. Should
LayoutPlanandvalidation::Planmerge, so a rendered decoder checks a field as it reads it? - Code size budgets for browser targets, and where the gates set them.
Rollout
Every step is gated by stage 1. Nothing is deleted until the gates show its replacement is equal and at least as fast.
- The plan in the kernel.
layout.rswithLayoutPlanandRules, a test thatRulesmatches each conformance profile, and the CI-only Rust renderer fuzzed against serde. packed@1first. It's the default format, it has the strictest rules, and TypeScript'sPackedCodecis the clearest generic walker to beat.- The TypeScript backend renders encode and decode functions per type.
PackedCodecand theLayoutconstants stay until the gates pass, then go.- JSON, then MessagePack.
- TypeScript's JSON codec moves to rendered code with an exact tokenizer.
parseJsonstays for the JSON-RPC framing until stage 4 moves framing to the core, andjsonEqualstays as the test comparator.- TypeScript gains MessagePack.
- The glue.
protocol.rswithProtocolPlan; both generators render it, and the duplicatedFnInfo, fallbacks and policies go.
What it touches
- generation (
comline-codegen): newlayout.rsandprotocol.rs, built onsymbols.rsandconnections.rs; profile-agreement tests; the CI-only Rust renderer. - comline-rust: keeps serde derives; renders
ProtocolPlanin place of its ownFnInfo,dispatch_armspolicy and client mapping. - comline-typescript: a plan renderer in the generator. The runtime keeps
Writer,Readerand a JSON tokenizer, and losesPackedCodec,JsonCodec'sShapewalk,jsonShapeand the generatedLayoutconstants once the gates pass. - runtime: the conformance profiles become an input the kernel is tested
against.
format::Packed,JsonandMsgPackstay as the reference. - simulator: unchanged. It keeps driving the reference serializer from its schema.
- Docs: the TypeScript and Rust language guides' format sections, and How generators are built.