Skip to content

Static channel config and the ASIL client - #136

Draft
dallison wants to merge 21 commits into
mainfrom
asil
Draft

dallison wants to merge 21 commits into
mainfrom
asil

Conversation

@dallison

@dallison dallison commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

This branch adds four things:

  1. Static channel config (--channel_config=FILE). The server creates the configured channels and multiplexers before it accepts clients, and their layout is fixed. Publishers on them must set server_defined. A standard publisher can still create a channel that isn't configured, by specifying its layout, and any subscriber can subscribe to it, including an ASIL subscriber. An ASIL publisher cannot create or join a channel that isn't configured while a static config is loaded.
  2. The ASIL client (asil_client/). A C++17 client for safety-related software, built with no exceptions or RTTI and no heap allocation. It shares channels with the standard clients through the same shared memory protocol. Its server handshake uses the Phaser zero-copy protocol below, in fixed buffers.
  3. Map at open, and split buffers. ASIL publishers and subscribers acquire all of their shared memory in Open(), so publishing and reading make no mmap or shm_open calls. Split buffers are supported.
  4. Phaser zero-copy server protocol. The C++ clients and the Rust client talk to the server by sending the Phaser message buffer itself (Data() / ByteSizeLong()), with no protobuf transcoding. ServerWire in common/server_wire.h does this for both C++ clients. The standard client builds the messages in growable buffers. The ASIL client builds them in fixed buffers: 16 KB for the message and 16 KB for the wire payload, both inside PhaserServerConnection, so a handshake allocates nothing. A larger size passed to the constructor is allocated once and used for both buffers. Bridge, tunnel, discovery, the channel directory and statistics stay on protobuf.

Server changes

  • The server creates the message buffers of every static channel and multiplexer at startup, so a subscriber can map them before any publisher exists.
  • use_split_buffers in a static channel or multiplexer config gives it split buffers, which the server creates. A publisher whose split setting differs from the config is rejected.
  • GetClientBuffersRequest can fetch one prefix or slot of a split buffer set (filter_slot, is_prefix, slot_id). This keeps each response inside the ASIL client's 16 KB wire buffer.
  • Publishers on different virtual channels of a multiplexer are checked against each other for fixed size and locality. Before this, a static multiplexer could only have publishers on one virtual channel.

ASIL client

  • Supports:
    • unreliable and reliable publishers and subscribers
    • virtual channels and whole-multiplexer subscribers
    • up to 64 held messages
    • subscriber queues
    • checksums, metadata, kReadNext/kReadNewest, drop counts and activation messages
    • POSIX shm, /dev/shm and memfd
  • Each publisher or subscriber maps the channel's newest buffer when it opens.
    • On a non-static channel with no buffers yet, a subscriber's Open() returns kNoBuffers.
    • A message in a buffer that a standard publisher created by resizing after the subscriber opened is skipped, and the read returns kBufferNotMapped.
  • Split buffers are mapped into a SplitSlot array that the caller provides. Slots from a standard publisher's custom allocator are mapped through C map/unmap callbacks with a context pointer.

See docs/asil-client.md for details.

Dependencies

The pinned phaser and cpp_toolbelt commits are in dallison/phaser#13 and dallison/cpp_toolbelt#29. MODULE.bazel uses git_override until those versions reach the Bazel Central Registry.

Testing

  • bazelisk test --config=macos_arm64 //...: 28/28 pass.
  • CMake: asil_layout_test and asil_client_test pass.
  • bazelisk test --config=macos_arm64 //asil_client:asil_client_test: passes with the 16 KB message and wire buffers.
  • Linux (Ubuntu 24.04, arm64): //asil_client/... and //server/... pass with /dev/shm and with --config=linux_memfd.
  • asil_client_test (40 tests) exchanges messages between the ASIL and standard clients in both directions. It covers server-created static buffers, split buffers, custom allocators, resized channels and the rejected cases.
  • layout_test checks the ASIL shared memory layout against common/channel.h.

subspace_server --channel_config=FILE reads a StaticChannelConfig in
protobuf text format and creates its channels and multiplexers before the
server accepts any clients, so a subscriber that starts first gets the full
channel layout instead of a placeholder.

With a config loaded, clients can use only the configured channels and the
server's own /subspace/ChannelDirectory and /subspace/Statistics.  The
configured channels stay when they have no users, and their layout is
fixed: publishers must be fixed size and match the configured slot size,
number of slots, checksum size and metadata size.  Channels recovered from a
shadow must match the config.

A multiplexer with no publishers now reports its slot size.
asil_client/ is a C++17 client for safety-related software.  It shares
unreliable, fixed-size channels with the standard clients through the same
shared memory protocol, using only the standard library and POSIX.  It is
built without exceptions or RTTI, returns an error code from every
operation, and makes no heap allocations.

- Publishers support checksums, metadata and activation messages.
  Subscribers support kReadNext and kReadNewest, dropped message counts,
  checksum checking and activation messages.
- The server handshake is behind the ServerConnection interface.
  PhaserServerConnection implements the standard protocol with phaser
  messages in fixed buffers, sent and received in protobuf wire format, so
  the server sees the same bytes as from the other clients.
- Bazel, CMake and Soong build phaser and generate subspace_phaser from
  subspace.proto.  Android CMake cross-compiles take a host
  protoc-gen-phaser in PHASER_PLUGIN_EXECUTABLE.
- Bazel moves to Abseil 20250814.1, protobuf 33.4, cpp_toolbelt 2.1.6 and
  phaser 2.1.3, with cpp_toolbelt and phaser pinned by git_override until
  they are in the Bazel Central Registry.  CMake uses the same commits.
- layout_test checks the ASIL client's view of shared memory against
  common/channel.h.  asil_client_test exchanges messages with the standard
  client in both directions, checks that setup and teardown make no heap
  allocations, and checks that an oversized request leaves the connection
  usable.  It is skipped on memfd builds, which the ASIL client doesn't
  support yet.
The server now creates the message buffers of every static channel and
multiplexer when it starts, through the same code as a standard first
publisher, so subscribers can map them before any publisher exists.  A
static channel or multiplexer with use_split_buffers gets split buffers,
which the server creates, and publishers whose split setting differs
from the config are rejected.  GetClientBuffersRequest can ask for one
prefix or slot of a split buffer set.

ASIL publishers and subscribers map all of a channel's shared memory in
Open(), and publishing and reading make no shared memory calls.  They
map the channel's newest buffer; the resize, placeholder remap and
16-buffer table are gone.  A subscriber on a channel without buffers
gets kNoBuffers, and a message in a buffer created by a resize after the
subscriber opened returns kBufferNotMapped.

The ASIL client also supports split buffers, mapping each slot into an
array the caller provides.  Slots from a standard publisher's custom
allocator are mapped through C map/unmap callbacks with a context.

The ASIL client also gains reliable publishers and subscribers, virtual
channels, multiple held messages, subscriber queues and the memfd
backend, and the server checks fixed size and locality across the
virtual channels of a multiplexer.
The standard C++ client now builds its server requests and responses as
phaser messages, like the ASIL client. ServerWire in common/server_wire.h
serializes requests and decodes responses for both, with growable buffers
for the standard client and fixed buffers for the ASIL connection, which
still makes no heap allocations. The bytes on the socket are unchanged.

CMake now requires PHASER_PLUGIN_EXECUTABLE when cross-compiling, since
the standard client needs subspace_phaser.
A static channel's layout comes from the server's config, so its
publishers now set a server_defined option instead of the options that
shape the shared memory. The server rejects a publisher on a static
channel that leaves out server_defined or sets num_slots, slot_size,
fixed_size, checksum_size, metadata_size, subscriber_queue_arena_size or
use_split_buffers, and rejects server_defined on any other channel.

CreatePublisherResponse now reports the channel's slot size, checksum
size, metadata size, split buffer setting and fixed size, and a
server_defined publisher takes its layout from them. The C++, C, Python,
Rust and ASIL clients have the option and check it before asking the
server. The C++ and Rust default checksum_size is now 0, which means 4
bytes, and a bridge publisher on a static channel is server defined.
…ive Phaser payloads

* Send native Phaser payloads between the clients and the server.

The C++ clients, the server and the Rust client exchange the Phaser message
buffer itself instead of transcoding to protobuf wire format. Phaser 3.1.1
adds copying and parsing that payload from C and Rust. Fixed-buffer errors in
the ASIL connection keep their text without allocating, so an oversized
request or response says why it failed.

* Share the shared memory protocol between the ASIL and standard clients.

common/ now holds the memory layout, the channel control block view, the slot
search, publishing, taking, releasing and skipping messages, the message
checksum and CRC32. Both clients use them, so they can't drift. The ASIL
client keeps its no-exception, no-allocation build.

Sharing the code fixed these bugs:
- The ASIL publisher could reuse a slot with a stale retired bit, losing an
  unread message, and its checksum didn't mask transport flags.
- A reliable subscriber reading the newest message in the standard client
  didn't mark the skipped messages seen, so a reliable publisher could no
  longer get a slot once the free slots ran out.
- A standard subscriber ignoring an activation message kept its owner bit,
  so a crash later made the server drop another message's reference.
- Retirement notification for a bridged message crashed if its original
  slot id was beyond this channel's slots.
- crc32.cc included <nmmintrin.h> inside namespace subspace, which broke
  libc++ on Android x86_64, where SSE4.2 is on by default.
- The ASIL oversized response test bound a filesystem socket, but
  Connect() uses the abstract namespace on Linux.  On a failed connect it
  also hung, as closing a socket does not wake accept() on Linux.
- The CMake build of the Rust client had no Phaser bindings.  It now
  generates them with phaser's C frontend and links their C++ code into
  one shared library for Cargo.  The new phaser_c crate includes them.
- Pin phaser to dallison/phaser#22, which brings phaser's CMake build up
  to date and adds the C and Rust output.
The plugins include the server headers, which include the generated
subspace_phaser headers, but outside Android they don't link libserver
and so could compile before the headers were generated.
The type of a channel in a StaticChannelConfig was a string, so it could
not hold an opaque blob such as ROS metadata or a protobuf descriptor.
Make it bytes, the same as the channel type on the wire.  Quoted text
still parses to the same bytes, and protobuf text escapes carry blobs.

Add a test that a blob type is kept and that a publisher must match it.
- Pass -undefined dynamic_lookup when linking the plugin modules on macOS
  so server symbols can resolve at load time, matching Linux's
  --unresolved-symbols=ignore-all.
- Build static nop_plugin_lib and split_buffer_free_test_plugin_lib
  targets on macOS in CMake (and split_buffer_free_test_plugin_lib in
  Bazel) and link them into client_test, so NOP_Create() resolves and
  SplitBufferPluginTest can load the split-buffer test plugin via
  LoadBuiltinPlugin instead of skipping on macOS.
The ASIL client had no stress test, unlike the standard client.  Add
asil_client/stress_test.cc, which covers threads, the active message
limit, virtual channels, many channels, and mixing with standard
clients on the same channels.

The active message test checks that a held payload still holds the
sequence it was read with, and the many channel test fails when a
subscriber makes no progress for 10 seconds, rather than spinning when
its fd stays readable.

Add it to Bazel as a manual test, so it runs only when named, and to
the CMake build as asil_stress_test.  Document it in asil-client.md.
Checksums: the x86 SSE4.2 _mm_crc32_* instructions compute CRC-32C, not
the IEEE 802.3 CRC32 used by the Rust client and by every other build.
Builds with SSE4.2 enabled therefore produced checksums that did not
verify against the rest. x86 now always uses the IEEE lookup table. ARM
hardware CRC32 is IEEE and is unchanged.

Note: this changes checksum values for x86 builds with SSE4.2 enabled.
Such a build will not interoperate with builds from before this change.

asil_client test: the sanity-check allocation was a direct operator new,
which an optimized build can lower to malloc, so the counter stayed at 0
and HandshakeMakesNoHeapAllocations failed under -O2. Call operator new
through a volatile function pointer so the allocation is always counted.
With a static channel config loaded, the server used to reject every
channel the config doesn't list. Now:

- Configured channels are shared by standard and ASIL clients. Publishers
  on them must set server_defined, as before.
- A standard publisher can create an unconfigured channel by specifying
  its layout. The server rejects server_defined on such a channel.
- Any subscriber can subscribe to an unconfigured channel, including an
  ASIL subscriber, which maps no buffers until a publisher creates them.
- An ASIL publisher cannot create or join an unconfigured channel while a
  static config is loaded. The ASIL client sets InitRequest.asil to say
  so. The flag is advisory: the server trusts the client to set it.

InitRequest gets a new field, asil = 5. Older clients don't set it and
keep the standard behavior, and older servers ignore it.
PhaserServerConnection can be constructed with a larger wire buffer
size for a channel whose handshake needs more room. The decoded message
buffer was a fixed 32 KB, twice the wire buffer. That spare space is
never needed: the wire buffer carries the message's ByteSizeLong(), and
a message that fills the arena cannot be larger than that. So the
message buffer now has the same size as the wire buffer.

- The default object keeps both buffers inside the object, 16 KB each.
- A larger size allocates both once in the constructor, with the message
  buffer rounded up to 8 bytes so the wire buffer stays aligned.
- Responses that do not fit the wire buffer are still drained, so the
  stream stays in step. Allocation failure reports kCapacityExceeded.
- Adds a test that a 64 KB connection sends and receives a request the
  default connection cannot hold, with no heap allocations.
- An ASIL publisher creates a channel's buffer only when it is the first
  publisher and the server has no static config. With a config loaded it
  publishes only on the configured channels.
- The server handshake is a 4-byte length followed by the Phaser message
  buffer itself (Data() / ByteSizeLong()), the same bytes the standard C++
  and Rust clients send. The ASIL connection keeps that message in fixed
  buffers, 16 KB by default.
- The example gives each channel its layout, so it applies to a server
  with no static config, and points at server_defined for a configured
  channel.
Add a General Motors copyright line under the existing header in the
ASIL client sources and the new common/ files that the ASIL client
shares with the standard client.

Rename "General Motors LLC" and "General Motors Inc." to "General
Motors" everywhere in the repository. Only comments, copyright headers
and documentation change.
--channel_config and --tunnel_config now read a protobuf wire-format
file as well as text. The extension decides: a name ending in .pb or
.binpb (ignoring case) is wire format, and any other name is text, so
existing files keep working.

The wire format is strict like the text format. A message with a field
this build doesn't know, at any depth, is rejected rather than having
the field dropped. Both files are read in binary mode.

- server/config_format.h: ConfigFileIsText and HasUnknownFields.
- Tests: the extension selects the format, a wire file is rejected when
  read as text and the reverse, and unknown fields are rejected.
- Docs: README, docs/tunnel.md, CHANGELOG and the flag help.
- README and docs/tunnel.md: show protoc --encode for producing a .pb
  file from a text config, and say that unknown fields are rejected in
  either format.
- docs/server-architecture.md: list static_config and config_format.h.
- CHANGELOG: rewrap the tunnel_config entry.
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