zap-proto/cpp shipped a README telling readers to run cmake, and no CMakeLists, no headers, no source. Every C++ service that needed ZAP therefore hand-wrote frame handling, and the copies drifted from each other and from the wire. So here is the thing itself: the message layer, the call envelope with promise pipelining, and the symmetric transport, behind one exported target zap::zap. The bytes are not asserted against themselves. Every KAT in the codec and rpc suites was printed by github.com/zap-proto/go and pinned here, which is the only kind of evidence that distinguishes agreeing with the wire from agreeing with yourself. That caught one real rule a hand-written port would miss and did: set_bytes must hold its payload until finish(), because the reference lays a list started afterwards down FIRST — same fields, different bytes, and on a chain that is a fork. The transport suite proves the protocol rather than the framing, and without a timer, because a timing-based proof of "in flight at once" is not a proof. Both peers' handlers park until the other's request has arrived, so the test only passes if one connection carried both directions at once. And no request is answered until all eight have arrived; they are then answered in reverse, with the first held back until the caller — which by then already holds the last one's answer — releases it. Streams (direction tags 3-5) are not here. An unknown tag ends the connection rather than being quietly ignored.
4.3 KiB
zap-proto/cpp
The ZAP protocol in C++. Three layers, three headers, one library target
zap::zap.
What ZAP is, and what it is not
ZAP is a bidirectional binary protocol with pipelining. Both peers on a connection initiate; requests pipeline, many in flight, answers back out of order. It is not a codec and not a serialization format. Framing a struct and calling that ZAP loses exactly the properties that matter.
The typed schema is the source. JSON, MCP, docs, OpenAPI, CLI and SDKs are generated from it. Nothing here parses JSON into ZAP, and no service hand-rolls a frame writer.
Layers
include/zap/zap.hpp— the message. 16-byte header ("ZAP\0", version u16, flags u16, root u32, size u32), then 8-byte-aligned object payloads, list payloads and byte tails. Every pointer is relative to its own position. Little endian throughout.Message/Object/Listare views over caller-owned bytes and copy nothing; the buffer must outlive them.include/zap/rpc.hpp,src/rpc.cpp— the call envelope (request fixed size 28, response fixed size 20, flagskMsgTypeRouterBase << 8), plusSession(client-side PromiseID allocation and Target stamping) andPipeliner(server-side promise table).include/zap/transport.hpp,src/transport.cpp— frames[u32 len][u8 dir][envelope]over TCP or Unix.Connis symmetric andbegin()does not wait.
Rules that are not negotiable
- The canonical runtime is
github.com/zap-proto/go. Any layout question is settled by readingzap.go/builder.go/rpc/envelope.go, not by reasoning about what would be nicer. The KATs intest/codec_test.cppandtest/rpc_test.cppare bytes that runtime printed. ObjectBuilder::set_bytesholds its payload untilfinish(). Writing it on the spot produces different bytes whenever a list or nested object is started on the same builder between theset_bytesand thefinish. The reference defers; so does this.tail_then_listin the codec test is that exact case, and it is why the rule is written down here.ListBuilder::add_bytescounts BYTES, not elements. A caller writing fixed-stride records passes the real element count toset_listitself. "Fixing" it silently changes the length word on the wire.- Reads are total. An out-of-range read answers zero. A pointer target
inside the wire header is refused. A
bytesrelOffset is unsigned (forward only); anobject/listrelOffset is signed (a child may be finalized before its parent). Those four rules are the whole safety story; do not scatter bounds checks into callers. - This runtime writes version 2 (
kVersion).zap-proto/go's plainNewBuilderwrites version 1 and itsNewBuilderV2writes 2; both parse either, and the data segment is identical.
Additions over the Go runtime
These exist here because a schema needs them and the Go runtime expresses them by hand at the call site. They should be backported there, and to the Rust runtime, rather than re-invented per consumer:
Object::bytes_fixed(field, len)— a fixed-width byte run living in the payload (an id, a public key), as opposed to a pointer to a tail.Object::list_stride(field, stride)—list()with the tighter clamp the schema makes possible:length * stridemust fit the remaining buffer.List::object_ptr(i)/ListBuilder::add_object_ptr(pos)— an out-of-line list: one signed relative pointer per element, so elements may vary in size.message_length(buffer)— the self-delimiting length of the leading message, which is what splits a signed payload from a credential suffix.
Not implemented
The stream frames the Go runtime carries (direction tags 3-5: open / msg / end). An unknown direction tag ends the connection rather than being ignored.
Testing
ctest --test-dir build. Three suites: codec, rpc, transport.
The transport suite proves the two protocol properties without a timer, because a timing-based proof of "in flight at once" is not a proof. Both peers' handlers park until the other's request arrives (bidirectional); no request is answered until all eight have arrived and they are then answered in reverse, with the first held until the caller releases it (pipelining, out of order).
Run it under ThreadSanitizer before touching transport.cpp.