Tinverse logo Tinverse LLC

Part 2 of 14

Serialization and framing

A sample sitting in memory has a layout chosen by a compiler. The program reading it may have been built by a different compiler, for a different processor, in a different language. So the sample is written out in a form that both sides agree on, byte by byte, with the rules fixed by the specification rather than by either compiler.

Two things make this more than a mechanical translation. The reader is parsing bytes that arrived from the network, so every length it reads is a number chosen by somebody else. And the writer's version of the type may not be the reader's version, so fields can arrive reordered, missing, or unknown.

Layout

Alignment and padding

Runs when

any value is written to or read from an encoded body

How often

Every field of every sample, in both directions.

Each value is written at an offset that is a multiple of its size: a two-byte value at an even offset, an eight-byte value at a multiple of eight. Where the next value would land at the wrong offset, padding bytes are inserted until it does not. Both sides compute the same padding from the same rule, so the padding itself is never described on the wire.

The offsets are counted from the start of the encoded body, not from the start of the message. That is what lets a body be moved without re-encoding it.

Where the two versions differ

The first aligns every value to its own size, up to eight bytes. The second caps alignment at four, so an eight-byte value needs at most four bytes of padding rather than seven. The cap costs nothing in correctness and makes encoded bodies smaller.

char16, then float64 version 1 char16 6 bytes padding float64 16 bytes version 2 char16 2 pad float64 12 bytes — alignment capped at 4 0 4 8 12 16 offset counted from the start of the body
Figure 2.1 Padding is computed from the offset, never written down as a field. Capping alignment at four bytes shortens the body.

Byte order is not fixed. Each encoded body carries a small header saying which order it used, and the reader follows it rather than converting. One value is treated differently: the 16-byte floating-point type is held in one canonical order internally and reversed only when the stream is the other order, so both stream orders read back the identical bit pattern.

Scalars

Strings of wide characters

Runs when

a sample contains a wide-string field

How often

Once per such field per sample.

A wide string is written as a count followed by its characters. The count is in bytes, not characters, and each character is two bytes. There is no byte-order mark and no terminating zero.

An odd byte count

If the byte count is odd, it cannot be a whole number of two-byte characters, so the value is malformed no matter what follows it. The check is worth making explicitly rather than letting the division round: an odd count that is halved silently produces a string that ends half a character early, and the error surfaces somewhere else entirely.

Read-Wide-String(r)

r the reader — the input bytes, a cursor into them, and the configured length limit

  1. B = Read-32(r) // a byte count, chosen by the sender
  2. if B is odd
  3. return error
  4. if B > r.limit or B > Remaining(r)
  5. return error // refuse before reserving anything
  6. N = B / 2
  7. V = Borrow(r, B) // a view into the input, not a copy
  8. return View(V, N)

Line 7 hands back a view over the input bytes rather than a copy. Walking that view decodes two bytes at a time in the stream's byte order, and nothing is allocated. A caller that wants to keep the string past the lifetime of the input asks for an owned copy, and that one allocation is the only one on the path.

Cost

Fixed-size scalars are O(1). A wide string is O(N) in its characters. Reading a view allocates nothing; taking ownership costs one allocation and one copy.

Collections

Sequences and arrays

Runs when

a sample contains a collection field

How often

Once per collection per sample. The count check is the first thing an untrusted message meets.

A sequence carries its length on the wire because it varies. A fixed array does not, because both sides already know how many elements there are. The difference matters more than it first appears.

A sequence's length is a number sent by someone else. Reserving storage for it before checking it means a four-byte field decides how much memory this process allocates. A sender can claim four billion elements in a message that is twelve bytes long.

So the declared type carries a maximum, the storage for that maximum lives inside the value itself, and the count is checked against it before a single element is constructed.

incoming bytes count = 4 294 967 295 count = 3 count ≤ capacity? capacity = 8, fixed at compile time rejected nothing reserved 3 slots used of 8 — storage already inside the value
Figure 2.2 The count is admitted against a compile-time capacity before any element is built, so an announced length cannot drive allocation.

Read-Sequence(r, C)

r the reader · C the maximum element count the declared type allows

  1. n = Read-32(r)
  2. if n > C
  3. return error // before constructing any element
  4. S.length = 0
  5. for i = 1 to n
  6. v = Read-Element(r)
  7. if v == error
  8. return error // S is discarded, not returned partly filled
  9. S.slot[i] = v
  10. S.length = i
  11. return S

An array, where the risk is the other way round

An array has no count to check, so the risk is a half-built array escaping when an element fails to decode. Every position is filled with the type's default first, then replaced in order, and the array is returned only once every replacement has succeeded.

Read-Array(r, N)

r the reader · N the element count fixed by the declared type

  1. for i = 1 to N
  2. A[i] = Default()
  3. for i = 1 to N
  4. v = Read-Element(r)
  5. if v == error
  6. return error // A never leaves this procedure
  7. A[i] = v
  8. return A

Cost

O(K) element operations for K present elements, or O(N) for an array. Neither allocates: the storage is part of the value and was sized when the type was declared. Elements that own storage themselves, such as strings, still allocate their own.

Structures

Reading a structure by member number

Runs when

a sample of a type declared to allow added or reordered members is read

How often

Once per sample of such a type. Types not declared that way never take this path.

Two programs in the same system are not always built from the same version of a type. One may have gained a field. A structure can be declared to allow this: its members are written each with a small header giving the member's number and its length, so a reader can find the members it knows and step over the ones it does not.

A member the reader does not know

Stepping over an unknown member is not always safe. A field added to say that the coordinates are now relative rather than absolute cannot be ignored by a reader that does not know it — ignoring it means reading the numbers wrongly. So the writer marks each member with whether it may be skipped. A reader that meets an unknown member marked must-understand rejects the whole sample rather than guessing.

A member the reader declares and the writer never sent

That is not an error: an omitted member takes the type's default value.

members on the wire fields the reader declares id 2 · len 4 id 1 · len 8 id 9 · len 4 · skippable id 7 · len 2 · must understand order on the wire need not match unknown, skippable — step over 4 bytes unknown and must understand — reject the sample 1 · position 2 · sequence number 3 · never arrived takes the type's default value
Figure 2.3 Members are found by number rather than position. Unknown members are skipped only when the writer says they may be.

Read-Structure(r, scope, F)

r the reader · scope the byte extent this structure occupies · F the fields the reader's version of the type declares

  1. for each field f in F
  2. slot[f] = nil
  3. while Remaining(scope) > 0
  4. (id, len, must-understand) = Read-Member-Header(r)
  5. f = Find-Field(F, id)
  6. if f ≠ nil
  7. slot[f] = Read-Element(r, len)
  8. Seek(r, member end) // trust the declared length, not the decode
  9. else if must-understand
  10. return error // the writer said this cannot be ignored
  11. else
  12. Skip(r, len)
  13. for each field f in F
  14. if slot[f] == nil and f is not optional
  15. slot[f] = Default(f)
  16. return Build(slot)

Worked example

A writer built from version 2 of a type sends three members. A reader still on version 1 declares three fields, two of which the writer sent.

MemberIn the reader's versionWhat happens
2 · velocitydeclareddecoded into the velocity field, out of order but found by number
1 · positiondeclareddecoded into the position field
9 · battery, skippableunknownstepped over using its declared length
3 · timestampdeclared, absentnever sent; takes the type's default

The reader gets a usable sample from a writer a version ahead of it, without either side knowing about the other's schema.

Now suppose version 2 also changed position from absolute to relative and signalled it with member 7, marked must-understand. The version 1 reader has no field for member 7, so it rejects the whole sample rather than returning a position it would interpret as absolute. Losing the sample is the correct outcome: the alternative is a robot acting on coordinates that mean something else.

Line 8 is what makes the skipping reliable. After decoding a known member the cursor moves to where the member's declared length said it ends, rather than to wherever decoding happened to stop. If the writer's version of that member is longer than the reader's, the extra bytes are left behind and the next header is still found.

Line 16 builds the value once, at the end. Until then the decoded fields sit in slots that belong to nothing, so a rejection at line 10 cannot leave a half-populated structure anywhere a caller could see it.

Knowing a member's length before writing it

Each member's header states its length, which means the length has to be known before the member is written. Rather than serializing into a scratch buffer to measure it, the member is run twice: once through a sink that only counts bytes, then again into the real output. Both runs take the same value and make the same decision about whether an optional member is present, so the count matches what is written.

Cost

Finding the field for a member number is a scan of the declared fields, so decoding M members against F fields is O(M · F). Both are small and known when the type is declared, and the scan is generated rather than searched through a table built at run time — which is why no lookup structure is allocated per sample.

Framing

Destination and timestamp records

Runs when

a message containing one of these records is built or read

How often

A few times per message.

One network message carries several records. Some of them hold data; others say something about the records that follow. Two of those matter here: one names the participant the following records are meant for, and one gives the time the following samples were written.

Both are small and both have exactly one job, which makes their decoders short. The interesting part is what they refuse. The destination record must contain a 12-byte identifier and nothing else — a record declaring any other length is rejected rather than padded or truncated, and it is rejected before the destination is changed.

A record with an empty body

The timestamp record has two forms: one carrying a time, and one saying that there is no longer a time in force. The second has an empty body, and that is where an easy mistake lives. A record with an empty body still occupies its header. A reader that treats a length of zero as meaning "no record here" and moves on by nothing loops; one that assumes every record has a body swallows the record that follows it.

timestamp header + 8 bytes invalidate header, no body data advance 12 advance 4 next record starts here, still intact advancing by 0 would loop; assuming a body would eat the data record
Figure 2.4 A record with no body still advances traversal by its header.

Cost

Both records encode and decode in constant time with no allocation: the destination is a fixed 12-byte copy, the timestamp is at most two scalar loads. Walking a message parses one header per record and keeps only a borrowed slice of what remains.

Framing

Applying context in order

Runs when

any RTPS message is received

How often

Once per received message, before any of its records reach an endpoint.

Because those two records change how later records are read, the reader has to carry their values forward as it walks the message. The rule is that just before reading record i, the destination and timestamp in force are the most recent ones named among records 1 through i − 1.

The destination check has to happen early. A message can be addressed to a participant that is not this one, and the records inside it name endpoints. Looking up an endpoint, feeding a reliability state machine, or adding a fragment to a partly assembled sample are all changes to local state. So a record whose destination is not this participant is dropped before any of that, not after.

Dispatch-Message(p, msg)

p the local participant, whose identifier the destination is compared against · msg one received message and its records in wire order

  1. dest = all // no destination record seen yet
  2. time = nil
  3. for each record s in msg // wire order; do not stop at the first data record
  4. if s.kind == destination
  5. dest = s.prefix
  6. else if s.kind == timestamp
  7. if s.invalidating
  8. time = nil
  9. else
  10. time = s.value
  11. else if s.kind == padding
  12. continue // changes neither
  13. else
  14. if dest ≠ all and dest ≠ p.prefix
  15. continue // dropped before any local state is touched
  16. e = Resolve-Endpoint(msg.source, s.entity-id)
  17. Deliver(e, s, time)

The comment on line 3 is a real trap. It is tempting to return once a sample has been delivered, since that looks like the message's purpose. But records after it may carry their own destination and timestamp changes and their own samples, and stopping early discards them.

The timestamp arrives as a seconds field and a fractional field, where the fraction counts in units of 1/232 of a second. It is converted to whole nanoseconds with integer arithmetic:

To-Nanoseconds(seconds, fraction)

the two fields of a wire timestamp; fraction counts in units of 1/232 of a second

  1. return seconds · 109 + ⌊fraction · 109 / 232⌋

A sample split across records

For a sample split across several records, the timestamp is part of the metadata the pieces have to agree on. If a later piece arrives under a different timestamp, the assembly is rejected — and rejected before anything is written into the partly assembled sample, so a disagreement cannot leave a half-updated one behind.

Cost

O(k) for k records, with constant additional storage: the context is one optional identifier and one optional time. Each decision is a fixed-size comparison and at most a scalar copy. Nothing on this path allocates or copies a payload.

Next: reliability and fragmentation

Networks drop packets and refuse large messages. Every sample still has to arrive, complete and once.

Part 3 →

These descriptions follow the implementations in Tin DDS and Retina. Evaluation access and the full algorithm notes are available from engineering@tinverse.com.