The wire protocol is nanopb-based. Vendored nanopb runtime and generated C in
Sources/CLiveKitProto, a fixed-cost Swift runtime in Sources/LiveKitNanopb,
generated immutable facades in Sources/LiveKit/Protos, and the generator in
scripts/generate-swift-protos.swift — run it with make proto. SwiftProtobuf
is linked only by the test target, as an independent "oracle" implementation to
verify against.
The schema contributes no Swift types: every message is the one generic
NanopbMsg<Storage>, and Livekit_Room is a typealias for
NanopbMsg<livekit_Room>. Only field accessors are generated, in extensions
written through that typealias (extension Livekit_Room,
extension Livekit_Room.Builder). That is the shape that costs least — a
nominal type's metadata and conformance records are emitted into sections the
runtime must enumerate, so the linker keeps them whether or not anything
references the type.
-
Building messages:
Livekit_X.with { $0.field = ... }; messages have no setters, somsg.field = xdoes not compile. To derive one message from another usemsg.modifying { ... }, and mark the parameterconsumingwhere you can so it mutates in place instead of copying. Nested writes need an explicit submessage:$0.a = .with { $0.b = c }. -
Patching a field onto already-encoded bytes: protobuf defines concatenation as merge, and a singular scalar takes the last occurrence, so appending an encoded message that carries only that field overwrites it without touching the rest.
DataChannelPair.makeRequeststamps the reliable sequence this way rather than deriving a new packet withmodifying, which would re-encode the whole payload to write four bytes. Safe on the receiving side too: for a duplicate singular field nanopb reuses the allocation (allocate_field→pb_realloc) and releases first for submessages, so there is nothing to leak. Pinned byNanopbRuntimeTests.concatenationMerge, which asserts the merge through the SwiftProtobuf oracle as well as the facade — the oracle stands in for every other SDK's parser, and agreeing with ourselves would prove nothing. Note this cannot distinguish "unset" from "explicitly zero" (presence is the pointer), so a field patched this way can't meaningfully carry a deliberate zero. -
Nested type names are flat:
Livekit_DataPacket_Kind, notLivekit_DataPacket.Kind. Every message is the same generic type, and constrained extensions of one generic cannot each declare a member of the same name. Parents that only ever were namespaces (Livekit_DataStream,Livekit_Encryption) keep the dotted spelling through a caseless enum. -
Enums are open: a proto enum is a
RawRepresentablestruct with static members, not a Swiftenum, because proto3 enums are open — a peer can send a value this build has never heard of.case .reliablestill pattern-matches, but aswitchneeds adefault, and an unknown value isLivekit_TrackType(rawValue: 10)rather than a distinctUNRECOGNIZEDcase. -
Updating protos: bump the
protocolsubmodule, runmake proto, commit everything it changes (C files, facades, test oracle, conformance exemplars). The check-protocol CI job fails if a regeneration is missing. -
Never edit generated code:
Sources/CLiveKitProto/*.pb.*,Sources/LiveKit/Protos/,Tests/LiveKitNanopbTests/{Oracle,Generated}/are allmake protooutput (generator:scripts/generate-swift-protos.swift). -
Using a new proto type in SDK code: facades are emitted only for types the SDK references (type-level pruning) — spell the
Livekit_Xtype in code, re-runmake proto, and its facade appears. -
Validation:
Tests/LiveKitNanopbTestsasserts byte-identical encoding against the oracle for a generated, fully-populated exemplar of every message and every oneof variant.ConformanceEdgeCaseTestspins the known, deliberate differences: explicit zero scalars are encoded (pointer presence), unknown fields are dropped on re-encode, embedded NULs truncate strings. -
ABI: nanopb configuration (
PB_FIELD_32BITetc.) lives inlk_pb_config.h, included from the vendoredpb.h— never in build settings: SPMcSettingsare invisible to the Swift Clang importer and cause silent struct-layout mismatches.lk_abi_check.cguards this at compile time. -
Symbols: the vendored runtime's functions are renamed
pb_*→lk_pb_*(lk_pb_rename.h) so a second nanopb in the app (e.g. Firebase pods) can't silently bind against ours under static linking. After a nanopb upgrade the rename list must cover every exported symbol.
SwiftProtobuf links a ~1.1 MB runtime plus heavy per-message generated code
regardless of how little of it the SDK uses. nanopb keeps the wire format in
compact C field tables, and LiveKitNanopb is a fixed-cost (~770 line) bridge
that does not grow with the number of messages. The migration cut the SDK's
download-size contribution by ~1.7 MB. A stripped fork of SwiftProtobuf was
evaluated and bottoms out ~2.3× larger (see PR #1081 discussion) — the overhead
is structural (per-message metadata, codec logic), not trimmable.
The facades keep protoc-gen-swift's shape where it costs nothing (Livekit_Room,
.with {}, serializedBytes(), Equatable/Hashable/Sendable value types),
so most SDK call sites are unchanged, and Tests/LiveKitNanopbTests can assert
byte-identical encoding against SwiftProtobuf as an independent oracle. Three
deliberate departures: messages are immutable (msg.field = x does not
compile — build with .with { }, derive with .modifying { }), nested type
names are flat (Livekit_DataPacket_Kind), and enums are open
(RawRepresentable structs, so a switch needs a default).
Three layers:
-
CLiveKitProto— vendored nanopb 0.4.9.2 runtime + generated C structs and field descriptors. All fields useFT_POINTER(heap-allocated), so structs are small andpb_releasefrees everything. ABI defines and thepb_*→lk_pb_*symbol renames live inlk_pb_config.h/lk_pb_rename.h(see those headers for the Firebase-collision and SwiftPM-importer rationale). -
LiveKitNanopb—NanopbBoxownership, the genericNanopbMsg<Storage>(wire format, equality,with/modifying/owned()), theNanopbStorageprotocol each C struct conforms to, and thelk*field accessors the generated code calls. -
Generated facades — for each message the SDK references (type-level pruning): a one-line
NanopbStorageconformance on the imported C struct, a typealias, and the field accessors. No Swift type per message.extension livekit_Room: NanopbStorage { package static var descriptor: pb_msgdesc_t { livekit_Room_msg } package static let _emptyBox = NanopbBox<livekit_Room>(zero: .init(), descriptor: livekit_Room_msg) } typealias Livekit_Room = NanopbMsg<livekit_Room> extension Livekit_Room { /* getters */ } extension Livekit_Room.Builder { /* setters */ }
Why one generic type is the whole point. A nominal Swift type's metadata and conformance records live in sections the runtime must be able to enumerate, so the linker keeps them even when nothing references the type — measured on a dead-stripped link with a single exported symbol, message types the workload never touched still carried 79–205 live symbols each. Accessor code strips; types do not. Collapsing 107 nominal types into one took the facades from 214,686 to 90,867 bytes.
Extending through the typealias (
extension Livekit_Room) is exactlyextension NanopbMsg where S == livekit_Room; Swift resolves a typealias that binds a generic's parameters. Prefer the typealias form — it keeps the C struct name out of everything but the conformance.Three constraints the shape has to respect:
NanopbBuilder._pointeris stored: as a computed property off_boxit crashes the Swift 6.1 SIL verifier, and 6.1 is the floor.- Nested types are flattened to file scope (
Livekit_DataPacket_Kind), because constrained extensions of one generic cannot each declare a member of the same name — sixOneOf_Valuedeclarations would collide. A message that carries only nested types is emitted as a caseless enum namespace instead of a typealias, which keepsLivekit_DataStream.Headercompiling. _emptylives on the storage conformance as_emptyBox, because Swift forbids stored statics in a generic type and a computedSelf()would allocate on every read of an absent submessage.- Anything per-message that must survive dynamic dispatch belongs on
NanopbStorage, not a constrained extension. Messages share one conformance, so there is one witness for each protocol; a member onextension Livekit_TrackInfoonly binds where the concrete type is known statically.CustomStringConvertiblelearned this the hard way — the per-messagedescriptions were unreachable from interpolation until they moved onto a_describehook, and every log line rendered_livekit_TrackInfo.
A message value is NanopbMsg<Storage>, holding _owner: NanopbAnyBox
(lifetime) and _pointer (a nanopb C struct in a malloc'd, address-stable
allocation). It is in one of two states:
- Owning:
_owneris its ownNanopbBox;box.pointer == _pointer. - View:
_pointeraims at a nested C struct inside another message's allocation, and_owneris that parent's box. Submessage and repeated getters return views — reads are zero-copy pointer reads.
- Ownership:
NanopbBox.deinitrunslk_pb_release(frees all dynamic fields recursively) then deallocates the struct. Nothing else frees a message's tree; accessor setters free only the single field slot they replace (freeold string/bytes/pointer,strdup/mallocnew). - Views keep parents alive: a view retains the parent's box, so extracting
response.update.participants[0]and droppingresponseis safe — but storing a view long-term pins the entire decoded message's allocation. Callowned()when promoting a sub-message into long-lived state (the SDK does this forParticipant.info,latestInfo,serverInfo). Owning values pass throughowned()unchanged. Immutability does not remove this hazard: a view is still a pointer into a bigger allocation. - Copying is free: assigning a message bumps the box refcount and can
never deep-copy, because a message has no setters — there is no later
mutation for a copy to defend against. Building goes through
Builder, which allocates its own box, so it needs no uniqueness check either. - Deriving from an existing message:
modifying { }isconsuming. When the caller's value was the last owner the mutation happens in place; when it is shared (or is a view) it copies once for the whole batch, never per field. Marking a parameterconsumingon the way in is what lets the in-place path fire — seeRoom.send(dataPacket:). - Deep copy = encode/decode round trip: nanopb has no clone; a struct's
pointers cannot be shared between two trees that will both be released. The
round trip is the correctness-safe copy and doubles as tested code. Two
consequences: copies are O(subtree size), and unknown fields are dropped
on copy/re-encode (pinned in
ConformanceEdgeCaseTests— acceptable because the SDK never echoes messages back verbatim). - Crossing the C boundary copies: setting a submessage on a builder
(
$0.version = v) encodesvinto the builder's allocation; getting one hands out a view. There is no aliasing between two Swift values' storage except the read-only box/view sharing above. Nested mutation ($0.a.b = c) does not compile — write$0.a = .with { $0.b = c }. - Oneofs: union members share an address, so switching variants releases
the old payload with the old variant's descriptor before writing the new
one (
lkRelease), then zeroes the union. Getters for non-active variants return empty values. - Presence:
FT_POINTERmeans presence is the pointer — an explicitly set zero scalar is allocated and therefore encoded (a deliberate, oracle-pinned difference from SwiftProtobuf; harmless to consumers).
NanopbBox is @unchecked Sendable; messages are Sendable value types. The
justification is immutability, and the compiler enforces most of it:
- Storage a message can reach is never mutated. A message has no setters;
the only writer is a
Builder, andBuilderis~Copyableand consumed bybuild(), so no live handle can write to storage after it is published. - Concurrent reads of shared storage are therefore always safe, including reads through views into a shared parent.
modifyingwrites in place only afterisKnownUniquelyReferencedproves no other value — copy or view — can observe the storage.- Nothing in
LiveKitNanopbadds locks; safety comes from the type system, not synchronization.
Tests/LiveKitNanopbTests/ConcurrencyStressTests exercises the claim under
TSan (shared reads, concurrent modifying on copies, view lifetime, view
stability during sibling mutation, oneof churn, collection churn); CI's TSan
matrix leg runs it on every push. When touching modifying, owned(), view
construction, or any accessor's free/alloc ordering, run:
swift test --filter 'Nanopb|Conformance|ConcurrencyStress' --sanitize=threadThe unsafe surface is the C boundary: NanopbBox's allocation, the lk*
accessors, and the pointer reads in the generated facades. Nothing unsafe is
reachable from public API — every declaration in LiveKitNanopb is package,
and no public type exposes a pointer.
Each site that carries an invariant the code cannot show for itself has a
// SAFETY: comment, the convention Apple's TrueType-hinting port uses
(apple/truetype-hinting-interpreter-example). Add one when introducing a new
unsafe operation; state the invariant, not what the line does.
Swift 6.2's strict memory safety (.strictMemorySafety(), unsafe expression
markers, @safe) is not adopted: the markers are 6.2 syntax, a 6.1
compiler rejects them, and they cannot be #if-guarded per expression, so
adopting them means moving the floor off Swift 6.1. Compiling LiveKitNanopb with
-strict-memory-safety today reports ~11,200 warnings, ~5,400 of them in
generated code (the generator would have to emit the markers). Revisit when the
minimum toolchain reaches 6.2.
Already adopted from that port, within the 6.1 floor: typed throws on the wire
API and the scoped borrows (a non-throwing body specialises to throws(Never),
so it needs no try and emits no error path), and a shared _empty per
message instead of allocating a throwaway value for an absent submessage —
their zone-sentinel trick, sound here only because messages are immutable.
Reachable improvements once the floor moves:
Span/RawSpanborrows hand a closure a bounds-checked view instead of a raw pointer, and back-deploy to iOS 12.2, so only the compiler floor gates them. AwithLkSpanwas built behind#if compiler(>=6.2)and removed once measured to be dead — the shape is recorded in the Borrowing primitives comment at the end ofNanopbAccessors.swift, to re-add if a hot path ever needs one.- Making views
~Escapablewith@_lifetimewould turn "stored a view and pinned the parent allocation" from a memory-growth bug into a compile error. Apple's port does exactly this for itsZoneprojection, but it needs.enableExperimentalFeature("Lifetimes").
-
Enums are
RawRepresentablestructs, not Swift enums: proto3 enums are open, so an unknown wire value is an ordinary value rather than a special case. That removes theUNRECOGNIZED(Int)payload and the switch-basedrawValue/init?(rawValue:)pair (~660 lines), and makes the failable init honest — it never could fail. The cost is that aswitchover one needs adefault, which an open enum always required semantically.CaseIterableis not emitted; nothing usedallCases. Enum values render asLivekit_VideoQuality(rawValue: 2)in logs rather thanhigh— naming them costs a switch per enum, and the raw value identifies them well enough. -
Equality/hashing via canonical bytes: nanopb encodes deterministically except map entry order is preserved, so equal maps built in different orders compare unequal. The SDK uses equality for change detection, where a false "changed" is benign. Don't use facade equality where map-order insensitivity matters.
-
No crashing in the runtime: encode/decode failures on paths that cannot throw (builder setters, the
modifyingcopy) are reported viananopbReportFailure(os_log.fault, subsystemio.livekit.nanopb) and degrade to the least-damaging outcome — a setter leaves the destination unchanged, a failed copy yields an empty value rather than aliasing storage. These only fire on allocation exhaustion or a descriptor bug. -
Collections materialize: repeated/map getters build Swift arrays and dictionaries of (for submessages) views. Submessage and repeated-submessage reads stay zero-copy — the elements are views — but scalar, string and bytes reads copy out.
There is deliberately no per-field borrow accessor. The generator used to emit one for every string and bytes field, 128 of them, and not a single call site ever used one; the receive path hands consumers a
Dataanyway, so the copy happens at the API boundary regardless.withLkRepeatedremains as a runtime primitive; the scalar borrows (withLkData,withLkSpan) were removed, with their shapes kept in a comment at the end ofNanopbAccessors.swift. The generator can re-emit wrappers on demand the same way it prunes types. -
packageaccess everywhere:LiveKitNanopbis SDK plumbing, never public API. In single-module builds (CocoaPods, xcframework) these sources compile into the product directly — see the#if LK_XCFRAMEWORK / #elseif !COCOAPODSimport guards at the top of each file, and keep them on any new file.
- Setters exist only on
Builder, never on a message — the generator'sverifyBuilderInvariantfails the build otherwise. It keys on the emittedextension <Type>.Builder {header, so keep that spelling. - Every
free/lk_pb_releasematches the allocation's actual layout — especially oneof switches (release with the old variant's descriptor). - New runtime symbols from a nanopb upgrade must be added to
lk_pb_rename.h(nm -gUthe runtime objects to enumerate). - No new synchronization primitives; safety is immutability.
- Conformance suites must stay green — they are the encoding oracle:
swift test --filter 'Nanopb|Conformance'. Livekit_DataPacketis decoded from bytes another participant sent, so the decoder is the SDK's only parser facing untrusted input.DecodeFuzzTestswalks mutated and random encodings through it from a fixed seed; run it under ASan (--sanitize=address) after touching decode or any accessor's pointer arithmetic, since an out-of-bounds read there need not crash.