diff --git a/.cursor/skills/subspace-builds/SKILL.md b/.cursor/skills/subspace-builds/SKILL.md index 2515a740..31a17abd 100644 --- a/.cursor/skills/subspace-builds/SKILL.md +++ b/.cursor/skills/subspace-builds/SKILL.md @@ -68,7 +68,7 @@ Android cross-compile so protobuf versions match: ```bash cmake -S . -B build/host-protoc -DCMAKE_BUILD_TYPE=Release -cmake --build build/host-protoc --target protoc --parallel +cmake --build build/host-protoc --target protoc protoc-gen-phaser --parallel cmake -S . -B build/android \ -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \ @@ -76,7 +76,8 @@ cmake -S . -B build/android \ -DANDROID_PLATFORM=android-28 \ -DANDROID_STL=c++_shared \ -DCMAKE_BUILD_TYPE=Release \ - -DPROTOC_EXECUTABLE="$PWD/build/host-protoc/_deps/protobuf-build/protoc" + -DPROTOC_EXECUTABLE="$PWD/build/host-protoc/_deps/protobuf-build/protoc" \ + -DPHASER_PLUGIN_EXECUTABLE="$PWD/build/host-protoc/_deps/phaser-build/protoc-gen-phaser" cmake --build build/android --parallel ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2960d0a4..9b21da80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -470,10 +470,10 @@ jobs: - name: Set up Android NDK run: echo "ANDROID_NDK_HOME=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV - - name: Build host protoc for cross-compilation + - name: Build host protoc and phaser plugin for cross-compilation run: | cmake -S . -B build/host-protoc -DCMAKE_BUILD_TYPE=Release - cmake --build build/host-protoc --target protoc --parallel $(nproc) + cmake --build build/host-protoc --target protoc protoc-gen-phaser --parallel $(nproc) - name: Configure CMake for Android x86_64 run: | @@ -483,7 +483,8 @@ jobs: -DANDROID_PLATFORM=android-28 \ -DANDROID_STL=c++_shared \ -DCMAKE_BUILD_TYPE=Release \ - -DPROTOC_EXECUTABLE="$PWD/build/host-protoc/_deps/protobuf-build/protoc" + -DPROTOC_EXECUTABLE="$PWD/build/host-protoc/_deps/protobuf-build/protoc" \ + -DPHASER_PLUGIN_EXECUTABLE="$PWD/build/host-protoc/_deps/phaser-build/protoc-gen-phaser" - name: Build run: cmake --build build/android --parallel $(nproc) diff --git a/Android.bp b/Android.bp index d518f1aa..a453a483 100644 --- a/Android.bp +++ b/Android.bp @@ -47,3 +47,61 @@ cc_library_static { export_shared_lib_headers: ["libprotobuf-cpp-full"], rtti: false, } + +// Phaser messages generated from subspace.proto for the ASIL client's server +// connection. They are sent and received in protobuf wire format. As in the +// Bazel phaser_library rule, the plugin writes +// proto/subspace_phaser/.phaser.{cc,h}, generating any.proto too, +// and a short proto/subspace.phaser.h header includes the generated one. The +// .cc and .h outputs come from separate genrules so the headers never end up +// in `generated_sources`. +subspace_phaser_cmd = "$(location aprotoc) " + + "--plugin=protoc-gen-phaser=$(location protoc-gen-phaser) " + + "--phaser_out=add_namespace=phaser,package_name=proto," + + "target_name=subspace_phaser,frontend=protobuf:$(genDir) " + + "-Iexternal/subspace -Iexternal/protobuf/src " + + "$(in) external/protobuf/src/google/protobuf/any.proto" + +genrule { + name: "libsubspace_phaser_cc", + tools: [ + "aprotoc", + "protoc-gen-phaser", + ], + cmd: subspace_phaser_cmd, + srcs: ["proto/subspace.proto"], + out: [ + "proto/subspace_phaser/proto/subspace.phaser.cc", + "proto/subspace_phaser/google/protobuf/any.phaser.cc", + ], +} + +genrule { + name: "libsubspace_phaser_h", + tools: [ + "aprotoc", + "protoc-gen-phaser", + ], + cmd: subspace_phaser_cmd + " && " + + "echo '#pragma once' > $(genDir)/proto/subspace.phaser.h && " + + "echo '#include \"proto/subspace_phaser/proto/subspace.phaser.h\"' " + + ">> $(genDir)/proto/subspace.phaser.h", + srcs: ["proto/subspace.proto"], + out: [ + "proto/subspace.phaser.h", + "proto/subspace_phaser/proto/subspace.phaser.h", + "proto/subspace_phaser/google/protobuf/any.phaser.h", + ], +} + +cc_library_static { + name: "libsubspace_phaser", + defaults: ["subspace_defaults"], + generated_sources: ["libsubspace_phaser_cc"], + generated_headers: ["libsubspace_phaser_h"], + export_generated_headers: ["libsubspace_phaser_h"], + cppflags: ["-fno-exceptions"], + rtti: false, + static_libs: ["libphaser_runtime"], + export_static_lib_headers: ["libphaser_runtime"], +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d5e24d..38739165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,72 @@ ## Unreleased +### Static Channel Config +- `subspace_server --channel_config=FILE` reads a `StaticChannelConfig` in + protobuf text format and creates its channels and multiplexers before the + server accepts clients. Clients can then use only those channels and the + server's own, the channels stay when they have no users, and the server + defines their layout. +- Publishers on a static channel must set the new `server_defined` option + (`PublisherOptions::SetServerDefined`, `server_defined` in the C, Python, + Rust and ASIL clients) and can't set the slot size, number of slots, fixed + size, checksum size, metadata size, subscriber queue arena size or split + buffer setting. The server 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 uses them. +- `subspace_publisher_options_server_defined()` in the C client gives + options for a static channel. +- The default `checksum_size` in the C++ and Rust `PublisherOptions` is now 0, + which means 4 bytes as before, so a `server_defined` publisher can use the + default options. +- A multiplexer with no publishers now reports its slot size. +- Publishers on different virtual channels of a multiplexer are checked + against each other for fixed size and locality, as they share the + multiplexer's buffers. Before, a second fixed-size publisher on another + virtual channel was rejected, so a static multiplexer could only have + publishers on one virtual channel. +- The server creates the message buffers of every static channel and + multiplexer when it starts, so subscribers 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. +- `GetClientBuffersRequest` can ask for one prefix or slot of a split buffer + set with `filter_slot`, `is_prefix` and `slot_id`. + +### ASIL Client +- `asil_client/` is a new C++17 client for safety-related software. It + shares 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 allocates no memory while publishing or reading. + Checksums, metadata, `ReadNewest`, dropped message counts and activation + messages work with the standard clients. The server handshake is behind a + replaceable `ServerConnection` interface. `PhaserServerConnection` + implements the standard protocol with phaser messages in fixed buffers, + sent and received in protobuf wire format, so the handshake makes no heap + allocations either. See `docs/asil-client.md`. +- The ASIL client also supports reliable publishers and subscribers, virtual + channels and multiplexer subscribers, subscribers holding up to 64 + messages, subscriber queues, split buffers (with C callbacks for slots from + a custom allocator), and the memfd backend. Reading the newest message on + a reliable subscriber marks the skipped messages as read. +- ASIL publishers and subscribers map all of a channel's shared memory when + they open, and publishing and reading make no shared memory calls. A + subscriber needs the channel to have buffers when it opens: always true of + static channels, otherwise a publisher must have opened. Messages in a + buffer that a standard publisher created by resizing after the subscriber + opened return `kBufferNotMapped`. +- The standard C++ client builds its server requests and responses as + phaser messages too, in growable buffers. `ServerWire` in + `common/server_wire.h` encodes them for both C++ clients, with fixed or + growable buffers. The bytes on the socket are unchanged. +- Bazel, CMake and Soong build phaser and generate `subspace_phaser` from + `subspace.proto`. Android CMake cross-compiles need a host + `protoc-gen-phaser` in `PHASER_PLUGIN_EXECUTABLE`. +- Abseil 20250814.1, protobuf 33.4, cpp_toolbelt 2.1.6 and phaser 2.1.3 in + the Bazel build. CMake uses cpp_toolbelt 2.1.6. + ### Stuck Subscriber Warning - A subscriber now logs an error when a message has been waiting for 5 seconds that it can't read because it already holds `max_active_messages` diff --git a/CMakeLists.txt b/CMakeLists.txt index d506b5d4..4891443d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -144,7 +144,8 @@ endif() FetchContent_Declare( cpp_toolbelt GIT_REPOSITORY https://github.com/dallison/cpp_toolbelt.git - GIT_TAG 2.1.2 + # cpp_toolbelt 2.1.6, as in the git_override in MODULE.bazel. + GIT_TAG 86b45cdd3da46a97a4476be2f2a61909677284a2 CMAKE_ARGS -DCMAKE_OSX_ARCHITECTURES="${CMAKE_OSX_ARCHITECTURES}" ) @@ -165,6 +166,20 @@ FetchContent_MakeAvailable(protobuf) # Protobuf provides targets like protobuf::libprotobuf and protobuf::protoc # These targets can be used directly without find_package +# --- External Dependency: phaser (using FetchContent for native CMake) --- +# The C++ clients build their server requests and responses as phaser +# messages: in growable buffers for the standard client and fixed buffers for +# the ASIL client. Phaser uses the Abseil, cpp_toolbelt and protobuf fetched +# above. +# Cross-compiles take a host protoc-gen-phaser in PHASER_PLUGIN_EXECUTABLE. +FetchContent_Declare( + phaser + GIT_REPOSITORY https://github.com/dallison/phaser.git + # phaser 2.1.3, as in the git_override in MODULE.bazel. + GIT_TAG 3d5eca68b74da5ad982a2768b9b54da728fd17f7 +) +FetchContent_MakeAvailable(phaser) + # --- Option: coroutine/networking backend for the server --- # Mirrors the Bazel //:coro_backend flag. "co" (default) builds against the co # coroutine library; "asio" builds the server against Boost.Asio. The value is @@ -256,6 +271,7 @@ add_subdirectory(server) add_subdirectory(plugins) add_subdirectory(shadow) add_subdirectory(c_client) +add_subdirectory(asil_client) add_subdirectory(android) add_subdirectory(rust_client) add_subdirectory(rpc) diff --git a/MODULE.bazel b/MODULE.bazel index 2c54ab4f..eb0332cd 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -11,9 +11,16 @@ use_repo(subspace, "subspace_max_channels") bazel_dep(name = "bazel_skylib", version = "1.9.0") bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "abseil-cpp", version = "20250512.1") +bazel_dep(name = "abseil-cpp", version = "20250814.1") bazel_dep(name = "googletest", version = "1.17.0") -bazel_dep(name = "protobuf", version = "33.2") +bazel_dep(name = "protobuf", version = "33.4") + +# Phaser asks for protobuf 34.1, whose rules_android dependency breaks the +# Android SDK toolchain here. +single_version_override( + module_name = "protobuf", + version = "33.4", +) bazel_dep(name = "rules_cc", version = "0.2.17") bazel_dep(name = "rules_java", version = "9.1.0") @@ -21,7 +28,22 @@ bazel_dep(name = "rules_pkg", version = "1.0.1") bazel_dep(name = "rules_android_ndk", version = "0.1.5") bazel_dep(name = "zlib", version = "1.3.1.bcr.5") bazel_dep(name = "coroutines", version = "3.3.5") -bazel_dep(name = "cpp_toolbelt", version = "2.1.3") +bazel_dep(name = "cpp_toolbelt", version = "2.1.6") +bazel_dep(name = "phaser", version = "2.1.3") + +# Remove once cpp_toolbelt 2.1.6 and phaser 2.1.3 are in the Bazel Central +# Registry. +git_override( + module_name = "cpp_toolbelt", + commit = "86b45cdd3da46a97a4476be2f2a61909677284a2", + remote = "https://github.com/dallison/cpp_toolbelt.git", +) + +git_override( + module_name = "phaser", + commit = "3d5eca68b74da5ad982a2768b9b54da728fd17f7", + remote = "https://github.com/dallison/phaser.git", +) bazel_dep(name = "rules_python", version = "1.7.0") # Boost (for asio_rpc) diff --git a/README.md b/README.md index ff7c8436..26280185 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ See the file docs/subspace.pdf for full documentation. Additional documentation - [Slot Sizes](docs/slot-sizes.md) - [Channel Limit](docs/max-channels.md) - [C Client API](docs/c-client.md) +- [ASIL Client](docs/asil-client.md) - [Client Architecture](docs/client-architecture.md) - [Server Architecture](docs/server-architecture.md) - [Asio Backend and vsock Bridging](docs/asio-backend.md) @@ -2008,6 +2009,61 @@ The `manual_tests/pub` tool takes `--local=false` and `--channel=NAME`, and `manual_tests/sub` takes `--channel=NAME`, so you can also drive bridging by hand against servers on two different computers. +## Static Channel Config + +By default the server creates channels on demand as publishers and subscribers +arrive. For systems that need a fixed, known set of channels, start the server +with `--channel_config=FILE`. The file is a `StaticChannelConfig` +(`proto/subspace.proto`) in protobuf text format: + +``` +multiplexers { name: "/sensors" slot_size: 1024 num_slots: 16 type: "Sensor" } +channels { name: "/vehicle/state" slot_size: 256 num_slots: 8 type: "State" } +channels { name: "/sensors/lidar" mux: "/sensors" vchan_id: 0 } +channels { name: "/sensors/radar" mux: "/sensors" vchan_id: 1 } +``` + +The server creates every configured channel and its message buffers before it +accepts clients, so a subscriber that starts first gets the full channel +layout and maps the buffers instead of waiting for a publisher. With a config +loaded: + +- Clients can use only the configured channels and the server's own + `/subspace/ChannelDirectory` and `/subspace/Statistics`. Creating a + publisher or subscriber for any other channel fails. +- Configured channels stay for the life of the server, including when they + have no publishers or subscribers. +- The server defines the layout. Publishers on a configured channel must set + `SetServerDefined(true)` and leave every option that shapes the shared + memory unset: `SetSlotSize`, `SetNumSlots`, `SetFixedSize`, + `SetChecksumSize`, `SetMetadataSize`, `SetSubscriberQueueArenaSize` and + `SetUseSplitBuffers`. The server rejects a publisher that sets any of them + or leaves out `server_defined`, and also a `server_defined` publisher on a + channel that isn't configured. The publisher takes its slot size, number of + slots, checksum size, metadata size and split buffer setting from the + server, and is always fixed size: + + ```c++ + absl::StatusOr pub = client.CreatePublisher( + "/vehicle/state", subspace::PublisherOptions().SetServerDefined(true)); + ``` + + The C client has `subspace_publisher_options_server_defined()`, Python has + `set_server_defined(True)`, Rust has `set_server_defined(true)` and the ASIL + client has `PublisherOptions::server_defined`. Options that don't change + the layout, such as reliable, checksum, type and mux, work as usual. +- `use_split_buffers: true` gives a channel or multiplexer + [split buffers](docs/split-buffers.md), which the server creates. +- A channel on a multiplexer takes its slot size, number of slots, type, + prefix layout and split buffer setting from the multiplexer and needs an + explicit `vchan_id`. +- Names starting with `/subspace/` are reserved for the server. +- After a restart from a shadow, the recovered channels must match the config + or the server doesn't start. + +The [ASIL client](docs/asil-client.md) is a C++17 client for safety-related +software that shares these channels with the standard clients. + ## Shadow Server (Crash Recovery) Subspace supports a **shadow process** that mirrors the server's channel, @@ -2083,6 +2139,7 @@ its full state from whichever shadow is available. |------|---------|-------------| | `--shadow_socket` | `""` (disabled) | Unix socket path for the primary shadow process | | `--secondary_shadow_socket` | `""` (disabled) | Unix socket path for the secondary shadow process | +| `--channel_config` | `""` (disabled) | `StaticChannelConfig` text format file. See [Static Channel Config](#static-channel-config). | ### Shadow Flags diff --git a/asil_client/Android.bp b/asil_client/Android.bp new file mode 100644 index 00000000..7706cb1d --- /dev/null +++ b/asil_client/Android.bp @@ -0,0 +1,36 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// The ASIL client core: C++17, the standard library and POSIX only. It is +// built without exceptions or RTTI. +cc_library_static { + name: "libsubspace_asil_client", + defaults: ["subspace_defaults"], + srcs: [ + "channel_memory.cc", + "checksum.cc", + "client.cc", + "error.cc", + "publisher.cc", + "subscriber.cc", + ], + include_dirs: ["external/subspace"], + cppflags: ["-fno-exceptions"], + rtti: false, +} + +// The handshake with the server, using phaser messages in fixed buffers. +cc_library_static { + name: "libsubspace_asil_phaser_connection", + defaults: ["subspace_defaults"], + srcs: ["phaser_connection.cc"], + include_dirs: ["external/subspace"], + cppflags: ["-fno-exceptions"], + rtti: false, + static_libs: [ + "libsubspace_asil_client", + "libsubspace_phaser", + ], + export_static_lib_headers: ["libsubspace_phaser"], +} diff --git a/asil_client/BUILD.bazel b/asil_client/BUILD.bazel new file mode 100644 index 00000000..d673e5d9 --- /dev/null +++ b/asil_client/BUILD.bazel @@ -0,0 +1,89 @@ +package(default_visibility = ["//visibility:public"]) + +load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test") + +# The ASIL client core: C++17, the standard library and POSIX only. It is +# built without exceptions or RTTI. +cc_library( + name = "asil_client", + srcs = [ + "channel_memory.cc", + "checksum.cc", + "client.cc", + "error.cc", + "publisher.cc", + "subscriber.cc", + ], + hdrs = [ + "bitset.h", + "channel_memory.h", + "checksum.h", + "client.h", + "error.h", + "publisher.h", + "server_connection.h", + "shm_layout.h", + "split_buffer.h", + "subscriber.h", + "unique_fd.h", + ], + copts = [ + "-fno-exceptions", + "-fno-rtti", + ], + linkopts = select({ + "//:macos_x86_64": [], + "//:macos_arm64": [], + "//:macos_default": [], + "//:android": [], + "//:qnx": ["-lsocket"], + "//conditions:default": ["-lrt"], + }), + deps = ["//common:max_channels_setting"], +) + +# The standard server handshake, using Phaser messages in fixed buffers and +# protobuf wire format. +cc_library( + name = "phaser_connection", + srcs = ["phaser_connection.cc"], + hdrs = ["phaser_connection.h"], + copts = [ + "-fno-exceptions", + "-fno-rtti", + ], + deps = [ + ":asil_client", + "//common:server_wire", + ], +) + +cc_test( + name = "layout_test", + size = "small", + srcs = ["layout_test.cc"], + deps = [ + ":asil_client", + "//client:subspace_client", + "//common:subspace_common", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "asil_client_test", + size = "small", + srcs = ["asil_client_test.cc"], + deps = [ + ":asil_client", + ":phaser_connection", + "//client:subspace_client", + "//client:test_fixture", + "//server", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@coroutines//:co", + "@googletest//:gtest", + ], +) diff --git a/asil_client/CMakeLists.txt b/asil_client/CMakeLists.txt new file mode 100644 index 00000000..e1692392 --- /dev/null +++ b/asil_client/CMakeLists.txt @@ -0,0 +1,46 @@ +# CMakeLists.txt for the asil_client subdirectory + +# The ASIL client core: C++17, the standard library and POSIX only. It is +# built without exceptions or RTTI. +add_library(subspace_asil_client STATIC + channel_memory.cc + checksum.cc + client.cc + error.cc + publisher.cc + subscriber.cc +) +target_compile_options(subspace_asil_client PRIVATE -fno-exceptions -fno-rtti) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT ANDROID) + target_link_libraries(subspace_asil_client PUBLIC rt) +endif() + +add_executable(asil_layout_test layout_test.cc) +add_test(NAME asil_layout_test COMMAND asil_layout_test) +target_link_libraries(asil_layout_test PUBLIC + subspace_asil_client + subspace_client + subspace_common + gtest + gtest_main +) + +# The handshake with the server, using phaser messages in fixed buffers. +add_library(subspace_asil_phaser_connection STATIC + phaser_connection.cc +) +target_compile_options(subspace_asil_phaser_connection PRIVATE -fno-exceptions -fno-rtti) +target_link_libraries(subspace_asil_phaser_connection PUBLIC + subspace_asil_client + subspace_phaser +) + +add_executable(asil_client_test asil_client_test.cc) +add_test(NAME asil_client_test COMMAND asil_client_test) +target_link_libraries(asil_client_test PUBLIC + subspace_asil_client + subspace_asil_phaser_connection + subspace_client + libserver + gmock +) diff --git a/asil_client/asil_client_test.cc b/asil_client/asil_client_test.cc new file mode 100644 index 00000000..bd7c5476 --- /dev/null +++ b/asil_client/asil_client_test.cc @@ -0,0 +1,1409 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// Interoperability tests: the ASIL client and the standard client share +// channels on a server running a static channel config. Static channels keep +// their messages for the life of the server, so each test uses its own +// channel. + +#include "client/test_fixture.h" + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "asil_client/client.h" +#include "asil_client/phaser_connection.h" +#include "asil_client/publisher.h" +#include "asil_client/shm_layout.h" +#include "asil_client/split_buffer.h" +#include "asil_client/subscriber.h" +#include "server/static_config.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +// Counts heap allocations made by this thread while counting is set. +thread_local bool counting_allocations = false; +thread_local int allocations = 0; +} // namespace + +void *operator new(std::size_t size) { + if (counting_allocations) { + allocations++; + } + void *p = std::malloc(size == 0 ? 1 : size); + if (p == nullptr) { + throw std::bad_alloc(); + } + return p; +} + +void operator delete(void *p) noexcept { std::free(p); } +void operator delete(void *p, std::size_t) noexcept { std::free(p); } + +namespace { + +namespace asil = ::subspace::asil; +using ::testing::HasSubstr; + +constexpr char kConfig[] = R"pb( + multiplexers { name: "/asil/mux" slot_size: 128 num_slots: 8 } + channels { name: "/asil/to_std" slot_size: 256 num_slots: 8 type: "T" } + channels { name: "/asil/from_std" slot_size: 256 num_slots: 8 type: "T" } + channels { name: "/asil/wait" slot_size: 64 num_slots: 4 } + channels { name: "/asil/late_sub" slot_size: 64 num_slots: 4 } + channels { name: "/asil/sum_to_std" slot_size: 128 num_slots: 4 } + channels { name: "/asil/sum_from_std" slot_size: 128 num_slots: 4 } + channels { name: "/asil/meta" slot_size: 64 num_slots: 8 metadata_size: 16 } + channels { name: "/asil/newest" slot_size: 64 num_slots: 8 } + channels { name: "/asil/drops_in" slot_size: 64 num_slots: 4 } + channels { name: "/asil/drops_out" slot_size: 64 num_slots: 4 } + channels { name: "/asil/activate" slot_size: 64 num_slots: 4 } + channels { name: "/asil/cycle" slot_size: 64 num_slots: 4 } + channels { name: "/asil/hold" slot_size: 64 num_slots: 4 } + channels { name: "/asil/reject" slot_size: 64 num_slots: 4 } + channels { name: "/asil/close" slot_size: 64 num_slots: 4 } + channels { name: "/asil/v1" mux: "/asil/mux" vchan_id: 1 } + channels { name: "/asil/v2" mux: "/asil/mux" vchan_id: 2 } + channels { name: "/asil/rel_to_std" slot_size: 64 num_slots: 4 } + channels { name: "/asil/rel_from_std" slot_size: 64 num_slots: 4 } + channels { name: "/asil/rel_newest" slot_size: 64 num_slots: 4 } + channels { name: "/asil/rel_late" slot_size: 64 num_slots: 4 } + channels { name: "/asil/multi" slot_size: 64 num_slots: 8 } + channels { name: "/asil/no_alloc" slot_size: 64 num_slots: 4 } + channels { name: "/asil/oversize" slot_size: 64 num_slots: 4 } + channels { name: "/asil/early_sub" slot_size: 64 num_slots: 4 } + channels { + name: "/asil/split_to_std" + slot_size: 256 + num_slots: 4 + use_split_buffers: true + } + channels { + name: "/asil/split_from_std" + slot_size: 256 + num_slots: 4 + use_split_buffers: true + } + channels { + name: "/asil/split_reject" + slot_size: 64 + num_slots: 4 + use_split_buffers: true + } + multiplexers { + name: "/asil/split_mux" + slot_size: 128 + num_slots: 8 + use_split_buffers: true + } + channels { name: "/asil/split_v" mux: "/asil/split_mux" vchan_id: 4 } +)pb"; + +subspace::PublisherOptions FixedSize() { + return subspace::PublisherOptions().SetFixedSize(true); +} + +// A standard publisher on a static channel. +subspace::PublisherOptions ServerDefined() { + return subspace::PublisherOptions().SetServerDefined(true); +} + +void StdPublish(subspace::Publisher &pub, const std::string &text) { + absl::StatusOr buffer = pub.GetMessageBuffer(); + ASSERT_OK(buffer); + memcpy(*buffer, text.data(), text.size()); + ASSERT_OK(pub.PublishMessage(static_cast(text.size()))); +} + +void AsilPublish(asil::Publisher &pub, const std::string &text) { + void *buffer = pub.Buffer(); + ASSERT_NE(nullptr, buffer); + memcpy(buffer, text.data(), text.size()); + ASSERT_EQ(asil::Error::kOk, pub.Publish(text.size())); +} + +std::string Text(const asil::Message &msg) { + return std::string(static_cast(msg.data), msg.length); +} + +std::string Text(const subspace::Message &msg) { + return std::string(static_cast(msg.buffer), msg.length); +} + +// A copy of a message read by a standard subscriber. The message itself is +// released before returning because standard subscribers default to one +// active message. +struct StdMessage { + std::string text; + uint64_t ordinal = 0; + uint64_t timestamp = 0; + bool is_activation = false; + bool checksum_error = false; + std::string metadata; +}; + +StdMessage StdRead(subspace::Subscriber &sub) { + absl::StatusOr msg = sub.ReadMessage(); + EXPECT_TRUE(msg.ok()) << msg.status(); + StdMessage copy; + if (!msg.ok()) { + return copy; + } + copy.text = Text(*msg); + copy.ordinal = msg->ordinal; + copy.timestamp = msg->timestamp; + copy.is_activation = msg->is_activation; + copy.checksum_error = msg->checksum_error; + absl::Span metadata = sub.GetMetadata(); + copy.metadata.assign(reinterpret_cast(metadata.data()), + metadata.size()); + return copy; +} + +bool Readable(int fd) { + struct pollfd p = {fd, POLLIN, 0}; + return ::poll(&p, 1, 0) == 1; +} + +asil::PublisherOptions AsilPubOptions(int64_t slot_size, int32_t num_slots) { + asil::PublisherOptions options; + options.slot_size = slot_size; + options.num_slots = num_slots; + return options; +} + +// An ASIL publisher on a static channel. +asil::PublisherOptions StaticPubOptions() { + asil::PublisherOptions options; + options.server_defined = true; + return options; +} + +// A server on its own thread. config is a static channel config, or null +// for a server that creates channels on demand. +class TestServer { +public: + void Start(const char *config) { +#if defined(__ANDROID__) + char socket_name_template[] = "/data/local/tmp/subspaceXXXXXX"; // NOLINT +#else + char socket_name_template[] = "/tmp/subspaceXXXXXX"; // NOLINT +#endif + ::close(mkstemp(&socket_name_template[0])); + socket_ = &socket_name_template[0]; + + (void)pipe(server_pipe_); + + server_ = std::make_unique( + engine_, socket_, "", 0, 0, + /*local=*/true, server_pipe_[1], /*initial_ordinal=*/1, + /*wait_for_clients=*/true); + if (config != nullptr) { + absl::StatusOr parsed = + subspace::ParseStaticChannelConfig(config); + ASSERT_OK(parsed); + ASSERT_OK(server_->SetStaticChannelConfig(*std::move(parsed))); + } + + server_thread_ = std::thread([this]() { + absl::Status s = server_->Run(); + if (!s.ok()) { + fprintf(stderr, "Error running Subspace server: %s\n", + s.ToString().c_str()); + exit(1); + } + }); + + char buf[8]; + (void)::read(server_pipe_[0], buf, 8); + } + + void Stop() { + server_->Stop(); + + char buf[8]; + (void)::read(server_pipe_[0], buf, 8); + server_thread_.join(); + server_->CleanupAfterSession(); + server_.reset(); + ::close(server_pipe_[0]); + ::close(server_pipe_[1]); + (void)remove(socket_.c_str()); + } + + const std::string &Socket() const { return socket_; } + +private: + subspace::async::RuntimeEngine engine_; + std::string socket_; + int server_pipe_[2]; + std::unique_ptr server_; + std::thread server_thread_; +}; + +// Connects a standard client and an ASIL client to a test server. +class AsilTestBase : public ::testing::Test { +public: + subspace::Client &Std() { return std_client_; } + asil::Client &Asil() { return asil_client_; } + asil::PhaserServerConnection &Connection() { return connection_; } + +protected: + void Connect(const std::string &socket) { + signal(SIGPIPE, SIG_IGN); + ASSERT_OK(std_client_.Init(socket)); + ASSERT_EQ(asil::Error::kOk, connection_.Connect(socket.c_str())); + ASSERT_EQ(asil::Error::kOk, asil_client_.Init(connection_, "asil_test")); + } + +private: + subspace::Client std_client_; + asil::PhaserServerConnection connection_; + asil::Client asil_client_; +}; + +// A server with a static channel config. +class AsilClientTest : public AsilTestBase { +public: + static void SetUpTestSuite() { server_.Start(kConfig); } + static void TearDownTestSuite() { server_.Stop(); } + void SetUp() override { Connect(server_.Socket()); } + +private: + inline static TestServer server_; +}; + +// A server that creates channels on demand, so channels can resize and use +// subscriber queues. +class AsilDynamicTest : public AsilTestBase { +public: + static void SetUpTestSuite() { server_.Start(nullptr); } + static void TearDownTestSuite() { server_.Stop(); } + void SetUp() override { Connect(server_.Socket()); } + +private: + inline static TestServer server_; +}; + +TEST_F(AsilClientTest, AsilPublisherStandardSubscriber) { + absl::StatusOr sub = + Std().CreateSubscriber("/asil/to_std"); + ASSERT_OK(sub); + + asil::Publisher pub; + asil::PublisherOptions options = StaticPubOptions(); + options.type = "T"; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/to_std", options, pub)); + EXPECT_EQ(256, pub.SlotSize()); + EXPECT_EQ(8, pub.NumSlots()); + + asil::PublishedMessage published; + void *buffer = pub.Buffer(); + ASSERT_NE(nullptr, buffer); + memcpy(buffer, "one", 3); + ASSERT_EQ(asil::Error::kOk, pub.Publish(3, &published)); + EXPECT_NE(0u, published.ordinal); + AsilPublish(pub, "two"); + AsilPublish(pub, "three"); + + StdMessage msg = StdRead(*sub); + EXPECT_EQ("one", msg.text); + EXPECT_EQ(published.ordinal, msg.ordinal); + EXPECT_EQ(published.timestamp, msg.timestamp); + EXPECT_EQ("two", StdRead(*sub).text); + EXPECT_EQ("three", StdRead(*sub).text); + EXPECT_EQ("", StdRead(*sub).text); +} + +TEST_F(AsilClientTest, StandardPublisherAsilSubscriber) { + asil::Subscriber sub; + asil::SubscriberOptions options; + options.type = "T"; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/from_std", options, sub)); + + // Nothing has been published, so there is no buffer to read yet. + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); + + absl::StatusOr pub = + Std().CreatePublisher("/asil/from_std", ServerDefined()); + ASSERT_OK(pub); + StdPublish(*pub, "alpha"); + StdPublish(*pub, "beta"); + + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("alpha", Text(msg)); + EXPECT_EQ(0u, msg.dropped); + const uint64_t first = msg.ordinal; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("beta", Text(msg)); + EXPECT_EQ(first + 1, msg.ordinal); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); +} + +TEST_F(AsilClientTest, AsilToAsilWithWait) { + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/wait", {}, sub)); + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/wait", StaticPubOptions(), pub)); + + // Drain the trigger set when the subscriber opened. + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); + EXPECT_EQ(asil::Error::kTimeout, sub.Wait(10)); + + AsilPublish(pub, "wake"); + EXPECT_EQ(asil::Error::kOk, sub.Wait(1000)); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("wake", Text(msg)); +} + +TEST_F(AsilClientTest, SubscriberJoinsAfterPublisher) { + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/late_sub", StaticPubOptions(), pub)); + + absl::StatusOr sub = + Std().CreateSubscriber("/asil/late_sub"); + ASSERT_OK(sub); + EXPECT_EQ("", StdRead(*sub).text); + ASSERT_FALSE(Readable(sub->GetPollFd().fd)); + + // The publisher learns of the new subscriber's trigger when it publishes. + AsilPublish(pub, "late"); + EXPECT_TRUE(Readable(sub->GetPollFd().fd)); + EXPECT_EQ("late", StdRead(*sub).text); +} + +TEST_F(AsilClientTest, ChecksumToStandard) { + absl::StatusOr sub = Std().CreateSubscriber( + "/asil/sum_to_std", subspace::SubscriberOptions().SetChecksum(true)); + ASSERT_OK(sub); + asil::Publisher pub; + asil::PublisherOptions options = StaticPubOptions(); + options.checksum = true; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/sum_to_std", options, pub)); + AsilPublish(pub, "checked"); + + StdMessage msg = StdRead(*sub); + EXPECT_EQ("checked", msg.text); + EXPECT_FALSE(msg.checksum_error); +} + +TEST_F(AsilClientTest, ChecksumFromStandard) { + asil::SubscriberOptions strict; + strict.checksum = true; + asil::Subscriber strict_sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/sum_from_std", strict, strict_sub)); + asil::SubscriberOptions lenient = strict; + lenient.pass_checksum_errors = true; + asil::Subscriber lenient_sub; + ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/sum_from_std", + lenient, lenient_sub)); + + absl::StatusOr pub = Std().CreatePublisher( + "/asil/sum_from_std", ServerDefined().SetChecksum(true)); + ASSERT_OK(pub); + StdPublish(*pub, "good"); + + // Corrupt the second message after it has been published. + absl::StatusOr buffer = pub->GetMessageBuffer(); + ASSERT_OK(buffer); + memcpy(*buffer, "bad", 3); + ASSERT_OK(pub->PublishMessage(3)); + static_cast(*buffer)[0] = 'B'; + + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, strict_sub.ReadMessage(msg)); + EXPECT_EQ("good", Text(msg)); + EXPECT_FALSE(msg.checksum_error); + EXPECT_EQ(asil::Error::kChecksumMismatch, strict_sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); + + ASSERT_EQ(asil::Error::kOk, lenient_sub.ReadMessage(msg)); + EXPECT_EQ("good", Text(msg)); + ASSERT_EQ(asil::Error::kOk, lenient_sub.ReadMessage(msg)); + EXPECT_EQ("Bad", Text(msg)); + EXPECT_TRUE(msg.checksum_error); +} + +TEST_F(AsilClientTest, Metadata) { + asil::Subscriber asil_sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/meta", {}, asil_sub)); + absl::StatusOr std_sub = + Std().CreateSubscriber("/asil/meta"); + ASSERT_OK(std_sub); + + asil::Publisher asil_pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/meta", StaticPubOptions(), asil_pub)); + ASSERT_EQ(16u, asil_pub.MetadataSize()); + memcpy(asil_pub.Metadata(), "asil-metadata!!", 16); + AsilPublish(asil_pub, "from asil"); + + StdMessage std_msg = StdRead(*std_sub); + EXPECT_EQ("from asil", std_msg.text); + EXPECT_EQ(std::string("asil-metadata!!", 16), std_msg.metadata); + + absl::StatusOr std_pub = + Std().CreatePublisher("/asil/meta", ServerDefined()); + ASSERT_OK(std_pub); + absl::StatusOr buffer = std_pub->GetMessageBuffer(); + ASSERT_OK(buffer); + absl::Span meta = std_pub->GetMetadata(); + ASSERT_EQ(16u, meta.size()); + memcpy(meta.data(), "std-metadata!!!", 16); + memcpy(*buffer, "from std", 8); + ASSERT_OK(std_pub->PublishMessage(8)); + + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, asil_sub.ReadMessage(msg)); + EXPECT_EQ("from asil", Text(msg)); + ASSERT_EQ(16u, msg.metadata_length); + EXPECT_EQ(0, memcmp(msg.metadata, "asil-metadata!!", 16)); + ASSERT_EQ(asil::Error::kOk, asil_sub.ReadMessage(msg)); + EXPECT_EQ("from std", Text(msg)); + ASSERT_EQ(16u, msg.metadata_length); + EXPECT_EQ(0, memcmp(msg.metadata, "std-metadata!!!", 16)); +} + +TEST_F(AsilClientTest, ReadNewest) { + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/newest", {}, sub)); + absl::StatusOr pub = + Std().CreatePublisher("/asil/newest", ServerDefined()); + ASSERT_OK(pub); + StdPublish(*pub, "old"); + StdPublish(*pub, "older"); + StdPublish(*pub, "newest"); + + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, + sub.ReadMessage(msg, asil::ReadMode::kReadNewest)); + EXPECT_EQ("newest", Text(msg)); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); + + StdPublish(*pub, "next"); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("next", Text(msg)); +} + +TEST_F(AsilClientTest, AsilSubscriberCountsDrops) { + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/drops_in", {}, sub)); + absl::StatusOr pub = + Std().CreatePublisher("/asil/drops_in", ServerDefined()); + ASSERT_OK(pub); + StdPublish(*pub, "first"); + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("first", Text(msg)); + const uint64_t first = msg.ordinal; + sub.ReleaseMessage(); + + for (int i = 0; i < 10; i++) { + StdPublish(*pub, "m" + std::to_string(i)); + } + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_GT(msg.dropped, 0u); + EXPECT_EQ(first + 1 + msg.dropped, msg.ordinal); + uint64_t last = msg.ordinal; + std::string last_text = Text(msg); + int received = 1; + for (;;) { + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + if (msg.length == 0) { + break; + } + EXPECT_EQ(last + 1, msg.ordinal); + EXPECT_EQ(0u, msg.dropped); + last = msg.ordinal; + last_text = Text(msg); + received++; + } + EXPECT_EQ("m9", last_text); + EXPECT_EQ(first + 10, last); + EXPECT_LT(received, 10); +} + +TEST_F(AsilClientTest, StandardSubscriberSeesAsilDrops) { + absl::StatusOr sub = + Std().CreateSubscriber("/asil/drops_out"); + ASSERT_OK(sub); + int64_t dropped = 0; + ASSERT_OK(sub->RegisterDroppedMessageCallback( + [&dropped](subspace::Subscriber *, int64_t n) { dropped += n; })); + + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/drops_out", StaticPubOptions(), pub)); + AsilPublish(pub, "first"); + EXPECT_EQ("first", StdRead(*sub).text); + + for (int i = 0; i < 10; i++) { + AsilPublish(pub, "m" + std::to_string(i)); + } + int received = 0; + std::string last; + for (;;) { + const StdMessage msg = StdRead(*sub); + if (msg.text.empty()) { + break; + } + last = msg.text; + received++; + } + EXPECT_EQ("m9", last); + EXPECT_GT(dropped, 0); + EXPECT_EQ(10, received + dropped); +} + +TEST_F(AsilClientTest, Activation) { + absl::StatusOr std_sub = Std().CreateSubscriber( + "/asil/activate", subspace::SubscriberOptions().SetPassActivation(true)); + ASSERT_OK(std_sub); + asil::Subscriber asil_sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/activate", {}, asil_sub)); + + asil::Publisher pub; + asil::PublisherOptions options = StaticPubOptions(); + options.activate = true; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/activate", options, pub)); + AsilPublish(pub, "payload"); + + EXPECT_TRUE(StdRead(*std_sub).is_activation); + StdMessage msg = StdRead(*std_sub); + EXPECT_FALSE(msg.is_activation); + EXPECT_EQ("payload", msg.text); + + asil::Message asil_msg; + ASSERT_EQ(asil::Error::kOk, asil_sub.ReadMessage(asil_msg)); + EXPECT_FALSE(asil_msg.is_activation); + EXPECT_EQ("payload", Text(asil_msg)); + EXPECT_EQ(0u, asil_msg.dropped); +} + +TEST_F(AsilClientTest, ManyMessagesThroughFewSlots) { + asil::Subscriber asil_sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/cycle", {}, asil_sub)); + absl::StatusOr std_sub = + Std().CreateSubscriber("/asil/cycle"); + ASSERT_OK(std_sub); + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/cycle", StaticPubOptions(), pub)); + + for (int i = 0; i < 200; i++) { + const std::string text = "message " + std::to_string(i); + AsilPublish(pub, text); + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, asil_sub.ReadMessage(msg)); + ASSERT_EQ(text, Text(msg)); + ASSERT_EQ(0u, msg.dropped); + ASSERT_EQ(text, StdRead(*std_sub).text); + } +} + +TEST_F(AsilClientTest, HeldMessageIsNotOverwritten) { + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/hold", {}, sub)); + absl::StatusOr pub = + Std().CreatePublisher("/asil/hold", ServerDefined()); + ASSERT_OK(pub); + StdPublish(*pub, "held"); + asil::Message held; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(held)); + ASSERT_EQ("held", Text(held)); + + for (int i = 0; i < 20; i++) { + StdPublish(*pub, "overwrite " + std::to_string(i)); + } + EXPECT_EQ("held", Text(held)); + sub.ReleaseMessage(); + + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, + sub.ReadMessage(msg, asil::ReadMode::kReadNewest)); + EXPECT_EQ("overwrite 19", Text(msg)); +} + +TEST_F(AsilClientTest, Rejections) { + // A static channel's publishers let the server define its layout. + asil::Publisher pub; + EXPECT_EQ(asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/reject", AsilPubOptions(64, 4), pub)); + EXPECT_THAT(Asil().LastServerError(), HasSubstr("must set server_defined")); + EXPECT_FALSE(pub.IsOpen()); + + EXPECT_EQ(asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/unknown", StaticPubOptions(), pub)); + EXPECT_THAT(Asil().LastServerError(), + HasSubstr("isn't in the static channel config")); + + asil::Subscriber sub; + EXPECT_EQ(asil::Error::kServerRejected, + Asil().CreateSubscriber("/asil/unknown", {}, sub)); + + // Layout options alongside server_defined. + for (int field = 0; field < 6; field++) { + asil::PublisherOptions options = StaticPubOptions(); + switch (field) { + case 0: + options.slot_size = 64; + break; + case 1: + options.num_slots = 4; + break; + case 2: + options.checksum_size = 4; + break; + case 3: + options.metadata_size = 16; + break; + case 4: + options.subscriber_queue_arena_size = 4096; + break; + default: + options.use_split_buffers = true; + break; + } + EXPECT_EQ(asil::Error::kInvalidArgument, + Asil().CreatePublisher("/asil/reject", options, pub)) + << field; + } + EXPECT_EQ(asil::Error::kCapacityExceeded, + Asil().CreatePublisher(std::string(300, 'x').c_str(), + StaticPubOptions(), pub)); + + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/reject", StaticPubOptions(), pub)); + EXPECT_EQ(64, pub.SlotSize()); + EXPECT_EQ(4, pub.NumSlots()); + EXPECT_EQ(asil::Error::kAlreadyInitialized, + Asil().CreatePublisher("/asil/reject", StaticPubOptions(), pub)); + EXPECT_EQ(asil::Error::kMessageTooLarge, pub.Publish(65)); + + asil::Client uninitialized; + asil::Publisher other; + EXPECT_EQ( + asil::Error::kNotInitialized, + uninitialized.CreatePublisher("/asil/reject", StaticPubOptions(), other)); +} + +TEST_F(AsilClientTest, CloseRemovesFromServer) { + asil::Publisher pub; + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/close", StaticPubOptions(), pub)); + ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/close", {}, sub)); + absl::StatusOr info = + Std().GetChannelInfo("/asil/close"); + ASSERT_OK(info); + EXPECT_EQ(1, info->num_publishers); + EXPECT_EQ(1, info->num_subscribers); + + EXPECT_EQ(asil::Error::kOk, pub.Close()); + EXPECT_EQ(asil::Error::kOk, sub.Close()); + EXPECT_FALSE(pub.IsOpen()); + EXPECT_EQ(asil::Error::kOk, pub.Close()); + absl::StatusOr closed_info = + Std().GetChannelInfo("/asil/close"); + ASSERT_OK(closed_info); + EXPECT_EQ(0, closed_info->num_publishers); + EXPECT_EQ(0, closed_info->num_subscribers); + + // The channel can be used again. + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/close", StaticPubOptions(), pub)); + ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/close", {}, sub)); + AsilPublish(pub, "again"); + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("again", Text(msg)); +} + +TEST_F(AsilClientTest, HandshakeMakesNoHeapAllocations) { + asil::Publisher pub; + asil::Subscriber sub; + allocations = 0; + counting_allocations = true; + int *volatile counted = new int(1); + counting_allocations = false; + delete counted; + ASSERT_EQ(1, allocations); + + allocations = 0; + counting_allocations = true; + const asil::Error pub_error = + Asil().CreatePublisher("/asil/no_alloc", StaticPubOptions(), pub); + const asil::Error sub_error = + Asil().CreateSubscriber("/asil/no_alloc", {}, sub); + const asil::Error pub_close = pub.Close(); + const asil::Error sub_close = sub.Close(); + counting_allocations = false; + + EXPECT_EQ(asil::Error::kOk, pub_error); + EXPECT_EQ(asil::Error::kOk, sub_error); + EXPECT_EQ(asil::Error::kOk, pub_close); + EXPECT_EQ(asil::Error::kOk, sub_close); + EXPECT_EQ(0, allocations); +} + +TEST_F(AsilClientTest, OversizedRequestKeepsTheConnection) { + const std::string huge(asil::PhaserServerConnection::kWireBufferSize, 'x'); + asil::TriggersReply reply; + EXPECT_EQ(asil::Error::kCapacityExceeded, + Connection().GetTriggers(huge.c_str(), reply)); + EXPECT_TRUE(Connection().Connected()); + + asil::TriggerFdList subscribers; + reply.subscriber_triggers = &subscribers; + EXPECT_EQ(asil::Error::kOk, + Connection().GetTriggers("/asil/oversize", reply)); +} + +// Publishes "" for n from first until the reliable publisher has +// no free slot, and returns the number published. +int FillReliable(asil::Publisher &pub, const std::string &prefix, int first) { + int n = first; + for (; n < first + 100; n++) { + void *buffer = pub.Buffer(); + if (buffer == nullptr) { + break; + } + const std::string text = prefix + std::to_string(n); + memcpy(buffer, text.data(), text.size()); + EXPECT_EQ(asil::Error::kOk, pub.Publish(text.size())); + } + return n - first; +} + +asil::PublisherOptions ReliablePubOptions() { + asil::PublisherOptions options = StaticPubOptions(); + options.reliable = true; + return options; +} + +TEST_F(AsilClientTest, ReliableAsilPublisherStandardSubscriber) { + absl::StatusOr sub = Std().CreateSubscriber( + "/asil/rel_to_std", subspace::SubscriberOptions().SetReliable(true)); + ASSERT_OK(sub); + asil::Publisher pub; + ASSERT_EQ( + asil::Error::kOk, + Asil().CreatePublisher("/asil/rel_to_std", ReliablePubOptions(), pub)); + EXPECT_TRUE(pub.IsReliable()); + + // The activation message and the unread messages fill the channel. + const int published = FillReliable(pub, "r", 0); + EXPECT_EQ(3, published); + EXPECT_EQ(asil::Error::kTimeout, pub.Wait(10)); + + // Reading frees slots and wakes the publisher. + EXPECT_EQ("r0", StdRead(*sub).text); + EXPECT_EQ(asil::Error::kOk, pub.Wait(1000)); + const int more = FillReliable(pub, "r", published); + EXPECT_GT(more, 0); + for (int i = 1; i < published + more; i++) { + EXPECT_EQ("r" + std::to_string(i), StdRead(*sub).text); + } + EXPECT_EQ("", StdRead(*sub).text); +} + +TEST_F(AsilClientTest, StandardReliablePublisherAsilSubscriber) { + asil::Subscriber sub; + asil::SubscriberOptions options; + options.reliable = true; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/rel_from_std", options, sub)); + EXPECT_TRUE(sub.IsReliable()); + absl::StatusOr pub = Std().CreatePublisher( + "/asil/rel_from_std", ServerDefined().SetReliable(true)); + ASSERT_OK(pub); + + int published = 0; + for (; published < 100; published++) { + absl::StatusOr buffer = pub->GetMessageBuffer(); + ASSERT_OK(buffer); + if (*buffer == nullptr) { + break; + } + const std::string text = "s" + std::to_string(published); + memcpy(*buffer, text.data(), text.size()); + ASSERT_OK(pub->PublishMessage(static_cast(text.size()))); + } + EXPECT_EQ(3, published); + EXPECT_FALSE(Readable(pub->GetPollFd().fd)); + + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("s0", Text(msg)); + EXPECT_FALSE(msg.is_activation); + EXPECT_TRUE(Readable(pub->GetPollFd().fd)); + StdPublish(*pub, "s3"); + + uint64_t last = msg.ordinal; + for (int i = 1; i <= published; i++) { + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("s" + std::to_string(i), Text(msg)); + EXPECT_EQ(last + 1, msg.ordinal); + EXPECT_EQ(0u, msg.dropped); + last = msg.ordinal; + } + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); +} + +TEST_F(AsilClientTest, ReliableReadNewestFreesSkippedSlots) { + asil::SubscriberOptions options; + options.reliable = true; + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/rel_newest", options, sub)); + asil::Publisher pub; + ASSERT_EQ( + asil::Error::kOk, + Asil().CreatePublisher("/asil/rel_newest", ReliablePubOptions(), pub)); + ASSERT_EQ(3, FillReliable(pub, "n", 0)); + + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, + sub.ReadMessage(msg, asil::ReadMode::kReadNewest)); + EXPECT_EQ("n2", Text(msg)); + EXPECT_EQ(asil::Error::kOk, pub.Wait(1000)); + + // The skipped messages no longer hold back the publisher. The held message + // keeps its slot. + EXPECT_EQ(3, FillReliable(pub, "m", 0)); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("m" + std::to_string(i), Text(msg)); + } +} + +TEST_F(AsilClientTest, ReliablePublisherWaitsForSubscriber) { + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher( + "/asil/rel_late", ReliablePubOptions(), pub)); + EXPECT_EQ(nullptr, pub.Buffer()); + + absl::StatusOr sub = Std().CreateSubscriber( + "/asil/rel_late", subspace::SubscriberOptions().SetReliable(true)); + ASSERT_OK(sub); + AsilPublish(pub, "hello"); + EXPECT_EQ("hello", StdRead(*sub).text); +} + +TEST_F(AsilClientTest, VirtualChannels) { + asil::Publisher rejected; + EXPECT_EQ(asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/v1", StaticPubOptions(), rejected)); + + asil::Subscriber mux_sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/mux", {}, mux_sub)); + asil::SubscriberOptions v2_options; + v2_options.mux = "/asil/mux"; + asil::Subscriber v2_sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/v2", v2_options, v2_sub)); + EXPECT_EQ(2, v2_sub.VirtualChannelId()); + absl::StatusOr v1_sub = Std().CreateSubscriber( + "/asil/v1", subspace::SubscriberOptions().SetMux("/asil/mux")); + ASSERT_OK(v1_sub); + + asil::PublisherOptions v1_options = StaticPubOptions(); + v1_options.mux = "/asil/mux"; + asil::Publisher v1_pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/v1", v1_options, v1_pub)); + EXPECT_EQ(1, v1_pub.VirtualChannelId()); + absl::StatusOr v2_pub = + Std().CreatePublisher("/asil/v2", ServerDefined().SetMux("/asil/mux")); + ASSERT_OK(v2_pub); + + AsilPublish(v1_pub, "a1"); + StdPublish(*v2_pub, "b1"); + AsilPublish(v1_pub, "a2"); + + EXPECT_EQ("a1", StdRead(*v1_sub).text); + EXPECT_EQ("a2", StdRead(*v1_sub).text); + EXPECT_EQ("", StdRead(*v1_sub).text); + + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, v2_sub.ReadMessage(msg)); + EXPECT_EQ("b1", Text(msg)); + EXPECT_EQ(2, msg.vchan_id); + ASSERT_EQ(asil::Error::kOk, v2_sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); + + // Each virtual channel has its own ordinals, so interleaving is no drop. + const struct { + const char *text; + int vchan_id; + } expected[] = {{"a1", 1}, {"b1", 2}, {"a2", 1}}; + for (const auto &e : expected) { + ASSERT_EQ(asil::Error::kOk, mux_sub.ReadMessage(msg)); + EXPECT_EQ(e.text, Text(msg)); + EXPECT_EQ(e.vchan_id, msg.vchan_id); + EXPECT_EQ(0u, msg.dropped); + } + ASSERT_EQ(asil::Error::kOk, mux_sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); +} + +TEST_F(AsilClientTest, MultipleActiveMessages) { + asil::SubscriberOptions options; + options.max_active_messages = 3; + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/multi", options, sub)); + absl::StatusOr pub = + Std().CreatePublisher("/asil/multi", ServerDefined()); + ASSERT_OK(pub); + for (const char *text : {"a", "b", "c", "d"}) { + StdPublish(*pub, text); + } + + asil::Message a, b, c, d; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(a)); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(b)); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(c)); + EXPECT_EQ(3, sub.NumActiveMessages()); + EXPECT_EQ(asil::Error::kActiveMessageLimit, sub.ReadMessage(d)); + EXPECT_EQ(0u, d.length); + + // Held messages survive the publisher cycling through the other slots. + for (int i = 0; i < 20; i++) { + StdPublish(*pub, "x" + std::to_string(i)); + } + EXPECT_EQ("a", Text(a)); + EXPECT_EQ("b", Text(b)); + EXPECT_EQ("c", Text(c)); + + EXPECT_EQ(asil::Error::kOk, sub.ReleaseMessage(b)); + EXPECT_EQ(2, sub.NumActiveMessages()); + EXPECT_NE(asil::Error::kOk, sub.ReleaseMessage(b)); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(d)); + EXPECT_FALSE(Text(d).empty()); + EXPECT_EQ(3, sub.NumActiveMessages()); + EXPECT_EQ("a", Text(a)); + sub.ReleaseMessage(); + EXPECT_EQ(0, sub.NumActiveMessages()); +} + +TEST_F(AsilClientTest, SubscriberOpensBeforeAnyPublisher) { + // The server created the channel's buffer, so the subscriber maps it now. + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/early_sub", {}, sub)); + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); + + absl::StatusOr pub = + Std().CreatePublisher("/asil/early_sub", ServerDefined()); + ASSERT_OK(pub); + StdPublish(*pub, "early"); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("early", Text(msg)); +} + +// An ASIL publisher on a static channel with split buffers. +asil::PublisherOptions SplitPubOptions(asil::SplitSlot *slots, + int32_t capacity) { + asil::PublisherOptions options = StaticPubOptions(); + options.split_slots = slots; + options.split_slot_capacity = capacity; + return options; +} + +asil::SubscriberOptions SplitSubOptions(asil::SplitSlot *slots, + int32_t capacity) { + asil::SubscriberOptions options; + options.split_slots = slots; + options.split_slot_capacity = capacity; + return options; +} + +TEST_F(AsilClientTest, SplitAsilPublisherStandardSubscriber) { + absl::StatusOr std_sub = + Std().CreateSubscriber("/asil/split_to_std"); + ASSERT_OK(std_sub); + EXPECT_TRUE(std_sub->UsesSplitBuffers()); + asil::SplitSlot sub_slots[4]; + asil::Subscriber asil_sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/split_to_std", + SplitSubOptions(sub_slots, 4), asil_sub)); + + asil::SplitSlot pub_slots[4]; + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/split_to_std", + SplitPubOptions(pub_slots, 4), pub)) + << Asil().LastServerError(); + EXPECT_GE(pub.SlotSize(), 256); + for (const asil::SplitSlot &slot : pub_slots) { + EXPECT_NE(nullptr, slot.mapping.address); + EXPECT_FALSE(slot.allocator_mapped); + } + + asil::Message msg; + for (int i = 0; i < 10; i++) { + const std::string text = "split " + std::to_string(i); + AsilPublish(pub, text); + EXPECT_EQ(text, StdRead(*std_sub).text); + ASSERT_EQ(asil::Error::kOk, asil_sub.ReadMessage(msg)); + EXPECT_EQ(text, Text(msg)); + EXPECT_EQ(sub_slots[msg.slot_id].mapping.address, msg.data); + } + pub.Close(); + asil_sub.Close(); + for (const asil::SplitSlot &slot : sub_slots) { + EXPECT_EQ(nullptr, slot.mapping.address); + } +} + +TEST_F(AsilClientTest, SplitStandardPublisherAsilSubscriber) { + asil::SplitSlot slots[4]; + asil::SubscriberOptions options = SplitSubOptions(slots, 4); + options.checksum = true; + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/split_from_std", options, sub)); + absl::StatusOr pub = Std().CreatePublisher( + "/asil/split_from_std", ServerDefined().SetChecksum(true)); + ASSERT_OK(pub); + + asil::Message msg; + for (int i = 0; i < 10; i++) { + const std::string text = "from std " + std::to_string(i); + StdPublish(*pub, text); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(text, Text(msg)); + EXPECT_FALSE(msg.checksum_error); + } +} + +TEST_F(AsilClientTest, SplitVirtualChannel) { + asil::SplitSlot sub_slots[8]; + asil::SubscriberOptions sub_options = SplitSubOptions(sub_slots, 8); + sub_options.mux = "/asil/split_mux"; + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/asil/split_v", sub_options, sub)); + EXPECT_EQ(4, sub.VirtualChannelId()); + + asil::SplitSlot pub_slots[8]; + asil::PublisherOptions pub_options = SplitPubOptions(pub_slots, 8); + pub_options.mux = "/asil/split_mux"; + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/split_v", pub_options, pub)) + << Asil().LastServerError(); + AsilPublish(pub, "vchan"); + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("vchan", Text(msg)); + EXPECT_EQ(4, msg.vchan_id); +} + +TEST_F(AsilClientTest, SplitRejections) { + asil::Subscriber sub; + EXPECT_EQ(asil::Error::kInvalidArgument, + Asil().CreateSubscriber("/asil/split_reject", {}, sub)); + asil::SplitSlot slots[4]; + EXPECT_EQ(asil::Error::kCapacityExceeded, + Asil().CreateSubscriber("/asil/split_reject", + SplitSubOptions(slots, 3), sub)); + EXPECT_FALSE(sub.IsOpen()); + + // The server says the channel has split buffers, and a publisher needs + // somewhere to map them too. + asil::Publisher pub; + EXPECT_EQ( + asil::Error::kInvalidArgument, + Asil().CreatePublisher("/asil/split_reject", StaticPubOptions(), pub)); + EXPECT_EQ(asil::Error::kCapacityExceeded, + Asil().CreatePublisher("/asil/split_reject", + SplitPubOptions(slots, 3), pub)); + EXPECT_FALSE(pub.IsOpen()); +} + +TEST_F(AsilDynamicTest, AsilPublisherCreatesChannel) { + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/dyn/create", AsilPubOptions(64, 8), pub)); + asil::Subscriber asil_sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/dyn/create", {}, asil_sub)); + absl::StatusOr std_sub = + Std().CreateSubscriber("/dyn/create"); + ASSERT_OK(std_sub); + asil::Publisher second; + ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher( + "/dyn/create", AsilPubOptions(64, 8), second)) + << Asil().LastServerError(); + absl::StatusOr std_pub = + Std().CreatePublisher("/dyn/create", 64, 8, FixedSize()); + ASSERT_OK(std_pub); + + AsilPublish(pub, "one"); + AsilPublish(second, "two"); + StdPublish(*std_pub, "three"); + asil::Message msg; + for (const char *text : {"one", "two", "three"}) { + EXPECT_EQ(text, StdRead(*std_sub).text); + ASSERT_EQ(asil::Error::kOk, asil_sub.ReadMessage(msg)); + EXPECT_EQ(text, Text(msg)); + } +} + +TEST_F(AsilDynamicTest, SubscriberNeedsBuffers) { + asil::Subscriber sub; + EXPECT_EQ(asil::Error::kNoBuffers, + Asil().CreateSubscriber("/dyn/placeholder", {}, sub)); + EXPECT_FALSE(sub.IsOpen()); + + absl::StatusOr pub = + Std().CreatePublisher("/dyn/placeholder", 64, 4); + ASSERT_OK(pub); + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/dyn/placeholder", {}, sub)); + StdPublish(*pub, "first"); + EXPECT_EQ(asil::Error::kOk, sub.Wait(1000)); + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("first", Text(msg)); +} + +TEST_F(AsilDynamicTest, SubscriberSkipsMessagesAfterResize) { + asil::Subscriber sub; + absl::StatusOr pub = + Std().CreatePublisher("/dyn/resize", 64, 4); + ASSERT_OK(pub); + ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/dyn/resize", {}, sub)); + StdPublish(*pub, "small"); + + const std::string big(1000, 'B'); + absl::StatusOr buffer = pub->GetMessageBuffer(1000); + ASSERT_OK(buffer); + memcpy(*buffer, big.data(), big.size()); + ASSERT_OK(pub->PublishMessage(static_cast(big.size()))); + StdPublish(*pub, "after"); + + // The subscriber maps only the buffer that existed when it opened. + asil::Message msg; + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("small", Text(msg)); + EXPECT_EQ(asil::Error::kBufferNotMapped, sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); + EXPECT_EQ(asil::Error::kBufferNotMapped, sub.ReadMessage(msg)); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(0u, msg.length); + + // A subscriber that opens after the resize maps the new buffer. + asil::Subscriber later; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/dyn/resize", {}, later)); + EXPECT_EQ(asil::Error::kBufferNotMapped, later.ReadMessage(msg)); + ASSERT_EQ(asil::Error::kOk, later.ReadMessage(msg)); + EXPECT_EQ(big, Text(msg)); + ASSERT_EQ(asil::Error::kOk, later.ReadMessage(msg)); + EXPECT_EQ("after", Text(msg)); + for (int i = 0; i < 10; i++) { + StdPublish(*pub, "later " + std::to_string(i)); + ASSERT_EQ(asil::Error::kOk, later.ReadMessage(msg)); + ASSERT_EQ("later " + std::to_string(i), Text(msg)); + } +} + +TEST_F(AsilDynamicTest, AsilPublisherJoinsResizedChannel) { + asil::Subscriber sub; + { + absl::StatusOr std_pub = + Std().CreatePublisher("/dyn/rejoin", 64, 4); + ASSERT_OK(std_pub); + StdPublish(*std_pub, "small"); + absl::StatusOr buffer = std_pub->GetMessageBuffer(1000); + ASSERT_OK(buffer); + ASSERT_OK(std_pub->PublishMessage(1000)); + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/dyn/rejoin", {}, sub)); + } + + // The publisher maps the newest buffer, which the subscriber also mapped. + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/dyn/rejoin", AsilPubOptions(64, 4), pub)); + EXPECT_GE(pub.SlotSize(), 1000); + AsilPublish(pub, "asil"); + + asil::Message msg; + EXPECT_EQ(asil::Error::kBufferNotMapped, sub.ReadMessage(msg)); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(1000u, msg.length); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("asil", Text(msg)); +} + +TEST_F(AsilDynamicTest, AsilPublisherFillsSubscriberQueues) { + asil::PublisherOptions options = AsilPubOptions(64, 8); + options.subscriber_queue_arena_size = 4096; + asil::Publisher pub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/dyn/queue", options, pub)); + absl::StatusOr sub = Std().CreateSubscriber( + "/dyn/queue", subspace::SubscriberOptions().SetSubscriberQueueSize(2)); + ASSERT_OK(sub); + asil::Subscriber asil_sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/dyn/queue", {}, asil_sub)); + + for (int i = 0; i < 5; i++) { + AsilPublish(pub, "q" + std::to_string(i)); + } + // A two entry queue keeps the newest two messages, so they are read first. + EXPECT_EQ("q3", StdRead(*sub).text); + EXPECT_EQ("q4", StdRead(*sub).text); + + asil::Message msg; + for (int i = 0; i < 5; i++) { + ASSERT_EQ(asil::Error::kOk, asil_sub.ReadMessage(msg)); + EXPECT_EQ("q" + std::to_string(i), Text(msg)); + } + + asil::Publisher wrong_arena; + EXPECT_EQ( + asil::Error::kServerRejected, + Asil().CreatePublisher("/dyn/queue", AsilPubOptions(64, 8), wrong_arena)); +} + +// Payloads that a standard publisher's custom allocator put on the heap. +// Clients in one process can share them by address. +struct HeapAllocations { + std::map> memory; + uintptr_t next_handle = 0; + int maps = 0; + int unmaps = 0; +}; + +subspace::SplitBufferCallbacks HeapCallbacks(HeapAllocations &heap) { + subspace::SplitBufferCallbacks callbacks; + callbacks.allocate = [&heap](const subspace::SplitBufferMetadata &metadata) + -> absl::StatusOr { + const uintptr_t handle = ++heap.next_handle; + auto &memory = heap.memory[handle]; + memory = std::make_unique(metadata.allocation_size); + subspace::SplitBufferMapping mapping; + mapping.handle = handle; + mapping.address = memory.get(); + mapping.size = static_cast(metadata.allocation_size); + return mapping; + }; + callbacks.unmap = [](const subspace::SplitBufferMetadata &, + const subspace::SplitBufferMapping &) { + return absl::OkStatus(); + }; + callbacks.free = [](const subspace::SplitBufferMetadata &, + const subspace::SplitBufferMapping &) { + return absl::OkStatus(); + }; + return callbacks; +} + +asil::Error HeapMap(void *context, const asil::SplitBufferInfo &info, + asil::SplitBufferMapping &mapping) { + auto *heap = static_cast(context); + auto it = heap->memory.find(static_cast(info.handle)); + if (it == heap->memory.end()) { + return asil::Error::kInvalidArgument; + } + heap->maps++; + mapping.address = it->second.get(); + mapping.size = info.allocation_size; + return asil::Error::kOk; +} + +void HeapUnmap(void *context, const asil::SplitBufferInfo &, + const asil::SplitBufferMapping &) { + static_cast(context)->unmaps++; +} + +TEST_F(AsilDynamicTest, SplitCustomAllocator) { + HeapAllocations heap; + asil::SplitSlot slots[4]; + asil::SubscriberOptions options; + options.split_slots = slots; + options.split_slot_capacity = 4; + options.split_allocator.map = HeapMap; + options.split_allocator.unmap = HeapUnmap; + options.split_allocator.context = &heap; + + // There is nothing to map until the publisher creates the buffers. + asil::Subscriber sub; + EXPECT_EQ(asil::Error::kNoBuffers, + Asil().CreateSubscriber("/dyn/custom", options, sub)); + + absl::StatusOr pub = Std().CreatePublisher( + "/dyn/custom", subspace::PublisherOptions() + .SetSlotSize(64) + .SetNumSlots(4) + .SetFixedSize(true) + .SetUseSplitBuffers(true) + .SetSplitBufferCallbacks(HeapCallbacks(heap))); + ASSERT_OK(pub); + + asil::Subscriber no_allocator; + asil::SubscriberOptions without = options; + without.split_allocator = asil::SplitBufferAllocator(); + EXPECT_EQ(asil::Error::kUnsupported, + Asil().CreateSubscriber("/dyn/custom", without, no_allocator)); + + ASSERT_EQ(asil::Error::kOk, + Asil().CreateSubscriber("/dyn/custom", options, sub)); + EXPECT_EQ(4, heap.maps); + for (const asil::SplitSlot &slot : slots) { + EXPECT_TRUE(slot.allocator_mapped); + EXPECT_EQ(asil::BufferAllocator::kSplitCallback, slot.info.allocator); + } + + asil::Message msg; + for (int i = 0; i < 8; i++) { + const std::string text = "heap " + std::to_string(i); + StdPublish(*pub, text); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ(text, Text(msg)); + } + sub.Close(); + EXPECT_EQ(4, heap.unmaps); +} + +} // namespace + +int main(int argc, char **argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/asil_client/bitset.h b/asil_client/bitset.h new file mode 100644 index 00000000..fcdeac82 --- /dev/null +++ b/asil_client/bitset.h @@ -0,0 +1,101 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#pragma once + +#include "asil_client/shm_layout.h" + +#include +#include +#include + +namespace subspace { +namespace asil { + +// A view of an AtomicBitSet in shared memory. The memory orders match +// common/atomic_bitset.h. The bit count stored in shared memory is ignored; +// the view uses the count the server reported for the channel. +class BitsetView { +public: + BitsetView() = default; + BitsetView(void *bitset, int num_bits) + : words_(reinterpret_cast *>( + static_cast(bitset) + sizeof(size_t))), + num_bits_(num_bits) {} + + void Set(int bit) { + words_[bit / 64].fetch_or(Mask(bit), std::memory_order_relaxed); + } + + void Clear(int bit) { + words_[bit / 64].fetch_and(~Mask(bit), std::memory_order_relaxed); + } + + bool SetWasClear(int bit) { + const uint64_t old = + words_[bit / 64].fetch_or(Mask(bit), std::memory_order_release); + return (old & Mask(bit)) == 0; + } + + bool ClearWasSet(int bit) { + const uint64_t old = + words_[bit / 64].fetch_and(~Mask(bit), std::memory_order_acquire); + return (old & Mask(bit)) != 0; + } + + bool IsSet(int bit) const { + return (words_[bit / 64].load(std::memory_order_relaxed) & Mask(bit)) != 0; + } + + bool IsSetSeqCst(int bit) const { + return (words_[bit / 64].load(std::memory_order_seq_cst) & Mask(bit)) != 0; + } + + bool IsEmpty() const { + for (int i = 0; i < NumWords(); i++) { + if (words_[i].load(std::memory_order_relaxed) != 0) { + return false; + } + } + return true; + } + + int FindFirstSet() const { + for (int i = 0; i < NumWords(); i++) { + const uint64_t word = words_[i].load(std::memory_order_relaxed); + if (word != 0) { + const int bit = i * 64 + __builtin_ctzll(word); + return bit < num_bits_ ? bit : -1; + } + } + return -1; + } + + // Calls fn(bit) for each set bit, loading each word once. + template + void Traverse(Fn &&fn, + std::memory_order order = std::memory_order_relaxed) const { + for (int i = 0; i < NumWords(); i++) { + uint64_t word = words_[i].load(order); + while (word != 0) { + const int bit = i * 64 + __builtin_ctzll(word); + if (bit >= num_bits_) { + return; + } + fn(bit); + word &= word - 1; + } + } + } + +private: + static uint64_t Mask(int bit) { return uint64_t{1} << (bit % 64); } + int NumWords() const { return static_cast(shm::BitsToWords(num_bits_)); } + + std::atomic *words_ = nullptr; + int num_bits_ = 0; +}; + +} // namespace asil +} // namespace subspace diff --git a/asil_client/channel_memory.cc b/asil_client/channel_memory.cc new file mode 100644 index 00000000..dfb38f95 --- /dev/null +++ b/asil_client/channel_memory.cc @@ -0,0 +1,884 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#include "asil_client/channel_memory.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if ASIL_SHM_MODE == ASIL_SHM_MEMFD +#include +#endif + +namespace subspace { +namespace asil { +namespace internal { + +namespace { + +constexpr size_t kMaxCcbSize = size_t{1} << 30; +// How long to wait for another client to finish creating a buffer. +constexpr int kBufferWaitMilliseconds = 100; + +void SleepMillisecond() { + struct timespec ts = {0, 1000000}; + (void)::nanosleep(&ts, nullptr); +} + +void *MapFd(int fd, size_t size, int prot) { + return ::mmap(nullptr, size, prot, MAP_SHARED, fd, 0); +} + +uint64_t PageAligned(uint64_t size) { + const uint64_t page = static_cast(::sysconf(_SC_PAGESIZE)); + return (size + page - 1) & ~(page - 1); +} + +#if ASIL_SHM_MODE != ASIL_SHM_MEMFD +// Android's libc has no shm_open, and memfd mode never opens a buffer by name. +constexpr mode_t kShmMode = S_IRUSR | S_IWUSR | S_IROTH | S_IWOTH; + +int OpenShm(const char *name, int flags) { + const mode_t old_umask = ::umask(0); + const int fd = ::shm_open(name, flags, kShmMode); + const int saved_errno = errno; + (void)::umask(old_umask); + errno = saved_errno; + return fd; +} +#endif + +#if ASIL_SHM_MODE == ASIL_SHM_POSIX +// The shm object for a buffer is named after the inode of its shadow file. +Error PosixShmName(const char *shadow_file, char (&out)[64]) { + struct stat st; + if (::stat(shadow_file, &st) != 0) { + return Error::kSharedMemoryError; + } + (void)std::snprintf(out, sizeof(out), "/subspace_%llu", + static_cast(st.st_ino)); + return Error::kOk; +} + +// The shadow file's length is the buffer's true size. Only the first +// publisher sets it. +Error CreateShadowFile(const char *shadow_file, uint64_t size) { + const int fd = ::open(shadow_file, O_RDWR | O_CREAT | O_CLOEXEC, 0666); + if (fd < 0) { + return Error::kSharedMemoryError; + } + struct stat st; + Error e = Error::kOk; + if (::fstat(fd, &st) != 0) { + e = Error::kSharedMemoryError; + } else if (st.st_size == 0 && + ::ftruncate(fd, static_cast(size)) != 0) { + e = Error::kSharedMemoryError; + } + (void)::close(fd); + return e; +} +#endif + +#if ASIL_SHM_MODE == ASIL_SHM_MEMFD +int CreateMemfd(const char *name, uint64_t size) { + constexpr unsigned int kMemfdCloexec = 1; + const int fd = + static_cast(::syscall(__NR_memfd_create, name, kMemfdCloexec)); + if (fd < 0) { + return -1; + } + if (::ftruncate(fd, static_cast(size)) != 0) { + const int saved_errno = errno; + (void)::close(fd); + errno = saved_errno; + return -1; + } + return fd; +} +#endif + +} // namespace + +uint64_t Now() { + struct timespec ts; +#if defined(__APPLE__) + // toolbelt::Now() uses mach_absolute_time(), which is this clock. + (void)::clock_gettime(CLOCK_UPTIME_RAW, &ts); +#else + (void)::clock_gettime(CLOCK_MONOTONIC, &ts); +#endif + return static_cast(ts.tv_sec) * 1000000000ULL + + static_cast(ts.tv_nsec); +} + +void Trigger(int fd) { +#if defined(__linux__) + const int64_t value = 1; + (void)::write(fd, &value, sizeof(value)); +#else + const char value = 'x'; + (void)::write(fd, &value, sizeof(value)); +#endif +} + +void ClearTrigger(int fd) { +#if defined(__linux__) + int64_t value; + (void)::read(fd, &value, sizeof(value)); +#else + char buffer[256]; + while (::read(fd, buffer, sizeof(buffer)) > 0) { + } +#endif +} + +Error WaitReadable(int fd, int timeout_ms) { + struct pollfd p = {fd, POLLIN, 0}; + for (;;) { + const int n = ::poll(&p, 1, timeout_ms < 0 ? -1 : timeout_ms); + if (n > 0) { + return Error::kOk; + } + if (n == 0) { + return Error::kTimeout; + } + if (errno != EINTR) { + return Error::kInvalidArgument; + } + } +} + +Error CopyName(const char *name, char (&out)[kMaxChannelNameLength + 1]) { + if (name == nullptr || name[0] == '\0') { + return Error::kInvalidArgument; + } + const size_t length = std::strlen(name); + if (length > kMaxChannelNameLength) { + return Error::kCapacityExceeded; + } + std::memcpy(out, name, length + 1); + return Error::kOk; +} + +bool PushSlotQueue(shm::SlotQueue *queue, int32_t slot_id, uint64_t ordinal) { + const size_t capacity = queue->capacity; + if (capacity == 0) { + return false; + } + shm::SlotQueueEntry *entries = queue->Entries(); + shm::SlotQueueEntry *entry = nullptr; + uint64_t tail = queue->tail.load(std::memory_order_relaxed); + for (size_t attempt = 0; attempt < shm::kMaxSlotQueueCasAttempts; ++attempt) { + const uint64_t head = queue->head.load(std::memory_order_acquire); + if (tail - head >= capacity) { + if (!queue->drop_oldest) { + return false; + } + // Evict the oldest entry, as InPlaceSlotQueue::DropFront does. + shm::SlotQueueEntry &front = entries[head % capacity]; + uint64_t expected = head; + if (front.sequence.load(std::memory_order_acquire) != head + 1 || + !queue->head.compare_exchange_weak(expected, head + 1, + std::memory_order_acq_rel, + std::memory_order_relaxed)) { + return false; + } + front.sequence.store(head + capacity, std::memory_order_release); + queue->overflow_count.fetch_add(1, std::memory_order_release); + tail = queue->tail.load(std::memory_order_relaxed); + continue; + } + shm::SlotQueueEntry &candidate = entries[tail % capacity]; + if (candidate.sequence.load(std::memory_order_acquire) != tail) { + return false; + } + if (queue->tail.compare_exchange_strong(tail, tail + 1, + std::memory_order_acq_rel, + std::memory_order_relaxed)) { + entry = &candidate; + break; + } + } + if (entry == nullptr) { + return false; + } + entry->slot_id.store(slot_id, std::memory_order_relaxed); + entry->ordinal.store(ordinal, std::memory_order_relaxed); + entry->sequence.store(tail + 1, std::memory_order_release); + return true; +} + +void MarkSlotQueueInsertionFailure(shm::SlotQueue *queue) { + queue->insertion_failed.store(true, std::memory_order_release); +} + +Error ChannelMemory::Map(shm::SystemControlBlock *scb, int ccb_fd, int bcb_fd, + int num_slots, int32_t checksum_size, + int32_t metadata_size, + uint64_t subscriber_queue_arena_size, + const BufferContext *context) { + if (ccb_ != nullptr) { + return Error::kAlreadyInitialized; + } + if (num_slots <= 0 || context == nullptr) { + return Error::kInvalidArgument; + } + if (subscriber_queue_arena_size > kMaxCcbSize) { + return Error::kLayoutMismatch; + } + const size_t ccb_size = shm::CcbSize(num_slots, subscriber_queue_arena_size); + if (ccb_size > kMaxCcbSize) { + return Error::kLayoutMismatch; + } + void *ccb = MapFd(ccb_fd, ccb_size, PROT_READ | PROT_WRITE); + if (ccb == MAP_FAILED) { + return Error::kSharedMemoryError; + } + auto *typed_ccb = static_cast(ccb); + if (typed_ccb->version != shm::kChannelControlBlockVersion || + typed_ccb->num_slots != num_slots) { + (void)::munmap(ccb, ccb_size); + return Error::kLayoutMismatch; + } + void *bcb = + MapFd(bcb_fd, sizeof(shm::BufferControlBlock), PROT_READ | PROT_WRITE); + if (bcb == MAP_FAILED) { + (void)::munmap(ccb, ccb_size); + return Error::kSharedMemoryError; + } + scb_ = scb; + ccb_ = typed_ccb; + bcb_ = static_cast(bcb); + context_ = context; + ccb_size_ = ccb_size; + queue_arena_size_ = static_cast(subscriber_queue_arena_size); + num_slots_ = num_slots; + checksum_size_ = checksum_size > 0 ? checksum_size : 4; + metadata_size_ = metadata_size > 0 ? metadata_size : 0; + prefix_size_ = shm::PrefixSize(checksum_size_, metadata_size_); + return Error::kOk; +} + +void ChannelMemory::Unmap() { + UnmapBuffers(); + if (bcb_ != nullptr) { + (void)::munmap(bcb_, sizeof(shm::BufferControlBlock)); + bcb_ = nullptr; + } + if (ccb_ != nullptr) { + (void)::munmap(ccb_, ccb_size_); + ccb_ = nullptr; + ccb_size_ = 0; + } + scb_ = nullptr; + context_ = nullptr; + queue_arena_size_ = 0; + num_slots_ = 0; +} + +void ChannelMemory::UnmapBuffers() { + if (split_slots_ != nullptr) { + for (int i = 0; i < num_slots_; i++) { + SplitSlot &slot = split_slots_[i]; + if (slot.mapping.address != nullptr) { + if (!slot.allocator_mapped) { + (void)::munmap(slot.mapping.address, + static_cast(slot.mapping.size)); + } else if (allocator_.unmap != nullptr) { + allocator_.unmap(allocator_.context, slot.info, slot.mapping); + } + } + slot = SplitSlot(); + } + split_slots_ = nullptr; + } + allocator_ = SplitBufferAllocator(); + if (base_ != nullptr) { + (void)::munmap(base_, static_cast(size_)); + } + base_ = nullptr; + size_ = 0; + slot_size_ = 0; + index_ = -1; +} + +Error ChannelMemory::BufferName(int index, + char (&out)[kMaxChannelNameLength + 64]) const { + char sanitized[kMaxChannelNameLength + 1]; + if (Error e = CopyName(context_->channel_name, sanitized); e != Error::kOk) { + return e; + } + for (char *p = sanitized; *p != '\0'; p++) { + if (*p == '/') { + *p = '.'; + } + } +#if ASIL_SHM_MODE == ASIL_SHM_POSIX + const char *format = "/tmp/subspace_%llu_%s_%d"; +#else + const char *format = "subspace_%llu_%s_%d"; +#endif + const int n = std::snprintf( + out, sizeof(out), format, + static_cast(context_->session_id), sanitized, index); + if (n < 0 || static_cast(n) >= sizeof(out)) { + return Error::kCapacityExceeded; + } + return Error::kOk; +} + +Error ChannelMemory::NewestIndex(int &index) const { + const int num_buffers = ccb_->num_buffers.load(std::memory_order_acquire); + if (num_buffers <= 0) { + return Error::kNoBuffers; + } + if (num_buffers > shm::kMaxBuffers) { + return Error::kLayoutMismatch; + } + index = num_buffers - 1; + return Error::kOk; +} + +// Fetches a buffer from the server. Its creator registers it just after +// creating it, so it may not be there yet. +Error ChannelMemory::GetRegisteredBuffer(int index, bool is_prefix, + uint32_t slot_id, + BufferReply &reply) const { + BufferRequest request; + request.channel_name = context_->channel_name; + request.session_id = context_->session_id; + request.buffer_index = static_cast(index); + request.is_prefix = is_prefix; + request.slot_id = slot_id; + for (int attempt = 0; attempt < kBufferWaitMilliseconds; attempt++) { + if (Error e = context_->connection->GetBuffer(request, reply); + e != Error::kOk) { + return e; + } + if (reply.found) { + return Error::kOk; + } + SleepMillisecond(); + } + return Error::kSharedMemoryError; +} + +Error ChannelMemory::MapBuffer(int index, int fd, uint64_t size, int prot) { + const uint64_t prefixes = + static_cast(num_slots_) * static_cast(prefix_size_); + if (size <= prefixes) { + return Error::kLayoutMismatch; + } + void *p = MAP_FAILED; + for (int attempt = 0; attempt < kBufferWaitMilliseconds; attempt++) { + p = MapFd(fd, static_cast(size), prot); + if (p != MAP_FAILED || errno != EINVAL) { + break; + } + // The creator may not have sized the object yet. + SleepMillisecond(); + } + if (p == MAP_FAILED) { + return Error::kSharedMemoryError; + } + index_ = index; + base_ = static_cast(p); + size_ = size; + slot_size_ = static_cast((size - prefixes) / + static_cast(num_slots_)); + return Error::kOk; +} + +// Opens buffer index, which has already been created. +Error ChannelMemory::OpenBuffer(int index, bool writable, UniqueFd &fd, + uint64_t &size) const { +#if ASIL_SHM_MODE == ASIL_SHM_MEMFD + (void)writable; + BufferReply reply; + if (Error e = GetRegisteredBuffer(index, /*is_prefix=*/false, 0, reply); + e != Error::kOk) { + return e; + } + if (!reply.fd.Valid()) { + return Error::kSharedMemoryError; + } + fd = static_cast(reply.fd); + size = reply.full_size; + if (size == 0) { + struct stat st; + if (::fstat(fd.Get(), &st) != 0) { + return Error::kSharedMemoryError; + } + size = static_cast(st.st_size); + } + return Error::kOk; +#else + char name[kMaxChannelNameLength + 64]; + if (Error e = BufferName(index, name); e != Error::kOk) { + return e; + } + const int flags = writable ? O_RDWR : O_RDONLY; +#if ASIL_SHM_MODE == ASIL_SHM_POSIX + char shm_name[64]; + if (Error e = PosixShmName(name, shm_name); e != Error::kOk) { + return e; + } + fd.Reset(OpenShm(shm_name, flags)); + struct stat st; + if (!fd.Valid() || ::stat(name, &st) != 0) { + return Error::kSharedMemoryError; + } + size = static_cast(st.st_size); +#else + fd.Reset(OpenShm(name, flags)); + if (!fd.Valid()) { + return Error::kSharedMemoryError; + } + size = 0; + for (int attempt = 0; attempt < kBufferWaitMilliseconds && size == 0; + attempt++) { + struct stat st; + if (::fstat(fd.Get(), &st) != 0) { + return Error::kSharedMemoryError; + } + size = static_cast(st.st_size); + if (size == 0) { + // The creator hasn't sized the object yet. + SleepMillisecond(); + } + } +#endif + return Error::kOk; +#endif +} + +Error ChannelMemory::AttachBuffer(bool writable) { + if (ccb_ == nullptr) { + return Error::kNotInitialized; + } + if (index_ != -1) { + return Error::kAlreadyInitialized; + } + int index = -1; + if (Error e = NewestIndex(index); e != Error::kOk) { + return e; + } + UniqueFd fd; + uint64_t size = 0; + if (Error e = OpenBuffer(index, writable, fd, size); e != Error::kOk) { + return e; + } + return MapBuffer(index, fd.Get(), size, + writable ? PROT_READ | PROT_WRITE : PROT_READ); +} + +// Creates buffer 0 and publishes it in the CCB. If another publisher got +// there first this maps nothing, and the caller attaches to the channel's +// buffer. +Error ChannelMemory::CreateBuffer(uint64_t full_size) { + char name[kMaxChannelNameLength + 64]; + if (Error e = BufferName(0, name); e != Error::kOk) { + return e; + } +#if ASIL_SHM_MODE == ASIL_SHM_MEMFD + UniqueFd fd(CreateMemfd(name, full_size)); + if (!fd.Valid()) { + return Error::kSharedMemoryError; + } +#else + const bool root = ::getuid() == 0; +#if ASIL_SHM_MODE == ASIL_SHM_POSIX + if (Error e = CreateShadowFile(name, full_size); e != Error::kOk) { + return e; + } + char shm_name[64]; + if (Error e = PosixShmName(name, shm_name); e != Error::kOk) { + return e; + } + const char *path = name; + UniqueFd fd(OpenShm(shm_name, O_RDWR | O_CREAT | O_EXCL)); +#else + char path[kMaxChannelNameLength + 80]; + (void)std::snprintf(path, sizeof(path), "/dev/shm/%s", name); + UniqueFd fd(OpenShm(name, O_RDWR | O_CREAT | O_EXCL)); +#endif + if (!fd.Valid()) { + // EEXIST: another publisher created the buffer. + return errno == EEXIST ? Error::kOk : Error::kSharedMemoryError; + } + if (::ftruncate(fd.Get(), static_cast(full_size)) != 0 || + ::chmod(path, 0777) != 0 || + (root && ::chown(path, static_cast(context_->user_id), + static_cast(context_->group_id)) != 0)) { + return Error::kSharedMemoryError; + } +#endif + bcb_->sizes[0].store(full_size, std::memory_order_relaxed); + int expected = 0; + if (!ccb_->num_buffers.compare_exchange_strong( + expected, 1, std::memory_order_release, std::memory_order_relaxed)) { + return Error::kOk; + } +#if ASIL_SHM_MODE == ASIL_SHM_MEMFD + ClientBuffer registration; + registration.channel_name = context_->channel_name; + registration.session_id = context_->session_id; + registration.buffer_index = 0; + registration.full_size = full_size; + if (Error e = context_->connection->RegisterBuffer(registration, fd.Get()); + e != Error::kOk) { + expected = 1; + if (ccb_->num_buffers.compare_exchange_strong(expected, 0, + std::memory_order_acq_rel, + std::memory_order_relaxed)) { + bcb_->sizes[0].store(0, std::memory_order_relaxed); + } + return e; + } +#endif + return MapBuffer(0, fd.Get(), full_size, PROT_READ | PROT_WRITE); +} + +Error ChannelMemory::CreateOrAttachBuffer(int64_t slot_size) { + if (ccb_ == nullptr) { + return Error::kNotInitialized; + } + if (index_ != -1) { + return Error::kAlreadyInitialized; + } + const uint64_t full_size = + static_cast(num_slots_) * + (static_cast(slot_size) + static_cast(prefix_size_)); + for (int attempt = 0; attempt < kBufferWaitMilliseconds; attempt++) { + if (ccb_->num_buffers.load(std::memory_order_acquire) == 0) { + if (Error e = CreateBuffer(full_size); e != Error::kOk) { + return e; + } + if (index_ == -1) { + // Another publisher created the buffer. It may not have published + // it yet. + SleepMillisecond(); + continue; + } + } else if (Error e = AttachBuffer(/*writable=*/true); e != Error::kOk) { + return e; + } + if (slot_size_ < slot_size) { + UnmapBuffers(); + return Error::kLayoutMismatch; + } + return Error::kOk; + } + return Error::kSharedMemoryError; +} + +Error ChannelMemory::AttachSplitBuffers(bool writable, SplitSlot *slots, + int32_t capacity, + const SplitBufferAllocator &allocator) { + if (ccb_ == nullptr) { + return Error::kNotInitialized; + } + if (index_ != -1) { + return Error::kAlreadyInitialized; + } + if (slots == nullptr) { + return Error::kInvalidArgument; + } + if (capacity < num_slots_) { + return Error::kCapacityExceeded; + } + int index = -1; + if (Error e = NewestIndex(index); e != Error::kOk) { + return e; + } + const uint64_t full_size = bcb_->sizes[index].load(std::memory_order_acquire); + const uint64_t prefixes = + static_cast(num_slots_) * static_cast(prefix_size_); + if (full_size <= prefixes) { + return Error::kLayoutMismatch; + } + BufferReply prefix; + if (Error e = GetRegisteredBuffer(index, /*is_prefix=*/true, 0, prefix); + e != Error::kOk) { + return e; + } + if (!prefix.fd.Valid() || prefix.allocation_size < prefixes) { + return Error::kLayoutMismatch; + } + const int prot = writable ? PROT_READ | PROT_WRITE : PROT_READ; + void *p = + MapFd(prefix.fd.Get(), static_cast(prefix.allocation_size), prot); + if (p == MAP_FAILED) { + return Error::kSharedMemoryError; + } + index_ = index; + base_ = static_cast(p); + size_ = prefix.allocation_size; + slot_size_ = static_cast((full_size - prefixes) / + static_cast(num_slots_)); + split_slots_ = slots; + allocator_ = allocator; + for (int i = 0; i < num_slots_; i++) { + slots[i] = SplitSlot(); + } + const uint64_t payload_size = PageAligned(static_cast(slot_size_)); + for (int i = 0; i < num_slots_; i++) { + if (Error e = MapSplitSlot(i, payload_size, prot); e != Error::kOk) { + UnmapBuffers(); + return e; + } + } + return Error::kOk; +} + +Error ChannelMemory::MapSplitSlot(int slot_id, uint64_t payload_size, + int prot) { + BufferReply reply; + if (Error e = GetRegisteredBuffer(index_, /*is_prefix=*/false, + static_cast(slot_id), reply); + e != Error::kOk) { + return e; + } + SplitSlot &slot = split_slots_[slot_id]; + slot.info.channel_name = context_->channel_name; + slot.info.session_id = context_->session_id; + slot.info.buffer_index = static_cast(index_); + slot.info.slot_id = static_cast(slot_id); + slot.info.full_size = reply.full_size; + slot.info.allocation_size = reply.allocation_size; + slot.info.handle = reply.handle; + slot.info.map_offset = reply.map_offset; + slot.info.allocator = reply.allocator; + if (reply.allocator == BufferAllocator::kSplitCallback) { + if (allocator_.map == nullptr) { + return Error::kUnsupported; + } + slot.info.fd = reply.fd.Get(); + SplitBufferMapping mapping; + const Error e = allocator_.map(allocator_.context, slot.info, mapping); + slot.info.fd = -1; + if (e != Error::kOk) { + return e; + } + if (mapping.address == nullptr) { + return Error::kSharedMemoryError; + } + if (mapping.size == 0) { + mapping.size = payload_size; + } + slot.mapping = mapping; + slot.allocator_mapped = true; + return mapping.size < static_cast(slot_size_) + ? Error::kLayoutMismatch + : Error::kOk; + } + if (!reply.fd.Valid()) { + return Error::kSharedMemoryError; + } + void *p = MapFd(reply.fd.Get(), static_cast(payload_size), prot); + if (p == MAP_FAILED) { + return Error::kSharedMemoryError; + } + slot.mapping.address = p; + slot.mapping.size = payload_size; + return Error::kOk; +} + +shm::MessageSlot *ChannelMemory::Slot(int id) const { + auto *slots = reinterpret_cast( + reinterpret_cast(ccb_) + sizeof(shm::ChannelControlBlock)); + return &slots[id]; +} + +shm::MessagePrefix *ChannelMemory::Prefix(const shm::MessageSlot *slot) const { + if (!InMappedBuffer(slot)) { + return nullptr; + } + const int64_t stride = split_slots_ != nullptr + ? prefix_size_ + : prefix_size_ + shm::Aligned64(slot_size_); + return reinterpret_cast(base_ + stride * slot->id); +} + +char *ChannelMemory::Payload(const shm::MessageSlot *slot) const { + if (!InMappedBuffer(slot)) { + return nullptr; + } + if (split_slots_ != nullptr) { + return static_cast(split_slots_[slot->id].mapping.address); + } + return reinterpret_cast(Prefix(slot)) + prefix_size_; +} + +BitsetView ChannelMemory::RetiredSlots() const { + return BitsetView(reinterpret_cast(ccb_) + + shm::RetiredSlotsOffset(num_slots_), + num_slots_); +} + +BitsetView ChannelMemory::FreeSlots() const { + return BitsetView(reinterpret_cast(ccb_) + + shm::FreeSlotsOffset(num_slots_), + num_slots_); +} + +BitsetView ChannelMemory::AvailableSlots(int sub_id) const { + return BitsetView(reinterpret_cast(ccb_) + + shm::AvailableSlotsOffset(num_slots_, sub_id), + num_slots_); +} + +BitsetView ChannelMemory::Subscribers() const { + return BitsetView(&ccb_->subscribers, shm::kMaxSlotOwners); +} + +shm::AvailableSlotQueueIndex *ChannelMemory::QueueIndex() const { + return reinterpret_cast( + reinterpret_cast(ccb_) + shm::SlotQueueIndexOffset(num_slots_)); +} + +shm::SlotQueue *ChannelMemory::Queue(int sub_id) const { + const uint64_t offset = + QueueIndex()->offsets[sub_id].load(std::memory_order_acquire); + if (offset == shm::kInvalidSlotQueueOffset || offset > queue_arena_size_ || + queue_arena_size_ - offset < sizeof(shm::SlotQueue)) { + return nullptr; + } + auto *queue = reinterpret_cast( + reinterpret_cast(ccb_) + shm::SlotQueueArenaOffset(num_slots_) + + offset); + const size_t room = queue_arena_size_ - offset - sizeof(shm::SlotQueue); + if (queue->capacity > room / sizeof(shm::SlotQueueEntry)) { + return nullptr; + } + return queue; +} + +int ChannelMemory::NumSubscribers(int vchan_id) const { + const shm::SubscriberCounter &counter = ccb_->num_subs; + for (;;) { + const uint64_t before = counter.sequence.load(std::memory_order_acquire); + if ((before & 1) != 0) { + // A server died while updating the count. + return shm::kMaxSlotOwners; + } + const int mux_count = counter.counts[0].load(std::memory_order_relaxed); + const int count = vchan_id == -1 + ? mux_count + : mux_count + counter.counts[vchan_id + 1].load( + std::memory_order_relaxed); + if (counter.sequence.load(std::memory_order_acquire) == before) { + return count; + } + } +} + +uint64_t ChannelMemory::CleanupGeneration(int vchan_id) const { + const uint64_t mux_generation = + ccb_->subscriber_cleanup_generation[0].load(std::memory_order_acquire); + if (vchan_id == -1) { + return mux_generation; + } + return mux_generation + + ccb_->subscriber_cleanup_generation[vchan_id + 1].load( + std::memory_order_acquire); +} + +bool ChannelMemory::AtomicIncRefCount(shm::MessageSlot *slot, bool reliable, + int inc, uint64_t ordinal, int vchan_id, + bool retire, bool *retired) { + if (retired != nullptr) { + *retired = false; + } + ordinal &= shm::kOrdinalMask; + for (;;) { + uint64_t ref = slot->refs.load(std::memory_order_relaxed); + if ((ref & shm::kPubOwned) != 0) { + return false; + } + const uint64_t ref_ordinal = + (ref >> shm::kOrdinalShift) & shm::kOrdinalMask; + const int ref_vchan_id = shm::RefsVchanId(ref); + if (ref_ordinal != 0 && ordinal != 0 && + (ref_ordinal != ordinal || ref_vchan_id != vchan_id)) { + return false; + } + uint64_t refs = ref & shm::kRefCountMask; + uint64_t reliable_refs = + (ref >> shm::kReliableRefCountShift) & shm::kRefCountMask; + if (inc < 0 && refs == 0) { + return true; + } + refs = static_cast(static_cast(refs) + inc); + if (reliable) { + reliable_refs = + static_cast(static_cast(reliable_refs) + inc); + } + uint64_t retired_refs = + (ref >> shm::kRetiredRefsShift) & shm::kRetiredRefsMask; + if (retire) { + retired_refs++; + } + const uint64_t new_ref = + shm::BuildRefsBitField(ref_ordinal, ref_vchan_id, retired_refs) | + (reliable_refs << shm::kReliableRefCountShift) | refs; + if (slot->refs.compare_exchange_weak(ref, new_ref, + std::memory_order_acq_rel, + std::memory_order_relaxed)) { + if (retire && refs == 0 && reliable_refs == 0 && + retired_refs >= static_cast(NumSubscribers(ref_vchan_id)) && + RetiredSlots().SetWasClear(slot->id) && retired != nullptr) { + *retired = true; + } + return true; + } + } +} + +bool ChannelMemory::TryRetireSlot(shm::MessageSlot *slot) { + if (slot->ordinal.load(std::memory_order_relaxed) == 0) { + return false; + } + const uint64_t refs = slot->refs.load(std::memory_order_acquire); + if ((refs & shm::kPubOwned) != 0) { + return false; + } + const uint64_t ref_count = refs & shm::kRefCountMask; + const uint64_t reliable_ref_count = + (refs >> shm::kReliableRefCountShift) & shm::kRefCountMask; + const uint64_t retired_refs = + (refs >> shm::kRetiredRefsShift) & shm::kRetiredRefsMask; + if (ref_count != 0 || reliable_ref_count != 0 || + retired_refs < + static_cast(NumSubscribers(shm::RefsVchanId(refs)))) { + return false; + } + return RetiredSlots().SetWasClear(slot->id); +} + +void ChannelMemory::TriggerRetirement(const TriggerFdList &triggers, + int32_t slot_id) const { + if (triggers.Size() == 0) { + return; + } + if (slot_id >= 0 && slot_id < num_slots_ && + (Slot(slot_id)->flags.load(std::memory_order_relaxed) & + shm::kMessageIsActivation) != 0) { + return; + } + for (int i = 0; i < triggers.Size(); i++) { + (void)::write(triggers.Get(i), &slot_id, sizeof(slot_id)); + } +} + +} // namespace internal +} // namespace asil +} // namespace subspace diff --git a/asil_client/channel_memory.h b/asil_client/channel_memory.h new file mode 100644 index 00000000..3593016a --- /dev/null +++ b/asil_client/channel_memory.h @@ -0,0 +1,170 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// Internal to the ASIL client: the mapped shared memory of one channel and the +// slot operations that publishers and subscribers share. + +#pragma once + +#include "asil_client/bitset.h" +#include "asil_client/error.h" +#include "asil_client/server_connection.h" +#include "asil_client/shm_layout.h" +#include "asil_client/split_buffer.h" + +#include +#include + +namespace subspace { +namespace asil { + +constexpr size_t kMaxChannelNameLength = 255; + +namespace internal { + +// The clock used for message timestamps, which must match toolbelt::Now(). +uint64_t Now(); + +// Wakes the owner of a trigger fd: an eventfd on Linux, a pipe elsewhere. +void Trigger(int fd); +// Drains a poll fd. +void ClearTrigger(int fd); + +// Waits until fd is readable. A negative timeout waits forever. +Error WaitReadable(int fd, int timeout_ms); + +// Copies name into a fixed buffer. +Error CopyName(const char *name, char (&out)[kMaxChannelNameLength + 1]); + +// Where a channel's message buffers live. +struct BufferContext { + // The channel that owns the buffers: the multiplexer of a virtual channel. + const char *channel_name = nullptr; + uint64_t session_id = 0; + int32_t user_id = -1; + int32_t group_id = -1; + // Passes memfd buffers between clients. + ServerConnection *connection = nullptr; +}; + +// InPlaceSlotQueue::Push with report_insertion_failure false. +bool PushSlotQueue(shm::SlotQueue *queue, int32_t slot_id, uint64_t ordinal); +// InPlaceSlotQueue::MarkInsertionFailure. +void MarkSlotQueueInsertionFailure(shm::SlotQueue *queue); + +class ChannelMemory { +public: + ChannelMemory() = default; + ~ChannelMemory() { Unmap(); } + ChannelMemory(const ChannelMemory &) = delete; + ChannelMemory &operator=(const ChannelMemory &) = delete; + + // Maps the CCB and BCB. The SCB is mapped by the client. The context must + // outlive the mapping. + Error Map(shm::SystemControlBlock *scb, int ccb_fd, int bcb_fd, int num_slots, + int32_t checksum_size, int32_t metadata_size, + uint64_t subscriber_queue_arena_size, const BufferContext *context); + void Unmap(); + bool Mapped() const { return ccb_ != nullptr; } + + // A publisher or subscriber maps one buffer, the channel's newest, when it + // opens. Nothing is mapped after that. + + // Maps the newest buffer, creating the channel's first buffer if there is + // none. The buffer's slots must hold at least slot_size bytes. + Error CreateOrAttachBuffer(int64_t slot_size); + // Maps the newest buffer. Returns kNoBuffers if there is none. + Error AttachBuffer(bool writable); + // Maps the newest split buffer set: the prefixes, and each slot's payload + // into slots, which needs room for NumSlots() entries. The allocator maps + // slots that a custom allocator created. Returns kNoBuffers if there are + // none. + Error AttachSplitBuffers(bool writable, SplitSlot *slots, int32_t capacity, + const SplitBufferAllocator &allocator); + // The mapped buffer, or -1. + int BufferIndex() const { return index_; } + // Whether slot's message is in the mapped buffer. + bool InMappedBuffer(const shm::MessageSlot *slot) const { + return index_ != -1 && + slot->buffer_index.load(std::memory_order_relaxed) == index_; + } + + int NumSlots() const { return num_slots_; } + // The slot size of the mapped buffer. + int64_t SlotSize() const { return slot_size_; } + int32_t ChecksumSize() const { return checksum_size_; } + int32_t MetadataSize() const { return metadata_size_; } + + shm::ChannelControlBlock *Ccb() const { return ccb_; } + shm::BufferControlBlock *Bcb() const { return bcb_; } + const shm::ChannelCounters &Counters(int channel_id) const { + return scb_->counters[channel_id]; + } + + shm::MessageSlot *Slot(int id) const; + // The prefix and payload of slot, or null when it isn't in the mapped + // buffer. + shm::MessagePrefix *Prefix(const shm::MessageSlot *slot) const; + char *Payload(const shm::MessageSlot *slot) const; + + BitsetView RetiredSlots() const; + BitsetView FreeSlots() const; + BitsetView AvailableSlots(int sub_id) const; + BitsetView Subscribers() const; + shm::AvailableSlotQueueIndex *QueueIndex() const; + // The subscriber's queue, or null if it has none. + shm::SlotQueue *Queue(int sub_id) const; + + int NumSubscribers(int vchan_id) const; + uint64_t CleanupGeneration(int vchan_id) const; + + // Channel::AtomicIncRefCount. Sets *retired when this call retired the + // slot. + bool AtomicIncRefCount(shm::MessageSlot *slot, bool reliable, int inc, + uint64_t ordinal, int vchan_id, bool retire, + bool *retired); + // Channel::TryRetireSlot. + bool TryRetireSlot(shm::MessageSlot *slot); + + // Tells publishers that asked for retirement notification that slot_id is + // free. + void TriggerRetirement(const TriggerFdList &triggers, int32_t slot_id) const; + +private: + Error BufferName(int index, char (&out)[kMaxChannelNameLength + 64]) const; + Error NewestIndex(int &index) const; + Error OpenBuffer(int index, bool writable, UniqueFd &fd, + uint64_t &size) const; + Error MapBuffer(int index, int fd, uint64_t size, int prot); + Error CreateBuffer(uint64_t size); + Error GetRegisteredBuffer(int index, bool is_prefix, uint32_t slot_id, + BufferReply &reply) const; + Error MapSplitSlot(int slot_id, uint64_t payload_size, int prot); + void UnmapBuffers(); + + shm::SystemControlBlock *scb_ = nullptr; + shm::ChannelControlBlock *ccb_ = nullptr; + shm::BufferControlBlock *bcb_ = nullptr; + const BufferContext *context_ = nullptr; + size_t ccb_size_ = 0; + size_t queue_arena_size_ = 0; + int num_slots_ = 0; + int32_t checksum_size_ = 4; + int32_t metadata_size_ = 0; + int32_t prefix_size_ = 64; + + // The mapped buffer: the whole buffer, or the prefixes of a split buffer + // set. + int index_ = -1; + char *base_ = nullptr; + uint64_t size_ = 0; + int64_t slot_size_ = 0; + // The caller's slot storage for a split buffer set, or null. + SplitSlot *split_slots_ = nullptr; + SplitBufferAllocator allocator_; +}; + +} // namespace internal +} // namespace asil +} // namespace subspace diff --git a/asil_client/checksum.cc b/asil_client/checksum.cc new file mode 100644 index 00000000..b0ecbaba --- /dev/null +++ b/asil_client/checksum.cc @@ -0,0 +1,44 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#include "asil_client/checksum.h" + +#include + +namespace subspace { +namespace asil { + +namespace { + +#if defined(__x86_64__) && defined(__SSE4_2__) +constexpr uint32_t kPolynomial = 0x82F63B78; // CRC32C. +#else +constexpr uint32_t kPolynomial = 0xEDB88320; // IEEE 802.3. +#endif + +constexpr std::array MakeTable() { + std::array table = {}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t crc = i; + for (int bit = 0; bit < 8; bit++) { + crc = (crc & 1) != 0 ? (crc >> 1) ^ kPolynomial : crc >> 1; + } + table[i] = crc; + } + return table; +} + +constexpr std::array kTable = MakeTable(); + +} // namespace + +uint32_t Crc32(uint32_t crc, const uint8_t *data, size_t length) { + for (size_t i = 0; i < length; i++) { + crc = (crc >> 8) ^ kTable[(crc ^ data[i]) & 0xFF]; + } + return crc; +} + +} // namespace asil +} // namespace subspace diff --git a/asil_client/checksum.h b/asil_client/checksum.h new file mode 100644 index 00000000..689687a0 --- /dev/null +++ b/asil_client/checksum.h @@ -0,0 +1,19 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#pragma once + +#include +#include + +namespace subspace { +namespace asil { + +// The CRC32 that the standard client puts in message prefixes. x86-64 builds +// with SSE4.2 use the CRC32C polynomial; all other builds use the IEEE 802.3 +// polynomial. Pass 0xFFFFFFFF as the first crc and invert the final result. +uint32_t Crc32(uint32_t crc, const uint8_t *data, size_t length); + +} // namespace asil +} // namespace subspace diff --git a/asil_client/client.cc b/asil_client/client.cc new file mode 100644 index 00000000..3d97ab53 --- /dev/null +++ b/asil_client/client.cc @@ -0,0 +1,44 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#include "asil_client/client.h" + +#include + +namespace subspace { +namespace asil { + +Client::~Client() { + if (scb_ != nullptr) { + (void)::munmap(scb_, sizeof(shm::SystemControlBlock)); + } +} + +Error Client::Init(ServerConnection &connection, const char *client_name) { + if (scb_ != nullptr) { + return Error::kAlreadyInitialized; + } + InitReply reply; + if (Error e = connection.Init(client_name, reply); e != Error::kOk) { + return e; + } + void *scb = ::mmap(nullptr, sizeof(shm::SystemControlBlock), PROT_READ, + MAP_SHARED, reply.scb.Get(), 0); + if (scb == MAP_FAILED) { + return Error::kSharedMemoryError; + } + connection_ = &connection; + scb_ = static_cast(scb); + session_id_ = reply.session_id; + user_id_ = reply.user_id; + group_id_ = reply.group_id; + return Error::kOk; +} + +const char *Client::LastServerError() const { + return connection_ == nullptr ? "" : connection_->LastServerError(); +} + +} // namespace asil +} // namespace subspace diff --git a/asil_client/client.h b/asil_client/client.h new file mode 100644 index 00000000..054c0b85 --- /dev/null +++ b/asil_client/client.h @@ -0,0 +1,76 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// A Subspace client for safety-related software. +// +// The client publishes and reads messages on fixed-size channels, reliable or +// unreliable, plain or virtual, sharing them with the standard clients. It +// is written in C++17 using only the standard library and POSIX. It throws no +// exceptions and returns an Error from every operation that can fail. It +// makes no heap allocations: buffers and tables have fixed capacities, and +// publishing and reading messages talks to the server only when publishers, +// subscribers or memfd buffers change. +// +// subspace::asil::PhaserServerConnection connection; +// connection.Connect("/tmp/subspace"); +// subspace::asil::Client client; +// client.Init(connection, "my_client"); +// subspace::asil::PublisherOptions options; +// options.slot_size = 256; +// options.num_slots = 8; +// subspace::asil::Publisher publisher; +// client.CreatePublisher("/chan", options, publisher); + +#pragma once + +#include "asil_client/error.h" +#include "asil_client/publisher.h" +#include "asil_client/server_connection.h" +#include "asil_client/shm_layout.h" +#include "asil_client/subscriber.h" + +#include + +namespace subspace { +namespace asil { + +// A connection to one Subspace server. It must outlive its publishers and +// subscribers, and the ServerConnection must outlive it. It is not thread +// safe. +class Client { +public: + Client() = default; + ~Client(); + Client(const Client &) = delete; + Client &operator=(const Client &) = delete; + + Error Init(ServerConnection &connection, const char *client_name); + bool Initialized() const { return scb_ != nullptr; } + + Error CreatePublisher(const char *channel_name, + const PublisherOptions &options, Publisher &publisher) { + return publisher.Open(*this, channel_name, options); + } + Error CreateSubscriber(const char *channel_name, + const SubscriberOptions &options, + Subscriber &subscriber) { + return subscriber.Open(*this, channel_name, options); + } + + // Why the server rejected the last request that returned kServerRejected. + const char *LastServerError() const; + +private: + friend class Publisher; + friend class Subscriber; + + ServerConnection *connection_ = nullptr; + shm::SystemControlBlock *scb_ = nullptr; + uint64_t session_id_ = 0; + int32_t user_id_ = -1; + int32_t group_id_ = -1; +}; + +} // namespace asil +} // namespace subspace diff --git a/asil_client/error.cc b/asil_client/error.cc new file mode 100644 index 00000000..8b672716 --- /dev/null +++ b/asil_client/error.cc @@ -0,0 +1,53 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#include "asil_client/error.h" + +namespace subspace { +namespace asil { + +const char *ErrorString(Error error) { + switch (error) { + case Error::kOk: + return "ok"; + case Error::kInvalidArgument: + return "invalid argument"; + case Error::kNotInitialized: + return "not initialized"; + case Error::kAlreadyInitialized: + return "already initialized"; + case Error::kConnectionFailed: + return "connection to server failed"; + case Error::kProtocolError: + return "protocol error"; + case Error::kServerRejected: + return "server rejected request"; + case Error::kSharedMemoryError: + return "shared memory error"; + case Error::kLayoutMismatch: + return "shared memory layout mismatch"; + case Error::kUnsupported: + return "unsupported channel feature"; + case Error::kNoSlot: + return "no slot available"; + case Error::kMessageTooLarge: + return "message too large"; + case Error::kChecksumMismatch: + return "checksum mismatch"; + case Error::kTimeout: + return "timeout"; + case Error::kCapacityExceeded: + return "capacity exceeded"; + case Error::kActiveMessageLimit: + return "too many active messages"; + case Error::kNoBuffers: + return "channel has no buffers"; + case Error::kBufferNotMapped: + return "message buffer not mapped"; + } + return "unknown error"; +} + +} // namespace asil +} // namespace subspace diff --git a/asil_client/error.h b/asil_client/error.h new file mode 100644 index 00000000..58290ad8 --- /dev/null +++ b/asil_client/error.h @@ -0,0 +1,58 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#pragma once + +#include + +namespace subspace { +namespace asil { + +// Every ASIL client operation returns one of these. The client throws no +// exceptions. +enum class Error : int32_t { + kOk = 0, + // An argument is out of range or a required pointer is null. + kInvalidArgument, + // The object hasn't been set up, or has been closed. + kNotInitialized, + // The object is already set up. + kAlreadyInitialized, + // Talking to the server failed. The connection is unusable. + kConnectionFailed, + // The server sent something the client can't use. + kProtocolError, + // The server refused the request. Client::LastServerError() says why. + kServerRejected, + // A shared memory system call failed. errno is preserved. + kSharedMemoryError, + // The channel's shared memory doesn't have the expected layout. + kLayoutMismatch, + // The channel uses a feature that the ASIL client doesn't provide. + kUnsupported, + // No slot could be claimed. + kNoSlot, + // The message is bigger than the slot. + kMessageTooLarge, + // The message checksum is wrong. + kChecksumMismatch, + // The wait timed out. + kTimeout, + // A fixed capacity, such as the channel name length, was exceeded. + kCapacityExceeded, + // The subscriber already holds max_active_messages messages. + kActiveMessageLimit, + // The channel has no message buffers to map. The server creates them for + // a static channel; otherwise a publisher must open first. + kNoBuffers, + // The message is in a buffer that a standard publisher created after this + // subscriber opened, by resizing the channel. The message is skipped. + kBufferNotMapped, +}; + +// Returns a static description of the error. +const char *ErrorString(Error error); + +} // namespace asil +} // namespace subspace diff --git a/asil_client/layout_test.cc b/asil_client/layout_test.cc new file mode 100644 index 00000000..7320a6ef --- /dev/null +++ b/asil_client/layout_test.cc @@ -0,0 +1,268 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// Checks that the ASIL client's view of shared memory matches +// common/channel.h, and that its checksum matches the standard client's. + +#include "asil_client/channel_memory.h" +#include "asil_client/checksum.h" +#include "asil_client/shm_layout.h" +#include "client/checksum.h" +#include "common/channel.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +namespace shm = ::subspace::asil::shm; + +#define EXPECT_SAME_OFFSET(type, field) \ + EXPECT_EQ(offsetof(::subspace::type, field), offsetof(shm::type, field)) \ + << #type "::" #field + +TEST(LayoutTest, Constants) { + EXPECT_EQ(::subspace::kMaxChannels, shm::kMaxChannels); + EXPECT_EQ(::subspace::kMaxSlotOwners, shm::kMaxSlotOwners); + EXPECT_EQ(::subspace::kMaxVchanId, shm::kMaxVchanId); + EXPECT_EQ(static_cast(::subspace::kMaxBuffers), shm::kMaxBuffers); + EXPECT_EQ(::subspace::kChannelControlBlockVersion, + shm::kChannelControlBlockVersion); + EXPECT_EQ(::subspace::kInvalidSlotQueueOffset, shm::kInvalidSlotQueueOffset); + EXPECT_EQ(::subspace::kMessageActivate, shm::kMessageActivate); + EXPECT_EQ(::subspace::kMessageHasChecksum, shm::kMessageHasChecksum); + EXPECT_EQ(static_cast(::subspace::kMessageSeen), shm::kMessageSeen); + EXPECT_EQ(static_cast(::subspace::kMessageIsActivation), + shm::kMessageIsActivation); + EXPECT_EQ(static_cast(::subspace::kMessageSeenByReliable), + shm::kMessageSeenByReliable); + EXPECT_EQ(::subspace::kMaxSlotQueueCasAttempts, + shm::kMaxSlotQueueCasAttempts); + EXPECT_EQ(::subspace::kPubOwned, shm::kPubOwned); + EXPECT_EQ(::subspace::kRefsMask, shm::kRefsMask); + EXPECT_EQ(::subspace::kRefCountMask, shm::kRefCountMask); + EXPECT_EQ(::subspace::kReliableRefCountShift, shm::kReliableRefCountShift); + EXPECT_EQ(::subspace::kRetiredRefsShift, shm::kRetiredRefsShift); + EXPECT_EQ(::subspace::kRetiredRefsMask, shm::kRetiredRefsMask); + EXPECT_EQ(::subspace::kVchanIdShift, shm::kVchanIdShift); + EXPECT_EQ(::subspace::kVchanIdMask, shm::kVchanIdMask); + EXPECT_EQ(::subspace::kOrdinalShift, shm::kOrdinalShift); + EXPECT_EQ(::subspace::kOrdinalMask, shm::kOrdinalMask); +} + +TEST(LayoutTest, MessagePrefix) { + EXPECT_EQ(sizeof(::subspace::MessagePrefix), sizeof(shm::MessagePrefix)); + EXPECT_SAME_OFFSET(MessagePrefix, slot_id); + EXPECT_SAME_OFFSET(MessagePrefix, message_size); + EXPECT_SAME_OFFSET(MessagePrefix, ordinal); + EXPECT_SAME_OFFSET(MessagePrefix, timestamp); + EXPECT_SAME_OFFSET(MessagePrefix, flags); + EXPECT_SAME_OFFSET(MessagePrefix, vchan_id); + EXPECT_SAME_OFFSET(MessagePrefix, checksum_size); + EXPECT_SAME_OFFSET(MessagePrefix, metadata_size); + EXPECT_SAME_OFFSET(MessagePrefix, checksum); +} + +TEST(LayoutTest, MessageSlot) { + EXPECT_EQ(sizeof(::subspace::MessageSlot), sizeof(shm::MessageSlot)); + EXPECT_SAME_OFFSET(MessageSlot, refs); + EXPECT_SAME_OFFSET(MessageSlot, ordinal); + EXPECT_SAME_OFFSET(MessageSlot, message_size); + EXPECT_SAME_OFFSET(MessageSlot, id); + EXPECT_SAME_OFFSET(MessageSlot, buffer_index); + EXPECT_SAME_OFFSET(MessageSlot, vchan_id); + EXPECT_SAME_OFFSET(MessageSlot, sub_owners); + EXPECT_SAME_OFFSET(MessageSlot, timestamp); + EXPECT_SAME_OFFSET(MessageSlot, flags); + EXPECT_SAME_OFFSET(MessageSlot, bridged_slot_id); +} + +TEST(LayoutTest, ControlBlocks) { + EXPECT_EQ(sizeof(::subspace::ChannelCounters), sizeof(shm::ChannelCounters)); + EXPECT_SAME_OFFSET(ChannelCounters, num_pub_updates); + EXPECT_SAME_OFFSET(ChannelCounters, num_sub_updates); + EXPECT_EQ(sizeof(::subspace::SystemControlBlock), + sizeof(shm::SystemControlBlock)); + EXPECT_EQ(sizeof(::subspace::BufferControlBlock), + sizeof(shm::BufferControlBlock)); + EXPECT_SAME_OFFSET(BufferControlBlock, sizes); + EXPECT_EQ(sizeof(::subspace::AvailableSlotQueueIndex), + sizeof(shm::AvailableSlotQueueIndex)); + EXPECT_SAME_OFFSET(AvailableSlotQueueIndex, offsets); + EXPECT_SAME_OFFSET(AvailableSlotQueueIndex, active_publishers); +} + +TEST(LayoutTest, ChannelControlBlock) { + EXPECT_EQ(sizeof(::subspace::ChannelControlBlock), + sizeof(shm::ChannelControlBlock)); + EXPECT_EQ(offsetof(::subspace::ChannelControlBlock, slots), + sizeof(shm::ChannelControlBlock)); + EXPECT_SAME_OFFSET(ChannelControlBlock, num_slots); + EXPECT_SAME_OFFSET(ChannelControlBlock, subscriber_queue_size); + EXPECT_SAME_OFFSET(ChannelControlBlock, version); + EXPECT_SAME_OFFSET(ChannelControlBlock, ordinals); + EXPECT_SAME_OFFSET(ChannelControlBlock, activation_tracker); + EXPECT_SAME_OFFSET(ChannelControlBlock, buffer_index); + EXPECT_SAME_OFFSET(ChannelControlBlock, num_buffers); + EXPECT_SAME_OFFSET(ChannelControlBlock, subscribers); + EXPECT_SAME_OFFSET(ChannelControlBlock, sub_vchan_ids); + EXPECT_SAME_OFFSET(ChannelControlBlock, num_subs); + EXPECT_SAME_OFFSET(ChannelControlBlock, subscriber_cleanup_generation); + EXPECT_SAME_OFFSET(ChannelControlBlock, total_bytes); + EXPECT_SAME_OFFSET(ChannelControlBlock, total_messages); + EXPECT_SAME_OFFSET(ChannelControlBlock, max_message_size); + EXPECT_SAME_OFFSET(ChannelControlBlock, total_drops); + EXPECT_SAME_OFFSET(ChannelControlBlock, free_slots_exhausted); + EXPECT_EQ(sizeof(::subspace::SubscriberCounter), + sizeof(shm::SubscriberCounter)); + EXPECT_EQ(sizeof(::subspace::OrdinalAccumulator), + sizeof(shm::ChannelControlBlock::ordinals)); + EXPECT_EQ(sizeof(::subspace::ActivationTracker), + sizeof(shm::ChannelControlBlock::activation_tracker)); + EXPECT_EQ(sizeof(::subspace::SubscriberCleanupGeneration), + sizeof(shm::ChannelControlBlock::subscriber_cleanup_generation)); +} + +TEST(LayoutTest, CcbRegions) { + for (int num_slots : {1, 2, 7, 8, 63, 64, 65, 100, 1000, 4096}) { + SCOPED_TRACE(num_slots); + const size_t bitset = ::subspace::SizeofAtomicBitSet(num_slots); + EXPECT_EQ(shm::SizeofBitset(num_slots), bitset); + const size_t retired = static_cast( + ::subspace::Aligned(sizeof(::subspace::ChannelControlBlock) + + num_slots * sizeof(::subspace::MessageSlot))); + EXPECT_EQ(shm::RetiredSlotsOffset(num_slots), retired); + const size_t free_slots = + retired + static_cast(::subspace::Aligned(bitset)); + EXPECT_EQ(shm::FreeSlotsOffset(num_slots), free_slots); + const size_t available = + free_slots + static_cast(::subspace::Aligned(bitset)); + EXPECT_EQ(shm::AvailableSlotsOffset(num_slots, 0), available); + EXPECT_EQ(shm::AvailableSlotsOffset(num_slots, 5), available + 5 * bitset); + EXPECT_EQ(shm::SlotQueueIndexOffset(num_slots), + available + ::subspace::AvailableSlotsSize(num_slots)); + EXPECT_EQ(shm::SlotQueueArenaOffset(num_slots), + shm::SlotQueueIndexOffset(num_slots) + + ::subspace::AvailableSlotQueueIndexSize()); + EXPECT_EQ(shm::CcbSize(num_slots, 0), ::subspace::CcbSize(num_slots, 0)); + EXPECT_EQ(shm::CcbSize(num_slots, 64000), + ::subspace::CcbSize(num_slots, 64000)); + } +} + +TEST(LayoutTest, PrefixSize) { + for (int32_t checksum_size : {0, 4, 16, 17}) { + for (int32_t metadata_size : {0, 1, 16, 100}) { + EXPECT_EQ( + shm::PrefixSize(checksum_size, metadata_size), + ::subspace::Channel::ComputePrefixSize(checksum_size, metadata_size)); + } + } +} + +TEST(LayoutTest, RefsBitField) { + for (uint64_t ordinal : {0ULL, 1ULL, 12345ULL, (1ULL << 23) + 7}) { + for (int vchan_id : {-1, 0, 5, 1022}) { + for (int retired : {0, 1, 1023}) { + EXPECT_EQ(shm::BuildRefsBitField(ordinal, vchan_id, retired), + ::subspace::BuildRefsBitField(ordinal, vchan_id, retired)); + } + } + } +} + +TEST(LayoutTest, SlotQueue) { + EXPECT_EQ(sizeof(::subspace::InPlaceSlotQueue), sizeof(shm::SlotQueue)); + EXPECT_EQ(sizeof(::subspace::SlotQueueEntry), sizeof(shm::SlotQueueEntry)); + EXPECT_SAME_OFFSET(SlotQueueEntry, sequence); + EXPECT_SAME_OFFSET(SlotQueueEntry, ordinal); + EXPECT_SAME_OFFSET(SlotQueueEntry, slot_id); +} + +// A queue in a buffer, initialized by the standard client and used through +// both views. +struct QueueMemory { + explicit QueueMemory(size_t capacity, bool drop_oldest) + : memory(::subspace::SizeofSlotQueue(capacity) / sizeof(uint64_t) + 1), + standard(new (memory.data())::subspace::InPlaceSlotQueue(capacity, + drop_oldest)), + asil(reinterpret_cast(memory.data())) {} + + std::vector memory; + ::subspace::InPlaceSlotQueue *standard; + shm::SlotQueue *asil; +}; + +TEST(SlotQueueTest, AsilPushesStandardPops) { + QueueMemory queue(4, /*drop_oldest=*/true); + EXPECT_EQ(4u, queue.asil->capacity); + for (int32_t i = 0; i < 6; i++) { + EXPECT_TRUE( + ::subspace::asil::internal::PushSlotQueue(queue.asil, i, 100 + i)); + } + // The two oldest entries made room for the newest. + EXPECT_EQ(2u, queue.standard->OverflowCount()); + for (int32_t i = 2; i < 6; i++) { + ::subspace::QueuedSlot slot; + ASSERT_TRUE(queue.standard->TryPop(slot)); + EXPECT_EQ(i, slot.slot_id); + EXPECT_EQ(static_cast(100 + i), slot.ordinal); + } + ::subspace::QueuedSlot slot; + EXPECT_FALSE(queue.standard->TryPop(slot)); + + // Entries wrap around the ring. + EXPECT_TRUE(::subspace::asil::internal::PushSlotQueue(queue.asil, 9, 109)); + ASSERT_TRUE(queue.standard->TryPop(slot)); + EXPECT_EQ(9, slot.slot_id); +} + +TEST(SlotQueueTest, InheritedQueueRejectsWhenFull) { + QueueMemory queue(2, /*drop_oldest=*/false); + EXPECT_TRUE(::subspace::asil::internal::PushSlotQueue(queue.asil, 0, 1)); + EXPECT_TRUE(::subspace::asil::internal::PushSlotQueue(queue.asil, 1, 2)); + EXPECT_FALSE(::subspace::asil::internal::PushSlotQueue(queue.asil, 2, 3)); + EXPECT_FALSE(queue.standard->InsertionFailed()); + ::subspace::asil::internal::MarkSlotQueueInsertionFailure(queue.asil); + EXPECT_TRUE(queue.standard->InsertionFailed()); + EXPECT_EQ(0u, queue.standard->OverflowCount()); + + // A standard push is visible to the ASIL view. + QueueMemory other(2, /*drop_oldest=*/true); + ASSERT_TRUE(other.standard->Push(7, 70)); + EXPECT_EQ(1u, other.asil->tail.load()); + EXPECT_EQ(7, other.asil->Entries()[0].slot_id.load()); + EXPECT_EQ(70u, other.asil->Entries()[0].ordinal.load()); +} + +TEST(ChecksumTest, MatchesStandardClient) { + std::mt19937 random(42); + for (size_t length : {0, 1, 3, 4, 7, 8, 9, 63, 64, 1000}) { + SCOPED_TRACE(length); + std::vector data(length); + for (uint8_t &byte : data) { + byte = static_cast(random()); + } + std::array, 3> spans = { + absl::Span(data.data(), length / 3), + absl::Span(data.data() + length / 3, length / 3), + absl::Span(data.data() + 2 * (length / 3), + length - 2 * (length / 3))}; + uint32_t expected = 0; + ::subspace::CalculateCRC32Checksum( + spans, absl::Span(reinterpret_cast(&expected), + sizeof(expected))); + uint32_t crc = 0xFFFFFFFF; + for (const auto &span : spans) { + crc = ::subspace::asil::Crc32(crc, span.data(), span.size()); + } + EXPECT_EQ(expected, ~crc); + } +} + +} // namespace diff --git a/asil_client/phaser_connection.cc b/asil_client/phaser_connection.cc new file mode 100644 index 00000000..81cacc49 --- /dev/null +++ b/asil_client/phaser_connection.cc @@ -0,0 +1,679 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#include "asil_client/phaser_connection.h" + +#include "proto/subspace.phaser.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace subspace { +namespace asil { + +namespace wire = ::subspace::phaser; + +namespace { + +#if defined(MSG_NOSIGNAL) +constexpr int kSendFlags = MSG_NOSIGNAL; +#else +constexpr int kSendFlags = 0; +#endif + +// The server's limit on descriptors in one SCM_RIGHTS message. +constexpr size_t kMaxFdsPerMessage = 252; + +std::string_view View(const char *s) { + return s == nullptr ? std::string_view() : std::string_view(s); +} + +bool SendFully(int fd, const char *data, size_t length) { + while (length > 0) { + const ssize_t n = ::send(fd, data, length, kSendFlags); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + data += n; + length -= static_cast(n); + } + return true; +} + +bool ReceiveFully(int fd, char *data, size_t length) { + while (length > 0) { + const ssize_t n = ::recv(fd, data, length, 0); + if (n < 0 && errno == EINTR) { + continue; + } + if (n <= 0) { + return false; + } + data += n; + length -= static_cast(n); + } + return true; +} + +// Duplicates the descriptor at index in fds. Indexes can repeat, so each +// use gets its own descriptor. +Error DupFd(const PhaserServerConnection::ReceivedFds &fds, int32_t index, + UniqueFd &out) { + if (index < 0 || index >= fds.Size()) { + return Error::kProtocolError; + } + const int fd = ::fcntl(fds.Get(index), F_DUPFD_CLOEXEC, 0); + if (fd < 0) { + return Error::kProtocolError; + } + out.Reset(fd); + return Error::kOk; +} + +// index(i) returns the descriptor index of the i'th of count descriptors. +template +Error FillFdList(const PhaserServerConnection::ReceivedFds &fds, size_t count, + Index index, TriggerFdList *list) { + if (list == nullptr) { + return Error::kOk; + } + list->Clear(); + for (size_t i = 0; i < count; i++) { + UniqueFd fd; + if (Error e = DupFd(fds, index(i), fd); e != Error::kOk) { + return e; + } + if (!list->Add(static_cast(fd))) { + return Error::kCapacityExceeded; + } + } + return Error::kOk; +} + +} // namespace + +Error PhaserServerConnection::Connect(const char *socket_name) { + if (socket_name == nullptr) { + return Error::kInvalidArgument; + } + if (socket_.Valid()) { + return Error::kAlreadyInitialized; + } + struct sockaddr_un addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + const size_t length = std::strlen(socket_name); +#if defined(__linux__) + // The server binds in the abstract namespace on Linux. + if (length > sizeof(addr.sun_path) - 2) { + return Error::kCapacityExceeded; + } + std::memcpy(addr.sun_path + 1, socket_name, length); +#else + if (length > sizeof(addr.sun_path) - 1) { + return Error::kCapacityExceeded; + } + std::memcpy(addr.sun_path, socket_name, length); +#endif + UniqueFd fd(::socket(AF_UNIX, SOCK_STREAM, 0)); + if (!fd.Valid()) { + return Error::kConnectionFailed; + } +#if defined(SO_NOSIGPIPE) + const int on = 1; + (void)::setsockopt(fd.Get(), SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); +#endif + if (::connect(fd.Get(), reinterpret_cast(&addr), + sizeof(addr)) != 0) { + return Error::kConnectionFailed; + } + socket_ = static_cast(fd); + return Error::kOk; +} + +Error PhaserServerConnection::Rejected(const char *message, size_t length) { + if (length > sizeof(last_error_) - 1) { + length = sizeof(last_error_) - 1; + } + std::memcpy(last_error_, message, length); + last_error_[length] = '\0'; + return Error::kServerRejected; +} + +// Sends the serialized request in wire_ after its length. +Error PhaserServerConnection::Send(size_t length) { + char *const header = wire_.Payload() - kServerWireHeaderSize; + const uint32_t network_length = htonl(static_cast(length)); + std::memcpy(header, &network_length, sizeof(network_length)); + if (!SendFully(socket_.Get(), header, kServerWireHeaderSize + length)) { + return Error::kConnectionFailed; + } + return Error::kOk; +} + +// Sends one descriptor the way the server reads them: the descriptor count as +// an int32 with the descriptor as SCM_RIGHTS. +Error PhaserServerConnection::SendFd(int fd) { + alignas(struct cmsghdr) char control[CMSG_SPACE(sizeof(int))]; + std::memset(control, 0, sizeof(control)); + int32_t count = 1; + struct iovec iov; + iov.iov_base = &count; + iov.iov_len = sizeof(count); + struct msghdr msg; + std::memset(&msg, 0, sizeof(msg)); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control; + msg.msg_controllen = static_cast(sizeof(control)); + struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + std::memcpy(CMSG_DATA(cmsg), &fd, sizeof(int)); + for (;;) { + const ssize_t n = ::sendmsg(socket_.Get(), &msg, kSendFlags); + if (n == static_cast(sizeof(count))) { + return Error::kOk; + } + if (n < 0 && errno == EINTR) { + continue; + } + return Error::kConnectionFailed; + } +} + +// Receives a length-prefixed response into wire_. A response that does not +// fit is read and discarded so that the stream stays in step. +Error PhaserServerConnection::Receive(const char *&data, size_t &length) { + uint32_t network_length = 0; + if (!ReceiveFully(socket_.Get(), reinterpret_cast(&network_length), + sizeof(network_length))) { + return Error::kConnectionFailed; + } + length = ntohl(network_length); + if (char *buffer = wire_.ResponseBuffer(length); buffer != nullptr) { + data = buffer; + return ReceiveFully(socket_.Get(), buffer, length) + ? Error::kOk + : Error::kConnectionFailed; + } + char *const scratch = wire_.ResponseBuffer(kWireBufferSize); + for (size_t remaining = length; remaining > 0;) { + const size_t chunk = + remaining < kWireBufferSize ? remaining : kWireBufferSize; + if (!ReceiveFully(socket_.Get(), scratch, chunk)) { + return Error::kConnectionFailed; + } + remaining -= chunk; + } + return Error::kCapacityExceeded; +} + +// The server sends one or more messages, each holding the total descriptor +// count as an int32 and some of the descriptors as SCM_RIGHTS. Descriptors +// beyond kMaxReceivedFds are closed and reported as kCapacityExceeded once +// all of them have been read. +Error PhaserServerConnection::ReceiveFds() { + alignas( + struct cmsghdr) char control[CMSG_SPACE(kMaxFdsPerMessage * sizeof(int))]; + int32_t received = 0; + bool overflow = false; + for (;;) { + int32_t total = 0; + size_t total_bytes = 0; + bool saw_rights = false; + int32_t in_message = 0; + while (total_bytes < sizeof(total)) { + std::memset(control, 0, sizeof(control)); + struct iovec iov; + iov.iov_base = reinterpret_cast(&total) + total_bytes; + iov.iov_len = sizeof(total) - total_bytes; + struct msghdr msg; + std::memset(&msg, 0, sizeof(msg)); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control; + msg.msg_controllen = static_cast(sizeof(control)); +#if defined(MSG_CMSG_CLOEXEC) + const ssize_t n = ::recvmsg(socket_.Get(), &msg, MSG_CMSG_CLOEXEC); +#else + const ssize_t n = ::recvmsg(socket_.Get(), &msg, 0); +#endif + if (n < 0 && errno == EINTR) { + continue; + } + if (n <= 0) { + return Error::kConnectionFailed; + } + total_bytes += static_cast(n); + if ((msg.msg_flags & MSG_CTRUNC) != 0) { + return Error::kProtocolError; + } + for (struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg != nullptr; + cmsg = CMSG_NXTHDR(&msg, cmsg)) { + if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) { + continue; + } + saw_rights = true; + const size_t data_length = cmsg->cmsg_len - CMSG_LEN(0); + const int count = static_cast(data_length / sizeof(int)); + const unsigned char *data = CMSG_DATA(cmsg); + for (int i = 0; i < count; i++) { + int fd = -1; + std::memcpy(&fd, data + static_cast(i) * sizeof(int), + sizeof(int)); + if (!fds_.Add(UniqueFd(fd))) { + overflow = true; + } + } + in_message += count; + } + } + if (total == 0) { + return Error::kOk; + } + if (!saw_rights) { + return Error::kProtocolError; + } + received += in_message; + if (received >= total) { + return overflow ? Error::kCapacityExceeded : Error::kOk; + } + } +} + +// build(request) fills in the request and read(response) handles the reply. +// A send_fd of 0 or more goes to the server after the request. +template +Error PhaserServerConnection::Transact(Build build, Read read, int send_fd) { + if (!socket_.Valid()) { + return Error::kNotInitialized; + } + fds_.Clear(); + size_t length = 0; + { + // The response is built in the request's storage, so the request goes + // out of scope first. + absl::StatusOr request = wire_.NewRequest(); + if (!request.ok()) { + return Error::kCapacityExceeded; + } + build(*request); + absl::StatusOr serialized = wire_.Serialize(*request); + if (!serialized.ok()) { + return Error::kCapacityExceeded; + } + length = *serialized; + } + + Error e = Send(length); + if (e == Error::kOk && send_fd >= 0) { + e = SendFd(send_fd); + } + const char *data = nullptr; + if (e == Error::kOk) { + e = Receive(data, length); + } + if (e == Error::kOk || e == Error::kCapacityExceeded) { + // The descriptors follow even a response that did not fit. + const Error fds_error = ReceiveFds(); + if (fds_error != Error::kOk && + (e == Error::kOk || fds_error != Error::kCapacityExceeded)) { + e = fds_error; + } + } + if (e == Error::kConnectionFailed || e == Error::kProtocolError) { + // The stream position is unknown after a failure. + socket_.Reset(); + } + if (e != Error::kOk) { + fds_.Clear(); + return e; + } + + absl::StatusOr response = wire_.NewResponse(); + if (!response.ok()) { + fds_.Clear(); + return Error::kCapacityExceeded; + } + if (absl::Status status = wire_.Decode(data, length, *response); + !status.ok()) { + fds_.Clear(); + return status.code() == absl::StatusCode::kResourceExhausted + ? Error::kCapacityExceeded + : Error::kProtocolError; + } + e = read(static_cast(*response)); + fds_.Clear(); + return e; +} + +Error PhaserServerConnection::Init(const char *client_name, InitReply &reply) { + return Transact( + [&](wire::Request &request) { + request.mutable_init()->set_client_name(View(client_name)); + }, + [&](const wire::Response &response) { + if (!response.has_init()) { + return Error::kProtocolError; + } + const wire::InitResponse &init = response.init(); + if (Error e = DupFd(fds_, init.scb_fd_index(), reply.scb); + e != Error::kOk) { + return e; + } + reply.session_id = static_cast(init.session_id()); + reply.user_id = init.user_id(); + reply.group_id = init.group_id(); + return Error::kOk; + }); +} + +Error PhaserServerConnection::CreatePublisher(const PublisherRequest &request, + PublisherReply &reply) { + return Transact( + [&](wire::Request &req) { + wire::CreatePublisherRequest *cmd = req.mutable_create_publisher(); + cmd->set_channel_name(View(request.channel_name)); + cmd->set_slot_size(request.slot_size); + cmd->set_num_slots(request.num_slots); + cmd->set_is_fixed_size(!request.server_defined); + cmd->set_server_defined(request.server_defined); + cmd->set_is_reliable(request.reliable); + if (request.type != nullptr) { + cmd->set_type(View(request.type)); + } + if (request.mux != nullptr) { + cmd->set_mux(View(request.mux)); + } + cmd->set_vchan_id(request.vchan_id); + cmd->set_subscriber_queue_arena_size( + request.subscriber_queue_arena_size); + cmd->set_checksum_size(request.checksum_size); + cmd->set_metadata_size(request.metadata_size); + cmd->set_use_split_buffers(request.use_split_buffers); + cmd->set_publisher_id(-1); + cmd->set_process_id(static_cast(::getpid())); + cmd->set_max_outstanding_slot_leases(1); + }, + [&](const wire::Response &response) { + if (!response.has_create_publisher()) { + return Error::kProtocolError; + } + const wire::CreatePublisherResponse &pub = response.create_publisher(); + if (!pub.error().empty()) { + return Rejected(pub.error().data(), pub.error().size()); + } + reply.channel_id = pub.channel_id(); + reply.publisher_id = pub.publisher_id(); + reply.num_slots = pub.num_slots(); + reply.vchan_id = pub.vchan_id(); + reply.num_sub_updates = pub.num_sub_updates(); + reply.subscriber_queue_arena_size = pub.subscriber_queue_arena_size(); + reply.slot_size = pub.slot_size(); + reply.checksum_size = pub.checksum_size(); + reply.metadata_size = pub.metadata_size(); + reply.use_split_buffers = pub.use_split_buffers(); + if (Error e = DupFd(fds_, pub.ccb_fd_index(), reply.ccb); + e != Error::kOk) { + return e; + } + if (Error e = DupFd(fds_, pub.bcb_fd_index(), reply.bcb); + e != Error::kOk) { + return e; + } + if (Error e = DupFd(fds_, pub.pub_poll_fd_index(), reply.poll); + e != Error::kOk) { + return e; + } + if (Error e = FillFdList( + fds_, pub.sub_trigger_fd_indexes_size(), + [&](size_t i) { return pub.sub_trigger_fd_indexes(i); }, + reply.subscriber_triggers); + e != Error::kOk) { + return e; + } + return FillFdList( + fds_, pub.retirement_fd_indexes_size(), + [&](size_t i) { return pub.retirement_fd_indexes(i); }, + reply.retirement_triggers); + }); +} + +Error PhaserServerConnection::CreateSubscriber(const SubscriberRequest &request, + SubscriberReply &reply) { + return Transact( + [&](wire::Request &req) { + wire::CreateSubscriberRequest *cmd = req.mutable_create_subscriber(); + cmd->set_channel_name(View(request.channel_name)); + cmd->set_subscriber_id(-1); + cmd->set_is_reliable(request.reliable); + if (request.type != nullptr) { + cmd->set_type(View(request.type)); + } + cmd->set_max_active_messages(request.max_active_messages); + if (request.mux != nullptr) { + cmd->set_mux(View(request.mux)); + } + cmd->set_vchan_id(request.vchan_id); + cmd->set_process_id(static_cast(::getpid())); + }, + [&](const wire::Response &response) { + if (!response.has_create_subscriber()) { + return Error::kProtocolError; + } + const wire::CreateSubscriberResponse &sub = + response.create_subscriber(); + if (!sub.error().empty()) { + return Rejected(sub.error().data(), sub.error().size()); + } + reply.channel_id = sub.channel_id(); + reply.subscriber_id = sub.subscriber_id(); + reply.num_slots = sub.num_slots(); + reply.vchan_id = sub.vchan_id(); + reply.num_pub_updates = sub.num_pub_updates(); + reply.checksum_size = sub.checksum_size(); + reply.metadata_size = sub.metadata_size(); + reply.subscriber_queue_size = sub.subscriber_queue_size(); + reply.subscriber_queue_arena_size = sub.subscriber_queue_arena_size(); + reply.use_split_buffers = sub.use_split_buffers(); + if (Error e = DupFd(fds_, sub.ccb_fd_index(), reply.ccb); + e != Error::kOk) { + return e; + } + if (Error e = DupFd(fds_, sub.bcb_fd_index(), reply.bcb); + e != Error::kOk) { + return e; + } + if (Error e = DupFd(fds_, sub.trigger_fd_index(), reply.trigger); + e != Error::kOk) { + return e; + } + if (Error e = DupFd(fds_, sub.poll_fd_index(), reply.poll); + e != Error::kOk) { + return e; + } + if (Error e = FillFdList( + fds_, sub.reliable_pub_trigger_fd_indexes_size(), + [&](size_t i) { + return sub.reliable_pub_trigger_fd_indexes(i); + }, + reply.reliable_publisher_triggers); + e != Error::kOk) { + return e; + } + return FillFdList( + fds_, sub.retirement_fd_indexes_size(), + [&](size_t i) { return sub.retirement_fd_indexes(i); }, + reply.retirement_triggers); + }); +} + +Error PhaserServerConnection::GetTriggers(const char *channel_name, + TriggersReply &reply) { + return Transact( + [&](wire::Request &req) { + req.mutable_get_triggers()->set_channel_name(View(channel_name)); + }, + [&](const wire::Response &response) { + if (!response.has_get_triggers()) { + return Error::kProtocolError; + } + const wire::GetTriggersResponse &triggers = response.get_triggers(); + if (!triggers.error().empty()) { + return Rejected(triggers.error().data(), triggers.error().size()); + } + if (Error e = FillFdList( + fds_, triggers.reliable_pub_trigger_fd_indexes_size(), + [&](size_t i) { + return triggers.reliable_pub_trigger_fd_indexes(i); + }, + reply.reliable_publisher_triggers); + e != Error::kOk) { + return e; + } + if (Error e = FillFdList( + fds_, triggers.sub_trigger_fd_indexes_size(), + [&](size_t i) { return triggers.sub_trigger_fd_indexes(i); }, + reply.subscriber_triggers); + e != Error::kOk) { + return e; + } + return FillFdList( + fds_, triggers.retirement_fd_indexes_size(), + [&](size_t i) { return triggers.retirement_fd_indexes(i); }, + reply.retirement_triggers); + }); +} + +Error PhaserServerConnection::RemovePublisher(const char *channel_name, + int32_t publisher_id) { + return Transact( + [&](wire::Request &req) { + wire::RemovePublisherRequest *cmd = req.mutable_remove_publisher(); + cmd->set_channel_name(View(channel_name)); + cmd->set_publisher_id(publisher_id); + }, + [&](const wire::Response &response) { + const std::string_view error = response.remove_publisher().error(); + if (!error.empty()) { + return Rejected(error.data(), error.size()); + } + return Error::kOk; + }); +} + +Error PhaserServerConnection::RemoveSubscriber(const char *channel_name, + int32_t subscriber_id) { + return Transact( + [&](wire::Request &req) { + wire::RemoveSubscriberRequest *cmd = req.mutable_remove_subscriber(); + cmd->set_channel_name(View(channel_name)); + cmd->set_subscriber_id(subscriber_id); + }, + [&](const wire::Response &response) { + const std::string_view error = response.remove_subscriber().error(); + if (!error.empty()) { + return Rejected(error.data(), error.size()); + } + return Error::kOk; + }); +} + +Error PhaserServerConnection::RegisterBuffer(const ClientBuffer &buffer, + int fd) { + if (fd < 0) { + return Error::kInvalidArgument; + } + return Transact( + [&](wire::Request &req) { + wire::RegisterClientBufferRequest *cmd = + req.mutable_register_client_buffer(); + wire::ClientBufferHandleMetadataProto *metadata = + cmd->mutable_metadata(); + metadata->set_channel_name(View(buffer.channel_name)); + metadata->set_session_id(buffer.session_id); + metadata->set_buffer_index(buffer.buffer_index); + metadata->set_slot_id(0); + metadata->set_is_prefix(false); + metadata->set_full_size(buffer.full_size); + metadata->set_allocation_size(buffer.full_size); + metadata->set_handle(static_cast(fd)); + metadata->set_allocator(wire::CLIENT_BUFFER_ALLOCATOR_ANDROID_MEMFD); + cmd->set_has_fd(true); + cmd->set_fd_index(0); + }, + [&](const wire::Response &response) { + const std::string_view error = + response.register_client_buffer().error(); + if (!error.empty()) { + return Rejected(error.data(), error.size()); + } + return Error::kOk; + }, + fd); +} + +Error PhaserServerConnection::GetBuffer(const BufferRequest &request, + BufferReply &reply) { + reply = BufferReply(); + return Transact( + [&](wire::Request &req) { + wire::GetClientBuffersRequest *cmd = req.mutable_get_client_buffers(); + cmd->set_channel_name(View(request.channel_name)); + cmd->set_session_id(request.session_id); + cmd->set_buffer_index(request.buffer_index); + cmd->set_filter_slot(true); + cmd->set_is_prefix(request.is_prefix); + cmd->set_slot_id(request.slot_id); + }, + [&](const wire::Response &response) { + if (!response.has_get_client_buffers()) { + return Error::kProtocolError; + } + const wire::GetClientBuffersResponse &buffers = + response.get_client_buffers(); + if (!buffers.error().empty()) { + return Rejected(buffers.error().data(), buffers.error().size()); + } + if (buffers.metadata_size() != buffers.fd_indexes_size()) { + return Error::kProtocolError; + } + for (size_t i = 0; i < buffers.metadata_size(); i++) { + const wire::ClientBufferHandleMetadataProto metadata = + buffers.metadata(i); + if (metadata.is_prefix() != request.is_prefix || + metadata.slot_id() != request.slot_id) { + continue; + } + if (buffers.fd_indexes(i) >= 0) { + if (Error e = DupFd(fds_, buffers.fd_indexes(i), reply.fd); + e != Error::kOk) { + return e; + } + } + reply.found = true; + reply.full_size = metadata.full_size(); + reply.allocation_size = metadata.allocation_size(); + reply.handle = metadata.handle(); + reply.map_offset = metadata.map_offset(); + reply.allocator = static_cast(metadata.allocator()); + return Error::kOk; + } + return Error::kOk; + }); +} + +} // namespace asil +} // namespace subspace diff --git a/asil_client/phaser_connection.h b/asil_client/phaser_connection.h new file mode 100644 index 00000000..e017f8f6 --- /dev/null +++ b/asil_client/phaser_connection.h @@ -0,0 +1,79 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#pragma once + +#include "asil_client/server_connection.h" +#include "common/server_wire.h" + +#include +#include + +namespace subspace { +namespace asil { + +// The standard Subspace protocol: protobuf requests and responses over the +// server's Unix socket, with file descriptors passed as SCM_RIGHTS. Requests +// and responses are Phaser messages held in a ServerWire with fixed buffers +// inside this object, the same encoding as the standard client uses, so the +// handshake makes no heap allocations. A request or response that does not +// fit fails with kCapacityExceeded. +// +// The object holds about 60 KB of buffers, so keep it in static storage or +// inside a long-lived object rather than on a small stack. +class PhaserServerConnection : public ServerConnection { +public: + // Protobuf wire bytes for one request or response. + static constexpr size_t kWireBufferSize = 16 * 1024; + // The decoded request or response. + static constexpr size_t kMessageBufferSize = 32 * 1024; + // Descriptors in one response: the channel's control blocks, the + // publisher or subscriber's own triggers, and a trigger and a retirement + // descriptor for every other publisher or subscriber. + static constexpr int kMaxReceivedFds = 2 * kMaxTriggerFds + 8; + + PhaserServerConnection() = default; + ~PhaserServerConnection() override = default; + + PhaserServerConnection(const PhaserServerConnection &) = delete; + PhaserServerConnection &operator=(const PhaserServerConnection &) = delete; + + Error Connect(const char *socket_name); + void Close() { socket_.Reset(); } + bool Connected() const { return socket_.Valid(); } + + Error Init(const char *client_name, InitReply &reply) override; + Error CreatePublisher(const PublisherRequest &request, + PublisherReply &reply) override; + Error CreateSubscriber(const SubscriberRequest &request, + SubscriberReply &reply) override; + Error GetTriggers(const char *channel_name, TriggersReply &reply) override; + Error RemovePublisher(const char *channel_name, + int32_t publisher_id) override; + Error RemoveSubscriber(const char *channel_name, + int32_t subscriber_id) override; + Error RegisterBuffer(const ClientBuffer &buffer, int fd) override; + Error GetBuffer(const BufferRequest &request, BufferReply &reply) override; + + const char *LastServerError() const override { return last_error_; } + + using ReceivedFds = FdList; + +private: + template + Error Transact(Build build, Read read, int send_fd = -1); + Error Send(size_t length); + Error SendFd(int fd); + Error Receive(const char *&data, size_t &length); + Error ReceiveFds(); + Error Rejected(const char *message, size_t length); + + UniqueFd socket_; + ReceivedFds fds_; + char last_error_[256] = {}; + ServerWire> wire_; +}; + +} // namespace asil +} // namespace subspace diff --git a/asil_client/publisher.cc b/asil_client/publisher.cc new file mode 100644 index 00000000..5395b69b --- /dev/null +++ b/asil_client/publisher.cc @@ -0,0 +1,641 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// The publishing side of the shared memory protocol. This follows +// PublisherImpl::FindFreeSlotUnreliable, PublisherImpl::FindFreeSlotReliable +// and PublisherImpl::ActivateSlotAndGetAnother in client/publisher.cc. + +#include "asil_client/publisher.h" + +#include "asil_client/checksum.h" +#include "asil_client/client.h" + +#include +#include + +namespace subspace { +namespace asil { + +namespace { + +bool ValidLayoutSize(int32_t size) { return size >= 0 && size <= 0xFFFF; } + +// Slots are ordered by timestamp, then by id, as a stable sort by timestamp +// orders them. +bool Earlier(uint64_t timestamp, int id, uint64_t other_timestamp, + int other_id) { + if (timestamp != other_timestamp) { + return timestamp < other_timestamp; + } + return id < other_id; +} + +} // namespace + +Error Publisher::Open(Client &client, const char *channel_name, + const PublisherOptions &options) { + if (client_ != nullptr) { + return Error::kAlreadyInitialized; + } + if (!client.Initialized()) { + return Error::kNotInitialized; + } + if (options.server_defined) { + if (options.slot_size != 0 || options.num_slots != 0 || + options.checksum_size != 0 || options.metadata_size != 0 || + options.subscriber_queue_arena_size != 0 || options.use_split_buffers) { + return Error::kInvalidArgument; + } + } else if (options.slot_size <= 0 || options.num_slots <= 0 || + !ValidLayoutSize(options.checksum_size) || + !ValidLayoutSize(options.metadata_size)) { + return Error::kInvalidArgument; + } + if (options.vchan_id < -1 || options.vchan_id >= shm::kMaxVchanId) { + return Error::kInvalidArgument; + } + if (Error e = internal::CopyName(channel_name, name_); e != Error::kOk) { + return e; + } + if (Error e = internal::CopyName( + options.mux != nullptr ? options.mux : channel_name, resolved_name_); + e != Error::kOk) { + return e; + } + PublisherRequest request; + request.channel_name = name_; + request.server_defined = options.server_defined; + request.slot_size = + options.server_defined ? 0 : shm::Aligned64(options.slot_size); + request.num_slots = options.num_slots; + request.type = options.type; + request.checksum_size = options.checksum_size; + request.metadata_size = options.metadata_size; + request.reliable = options.reliable; + request.mux = options.mux; + request.vchan_id = options.vchan_id; + request.subscriber_queue_arena_size = options.subscriber_queue_arena_size; + request.use_split_buffers = options.use_split_buffers; + PublisherReply reply; + reply.subscriber_triggers = &subscriber_triggers_; + reply.retirement_triggers = &retirement_triggers_; + if (Error e = client.connection_->CreatePublisher(request, reply); + e != Error::kOk) { + Reset(); + return e; + } + client_ = &client; + channel_id_ = reply.channel_id; + publisher_id_ = reply.publisher_id; + vchan_id_ = reply.vchan_id; + num_sub_updates_ = static_cast(reply.num_sub_updates); + checksum_ = options.checksum; + reliable_ = options.reliable; + poll_ = static_cast(reply.poll); + buffer_context_.channel_name = resolved_name_; + buffer_context_.session_id = client.session_id_; + buffer_context_.user_id = client.user_id_; + buffer_context_.group_id = client.group_id_; + buffer_context_.connection = client.connection_; + + int64_t slot_size = request.slot_size; + int32_t checksum_size = options.checksum_size; + int32_t metadata_size = options.metadata_size; + bool use_split_buffers = options.use_split_buffers; + if (options.server_defined) { + slot_size = reply.slot_size; + checksum_size = reply.checksum_size; + metadata_size = reply.metadata_size; + use_split_buffers = reply.use_split_buffers; + } + + Error e = Error::kOk; + if (channel_id_ < 0 || channel_id_ >= shm::kMaxChannels || + publisher_id_ < 0 || publisher_id_ >= shm::kMaxSlotOwners || + vchan_id_ < -1 || vchan_id_ >= shm::kMaxVchanId || + (vchan_id_ != -1) != (options.mux != nullptr) || slot_size <= 0 || + (options.server_defined && reply.num_slots <= 0) || + !ValidLayoutSize(checksum_size) || !ValidLayoutSize(metadata_size)) { + e = Error::kProtocolError; + } + if (e == Error::kOk) { + const int num_slots = + reply.num_slots > 0 ? reply.num_slots : options.num_slots; + e = memory_.Map(client.scb_, reply.ccb.Get(), reply.bcb.Get(), num_slots, + checksum_size, metadata_size, + reply.subscriber_queue_arena_size, &buffer_context_); + } + if (e == Error::kOk) { + e = use_split_buffers + ? memory_.AttachSplitBuffers( + /*writable=*/true, options.split_slots, + options.split_slot_capacity, options.split_allocator) + : memory_.CreateOrAttachBuffer(slot_size); + } + if (e == Error::kOk && memory_.SlotSize() < slot_size) { + e = Error::kLayoutMismatch; + } + if (e == Error::kOk) { + slot_ = FindFreeSlot(); + if (slot_ == nullptr) { + e = Error::kNoSlot; + } + } + if (e != Error::kOk) { + (void)Close(); + return e; + } + BitsetView activations(&memory_.Ccb()->activation_tracker, + shm::kMaxVchanId + 1); + if (reliable_ || (options.activate && !activations.IsSet(vchan_id_ + 1))) { + // Reliable subscribers hold the activation message, which stops a + // reliable publisher overtaking them. + slot_->message_size.store(1, std::memory_order_relaxed); + PublishSlot(slot_, /*is_activation=*/true, nullptr); + slot_ = reliable_ ? nullptr : FindFreeSlot(); + } + TriggerSubscribers(); + return Error::kOk; +} + +void Publisher::Reset() { + memory_.Unmap(); + subscriber_triggers_.Clear(); + retirement_triggers_.Clear(); + poll_.Reset(); + client_ = nullptr; + slot_ = nullptr; + channel_id_ = -1; + publisher_id_ = -1; + vchan_id_ = -1; + reliable_ = false; +} + +Error Publisher::Close() { + if (client_ == nullptr) { + return Error::kOk; + } + // The server releases the slot this publisher holds. + const Error e = client_->connection_->RemovePublisher(name_, publisher_id_); + Reset(); + return e; +} + +Error Publisher::Wait(int timeout_ms) { + if (client_ == nullptr) { + return Error::kNotInitialized; + } + return internal::WaitReadable(poll_.Get(), timeout_ms); +} + +bool Publisher::HoldSlot() { + if (slot_ != nullptr) { + return true; + } + if (client_ == nullptr) { + return false; + } + if (reliable_) { + // A subscriber releasing a message after this makes PollFd() readable. + internal::ClearTrigger(poll_.Get()); + // With no subscribers nothing would stop the publisher taking every + // slot, and a subscriber that joined later would miss those messages. + if (memory_.NumSubscribers(vchan_id_) == 0) { + return false; + } + } + slot_ = FindFreeSlot(); + return slot_ != nullptr; +} + +void *Publisher::Buffer() { + return HoldSlot() ? memory_.Payload(slot_) : nullptr; +} + +void *Publisher::Metadata() { + if (memory_.MetadataSize() == 0 || !HoldSlot()) { + return nullptr; + } + return reinterpret_cast(&memory_.Prefix(slot_)->checksum) + + memory_.ChecksumSize(); +} + +Error Publisher::Publish(size_t message_size, PublishedMessage *published) { + if (client_ == nullptr) { + return Error::kNotInitialized; + } + if (message_size == 0) { + return Error::kInvalidArgument; + } + if (message_size > static_cast(memory_.SlotSize())) { + return Error::kMessageTooLarge; + } + if (slot_ == nullptr) { + // Buffer() found no slot, so there is nothing to publish. + return Error::kNoSlot; + } + if (Error e = RefreshTriggers(); e != Error::kOk) { + return e; + } + slot_->message_size.store(message_size, std::memory_order_relaxed); + PublishSlot(slot_, /*is_activation=*/false, published); + // A reliable publisher takes its next slot when Buffer() asks for one. + slot_ = reliable_ ? nullptr : FindFreeSlot(); + TriggerSubscribers(); + return Error::kOk; +} + +Error Publisher::RefreshTriggers() { + const uint16_t updates = memory_.Counters(channel_id_).num_sub_updates; + if (updates == num_sub_updates_) { + return Error::kOk; + } + TriggersReply reply; + reply.subscriber_triggers = &subscriber_triggers_; + reply.retirement_triggers = &retirement_triggers_; + const Error e = client_->connection_->GetTriggers(name_, reply); + if (e == Error::kOk) { + num_sub_updates_ = updates; + } + return e; +} + +void Publisher::TriggerSubscribers() const { + for (int i = 0; i < subscriber_triggers_.Size(); i++) { + internal::Trigger(subscriber_triggers_.Get(i)); + } +} + +shm::MessageSlot *Publisher::FindFreeSlot() { + return reliable_ ? FindFreeSlotReliable() : FindFreeSlotUnreliable(); +} + +int Publisher::FindRetiredSlotToReuse(bool oldest_first) const { + BitsetView retired = memory_.RetiredSlots(); + if (!oldest_first) { + return retired.FindFirstSet(); + } + // With no subscribers every slot retires as soon as it is published, so + // reuse the oldest to keep a window of recent messages for late joiners. + int oldest = -1; + uint64_t oldest_timestamp = ~uint64_t{0}; + for (int i = 0; i < memory_.NumSlots(); i++) { + if (!retired.IsSet(i)) { + continue; + } + const uint64_t timestamp = + memory_.Slot(i)->timestamp.load(std::memory_order_relaxed); + if (timestamp < oldest_timestamp) { + oldest_timestamp = timestamp; + oldest = i; + } + } + return oldest; +} + +// Takes ownership of slot if no other publisher or subscriber has it. +bool Publisher::ClaimSlot(shm::MessageSlot *slot) { + const uint64_t owner = shm::kPubOwned | static_cast(publisher_id_); + const uint64_t old_refs = slot->refs.load(std::memory_order_relaxed); + uint64_t expected = shm::BuildRefsBitField( + slot->ordinal.load(std::memory_order_relaxed), + static_cast((old_refs >> shm::kVchanIdShift) & shm::kVchanIdMask), + (old_refs >> shm::kRetiredRefsShift) & shm::kRetiredRefsMask); + return slot->refs.compare_exchange_weak( + expected, owner, std::memory_order_acquire, std::memory_order_relaxed); +} + +shm::MessageSlot *Publisher::FindFreeSlotUnreliable() { + shm::ChannelControlBlock *ccb = memory_.Ccb(); + BitsetView free_slots = memory_.FreeSlots(); + BitsetView retired_slots = memory_.RetiredSlots(); + constexpr int kMaxCasRetries = 1000; + int retries = memory_.NumSlots() * 1000; + int cas_retries = 0; + shm::MessageSlot *slot = nullptr; + int free_slot = -1; + int retired_slot = -1; + for (;;) { + slot = nullptr; + free_slot = -1; + retired_slot = -1; + const bool no_subscribers = memory_.NumSubscribers(vchan_id_) == 0; + if (!ccb->free_slots_exhausted.load(std::memory_order_relaxed) && + (free_slot = free_slots.FindFirstSet()) != -1) { + if (!free_slots.ClearWasSet(free_slot)) { + continue; + } + slot = memory_.Slot(free_slot); + if (free_slots.IsEmpty()) { + ccb->free_slots_exhausted.store(true, std::memory_order_relaxed); + } + } else if ((retired_slot = FindRetiredSlotToReuse(no_subscribers)) != -1) { + if (!retired_slots.ClearWasSet(retired_slot)) { + continue; + } + slot = memory_.Slot(retired_slot); + } else { + // Recycle the oldest message that no subscriber holds. + uint64_t earliest = ~uint64_t{0}; + for (int i = 0; i < memory_.NumSlots(); i++) { + shm::MessageSlot *s = memory_.Slot(i); + const uint64_t refs = s->refs.load(std::memory_order_relaxed); + if ((refs & shm::kPubOwned) != 0) { + continue; + } + const uint64_t timestamp = s->timestamp.load(std::memory_order_relaxed); + if ((refs & shm::kRefsMask) == 0 && timestamp < earliest) { + slot = s; + earliest = timestamp; + } + } + } + if (slot == nullptr) { + if (retries-- == 0) { + return nullptr; + } + continue; + } + if (ClaimSlot(slot)) { + break; + } + if (++cas_retries >= kMaxCasRetries) { + if (retries-- == 0) { + return nullptr; + } + cas_retries = 0; + (void)::sched_yield(); + } + } + PrepareClaimedSlot(slot, free_slot == -1 && retired_slot == -1); + return slot; +} + +// A reliable subscriber still needs a slot that it holds, and, when reliable +// subscribers exist, a message that none of them has seen. +bool Publisher::ReliableClaimable(shm::MessageSlot *slot, + bool require_reliable_seen) const { + const uint64_t refs = slot->refs.load(std::memory_order_relaxed); + if (((refs >> shm::kReliableRefCountShift) & shm::kRefCountMask) != 0) { + return false; + } + if (require_reliable_seen && + slot->ordinal.load(std::memory_order_relaxed) != 0 && + (slot->flags.load(std::memory_order_relaxed) & + shm::kMessageSeenByReliable) == 0) { + return false; + } + return (refs & shm::kRefsMask) == 0; +} + +// The oldest slot that no subscriber holds and that is older than every slot +// a reliable subscriber still needs. +shm::MessageSlot * +Publisher::OldestReliableCandidate(bool require_reliable_seen) const { + int stop = -1; + uint64_t stop_timestamp = 0; + for (int i = 0; i < memory_.NumSlots(); i++) { + shm::MessageSlot *s = memory_.Slot(i); + const uint64_t refs = s->refs.load(std::memory_order_relaxed); + if ((refs & shm::kPubOwned) != 0) { + continue; + } + const bool needed = + ((refs >> shm::kReliableRefCountShift) & shm::kRefCountMask) != 0 || + (require_reliable_seen && + s->ordinal.load(std::memory_order_relaxed) != 0 && + (s->flags.load(std::memory_order_relaxed) & + shm::kMessageSeenByReliable) == 0); + const uint64_t timestamp = s->timestamp.load(std::memory_order_relaxed); + if (needed && (stop == -1 || Earlier(timestamp, i, stop_timestamp, stop))) { + stop = i; + stop_timestamp = timestamp; + } + } + shm::MessageSlot *best = nullptr; + uint64_t best_timestamp = 0; + for (int i = 0; i < memory_.NumSlots(); i++) { + shm::MessageSlot *s = memory_.Slot(i); + const uint64_t refs = s->refs.load(std::memory_order_relaxed); + if ((refs & (shm::kPubOwned | shm::kRefsMask)) != 0) { + continue; + } + const uint64_t timestamp = s->timestamp.load(std::memory_order_relaxed); + if (stop != -1 && !Earlier(timestamp, i, stop_timestamp, stop)) { + continue; + } + if (best == nullptr || Earlier(timestamp, i, best_timestamp, best->id)) { + best = s; + best_timestamp = timestamp; + } + } + return best; +} + +shm::MessageSlot *Publisher::FindFreeSlotReliable() { + shm::ChannelControlBlock *ccb = memory_.Ccb(); + BitsetView free_slots = memory_.FreeSlots(); + BitsetView retired_slots = memory_.RetiredSlots(); + constexpr int kMaxCasRetries = 1000; + int retries = memory_.NumSlots() * 1000; + int cas_retries = 0; + shm::MessageSlot *slot = nullptr; + int free_slot = -1; + int retired_slot = -1; + for (;;) { + slot = nullptr; + free_slot = -1; + retired_slot = -1; + const bool require_reliable_seen = + memory_.Counters(channel_id_).num_reliable_subs != 0; + if (!ccb->free_slots_exhausted.load(std::memory_order_relaxed) && + (free_slot = free_slots.FindFirstSet()) != -1) { + if (!free_slots.ClearWasSet(free_slot)) { + continue; + } + if (free_slots.IsEmpty()) { + ccb->free_slots_exhausted.store(true, std::memory_order_relaxed); + } + // A free slot has never held a message. + slot = memory_.Slot(free_slot); + } else if ((retired_slot = retired_slots.FindFirstSet()) != -1) { + if (!retired_slots.ClearWasSet(retired_slot)) { + continue; + } + slot = memory_.Slot(retired_slot); + if (!ReliableClaimable(slot, require_reliable_seen)) { + // The slot is still retired; it just can't be reused yet. + retired_slots.Set(retired_slot); + return nullptr; + } + } else { + slot = OldestReliableCandidate(require_reliable_seen); + if (slot == nullptr) { + return nullptr; + } + } + if (ClaimSlot(slot)) { + retired_slots.Clear(slot->id); + break; + } + if (++cas_retries >= kMaxCasRetries) { + if (retries-- == 0) { + return nullptr; + } + cas_retries = 0; + (void)::sched_yield(); + } + } + PrepareClaimedSlot(slot, free_slot == -1 && retired_slot == -1); + return slot; +} + +// Resets a slot this publisher has just claimed. forced_reuse is true when +// the slot held a message that a subscriber might not have seen. +void Publisher::PrepareClaimedSlot(shm::MessageSlot *slot, bool forced_reuse) { + slot->ordinal.store(0, std::memory_order_relaxed); + slot->timestamp.store(0, std::memory_order_relaxed); + slot->vchan_id.store(static_cast(vchan_id_), + std::memory_order_relaxed); + SetSlotBuffer(slot); + shm::MessagePrefix *prefix = memory_.Prefix(slot); + prefix->flags = 0; + prefix->vchan_id = vchan_id_; + + // The old message is gone, so no subscriber should look for it. + memory_.Subscribers().Traverse([this, slot](int sub_id) { + memory_.AvailableSlots(sub_id).Clear(slot->id); + }); + + if (forced_reuse) { + memory_.TriggerRetirement(retirement_triggers_, slot->id); + } +} + +void Publisher::SetSlotBuffer(shm::MessageSlot *slot) { + shm::BufferControlBlock *bcb = memory_.Bcb(); + const int old_index = slot->buffer_index.load(std::memory_order_relaxed); + if (old_index >= 0 && old_index < shm::kMaxBuffers && + bcb->refs[old_index].load(std::memory_order_relaxed) > 0) { + bcb->refs[old_index]--; + } + const int index = memory_.BufferIndex(); + slot->buffer_index.store(static_cast(index), + std::memory_order_relaxed); + bcb->refs[index]++; +} + +void Publisher::PublishSlot(shm::MessageSlot *slot, bool is_activation, + PublishedMessage *published) { + shm::ChannelControlBlock *ccb = memory_.Ccb(); + shm::MessagePrefix *prefix = memory_.Prefix(slot); + const uint64_t ordinal = ccb->ordinals[vchan_id_ + 1].fetch_add(1); + const uint64_t timestamp = internal::Now(); + const uint64_t message_size = + slot->message_size.load(std::memory_order_relaxed); + slot->ordinal.store(ordinal, std::memory_order_relaxed); + slot->timestamp.store(timestamp, std::memory_order_relaxed); + slot->flags.store(0, std::memory_order_relaxed); + + prefix->message_size = message_size; + prefix->ordinal = ordinal; + prefix->timestamp = timestamp; + prefix->vchan_id = vchan_id_; + prefix->checksum_size = static_cast(memory_.ChecksumSize()); + prefix->metadata_size = static_cast(memory_.MetadataSize()); + prefix->flags = 0; + prefix->slot_id = slot->id; + slot->bridged_slot_id.store(slot->id, std::memory_order_relaxed); + if (is_activation) { + prefix->flags |= shm::kMessageActivate; + slot->flags.fetch_or(shm::kMessageIsActivation, std::memory_order_relaxed); + BitsetView(&ccb->activation_tracker, shm::kMaxVchanId + 1) + .Set(vchan_id_ + 1); + } + if (checksum_) { + prefix->flags |= shm::kMessageHasChecksum; + const auto *base = reinterpret_cast(prefix); + uint32_t crc = 0xFFFFFFFF; + crc = Crc32(crc, base + offsetof(shm::MessagePrefix, slot_id), + offsetof(shm::MessagePrefix, checksum) - + offsetof(shm::MessagePrefix, slot_id)); + crc = Crc32(crc, + base + offsetof(shm::MessagePrefix, checksum) + + memory_.ChecksumSize(), + static_cast(memory_.MetadataSize())); + crc = Crc32(crc, reinterpret_cast(memory_.Payload(slot)), + message_size); + crc = ~crc; + std::memcpy(&prefix->checksum, &crc, sizeof(crc)); + } + + const uint64_t cleanup_generation = memory_.CleanupGeneration(vchan_id_); + + // The server uses this count to tell when no publisher can be writing to a + // subscriber queue. + std::atomic &active_publishers = + memory_.QueueIndex()->active_publishers[publisher_id_]; + active_publishers.fetch_add(1, std::memory_order_seq_cst); + + // Mark the message available to every subscriber on the publisher's + // virtual channel while the slot is still publisher owned. A subscriber + // can leave between the traversal and the set, so check membership again + // afterwards. The bitset is the authoritative record, so a queue that is + // full only loses a hint. + memory_.Subscribers().Traverse( + [this, slot, ordinal](int sub_id) { + const int sub_vchan_id = memory_.Ccb()->sub_vchan_ids[sub_id]; + if (vchan_id_ != -1 && sub_vchan_id != -1 && + sub_vchan_id != vchan_id_) { + return; + } + BitsetView available = memory_.AvailableSlots(sub_id); + BitsetView subscribers = memory_.Subscribers(); + available.Set(slot->id); + if (!subscribers.IsSetSeqCst(sub_id)) { + available.Clear(slot->id); + if (!subscribers.IsSetSeqCst(sub_id)) { + return; + } + available.Set(slot->id); + } + shm::SlotQueue *queue = memory_.Queue(sub_id); + if (queue != nullptr && + !internal::PushSlotQueue(queue, slot->id, ordinal)) { + internal::MarkSlotQueueInsertionFailure(queue); + } + }, + std::memory_order_seq_cst); + + if (!is_activation) { + ccb->total_bytes += message_size; + if (message_size > ccb->max_message_size) { + ccb->max_message_size = message_size; + } + } + + // Releasing publisher ownership makes the message readable. + slot->refs.store(shm::BuildRefsBitField(ordinal, vchan_id_, 0), + std::memory_order_release); + ccb->total_messages.fetch_add(1, std::memory_order_seq_cst); + + // A subscriber removed while publishing lowers the retirement threshold. + if (!is_activation && + memory_.CleanupGeneration(vchan_id_) != cleanup_generation && + memory_.TryRetireSlot(slot)) { + memory_.TriggerRetirement(retirement_triggers_, slot->id); + } + + uint32_t active = active_publishers.load(std::memory_order_seq_cst); + while (active != 0 && !active_publishers.compare_exchange_strong( + active, active - 1, std::memory_order_seq_cst)) { + } + if (published != nullptr) { + published->ordinal = ordinal; + published->timestamp = timestamp; + } +} + +} // namespace asil +} // namespace subspace diff --git a/asil_client/publisher.h b/asil_client/publisher.h new file mode 100644 index 00000000..14e91122 --- /dev/null +++ b/asil_client/publisher.h @@ -0,0 +1,151 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#pragma once + +#include "asil_client/channel_memory.h" +#include "asil_client/error.h" +#include "asil_client/server_connection.h" +#include "asil_client/unique_fd.h" + +#include +#include + +namespace subspace { +namespace asil { + +class Client; + +struct PublisherOptions { + // The channel is in the server's static channel config, which defines its + // layout. Publishers on static channels must set this and leave + // slot_size, num_slots, checksum_size, metadata_size, + // subscriber_queue_arena_size and use_split_buffers zero; the publisher + // takes them from the server. split_slots and split_allocator still apply + // to a static channel with split buffers. + bool server_defined = false; + // The channel layout, for a channel that isn't static. + int64_t slot_size = 0; + int32_t num_slots = 0; + int32_t checksum_size = 0; // 0 means 4 bytes. + int32_t metadata_size = 0; + const char *type = nullptr; + // Put a checksum in every message. + bool checksum = false; + // Publish an activation message if the channel hasn't been activated. A + // reliable publisher always publishes one. + bool activate = false; + // Never overwrite a message that a reliable subscriber hasn't read. Buffer() + // returns null until there is room; wait for PollFd() and try again. + bool reliable = false; + // Publish on a virtual channel of this multiplexer. vchan_id -1 lets the + // server choose the channel's id. + const char *mux = nullptr; + int32_t vchan_id = -1; + // Bytes in the channel's control block for subscriber queues. This must + // match the channel; 0 for a channel without queues. + uint64_t subscriber_queue_arena_size = 0; + // The channel has split buffers, which a standard publisher must have + // created. On a static channel the server says whether there are split + // buffers, and creates them. split_slots has room for the channel's + // num_slots slot mappings, and the allocator maps slots that a custom + // allocator created. + bool use_split_buffers = false; + SplitSlot *split_slots = nullptr; + int32_t split_slot_capacity = 0; + SplitBufferAllocator split_allocator; +}; + +struct PublishedMessage { + uint64_t ordinal = 0; + uint64_t timestamp = 0; +}; + +// A publisher for a fixed-size channel. An unreliable publisher always holds +// a slot to write the next message into, recycling the oldest message that no +// subscriber holds. A reliable publisher takes a slot only when one is free +// of messages that reliable subscribers haven't read. +// +// A publisher is not thread safe and must not outlive its Client. +class Publisher { +public: + Publisher() = default; + ~Publisher() { (void)Close(); } + Publisher(const Publisher &) = delete; + Publisher &operator=(const Publisher &) = delete; + + Error Open(Client &client, const char *channel_name, + const PublisherOptions &options); + // Removes the publisher from the server. Safe to call more than once. + Error Close(); + bool IsOpen() const { return client_ != nullptr; } + + // Where to write the next message, or null if no slot is available. A + // reliable publisher also returns null while the channel has no + // subscribers. + void *Buffer(); + int64_t SlotSize() const { return memory_.SlotSize(); } + int NumSlots() const { return memory_.NumSlots(); } + + // The user metadata area for the next message, or null if the channel has + // none or no slot is available. + void *Metadata(); + size_t MetadataSize() const { + return static_cast(memory_.MetadataSize()); + } + + // Publishes the message_size bytes written to Buffer(). If a subscriber + // has joined since the last publish this asks the server for its trigger + // fd first. + Error Publish(size_t message_size, PublishedMessage *published = nullptr); + + // Readable when a reliable subscriber may have made room for a message. + int PollFd() const { return poll_.Get(); } + // Waits until PollFd() is readable. A negative timeout waits forever. + Error Wait(int timeout_ms); + + bool IsReliable() const { return reliable_; } + // The publisher's virtual channel id, or -1. + int VirtualChannelId() const { return vchan_id_; } + const char *Name() const { return name_; } + +private: + bool HoldSlot(); + Error RefreshTriggers(); + shm::MessageSlot *FindFreeSlot(); + shm::MessageSlot *FindFreeSlotUnreliable(); + shm::MessageSlot *FindFreeSlotReliable(); + shm::MessageSlot *OldestReliableCandidate(bool require_reliable_seen) const; + bool ReliableClaimable(shm::MessageSlot *slot, + bool require_reliable_seen) const; + bool ClaimSlot(shm::MessageSlot *slot); + void PrepareClaimedSlot(shm::MessageSlot *slot, bool forced_reuse); + int FindRetiredSlotToReuse(bool oldest_first) const; + void SetSlotBuffer(shm::MessageSlot *slot); + void PublishSlot(shm::MessageSlot *slot, bool is_activation, + PublishedMessage *published); + void TriggerSubscribers() const; + void Reset(); + + Client *client_ = nullptr; + internal::ChannelMemory memory_; + internal::BufferContext buffer_context_; + char name_[kMaxChannelNameLength + 1] = {}; + // The channel that owns the shared memory: the multiplexer of a virtual + // channel. + char resolved_name_[kMaxChannelNameLength + 1] = {}; + int32_t channel_id_ = -1; + int32_t publisher_id_ = -1; + int32_t vchan_id_ = -1; + uint16_t num_sub_updates_ = 0; + bool checksum_ = false; + bool reliable_ = false; + shm::MessageSlot *slot_ = nullptr; + UniqueFd poll_; + TriggerFdList subscriber_triggers_; + TriggerFdList retirement_triggers_; +}; + +} // namespace asil +} // namespace subspace diff --git a/asil_client/server_connection.h b/asil_client/server_connection.h new file mode 100644 index 00000000..80a84767 --- /dev/null +++ b/asil_client/server_connection.h @@ -0,0 +1,173 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// The handshake between the ASIL client and the Subspace server. +// +// The client talks to the server only to connect, to create and remove +// publishers and subscribers, to fetch trigger file descriptors when the +// set of publishers or subscribers on a channel changes, and, while opening a +// publisher or subscriber, to exchange buffer descriptors. Messages move +// through shared memory without the server. ServerConnection hides the wire +// format of these requests so that it can be replaced; PhaserServerConnection +// implements the standard protocol. + +#pragma once + +#include "asil_client/error.h" +#include "asil_client/shm_layout.h" +#include "asil_client/split_buffer.h" +#include "asil_client/unique_fd.h" + +#include + +namespace subspace { +namespace asil { + +constexpr int kMaxTriggerFds = shm::kMaxSlotOwners; +using TriggerFdList = FdList; + +struct InitReply { + UniqueFd scb; + uint64_t session_id = 0; + int32_t user_id = -1; + int32_t group_id = -1; +}; + +struct PublisherRequest { + const char *channel_name = nullptr; + int64_t slot_size = 0; + int32_t num_slots = 0; + const char *type = nullptr; + int32_t checksum_size = 0; + int32_t metadata_size = 0; + bool reliable = false; + // The multiplexer of a virtual channel, or null. + const char *mux = nullptr; + int32_t vchan_id = -1; + uint64_t subscriber_queue_arena_size = 0; + bool use_split_buffers = false; + // The server defines the layout of this static channel, and the layout + // fields above are zero. + bool server_defined = false; +}; + +struct PublisherReply { + int32_t channel_id = -1; + int32_t publisher_id = -1; + int32_t num_slots = 0; + int32_t vchan_id = -1; + int32_t num_sub_updates = 0; + uint64_t subscriber_queue_arena_size = 0; + // The rest of the channel's layout. + int64_t slot_size = 0; + int32_t checksum_size = 0; + int32_t metadata_size = 0; + bool use_split_buffers = false; + UniqueFd ccb; + UniqueFd bcb; + // Readable when a reliable subscriber has released a message. + UniqueFd poll; + // Filled when not null. + TriggerFdList *subscriber_triggers = nullptr; + TriggerFdList *retirement_triggers = nullptr; +}; + +struct SubscriberRequest { + const char *channel_name = nullptr; + const char *type = nullptr; + int32_t max_active_messages = 1; + bool reliable = false; + // The multiplexer of a virtual channel, or null. + const char *mux = nullptr; + int32_t vchan_id = -1; +}; + +struct SubscriberReply { + int32_t channel_id = -1; + int32_t subscriber_id = -1; + int32_t num_slots = 0; + int32_t vchan_id = -1; + int32_t num_pub_updates = 0; + int32_t checksum_size = 0; + int32_t metadata_size = 0; + int32_t subscriber_queue_size = 0; + uint64_t subscriber_queue_arena_size = 0; + bool use_split_buffers = false; + UniqueFd ccb; + UniqueFd bcb; + UniqueFd trigger; + UniqueFd poll; + // Filled when not null. + TriggerFdList *reliable_publisher_triggers = nullptr; + TriggerFdList *retirement_triggers = nullptr; +}; + +struct TriggersReply { + // Filled when not null. + TriggerFdList *reliable_publisher_triggers = nullptr; + TriggerFdList *subscriber_triggers = nullptr; + TriggerFdList *retirement_triggers = nullptr; +}; + +// A memfd message buffer. The server keeps the descriptor so that other +// clients can map the buffer. +struct ClientBuffer { + // The channel that owns the buffer: the multiplexer of a virtual channel. + const char *channel_name = nullptr; + uint64_t session_id = 0; + uint32_t buffer_index = 0; + uint64_t full_size = 0; +}; + +// A buffer registered with the server: buffer_index's single buffer, or the +// prefix or one slot of a split buffer set. +struct BufferRequest { + // The channel that owns the buffer: the multiplexer of a virtual channel. + const char *channel_name = nullptr; + uint64_t session_id = 0; + uint32_t buffer_index = 0; + bool is_prefix = false; + uint32_t slot_id = 0; +}; + +struct BufferReply { + // Whether the server has the buffer. + bool found = false; + // Invalid when the creator registered no descriptor. + UniqueFd fd; + uint64_t full_size = 0; + uint64_t allocation_size = 0; + uint64_t handle = 0; + int64_t map_offset = 0; + BufferAllocator allocator = BufferAllocator::kUnspecified; +}; + +class ServerConnection { +public: + virtual ~ServerConnection() = default; + + virtual Error Init(const char *client_name, InitReply &reply) = 0; + virtual Error CreatePublisher(const PublisherRequest &request, + PublisherReply &reply) = 0; + virtual Error CreateSubscriber(const SubscriberRequest &request, + SubscriberReply &reply) = 0; + virtual Error GetTriggers(const char *channel_name, TriggersReply &reply) = 0; + virtual Error RemovePublisher(const char *channel_name, + int32_t publisher_id) = 0; + virtual Error RemoveSubscriber(const char *channel_name, + int32_t subscriber_id) = 0; + + // Gives the server the descriptor of a memfd buffer that this client + // created. The client must own a publisher on the channel. + virtual Error RegisterBuffer(const ClientBuffer &buffer, int fd) = 0; + // Fetches a buffer that its creator registered. Returns kOk with + // reply.found false when it hasn't been registered yet. + virtual Error GetBuffer(const BufferRequest &request, BufferReply &reply) = 0; + + // The server's message for the last request that returned kServerRejected. + virtual const char *LastServerError() const = 0; +}; + +} // namespace asil +} // namespace subspace diff --git a/asil_client/shm_layout.h b/asil_client/shm_layout.h new file mode 100644 index 00000000..6301fc8d --- /dev/null +++ b/asil_client/shm_layout.h @@ -0,0 +1,268 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// The Subspace shared memory layout as seen by the ASIL client. +// +// These structures mirror common/channel.h byte for byte so that the ASIL +// client can share channels with the standard clients. They are declared +// here, rather than included, so that the ASIL client depends only on the C++ +// standard library and POSIX. layout_test.cc checks every size and offset +// against common/channel.h. + +#pragma once + +#include +#include +#include + +// The shared memory backend. This follows the selection in common/channel.h. +#define ASIL_SHM_POSIX 1 +#define ASIL_SHM_LINUX 2 +#define ASIL_SHM_MEMFD 3 + +#if defined(SUBSPACE_SHMEM_MODE) +#define ASIL_SHM_MODE SUBSPACE_SHMEM_MODE +#elif defined(__ANDROID__) +#define ASIL_SHM_MODE ASIL_SHM_MEMFD +#elif defined(__linux__) && defined(SUBSPACE_LINUX_USE_MEMFD) +#define ASIL_SHM_MODE ASIL_SHM_MEMFD +#elif defined(__linux__) +#define ASIL_SHM_MODE ASIL_SHM_LINUX +#else +#define ASIL_SHM_MODE ASIL_SHM_POSIX +#endif + +#ifndef SUBSPACE_MAX_CHANNELS +#define SUBSPACE_MAX_CHANNELS 1024 +#endif + +namespace subspace { +namespace asil { +namespace shm { + +static_assert(sizeof(void *) == 8, "Subspace shared memory needs 64 bits"); + +constexpr int kMaxChannels = SUBSPACE_MAX_CHANNELS; +constexpr int kMaxSlotOwners = 1024; +constexpr int kMaxVchanId = 1023; +constexpr int kMaxBuffers = 1024; +constexpr size_t kMaxChannelName = 64; +constexpr uint32_t kChannelControlBlockVersion = 5; +constexpr uint64_t kInvalidSlotQueueOffset = ~uint64_t{0}; + +// MessagePrefix flags. +constexpr int64_t kMessageActivate = 1; +constexpr int64_t kMessageHasChecksum = 4; + +// MessageSlot flags. +constexpr uint32_t kMessageSeen = 1; +constexpr uint32_t kMessageIsActivation = 2; +constexpr uint32_t kMessageSeenByReliable = 4; + +// Fields of MessageSlot::refs. +constexpr uint64_t kRefCountMask = 0x3ff; +constexpr uint64_t kReliableRefCountShift = 10; +constexpr uint64_t kPubOwned = uint64_t{1} << 63; +constexpr uint64_t kRefsMask = (uint64_t{1} << 20) - 1; +constexpr uint64_t kRetiredRefsMask = (uint64_t{1} << 10) - 1; +constexpr uint64_t kRetiredRefsShift = 20; +constexpr uint64_t kVchanIdMask = (uint64_t{1} << 10) - 1; +constexpr uint64_t kVchanIdShift = 30; +constexpr uint64_t kOrdinalMask = (uint64_t{1} << 23) - 1; +constexpr uint64_t kOrdinalShift = 40; + +inline uint64_t BuildRefsBitField(uint64_t ordinal, int vchan_id, + uint64_t retired_refs) { + const uint64_t vchan_bits = + ordinal == 0 + ? 0 + : ((static_cast(vchan_id) & kVchanIdMask) << kVchanIdShift); + return ((ordinal & kOrdinalMask) << kOrdinalShift) | vchan_bits | + ((retired_refs & kRetiredRefsMask) << kRetiredRefsShift); +} + +// The vchan_id stored in refs, where all ones means -1. +inline int RefsVchanId(uint64_t refs) { + const uint64_t v = (refs >> kVchanIdShift) & kVchanIdMask; + return v == kVchanIdMask ? -1 : static_cast(v); +} + +constexpr int64_t Aligned64(int64_t v) { return (v + 63) & ~int64_t{63}; } + +constexpr size_t BitsToWords(size_t bits) { + return bits == 0 ? 0 : ((bits - 1) / 64) + 1; +} + +// AtomicBitSet: the bit count followed by the words. +constexpr size_t SizeofBitset(size_t bits) { + return sizeof(size_t) + sizeof(uint64_t) * BitsToWords(bits); +} + +template struct FixedBitset { + size_t num_bits; + std::atomic words[BitsToWords(kBits)]; +}; + +struct MessagePrefix { + int32_t padding; + int32_t slot_id; + uint64_t message_size; + uint64_t ordinal; + uint64_t timestamp; + int64_t flags; + int32_t vchan_id; + uint16_t checksum_size; + uint16_t metadata_size; + uint32_t checksum; + char padding3[12]; +}; +static_assert(sizeof(MessagePrefix) == 64); +static_assert(offsetof(MessagePrefix, slot_id) == 4); +static_assert(offsetof(MessagePrefix, checksum) == 48); + +struct ChannelCounters { + uint16_t num_pub_updates; + uint16_t num_sub_updates; + uint16_t num_pubs; + uint16_t num_reliable_pubs; + uint16_t num_subs; + uint16_t num_reliable_subs; + uint16_t num_resizes; +}; +static_assert(sizeof(ChannelCounters) == 14); + +struct SystemControlBlock { + ChannelCounters counters[kMaxChannels]; +}; + +struct MessageSlot { + std::atomic refs; + std::atomic ordinal; + std::atomic message_size; + int32_t id; + std::atomic buffer_index; + std::atomic vchan_id; + FixedBitset sub_owners; + std::atomic timestamp; + std::atomic flags; + std::atomic bridged_slot_id; +}; +static_assert(sizeof(MessageSlot) == 184); +static_assert(offsetof(MessageSlot, id) == 24); +static_assert(offsetof(MessageSlot, sub_owners) == 32); +static_assert(offsetof(MessageSlot, timestamp) == 168); +static_assert(offsetof(MessageSlot, bridged_slot_id) == 180); + +struct SubscriberCounter { + std::atomic sequence; + std::atomic counts[kMaxVchanId + 1]; +}; + +struct ChannelControlBlock { + char channel_name[kMaxChannelName]; + int32_t num_slots; + int32_t subscriber_queue_size; + uint32_t version; + std::atomic ordinals[kMaxVchanId + 1]; + FixedBitset activation_tracker; + int32_t buffer_index; + std::atomic num_buffers; + FixedBitset subscribers; + int16_t sub_vchan_ids[kMaxSlotOwners]; + SubscriberCounter num_subs; + std::atomic subscriber_cleanup_generation[kMaxVchanId + 1]; + std::atomic total_bytes; + std::atomic total_messages; + std::atomic max_message_size; + std::atomic total_drops; + std::atomic free_slots_exhausted; + // Followed by num_slots MessageSlots. +}; +static_assert(offsetof(ChannelControlBlock, version) == 72); + +struct AvailableSlotQueueIndex { + std::atomic next_offset; + std::atomic offsets[kMaxSlotOwners]; + std::atomic active_publishers[kMaxSlotOwners]; +}; +static_assert(sizeof(AvailableSlotQueueIndex) == 12296); + +struct BufferControlBlock { + std::atomic refs[kMaxBuffers]; + std::atomic sizes[kMaxBuffers]; +}; + +// InPlaceSlotQueue: a bounded MPSC ring of published slots for one +// subscriber, in the CCB's queue arena. The entries follow the header. +constexpr size_t kMaxSlotQueueCasAttempts = 64; + +struct SlotQueueEntry { + std::atomic sequence; + std::atomic ordinal; + std::atomic slot_id; +}; +static_assert(sizeof(SlotQueueEntry) == 24); + +struct SlotQueue { + size_t capacity; + std::atomic head; + std::atomic tail; + std::atomic overflow_count; + std::atomic insertion_failed; + bool drop_oldest; + + SlotQueueEntry *Entries() { + return reinterpret_cast(this + 1); + } +}; +static_assert(sizeof(SlotQueue) == 32); +static_assert(offsetof(SlotQueue, overflow_count) == 24); +static_assert(offsetof(SlotQueue, insertion_failed) == 28); + +// Offsets of the regions that follow the slots in the CCB. A CCB holds the +// header and slots, the retired and free slot bitsets, one available-slot +// bitset per subscriber, the subscriber queue index and the queue arena. +inline size_t RetiredSlotsOffset(int num_slots) { + return static_cast(Aligned64(static_cast( + sizeof(ChannelControlBlock) + num_slots * sizeof(MessageSlot)))); +} + +inline size_t FreeSlotsOffset(int num_slots) { + return RetiredSlotsOffset(num_slots) + + static_cast( + Aligned64(static_cast(SizeofBitset(num_slots)))); +} + +inline size_t AvailableSlotsOffset(int num_slots, int sub_id) { + return FreeSlotsOffset(num_slots) + + static_cast( + Aligned64(static_cast(SizeofBitset(num_slots)))) + + SizeofBitset(num_slots) * static_cast(sub_id); +} + +inline size_t SlotQueueIndexOffset(int num_slots) { + return AvailableSlotsOffset(num_slots, kMaxSlotOwners); +} + +// Queue offsets in AvailableSlotQueueIndex are relative to this. +inline size_t SlotQueueArenaOffset(int num_slots) { + return SlotQueueIndexOffset(num_slots) + + static_cast( + Aligned64(static_cast(sizeof(AvailableSlotQueueIndex)))); +} + +inline size_t CcbSize(int num_slots, uint64_t subscriber_queue_arena_size) { + return SlotQueueArenaOffset(num_slots) + + static_cast(subscriber_queue_arena_size); +} + +inline int32_t PrefixSize(int32_t checksum_size, int32_t metadata_size) { + return static_cast( + Aligned64(static_cast(offsetof(MessagePrefix, checksum)) + + checksum_size + metadata_size)); +} + +} // namespace shm +} // namespace asil +} // namespace subspace diff --git a/asil_client/split_buffer.h b/asil_client/split_buffer.h new file mode 100644 index 00000000..8a7d131c --- /dev/null +++ b/asil_client/split_buffer.h @@ -0,0 +1,77 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// Split buffer channels keep the message prefixes in one shared memory object +// and each slot's payload in its own. The ASIL client maps all of them when a +// publisher or subscriber opens, into slot storage that the caller provides. + +#pragma once + +#include "asil_client/error.h" + +#include + +namespace subspace { +namespace asil { + +// How the creator of a split buffer allocated it. The values match the +// ClientBufferAllocator enum in proto/subspace.proto. +enum class BufferAllocator : int32_t { + kUnspecified = 0, + kMemfd = 1, + kSplitShm = 2, + // A custom allocator: mapping the slot needs a SplitBufferAllocator. + kSplitCallback = 3, + kSplitBufferFreeTest = 4, +}; + +// What the server knows about one split buffer slot. +struct SplitBufferInfo { + // The channel that owns the buffers: the multiplexer of a virtual channel. + const char *channel_name = nullptr; + uint64_t session_id = 0; + uint32_t buffer_index = 0; + uint32_t slot_id = 0; + uint64_t full_size = 0; + uint64_t allocation_size = 0; + // Defined by the allocator that created the slot. + uint64_t handle = 0; + int64_t map_offset = 0; + BufferAllocator allocator = BufferAllocator::kUnspecified; + // The descriptor the creator registered with the server, or -1. It is + // valid only during the map callback. + int fd = -1; +}; + +struct SplitBufferMapping { + void *address = nullptr; + // 0 from a map callback means the slot's payload size. + uint64_t size = 0; + // For the allocator's use. + void *private_data = nullptr; +}; + +// Maps slots that a custom allocator created. Both functions are called +// only from Open() and Close(), with the context given here. +struct SplitBufferAllocator { + Error (*map)(void *context, const SplitBufferInfo &info, + SplitBufferMapping &mapping) = nullptr; + void (*unmap)(void *context, const SplitBufferInfo &info, + const SplitBufferMapping &mapping) = nullptr; + void *context = nullptr; +}; + +// The mapping of one slot's payload. The caller provides an array of at +// least the channel's num_slots of these, which must outlive the publisher or +// subscriber. +struct SplitSlot { + SplitBufferInfo info; + SplitBufferMapping mapping; + // The allocator mapped the slot; otherwise the client mapped its + // descriptor. + bool allocator_mapped = false; +}; + +} // namespace asil +} // namespace subspace diff --git a/asil_client/subscriber.cc b/asil_client/subscriber.cc new file mode 100644 index 00000000..4070c1f7 --- /dev/null +++ b/asil_client/subscriber.cc @@ -0,0 +1,483 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// The reading side of the shared memory protocol. This follows +// SubscriberImpl::NextSlot, LastSlot, ClaimSlot and RemoveActiveMessage in +// client/subscriber.cc, using the subscriber's available-slot bitset as the +// record of unread messages. A subscriber queue, when the channel has one, +// only holds hints, so the subscriber leaves it to the publishers. + +#include "asil_client/subscriber.h" + +#include "asil_client/checksum.h" +#include "asil_client/client.h" + +#include + +namespace subspace { +namespace asil { + +namespace { + +constexpr int kMaxClaimAttempts = 1000; + +// Messages are ordered by timestamp, then ordinal. +bool Before(uint64_t timestamp, uint64_t ordinal, uint64_t other_timestamp, + uint64_t other_ordinal) { + if (timestamp != other_timestamp) { + return timestamp < other_timestamp; + } + return ordinal < other_ordinal; +} + +BitsetView Owners(shm::MessageSlot *slot) { + return BitsetView(&slot->sub_owners, shm::kMaxSlotOwners); +} + +bool ValidVchanId(int vchan_id) { + return vchan_id >= -1 && vchan_id < shm::kMaxVchanId; +} + +} // namespace + +SubscriberRequest Subscriber::BuildRequest() const { + SubscriberRequest request; + request.channel_name = name_; + request.type = options_.type; + request.max_active_messages = options_.max_active_messages; + request.reliable = options_.reliable; + request.mux = options_.mux; + request.vchan_id = options_.vchan_id; + return request; +} + +Error Subscriber::Open(Client &client, const char *channel_name, + const SubscriberOptions &options) { + if (client_ != nullptr) { + return Error::kAlreadyInitialized; + } + if (!client.Initialized()) { + return Error::kNotInitialized; + } + if (options.max_active_messages < 1 || + options.max_active_messages > kMaxActiveMessages || + !ValidVchanId(options.vchan_id)) { + return Error::kInvalidArgument; + } + if (Error e = internal::CopyName(channel_name, name_); e != Error::kOk) { + return e; + } + if (Error e = internal::CopyName( + options.mux != nullptr ? options.mux : channel_name, resolved_name_); + e != Error::kOk) { + return e; + } + options_ = options; + SubscriberReply reply; + reply.reliable_publisher_triggers = &reliable_publishers_; + reply.retirement_triggers = &retirement_triggers_; + if (Error e = client.connection_->CreateSubscriber(BuildRequest(), reply); + e != Error::kOk) { + Reset(); + return e; + } + client_ = &client; + channel_id_ = reply.channel_id; + subscriber_id_ = reply.subscriber_id; + vchan_id_ = reply.vchan_id; + num_pub_updates_ = static_cast(reply.num_pub_updates); + trigger_ = static_cast(reply.trigger); + poll_ = static_cast(reply.poll); + buffer_context_.channel_name = resolved_name_; + buffer_context_.session_id = client.session_id_; + buffer_context_.user_id = client.user_id_; + buffer_context_.group_id = client.group_id_; + buffer_context_.connection = client.connection_; + + Error e = Error::kOk; + if (channel_id_ < 0 || channel_id_ >= shm::kMaxChannels || + subscriber_id_ < 0 || subscriber_id_ >= shm::kMaxSlotOwners || + !ValidVchanId(vchan_id_)) { + e = Error::kProtocolError; + } + if (e == Error::kOk) { + // A channel without a layout has no buffers either. + e = reply.num_slots > 0 ? MapChannel(reply) : Error::kNoBuffers; + } + if (e != Error::kOk) { + (void)Close(); + return e; + } + // Wake the reader for messages published before it joined. + internal::Trigger(trigger_.Get()); + return Error::kOk; +} + +Error Subscriber::MapChannel(SubscriberReply &reply) { + if (Error e = + memory_.Map(client_->scb_, reply.ccb.Get(), reply.bcb.Get(), + reply.num_slots, reply.checksum_size, reply.metadata_size, + reply.subscriber_queue_arena_size, &buffer_context_); + e != Error::kOk) { + return e; + } + if (reply.use_split_buffers) { + return memory_.AttachSplitBuffers( + /*writable=*/false, options_.split_slots, options_.split_slot_capacity, + options_.split_allocator); + } + return memory_.AttachBuffer(/*writable=*/false); +} + +void Subscriber::Reset() { + memory_.Unmap(); + reliable_publishers_.Clear(); + retirement_triggers_.Clear(); + trigger_.Reset(); + poll_.Reset(); + client_ = nullptr; + num_held_ = 0; + for (uint64_t &ordinal : last_ordinals_) { + ordinal = 0; + } + channel_id_ = -1; + subscriber_id_ = -1; + vchan_id_ = -1; +} + +Error Subscriber::Close() { + if (client_ == nullptr) { + return Error::kOk; + } + ReleaseMessage(); + const Error e = client_->connection_->RemoveSubscriber(name_, subscriber_id_); + Reset(); + return e; +} + +Error Subscriber::Wait(int timeout_ms) { + if (client_ == nullptr) { + return Error::kNotInitialized; + } + return internal::WaitReadable(poll_.Get(), timeout_ms); +} + +void Subscriber::ReleaseHeld(int index) { + ReleaseSlot(held_[index].slot); + held_[index] = held_[--num_held_]; + held_[num_held_] = Held(); +} + +void Subscriber::ReleaseMessage() { + while (num_held_ > 0) { + ReleaseHeld(num_held_ - 1); + } +} + +Error Subscriber::ReleaseMessage(const Message &message) { + for (int i = 0; i < num_held_; i++) { + if (held_[i].slot->id == message.slot_id && + held_[i].ordinal == message.ordinal) { + ReleaseHeld(i); + return Error::kOk; + } + } + return Error::kInvalidArgument; +} + +void Subscriber::ReleaseSlot(shm::MessageSlot *slot) { + if (!Owners(slot).ClearWasSet(subscriber_id_)) { + return; + } + bool retired = false; + (void)memory_.AtomicIncRefCount( + slot, options_.reliable, -1, + slot->ordinal.load(std::memory_order_relaxed), + slot->vchan_id.load(std::memory_order_relaxed), /*retire=*/true, + &retired); + if (retired) { + memory_.TriggerRetirement( + retirement_triggers_, + slot->bridged_slot_id.load(std::memory_order_relaxed)); + } + if (options_.reliable) { + // A reliable publisher may be waiting for this slot. + TriggerReliablePublishers(); + } +} + +// Fetches new trigger fds when publishers have changed. +Error Subscriber::RefreshTriggers() { + const uint16_t updates = client_->scb_->counters[channel_id_].num_pub_updates; + if (updates == num_pub_updates_) { + return Error::kOk; + } + TriggersReply reply; + reply.reliable_publisher_triggers = &reliable_publishers_; + reply.retirement_triggers = &retirement_triggers_; + const Error e = client_->connection_->GetTriggers(name_, reply); + if (e == Error::kOk) { + num_pub_updates_ = updates; + } + return e; +} + +void Subscriber::TriggerReliablePublishers() const { + for (int i = 0; i < reliable_publishers_.Size(); i++) { + internal::Trigger(reliable_publishers_.Get(i)); + } +} + +// Whether slot holds a published message for this subscriber's virtual +// channel. A subscriber to a multiplexer sees every virtual channel. +bool Subscriber::Visible(const shm::MessageSlot *slot) const { + if ((slot->refs.load(std::memory_order_acquire) & shm::kPubOwned) != 0 || + slot->ordinal.load(std::memory_order_relaxed) == 0) { + return false; + } + const int slot_vchan_id = slot->vchan_id.load(std::memory_order_relaxed); + return vchan_id_ == -1 || slot_vchan_id == -1 || slot_vchan_id == vchan_id_; +} + +// Takes a reference to slot and records it so that the server can release +// the reference if this process dies. +bool Subscriber::Claim(shm::MessageSlot *slot, uint64_t ordinal) { + if (!memory_.AtomicIncRefCount(slot, options_.reliable, 1, ordinal, + slot->vchan_id.load(std::memory_order_relaxed), + false, nullptr)) { + return false; + } + Owners(slot).Set(subscriber_id_); + return true; +} + +// Finds the oldest unread message and takes a reference to it. +Subscriber::Find Subscriber::FindNext(shm::MessageSlot *&slot) { + BitsetView available = memory_.AvailableSlots(subscriber_id_); + for (int attempt = 0; attempt < kMaxClaimAttempts; attempt++) { + shm::MessageSlot *best = nullptr; + uint64_t best_ordinal = 0; + uint64_t best_timestamp = 0; + available.Traverse([&](int i) { + shm::MessageSlot *s = memory_.Slot(i); + if (!Visible(s)) { + return; + } + const uint64_t ordinal = s->ordinal.load(std::memory_order_relaxed); + const uint64_t timestamp = s->timestamp.load(std::memory_order_relaxed); + if (best == nullptr || + Before(timestamp, ordinal, best_timestamp, best_ordinal)) { + best = s; + best_ordinal = ordinal; + best_timestamp = timestamp; + } + }); + if (best == nullptr) { + return Find::kNone; + } + if (Claim(best, best_ordinal)) { + slot = best; + return Find::kFound; + } + // A publisher reclaimed the slot. Look again. + } + return Find::kNone; +} + +// Finds the newest unread message and takes a reference to it. +Subscriber::Find Subscriber::FindNewest(shm::MessageSlot *&slot) { + BitsetView available = memory_.AvailableSlots(subscriber_id_); + for (int attempt = 0; attempt < kMaxClaimAttempts; attempt++) { + shm::MessageSlot *best = nullptr; + uint64_t best_ordinal = 0; + uint64_t best_timestamp = 0; + available.Traverse([&](int i) { + shm::MessageSlot *s = memory_.Slot(i); + if (!Visible(s)) { + return; + } + const uint64_t ordinal = s->ordinal.load(std::memory_order_relaxed); + const uint64_t timestamp = s->timestamp.load(std::memory_order_relaxed); + if (best == nullptr || + Before(best_timestamp, best_ordinal, timestamp, ordinal)) { + best = s; + best_ordinal = ordinal; + best_timestamp = timestamp; + } + }); + if (best == nullptr) { + return Find::kNone; + } + if (Claim(best, best_ordinal)) { + slot = best; + return Find::kFound; + } + } + return Find::kNone; +} + +// Marks every unread message older than newest as read. Each slot is pinned +// while its bit is cleared so that a publisher recycling it can't lose the +// bit for the new message. A reliable subscriber also marks the messages +// seen so that a reliable publisher doesn't wait for it to read them. +void Subscriber::ClearOlder(const shm::MessageSlot *newest) { + BitsetView available = memory_.AvailableSlots(subscriber_id_); + const uint64_t newest_ordinal = + newest->ordinal.load(std::memory_order_relaxed); + const uint64_t newest_timestamp = + newest->timestamp.load(std::memory_order_relaxed); + bool marked_seen = false; + available.Traverse([&](int i) { + if (i == newest->id) { + return; + } + shm::MessageSlot *s = memory_.Slot(i); + if (!Visible(s)) { + return; + } + const uint64_t ordinal = s->ordinal.load(std::memory_order_relaxed); + const uint64_t timestamp = s->timestamp.load(std::memory_order_relaxed); + if (!Before(timestamp, ordinal, newest_timestamp, newest_ordinal)) { + return; + } + const int vchan_id = s->vchan_id.load(std::memory_order_relaxed); + if (memory_.AtomicIncRefCount(s, options_.reliable, 1, ordinal, vchan_id, + false, nullptr)) { + available.Clear(i); + if (options_.reliable) { + s->flags.fetch_or(shm::kMessageSeen | shm::kMessageSeenByReliable, + std::memory_order_relaxed); + marked_seen = true; + } + (void)memory_.AtomicIncRefCount(s, options_.reliable, -1, ordinal, + vchan_id, false, nullptr); + } + }); + if (marked_seen) { + TriggerReliablePublishers(); + } +} + +bool Subscriber::ChecksumValid(const shm::MessageSlot *slot, + size_t length) const { + const shm::MessagePrefix *prefix = memory_.Prefix(slot); + const auto *base = reinterpret_cast(prefix); + uint32_t crc = 0xFFFFFFFF; + crc = Crc32(crc, base + offsetof(shm::MessagePrefix, slot_id), + offsetof(shm::MessagePrefix, checksum) - + offsetof(shm::MessagePrefix, slot_id)); + crc = Crc32(crc, + base + offsetof(shm::MessagePrefix, checksum) + + memory_.ChecksumSize(), + static_cast(memory_.MetadataSize())); + crc = Crc32(crc, reinterpret_cast(memory_.Payload(slot)), + length); + uint32_t stored; + std::memcpy(&stored, &prefix->checksum, sizeof(stored)); + return stored == ~crc; +} + +Error Subscriber::ReadMessage(Message &message, ReadMode mode) { + message = Message(); + if (client_ == nullptr) { + return Error::kNotInitialized; + } + internal::ClearTrigger(poll_.Get()); + const bool single = options_.max_active_messages == 1; + // A reliable subscriber keeps its last message until it has the next one, + // which stops a reliable publisher overtaking it. + const bool keep_previous = single && options_.reliable; + if (single && !keep_previous) { + ReleaseMessage(); + } else if (!single && num_held_ >= options_.max_active_messages) { + return Error::kActiveMessageLimit; + } + if (Error e = RefreshTriggers(); e != Error::kOk) { + return e; + } + BitsetView available = memory_.AvailableSlots(subscriber_id_); + for (int attempt = 0; attempt < kMaxClaimAttempts; attempt++) { + shm::MessageSlot *slot = nullptr; + const Find found = + mode == ReadMode::kReadNext ? FindNext(slot) : FindNewest(slot); + if (found == Find::kNone) { + // Out of messages: tell reliable publishers there is room. + TriggerReliablePublishers(); + return Error::kOk; + } + const uint64_t ordinal = slot->ordinal.load(std::memory_order_relaxed); + const size_t length = + static_cast(slot->message_size.load(std::memory_order_relaxed)); + const int vchan_id = slot->vchan_id.load(std::memory_order_relaxed); + const bool mapped = memory_.InMappedBuffer(slot); + + if (mode == ReadMode::kReadNewest) { + ClearOlder(slot); + } + available.Clear(slot->id); + slot->flags.fetch_or(options_.reliable + ? shm::kMessageSeen | shm::kMessageSeenByReliable + : shm::kMessageSeen, + std::memory_order_relaxed); + + uint64_t unused_last_ordinal = 0; + uint64_t &last_ordinal = ValidVchanId(vchan_id) + ? last_ordinals_[vchan_id + 1] + : unused_last_ordinal; + if (!mapped) { + if (ordinal > last_ordinal) { + last_ordinal = ordinal; + } + ReleaseSlot(slot); + return Error::kBufferNotMapped; + } + const shm::MessagePrefix *prefix = memory_.Prefix(slot); + const bool is_activation = (prefix->flags & shm::kMessageActivate) != 0; + if (length == 0 || (is_activation && !options_.pass_activation)) { + if (ordinal > last_ordinal) { + last_ordinal = ordinal; + } + ReleaseSlot(slot); + continue; + } + if (mode == ReadMode::kReadNext && last_ordinal != 0 && + ordinal > last_ordinal + 1) { + message.dropped = static_cast(ordinal - last_ordinal - 1); + memory_.Ccb()->total_drops += message.dropped; + } + if (ordinal > last_ordinal) { + last_ordinal = ordinal; + } + const bool checksum_error = + options_.checksum && (prefix->flags & shm::kMessageHasChecksum) != 0 && + !ChecksumValid(slot, length); + if (checksum_error && !options_.pass_checksum_errors) { + ReleaseSlot(slot); + message = Message(); + return Error::kChecksumMismatch; + } + if (keep_previous) { + ReleaseMessage(); + } + held_[num_held_++] = Held{slot, ordinal}; + message.data = memory_.Payload(slot); + message.length = length; + message.ordinal = ordinal; + message.timestamp = slot->timestamp.load(std::memory_order_relaxed); + message.slot_id = slot->id; + message.vchan_id = vchan_id; + message.is_activation = is_activation; + message.checksum_error = checksum_error; + if (memory_.MetadataSize() > 0) { + message.metadata = reinterpret_cast(&prefix->checksum) + + memory_.ChecksumSize(); + message.metadata_length = static_cast(memory_.MetadataSize()); + } + return Error::kOk; + } + return Error::kOk; +} + +} // namespace asil +} // namespace subspace diff --git a/asil_client/subscriber.h b/asil_client/subscriber.h new file mode 100644 index 00000000..9d11ef07 --- /dev/null +++ b/asil_client/subscriber.h @@ -0,0 +1,160 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#pragma once + +#include "asil_client/channel_memory.h" +#include "asil_client/error.h" +#include "asil_client/server_connection.h" +#include "asil_client/unique_fd.h" + +#include +#include + +namespace subspace { +namespace asil { + +class Client; + +// The most messages that one subscriber can hold at once. +constexpr int kMaxActiveMessages = 64; + +struct SubscriberOptions { + const char *type = nullptr; + // Verify message checksums. + bool checksum = false; + // Deliver a message whose checksum is wrong, with checksum_error set, + // rather than failing the read. + bool pass_checksum_errors = false; + // Deliver activation messages. + bool pass_activation = false; + // Reliable publishers wait for this subscriber to read every message. + bool reliable = false; + // Subscribe to a virtual channel of this multiplexer. vchan_id -1 lets the + // server choose the channel's id. To read every virtual channel, subscribe + // to the multiplexer itself. + const char *mux = nullptr; + int32_t vchan_id = -1; + // How many messages the subscriber can hold, up to kMaxActiveMessages. + // With 1, each read releases the previous message. With more, messages are + // held until ReleaseMessage(). + int32_t max_active_messages = 1; + // For a channel with split buffers: room for the channel's num_slots slot + // mappings, and the allocator for slots that a custom allocator created. + SplitSlot *split_slots = nullptr; + int32_t split_slot_capacity = 0; + SplitBufferAllocator split_allocator; +}; + +enum class ReadMode { + kReadNext, + kReadNewest, +}; + +// A message in shared memory. The pointers stay valid until the message is +// released: by the next read when max_active_messages is 1, otherwise by +// ReleaseMessage(), and always by Close(). +struct Message { + const void *data = nullptr; + size_t length = 0; // 0 when there was no message. + uint64_t ordinal = 0; + uint64_t timestamp = 0; + int32_t slot_id = -1; + // The virtual channel the message was published on, or -1. + int32_t vchan_id = -1; + bool is_activation = false; + bool checksum_error = false; + // Messages missed since the previous read on the same virtual channel, for + // kReadNext. + uint32_t dropped = 0; + const void *metadata = nullptr; + size_t metadata_length = 0; +}; + +// A subscriber to a fixed-size channel. +// +// A subscriber is not thread safe and must not outlive its Client. It holds +// a table of the last ordinal seen on each virtual channel, about 8 KB. +class Subscriber { +public: + Subscriber() = default; + ~Subscriber() { (void)Close(); } + Subscriber(const Subscriber &) = delete; + Subscriber &operator=(const Subscriber &) = delete; + + Error Open(Client &client, const char *channel_name, + const SubscriberOptions &options); + // Removes the subscriber from the server. Safe to call more than once. + Error Close(); + bool IsOpen() const { return client_ != nullptr; } + + // Reads a message. Returns kOk with message.length == 0 when there is no + // message, and kActiveMessageLimit when the subscriber already holds + // max_active_messages messages. If a publisher has joined since the last + // read this asks the server for its trigger fds first. The channel's + // buffers are mapped by Open(), so this maps nothing. + Error ReadMessage(Message &message, ReadMode mode = ReadMode::kReadNext); + // Releases every message the subscriber holds. + void ReleaseMessage(); + // Releases one message. Returns kInvalidArgument if the subscriber doesn't + // hold it. + Error ReleaseMessage(const Message &message); + int NumActiveMessages() const { return num_held_; } + + // Readable when there may be messages to read. + int PollFd() const { return poll_.Get(); } + // Waits until PollFd() is readable. A negative timeout waits forever. + Error Wait(int timeout_ms); + + bool IsReliable() const { return options_.reliable; } + // The subscriber's virtual channel id, or -1. + int VirtualChannelId() const { return vchan_id_; } + const char *Name() const { return name_; } + +private: + enum class Find { kFound, kNone }; + + struct Held { + shm::MessageSlot *slot = nullptr; + uint64_t ordinal = 0; + }; + + SubscriberRequest BuildRequest() const; + Error MapChannel(SubscriberReply &reply); + Error RefreshTriggers(); + bool Visible(const shm::MessageSlot *slot) const; + Find FindNext(shm::MessageSlot *&slot); + Find FindNewest(shm::MessageSlot *&slot); + bool Claim(shm::MessageSlot *slot, uint64_t ordinal); + void ClearOlder(const shm::MessageSlot *newest); + void ReleaseSlot(shm::MessageSlot *slot); + void ReleaseHeld(int index); + bool ChecksumValid(const shm::MessageSlot *slot, size_t length) const; + void TriggerReliablePublishers() const; + void Reset(); + + Client *client_ = nullptr; + internal::ChannelMemory memory_; + internal::BufferContext buffer_context_; + char name_[kMaxChannelNameLength + 1] = {}; + // The channel that owns the shared memory: the multiplexer of a virtual + // channel. + char resolved_name_[kMaxChannelNameLength + 1] = {}; + SubscriberOptions options_; + int32_t channel_id_ = -1; + int32_t subscriber_id_ = -1; + int32_t vchan_id_ = -1; + uint16_t num_pub_updates_ = 0; + UniqueFd trigger_; + UniqueFd poll_; + Held held_[kMaxActiveMessages]; + int num_held_ = 0; + // The newest ordinal read on each virtual channel, indexed by vchan_id + 1. + uint64_t last_ordinals_[shm::kMaxVchanId + 1] = {}; + TriggerFdList reliable_publishers_; + TriggerFdList retirement_triggers_; +}; + +} // namespace asil +} // namespace subspace diff --git a/asil_client/unique_fd.h b/asil_client/unique_fd.h new file mode 100644 index 00000000..fbc39744 --- /dev/null +++ b/asil_client/unique_fd.h @@ -0,0 +1,80 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#pragma once + +#include + +namespace subspace { +namespace asil { + +// An owned file descriptor, closed on destruction. +class UniqueFd { +public: + UniqueFd() = default; + explicit UniqueFd(int fd) : fd_(fd) {} + ~UniqueFd() { Reset(); } + + UniqueFd(const UniqueFd &) = delete; + UniqueFd &operator=(const UniqueFd &) = delete; + + UniqueFd(UniqueFd &&other) noexcept : fd_(other.Release()) {} + UniqueFd &operator=(UniqueFd &&other) noexcept { + if (this != &other) { + Reset(other.Release()); + } + return *this; + } + + int Get() const { return fd_; } + bool Valid() const { return fd_ >= 0; } + + int Release() { + const int fd = fd_; + fd_ = -1; + return fd; + } + + void Reset(int fd = -1) { + if (fd_ >= 0) { + (void)::close(fd_); + } + fd_ = fd; + } + +private: + int fd_ = -1; +}; + +// A fixed-capacity list of owned file descriptors. +template class FdList { +public: + static constexpr int Capacity() { return kCapacity; } + + // Takes ownership of fd. Returns false, closing fd, if the list is full. + bool Add(UniqueFd fd) { + if (count_ >= kCapacity) { + return false; + } + fds_[count_++] = static_cast(fd); + return true; + } + + void Clear() { + for (int i = 0; i < count_; i++) { + fds_[i].Reset(); + } + count_ = 0; + } + + int Size() const { return count_; } + int Get(int i) const { return fds_[i].Get(); } + +private: + UniqueFd fds_[kCapacity]; + int count_ = 0; +}; + +} // namespace asil +} // namespace subspace diff --git a/c_client/subspace.cc b/c_client/subspace.cc index 2d01c9f6..314035f3 100644 --- a/c_client/subspace.cc +++ b/c_client/subspace.cc @@ -576,6 +576,13 @@ subspace_publisher_options_default(SubspaceSlotSize slot_size, int num_slots) { return options; } +SubspacePublisherOptions subspace_publisher_options_server_defined(void) { + SubspacePublisherOptions options = subspace_publisher_options_default(0, 0); + options.checksum_size = 0; + options.server_defined = true; + return options; +} + SubspaceSubscriber subspace_create_subscriber(SubspaceClient client, const char *channel_name, SubspaceSubscriberOptions options) { @@ -648,7 +655,8 @@ SubspacePublisher subspace_create_publisher(SubspaceClient client, .SetMaxPublishers(options.max_publishers) .SetUseSplitBuffers(options.use_split_buffers) .SetSplitBuffersOverBridge(options.split_buffers_over_bridge) - .SetSplitBufferCallbacks(ToCppSplitCallbacks(options.split_callbacks)); + .SetSplitBufferCallbacks(ToCppSplitCallbacks(options.split_callbacks)) + .SetServerDefined(options.server_defined); subspace_clear_error(); SubspacePublisher publisher; memset(&publisher, 0, sizeof(publisher)); diff --git a/c_client/subspace.h b/c_client/subspace.h index 1244d9c8..83ed94aa 100644 --- a/c_client/subspace.h +++ b/c_client/subspace.h @@ -318,6 +318,12 @@ typedef struct { bool split_buffers_over_bridge; int32_t max_publishers; SubspaceSplitBufferCallbacks split_callbacks; + // The channel is in the server's static channel config, which defines its + // layout. Publishers on static channels must set this and leave + // slot_size, num_slots, fixed_size, checksum_size, metadata_size, + // subscriber_queue_arena_size and use_split_buffers zero. Use + // subspace_publisher_options_server_defined() to get such options. + bool server_defined; } SubspacePublisherOptions; typedef struct { @@ -437,6 +443,9 @@ bool subspace_get_all_channel_stats(SubspaceClient client, SubspaceSubscriberOptions subspace_subscriber_options_default(void); SubspacePublisherOptions subspace_publisher_options_default(SubspaceSlotSize slot_size, int num_slots); +// Options for a publisher on a static channel, whose layout the server +// defines. +SubspacePublisherOptions subspace_publisher_options_server_defined(void); // Create a subscriber or publisher. If the subscriber or publisher is created // OK, the 'subscriber' or 'publisher' pointer will be non-null. If there is diff --git a/client/Android.bp b/client/Android.bp index 01daef2a..955902c1 100644 --- a/client/Android.bp +++ b/client/Android.bp @@ -19,10 +19,12 @@ cc_library_shared { static_libs: [ "libsubspace_common", "libsubspace_proto", + "libsubspace_phaser", "libcoroutines", "libcpp_toolbelt", "libabsl", ], + export_static_lib_headers: ["libsubspace_phaser"], shared_libs: [ "libprotobuf-cpp-full", "liblog", diff --git a/client/BUILD.bazel b/client/BUILD.bazel index 47ad364d..a340655d 100644 --- a/client/BUILD.bazel +++ b/client/BUILD.bazel @@ -25,9 +25,11 @@ cc_library( strip_include_prefix = ".", include_prefix = "subspace/client", deps = [ + "//common:server_wire", "//common:subspace_common", "//common/async:async", "//proto:subspace_cc_proto", + "//proto:subspace_phaser", "@abseil-cpp//absl/container:flat_hash_map", "@abseil-cpp//absl/container:flat_hash_set", "@abseil-cpp//absl/status", diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 41b7c238..265b2bed 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -32,6 +32,7 @@ target_link_libraries(subspace_client PUBLIC subspace_common subspace_async subspace_proto + subspace_phaser absl::flags absl::flat_hash_map absl::flat_hash_set diff --git a/client/client.cc b/client/client.cc index 860173e0..ec13b0af 100644 --- a/client/client.cc +++ b/client/client.cc @@ -6,7 +6,7 @@ #include "absl/strings/str_format.h" #include "client.h" #include "common/syscall_shim.h" -#include "proto/subspace.pb.h" +#include "common/server_wire.h" #include "toolbelt/clock.h" #include "toolbelt/hexdump.h" #include "toolbelt/mutex.h" @@ -42,35 +42,37 @@ void ConfigureQnxClockPeriod() { } #endif -ClientBufferAllocatorKind FromProtoAllocator(ClientBufferAllocator allocator) { +ClientBufferAllocatorKind +FromProtoAllocator(phaser::ClientBufferAllocator allocator) { switch (allocator) { - case CLIENT_BUFFER_ALLOCATOR_ANDROID_MEMFD: + case phaser::CLIENT_BUFFER_ALLOCATOR_ANDROID_MEMFD: return ClientBufferAllocatorKind::kAndroidMemfd; - case CLIENT_BUFFER_ALLOCATOR_SPLIT_SHM: + case phaser::CLIENT_BUFFER_ALLOCATOR_SPLIT_SHM: return ClientBufferAllocatorKind::kSplitShm; - case CLIENT_BUFFER_ALLOCATOR_SPLIT_CALLBACK: + case phaser::CLIENT_BUFFER_ALLOCATOR_SPLIT_CALLBACK: return ClientBufferAllocatorKind::kSplitCallback; - case CLIENT_BUFFER_ALLOCATOR_SPLIT_BUFFER_FREE_TEST: + case phaser::CLIENT_BUFFER_ALLOCATOR_SPLIT_BUFFER_FREE_TEST: return ClientBufferAllocatorKind::kSplitBufferFreeTest; - case CLIENT_BUFFER_ALLOCATOR_UNSPECIFIED: + case phaser::CLIENT_BUFFER_ALLOCATOR_UNSPECIFIED: default: return ClientBufferAllocatorKind::kUnspecified; } } -ClientBufferAllocator ToProtoAllocator(ClientBufferAllocatorKind allocator) { +phaser::ClientBufferAllocator +ToProtoAllocator(ClientBufferAllocatorKind allocator) { switch (allocator) { case ClientBufferAllocatorKind::kAndroidMemfd: - return CLIENT_BUFFER_ALLOCATOR_ANDROID_MEMFD; + return phaser::CLIENT_BUFFER_ALLOCATOR_ANDROID_MEMFD; case ClientBufferAllocatorKind::kSplitShm: - return CLIENT_BUFFER_ALLOCATOR_SPLIT_SHM; + return phaser::CLIENT_BUFFER_ALLOCATOR_SPLIT_SHM; case ClientBufferAllocatorKind::kSplitCallback: - return CLIENT_BUFFER_ALLOCATOR_SPLIT_CALLBACK; + return phaser::CLIENT_BUFFER_ALLOCATOR_SPLIT_CALLBACK; case ClientBufferAllocatorKind::kSplitBufferFreeTest: - return CLIENT_BUFFER_ALLOCATOR_SPLIT_BUFFER_FREE_TEST; + return phaser::CLIENT_BUFFER_ALLOCATOR_SPLIT_BUFFER_FREE_TEST; case ClientBufferAllocatorKind::kUnspecified: default: - return CLIENT_BUFFER_ALLOCATOR_UNSPECIFIED; + return phaser::CLIENT_BUFFER_ALLOCATOR_UNSPECIFIED; } } } // namespace @@ -99,7 +101,7 @@ static uint64_t GetThreadId() { } static void ToProto(const ClientBufferHandleMetadata &metadata, - ClientBufferHandleMetadataProto *proto) { + phaser::ClientBufferHandleMetadataProto *proto) { proto->set_channel_name(metadata.channel_name); proto->set_session_id(metadata.session_id); proto->set_buffer_index(metadata.buffer_index); @@ -115,7 +117,7 @@ static void ToProto(const ClientBufferHandleMetadata &metadata, } static ClientBufferHandleMetadata -FromProto(const ClientBufferHandleMetadataProto &proto) { +FromProto(const phaser::ClientBufferHandleMetadataProto &proto) { ClientBufferHandleMetadata metadata; metadata.channel_name = proto.channel_name(); metadata.session_id = proto.session_id(); @@ -241,9 +243,9 @@ absl::Status ClientImpl::Init(const std::string &server_socket, name_ = client_name; socket_name_ = server_socket; - Request req; + phaser::Request req; req.mutable_init()->set_client_name(client_name); - Response resp; + phaser::Response resp; std::vector fds; fds.reserve(100); status = SendRequestReceiveResponse(req, resp, fds); @@ -432,8 +434,19 @@ ClientImpl::CreatePublisher(const std::string &channel_name, if (absl::Status status = CheckConnected(); !status.ok()) { return status; } - if (opts.MaxOutstandingSlotLeases() < 1 || - opts.MaxOutstandingSlotLeases() > opts.NumSlots()) { + if (opts.ServerDefined()) { + if (opts.slot_size != 0 || opts.num_slots != 0 || opts.fixed_size || + opts.checksum_size != 0 || opts.metadata_size != 0 || + opts.subscriber_queue_arena_size != 0 || opts.use_split_buffers) { + return absl::InvalidArgumentError(absl::StrFormat( + "Publisher on %s is server defined, so it can't set the slot size, " + "number of slots, fixed size, checksum size, metadata size, " + "subscriber queue arena size or split buffers", + channel_name)); + } + // The server checks the leases against the channel's slots. + } else if (opts.MaxOutstandingSlotLeases() < 1 || + opts.MaxOutstandingSlotLeases() > opts.NumSlots()) { return absl::InvalidArgumentError( "MaxOutstandingSlotLeases must be between 1 and NumSlots"); } @@ -446,29 +459,29 @@ ClientImpl::CreatePublisher(const std::string &channel_name, "Slot size %d for channel %s exceeds its maximum slot size of %d bytes", opts.slot_size, channel_name, opts.max_slot_size)); } - Request req; + phaser::Request req; FillCreatePublisherRequest(req.mutable_create_publisher(), channel_name, opts, -1); // Send request to server and wait for response. - Response resp; + phaser::Response resp; std::vector fds; fds.reserve(100); if (absl::Status status = SendRequestReceiveResponse(req, resp, fds); !status.ok()) { return status; } - auto &pub_resp = resp.create_publisher(); + const auto &pub_resp = resp.create_publisher(); if (!pub_resp.error().empty()) { return absl::InternalError(pub_resp.error()); } auto remove_server_publisher = [&]() { - Request remove_req; + phaser::Request remove_req; auto *cmd = remove_req.mutable_remove_publisher(); cmd->set_channel_name(channel_name); cmd->set_publisher_id(pub_resp.publisher_id()); - Response remove_resp; + phaser::Response remove_resp; std::vector remove_fds; (void)SendRequestReceiveResponse(remove_req, remove_resp, remove_fds); }; @@ -481,11 +494,20 @@ ClientImpl::CreatePublisher(const std::string &channel_name, if (pub_resp.num_slots() > 0) { channel_opts.num_slots = pub_resp.num_slots(); } + if (opts.ServerDefined()) { + channel_opts.slot_size = pub_resp.slot_size(); + channel_opts.fixed_size = pub_resp.is_fixed_size(); + channel_opts.checksum_size = pub_resp.checksum_size(); + channel_opts.metadata_size = pub_resp.metadata_size(); + channel_opts.subscriber_queue_arena_size = + pub_resp.subscriber_queue_arena_size(); + channel_opts.use_split_buffers = pub_resp.use_split_buffers(); + } std::shared_ptr channel = std::make_shared( channel_name, channel_opts.num_slots, pub_resp.subscriber_queue_size(), pub_resp.subscriber_queue_arena_size(), pub_resp.channel_id(), pub_resp.publisher_id(), pub_resp.vchan_id(), session_id_, - pub_resp.type(), channel_opts, + std::string(pub_resp.type()), channel_opts, [this](Channel *c) { return CheckReload(static_cast(c)); }, @@ -505,8 +527,10 @@ ClientImpl::CreatePublisher(const std::string &channel_name, uint32_t buffer_index) { return UnregisterClientBuffer(channel_name, session_id, buffer_index); }); - int32_t cs = opts.ChecksumSize() > 0 ? opts.ChecksumSize() : 4; - int32_t ms = opts.MetadataSize() > 0 ? opts.MetadataSize() : 0; + int32_t cs = + channel_opts.ChecksumSize() > 0 ? channel_opts.ChecksumSize() : 4; + int32_t ms = + channel_opts.MetadataSize() > 0 ? channel_opts.MetadataSize() : 0; channel->SetChecksumSize(cs); channel->SetMetadataSize(ms); channel->SetPrefixSize(Channel::ComputePrefixSize(cs, ms)); @@ -532,7 +556,7 @@ ClientImpl::CreatePublisher(const std::string &channel_name, } if (absl::Status status = - channel->CreateOrAttachBuffers(Aligned(opts.slot_size)); + channel->CreateOrAttachBuffers(Aligned(channel_opts.slot_size)); !status.ok()) { remove_server_publisher(); return status; @@ -601,12 +625,12 @@ ClientImpl::CreateSubscriber(const std::string &channel_name, "at least 0, not %g", opts.StuckWarningGracePeriod())); } - Request req; + phaser::Request req; FillCreateSubscriberRequest(req.mutable_create_subscriber(), channel_name, opts, -1); // Send request to server and wait for response. - Response resp; + phaser::Response resp; std::vector fds; fds.reserve(100); if (absl::Status status = SendRequestReceiveResponse(req, resp, fds); @@ -614,7 +638,7 @@ ClientImpl::CreateSubscriber(const std::string &channel_name, return status; } - auto &sub_resp = resp.create_subscriber(); + const auto &sub_resp = resp.create_subscriber(); if (!sub_resp.error().empty()) { return absl::InternalError(sub_resp.error()); } @@ -626,14 +650,15 @@ ClientImpl::CreateSubscriber(const std::string &channel_name, const std::string mapped_channel_name = sub_resp.resolved_channel_name().empty() ? channel_name - : sub_resp.resolved_channel_name(); + : std::string(sub_resp.resolved_channel_name()); std::shared_ptr channel = std::make_shared( mapped_channel_name, sub_resp.num_slots(), sub_resp.default_subscriber_queue_size(), sub_resp.subscriber_queue_arena_size(), sub_resp.subscriber_queue_size(), sub_resp.channel_id(), - sub_resp.subscriber_id(), sub_resp.vchan_id(), session_id_, sub_resp.type(), + sub_resp.subscriber_id(), sub_resp.vchan_id(), session_id_, + std::string(sub_resp.type()), subscriber_options, [this](Channel *c) { return CheckReload(static_cast(c)); @@ -1732,13 +1757,13 @@ absl::Status ClientImpl::ReloadSubscriber(SubscriberImpl *subscriber) { if (absl::Status status = CheckConnected(); !status.ok()) { return status; } - Request req; + phaser::Request req; FillCreateSubscriberRequest(req.mutable_create_subscriber(), subscriber->RequestName(), subscriber->options_, subscriber->GetSubscriberId()); // Send request to server and wait for response. - Response resp; + phaser::Response resp; std::vector fds; fds.reserve(100); if (absl::Status status = SendRequestReceiveResponse(req, resp, fds); @@ -1746,7 +1771,7 @@ absl::Status ClientImpl::ReloadSubscriber(SubscriberImpl *subscriber) { return status; } - auto &sub_resp = resp.create_subscriber(); + const auto &sub_resp = resp.create_subscriber(); if (!sub_resp.error().empty()) { return absl::InternalError(sub_resp.error()); } @@ -1761,7 +1786,7 @@ absl::Status ClientImpl::ReloadSubscriber(SubscriberImpl *subscriber) { } if (!sub_resp.type().empty()) { - subscriber->SetType(sub_resp.type()); + subscriber->SetType(std::string(sub_resp.type())); } subscriber->options_.use_split_buffers = sub_resp.use_split_buffers(); subscriber->SetNumSlots(sub_resp.num_slots()); @@ -1827,12 +1852,12 @@ ClientImpl::ReloadSubscribersIfNecessary(PublisherImpl *publisher) { // We do have updates, get a new list of subscriber for // the channel. - Request req; + phaser::Request req; auto *cmd = req.mutable_get_triggers(); cmd->set_channel_name(publisher->Name()); // Send request to server and wait for response. - Response resp; + phaser::Response resp; std::vector fds; fds.reserve(100); if (absl::Status status = SendRequestReceiveResponse(req, resp, fds); @@ -1840,7 +1865,7 @@ ClientImpl::ReloadSubscribersIfNecessary(PublisherImpl *publisher) { return status; } - auto &sub_resp = resp.get_triggers(); + const auto &sub_resp = resp.get_triggers(); // Add all subscriber triggers fds to the publisher channel. publisher->ClearSubscribers(); for (auto index : sub_resp.sub_trigger_fd_indexes()) { @@ -1870,12 +1895,12 @@ ClientImpl::ReloadReliablePublishersIfNecessary(SubscriberImpl *subscriber) { } // We do have updates, get a new list of subscriber for // the channel. - Request req; + phaser::Request req; auto *cmd = req.mutable_get_triggers(); cmd->set_channel_name(subscriber->Name()); // Send request to server and wait for response. - Response resp; + phaser::Response resp; std::vector fds; fds.reserve(100); if (absl::Status status = SendRequestReceiveResponse(req, resp, fds); @@ -1883,7 +1908,7 @@ ClientImpl::ReloadReliablePublishersIfNecessary(SubscriberImpl *subscriber) { return status; } - auto &sub_resp = resp.get_triggers(); + const auto &sub_resp = resp.get_triggers(); // Add all subscriber triggers fds to the publisher channel. subscriber->ClearPublishers(); for (auto index : sub_resp.reliable_pub_trigger_fd_indexes()) { @@ -1964,13 +1989,13 @@ absl::Status ClientImpl::RemovePublisher(PublisherImpl *publisher) { if (!socket_.Connected()) { return RemoveChannel(publisher); } - Request req; + phaser::Request req; auto *cmd = req.mutable_remove_publisher(); cmd->set_channel_name(publisher->Name()); cmd->set_publisher_id(publisher->GetPublisherId()); // Send request to server and wait for response. - Response response; + phaser::Response response; std::vector fds; fds.reserve(100); if (absl::Status status = SendRequestReceiveResponse(req, response, fds); @@ -1978,7 +2003,7 @@ absl::Status ClientImpl::RemovePublisher(PublisherImpl *publisher) { return RemoveChannel(publisher); } - auto &resp = response.remove_publisher(); + const auto &resp = response.remove_publisher(); if (!resp.error().empty()) { return absl::InternalError(resp.error()); } @@ -1990,14 +2015,14 @@ absl::Status ClientImpl::RemoveSubscriber(SubscriberImpl *subscriber) { if (!socket_.Connected()) { return RemoveChannel(subscriber); } - Request req; + phaser::Request req; auto *cmd = req.mutable_remove_subscriber(); cmd->set_channel_name(subscriber->RequestName()); cmd->set_subscriber_id(subscriber->GetSubscriberId()); cmd->set_telemetry(subscriber->options_.Telemetry()); // Send request to server and wait for response. - Response response; + phaser::Response response; std::vector fds; fds.reserve(100); if (absl::Status status = SendRequestReceiveResponse(req, response, fds); @@ -2005,7 +2030,7 @@ absl::Status ClientImpl::RemoveSubscriber(SubscriberImpl *subscriber) { return RemoveChannel(subscriber); } - auto &resp = response.remove_subscriber(); + const auto &resp = response.remove_subscriber(); if (!resp.error().empty()) { return absl::InternalError(resp.error()); } @@ -2033,19 +2058,19 @@ ClientImpl::GetChannelInfo(const std::string &channel) { if (absl::Status status = CheckConnected(); !status.ok()) { return status; } - Request req; + phaser::Request req; auto *cmd = req.mutable_get_channel_info(); cmd->set_channel_name(channel); // Send request to server and wait for response. - Response response; + phaser::Response response; std::vector fds; if (absl::Status status = SendRequestReceiveResponse(req, response, fds); !status.ok()) { return status; } - auto &resp = response.get_channel_info(); + const auto &resp = response.get_channel_info(); if (!resp.error().empty()) { return absl::InternalError(resp.error()); } @@ -2054,7 +2079,7 @@ ClientImpl::GetChannelInfo(const std::string &channel) { return absl::InternalError("Invalid response for getChannelInfo"); } ChannelInfo result; - const ChannelInfoProto &info = resp.channels()[0]; + const phaser::ChannelInfoProto info = resp.channels(0); result.channel_name = info.name(); result.num_publishers = info.num_pubs(); result.num_subscribers = info.num_subs(); @@ -2078,22 +2103,22 @@ absl::StatusOr> ClientImpl::GetChannelInfo() { if (absl::Status status = CheckConnected(); !status.ok()) { return status; } - Request req; + phaser::Request req; [[maybe_unused]] auto cmd = req.mutable_get_channel_info(); // Send request to server and wait for response. - Response response; + phaser::Response response; std::vector fds; if (absl::Status status = SendRequestReceiveResponse(req, response, fds); !status.ok()) { return status; } - auto &resp = response.get_channel_info(); + const auto &resp = response.get_channel_info(); if (!resp.error().empty()) { return absl::InternalError(resp.error()); } std::vector r; - for (auto &info : resp.channels()) { + for (const auto &info : resp.channels()) { ChannelInfo result; result.channel_name = info.name(); result.num_publishers = info.num_pubs(); @@ -2130,18 +2155,18 @@ ClientImpl::GetChannelStats(const std::string &channel) { if (absl::Status status = CheckConnected(); !status.ok()) { return status; } - Request req; + phaser::Request req; auto *cmd = req.mutable_get_channel_stats(); cmd->set_channel_name(channel); // Send request to server and wait for response. - Response response; + phaser::Response response; std::vector fds; if (absl::Status status = SendRequestReceiveResponse(req, response, fds); !status.ok()) { return status; } - auto &resp = response.get_channel_stats(); + const auto &resp = response.get_channel_stats(); if (!resp.error().empty()) { return absl::InternalError(resp.error()); } @@ -2150,7 +2175,7 @@ ClientImpl::GetChannelStats(const std::string &channel) { return absl::InternalError("Invalid response for getChannelStats"); } ChannelStats result; - const ChannelStatsProto &stats = resp.channels()[0]; + const phaser::ChannelStatsProto stats = resp.channels(0); result.channel_name = stats.channel_name(); result.total_bytes = stats.total_bytes(); result.total_messages = stats.total_messages(); @@ -2165,22 +2190,22 @@ absl::StatusOr> ClientImpl::GetChannelStats() { return status; } - Request req; + phaser::Request req; [[maybe_unused]] auto cmd = req.mutable_get_channel_stats(); // Send request to server and wait for response. - Response response; + phaser::Response response; std::vector fds; if (absl::Status status = SendRequestReceiveResponse(req, response, fds); !status.ok()) { return status; } - auto &resp = response.get_channel_stats(); + const auto &resp = response.get_channel_stats(); if (!resp.error().empty()) { return absl::InternalError(resp.error()); } std::vector r; - for (auto &stats : resp.channels()) { + for (const auto &stats : resp.channels()) { ChannelStats result; result.channel_name = stats.channel_name(); result.total_bytes = stats.total_bytes(); @@ -2221,29 +2246,34 @@ absl::Status ClientImpl::ResizeChannel(PublisherImpl *publisher, return publisher->CreateOrAttachBuffers(Aligned(new_slot_size)); } -void ClientImpl::FillCreatePublisherRequest(CreatePublisherRequest *cmd, - const std::string &channel_name, - const PublisherOptions &opts, - int publisher_id) { +void ClientImpl::FillCreatePublisherRequest( + phaser::CreatePublisherRequest *cmd, const std::string &channel_name, + const PublisherOptions &opts, int publisher_id) { cmd->set_channel_name(channel_name); - cmd->set_slot_size(Aligned(opts.slot_size)); - cmd->set_num_slots(opts.num_slots); + // A server defined publisher's options hold the layout from the server, + // which it must not send back. + if (opts.ServerDefined()) { + cmd->set_server_defined(true); + } else { + cmd->set_slot_size(Aligned(opts.slot_size)); + cmd->set_num_slots(opts.num_slots); + cmd->set_is_fixed_size(opts.IsFixedSize()); + cmd->set_checksum_size(opts.ChecksumSize()); + cmd->set_metadata_size(opts.MetadataSize()); + cmd->set_use_split_buffers(opts.UseSplitBuffers()); + cmd->set_subscriber_queue_arena_size(opts.SubscriberQueueArenaSize()); + } cmd->set_is_local(opts.IsLocal()); cmd->set_is_reliable(opts.IsReliable()); cmd->set_is_bridge(opts.IsBridge()); cmd->set_for_tunnel(opts.ForTunnel()); - cmd->set_is_fixed_size(opts.IsFixedSize()); cmd->set_type(opts.Type()); cmd->set_mux(opts.Mux()); cmd->set_vchan_id(opts.VchanId()); cmd->set_notify_retirement(opts.notify_retirement); - cmd->set_checksum_size(opts.ChecksumSize()); - cmd->set_metadata_size(opts.MetadataSize()); cmd->set_publisher_id(publisher_id); cmd->set_max_publishers(opts.MaxPublishers()); - cmd->set_use_split_buffers(opts.UseSplitBuffers()); cmd->set_split_buffers_over_bridge(opts.SplitBuffersOverBridge()); - cmd->set_subscriber_queue_arena_size(opts.SubscriberQueueArenaSize()); cmd->set_process_id(static_cast(getpid())); cmd->set_max_outstanding_slot_leases(opts.MaxOutstandingSlotLeases()); // Align the cap the same way as the slot size so that a publisher asking for @@ -2253,7 +2283,7 @@ void ClientImpl::FillCreatePublisherRequest(CreatePublisherRequest *cmd, } void ClientImpl::ApplyPublisherResponseFds( - PublisherImpl *publisher, const CreatePublisherResponse &resp, + PublisherImpl *publisher, const phaser::CreatePublisherResponse &resp, std::vector &fds) { publisher->SetTriggerFd(std::move(fds[resp.pub_trigger_fd_index()])); publisher->SetPollFd(std::move(fds[resp.pub_poll_fd_index()])); @@ -2276,10 +2306,9 @@ void ClientImpl::ApplyPublisherResponseFds( publisher->SetNumUpdates(resp.num_sub_updates()); } -void ClientImpl::FillCreateSubscriberRequest(CreateSubscriberRequest *cmd, - const std::string &channel_name, - const SubscriberOptions &opts, - int subscriber_id) { +void ClientImpl::FillCreateSubscriberRequest( + phaser::CreateSubscriberRequest *cmd, const std::string &channel_name, + const SubscriberOptions &opts, int subscriber_id) { cmd->set_channel_name(channel_name); cmd->set_subscriber_id(subscriber_id); cmd->set_is_reliable(opts.IsReliable()); @@ -2297,7 +2326,7 @@ void ClientImpl::FillCreateSubscriberRequest(CreateSubscriberRequest *cmd, } void ClientImpl::ApplySubscriberResponseFds( - SubscriberImpl *subscriber, const CreateSubscriberResponse &resp, + SubscriberImpl *subscriber, const phaser::CreateSubscriberResponse &resp, std::vector &fds) { subscriber->SetTriggerFd(std::move(fds[resp.trigger_fd_index()])); subscriber->SetPollFd(std::move(fds[resp.poll_fd_index()])); @@ -2331,9 +2360,9 @@ absl::Status ClientImpl::Reconnect() { return status; } - Request init_req; + phaser::Request init_req; init_req.mutable_init()->set_client_name(name_); - Response init_resp; + phaser::Response init_resp; std::vector init_fds; init_fds.reserve(100); status = SendRequestReceiveResponse(init_req, init_resp, init_fds); @@ -2367,21 +2396,21 @@ absl::Status ClientImpl::Reconnect() { } absl::Status ClientImpl::ReregisterPublisher(PublisherImpl *publisher) { - Request req; + phaser::Request req; FillCreatePublisherRequest(req.mutable_create_publisher(), publisher->Name(), publisher->options_, publisher->GetPublisherId()); req.mutable_create_publisher()->set_active_queue_publish_depth( publisher->ActiveQueuePublishDepth()); - Response resp; + phaser::Response resp; std::vector fds; fds.reserve(100); if (absl::Status status = SendRequestReceiveResponse(req, resp, fds); !status.ok()) { return status; } - auto &pub_resp = resp.create_publisher(); + const auto &pub_resp = resp.create_publisher(); if (!pub_resp.error().empty()) { return absl::InternalError(pub_resp.error()); } @@ -2391,19 +2420,19 @@ absl::Status ClientImpl::ReregisterPublisher(PublisherImpl *publisher) { } absl::Status ClientImpl::ReregisterSubscriber(SubscriberImpl *subscriber) { - Request req; + phaser::Request req; FillCreateSubscriberRequest(req.mutable_create_subscriber(), subscriber->Name(), subscriber->options_, subscriber->GetSubscriberId()); - Response resp; + phaser::Response resp; std::vector fds; fds.reserve(100); if (absl::Status status = SendRequestReceiveResponse(req, resp, fds); !status.ok()) { return status; } - auto &sub_resp = resp.create_subscriber(); + const auto &sub_resp = resp.create_subscriber(); if (!sub_resp.error().empty()) { return absl::InternalError(sub_resp.error()); } @@ -2413,21 +2442,20 @@ absl::Status ClientImpl::ReregisterSubscriber(SubscriberImpl *subscriber) { } absl::Status ClientImpl::SendRequestReceiveResponse( - const Request &req, Response &response, + const phaser::Request &req, phaser::Response &response, std::vector &fds, const std::vector &send_fds) { { - size_t msg_len = req.ByteSizeLong(); - std::vector send_msg(sizeof(int32_t) + msg_len); - char *sendbuf = send_msg.data() + sizeof(int32_t); - - if (!req.SerializeToArray(sendbuf, msg_len)) { - return absl::InternalError("Failed to serialize request"); + ServerWire wire; + absl::StatusOr msg_len = wire.Serialize(req); + if (!msg_len.ok()) { + return absl::InternalError(absl::StrFormat( + "Failed to serialize request: %s", msg_len.status().message())); } absl::StatusOr n = - socket_.SendMessage(sendbuf, msg_len, SocketContext()); + socket_.SendMessage(wire.Payload(), *msg_len, SocketContext()); if (!n.ok()) { socket_.Close(); if (was_connected_ && !reconnecting_) { @@ -2461,10 +2489,12 @@ absl::Status ClientImpl::SendRequestReceiveResponse( } return recv_msg.status(); } - if (!response.ParseFromArray(recv_msg->data(), - static_cast(recv_msg->size()))) { + if (absl::Status status = ServerWire::Decode( + recv_msg->data(), recv_msg->size(), response); + !status.ok()) { socket_.Close(); - return absl::InternalError("Failed to parse response"); + return absl::InternalError( + absl::StrFormat("Failed to parse response: %s", status.message())); } absl::Status s = socket_.ReceiveFds(fds, SocketContext()); @@ -2476,17 +2506,18 @@ absl::Status ClientImpl::SendRequestReceiveResponse( } absl::Status ClientImpl::SendOneWayRequest( - const Request &req, const std::vector &fds) { - size_t msg_len = req.ByteSizeLong(); - std::vector send_msg(sizeof(int32_t) + msg_len); - char *sendbuf = send_msg.data() + sizeof(int32_t); - - if (!req.SerializeToArray(sendbuf, msg_len)) { - return absl::InternalError("Failed to serialize one-way request"); + const phaser::Request &req, + const std::vector &fds) { + ServerWire wire; + absl::StatusOr msg_len = wire.Serialize(req); + if (!msg_len.ok()) { + return absl::InternalError(absl::StrFormat( + "Failed to serialize one-way request: %s", + msg_len.status().message())); } absl::StatusOr n = - socket_.SendMessage(sendbuf, msg_len, SocketContext()); + socket_.SendMessage(wire.Payload(), *msg_len, SocketContext()); if (!n.ok()) { socket_.Close(); if (was_connected_ && !reconnecting_) { @@ -2511,7 +2542,7 @@ absl::Status ClientImpl::SendOneWayRequest( absl::Status ClientImpl::RegisterClientBuffer(const ClientBufferHandleMetadata &metadata, const toolbelt::FileDescriptor *fd) { - Request req; + phaser::Request req; auto *register_buffer = req.mutable_register_client_buffer(); ToProto(metadata, register_buffer->mutable_metadata()); std::vector fds; @@ -2520,7 +2551,7 @@ ClientImpl::RegisterClientBuffer(const ClientBufferHandleMetadata &metadata, register_buffer->set_fd_index(0); fds.push_back(*fd); } - Response response; + phaser::Response response; std::vector response_fds; if (absl::Status status = SendRequestReceiveResponse(req, response, response_fds, fds); @@ -2536,13 +2567,13 @@ ClientImpl::RegisterClientBuffer(const ClientBufferHandleMetadata &metadata, absl::StatusOr> ClientImpl::GetClientBuffers(const std::string &channel_name, uint64_t session_id, uint32_t buffer_index) { - Request req; + phaser::Request req; auto *get = req.mutable_get_client_buffers(); get->set_channel_name(channel_name); get->set_session_id(session_id); get->set_buffer_index(buffer_index); - Response response; + phaser::Response response; std::vector fds; if (absl::Status status = SendRequestReceiveResponse(req, response, fds); !status.ok()) { @@ -2557,7 +2588,7 @@ ClientImpl::GetClientBuffers(const std::string &channel_name, } std::vector result; result.reserve(get_resp.metadata_size()); - for (int i = 0; i < get_resp.metadata_size(); ++i) { + for (size_t i = 0; i < get_resp.metadata_size(); ++i) { RegisteredClientBuffer buffer{ .metadata = FromProto(get_resp.metadata(i)), }; @@ -2573,7 +2604,7 @@ ClientImpl::GetClientBuffers(const std::string &channel_name, absl::Status ClientImpl::UnregisterClientBuffer(const std::string &channel_name, uint64_t session_id, uint32_t buffer_index) { - Request req; + phaser::Request req; auto *unregister = req.mutable_unregister_client_buffer(); unregister->set_channel_name(channel_name); unregister->set_session_id(session_id); diff --git a/client/client.h b/client/client.h index e1044309..b6559d87 100644 --- a/client/client.h +++ b/client/client.h @@ -20,7 +20,7 @@ #include "common/async/wait.h" #include "common/channel.h" #include "common/client_buffer.h" -#include "proto/subspace.pb.h" +#include "proto/subspace.phaser.h" #include "toolbelt/fd.h" #include "toolbelt/logging.h" @@ -712,11 +712,11 @@ class ClientImpl : public std::enable_shared_from_this { absl::Status CheckConnected() const; absl::Status SendRequestReceiveResponse( - const Request &req, Response &response, + const phaser::Request &req, phaser::Response &response, std::vector &fds, const std::vector &send_fds = {}); absl::Status - SendOneWayRequest(const Request &req, + SendOneWayRequest(const phaser::Request &req, const std::vector &fds = {}); absl::Status RegisterClientBuffer(const ClientBufferHandleMetadata &metadata, @@ -732,19 +732,19 @@ class ClientImpl : public std::enable_shared_from_this { absl::Status ReregisterPublisher(details::PublisherImpl *publisher); absl::Status ReregisterSubscriber(details::SubscriberImpl *subscriber); - static void FillCreatePublisherRequest(CreatePublisherRequest *cmd, + static void FillCreatePublisherRequest(phaser::CreatePublisherRequest *cmd, const std::string &channel_name, const PublisherOptions &opts, int publisher_id); void ApplyPublisherResponseFds(details::PublisherImpl *publisher, - const CreatePublisherResponse &resp, + const phaser::CreatePublisherResponse &resp, std::vector &fds); - static void FillCreateSubscriberRequest(CreateSubscriberRequest *cmd, + static void FillCreateSubscriberRequest(phaser::CreateSubscriberRequest *cmd, const std::string &channel_name, const SubscriberOptions &opts, int subscriber_id); void ApplySubscriberResponseFds(details::SubscriberImpl *subscriber, - const CreateSubscriberResponse &resp, + const phaser::CreateSubscriberResponse &resp, std::vector &fds); bool CheckReload(details::ClientChannel *channel); diff --git a/client/options.h b/client/options.h index 431cb168..106984be 100644 --- a/client/options.h +++ b/client/options.h @@ -99,6 +99,17 @@ struct PublisherOptions { } SlotSizeType MaxSlotSize() const { return max_slot_size; } + // The channel is in the server's static channel config, which defines its + // layout. Publishers on static channels must set this, and must leave the + // options that shape the channel's shared memory unset: slot size, number + // of slots, fixed size, checksum size, metadata size, subscriber queue + // arena size and split buffers. The publisher takes them from the server. + PublisherOptions &SetServerDefined(bool v) { + server_defined = v; + return *this; + } + bool ServerDefined() const { return server_defined; } + bool IsLocal() const { return local; } bool IsReliable() const { return reliable; } bool IsFixedSize() const { return fixed_size; } @@ -186,7 +197,7 @@ struct PublisherOptions { bool Checksum() const { return checksum; } // Number of bytes reserved for the checksum starting at the checksum - // field of MessagePrefix. Default is 4 (CRC32). + // field of MessagePrefix. 0, the default, means 4 (CRC32). PublisherOptions &SetChecksumSize(int32_t size) { checksum_size = size; return *this; @@ -280,6 +291,7 @@ struct PublisherOptions { bool bridge = false; bool for_tunnel = false; bool fixed_size = false; + bool server_defined = false; std::string type; bool activate = false; // If true, the channel will be activated even if unreliable. @@ -290,7 +302,7 @@ struct PublisherOptions { int32_t max_outstanding_slot_leases = 1; bool notify_retirement_on_forced_reuse = true; bool checksum = false; - int32_t checksum_size = 4; + int32_t checksum_size = 0; int32_t metadata_size = 0; // See SetPreferRetiredSlots() for description. diff --git a/client/python/client.cc b/client/python/client.cc index 312bddf6..48de226c 100644 --- a/client/python/client.cc +++ b/client/python/client.cc @@ -158,6 +158,13 @@ PYBIND11_MODULE(subspace, m) { "Set the number of slots for the publisher.") .def("num_slots", &PublisherOptions::NumSlots, "Get the number of slots for the publisher.") + .def("set_server_defined", &PublisherOptions::SetServerDefined, + "Set for a publisher on a static channel, whose layout the server " + "defines. The slot size, number of slots, fixed size, checksum " + "size, metadata size, subscriber queue arena size and split " + "buffers must then be left unset.") + .def("server_defined", &PublisherOptions::ServerDefined, + "Get whether the server defines the channel's layout.") .def("set_subscriber_queue_arena_size", &PublisherOptions::SetSubscriberQueueArenaSize, "Set the bytes reserved for packed subscriber queues. Queues are " diff --git a/common/BUILD.bazel b/common/BUILD.bazel index e39dadc5..e35b4473 100644 --- a/common/BUILD.bazel +++ b/common/BUILD.bazel @@ -47,6 +47,18 @@ cc_library( ], ) +# Server requests and responses as Phaser messages in protobuf wire format, +# shared by the standard and ASIL clients. +cc_library( + name = "server_wire", + hdrs = ["server_wire.h"], + deps = [ + "//proto:subspace_phaser", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + ], +) + cc_test( name = "common_test", size = "small", diff --git a/common/server_wire.h b/common/server_wire.h new file mode 100644 index 00000000..32bed75c --- /dev/null +++ b/common/server_wire.h @@ -0,0 +1,115 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#pragma once + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "proto/subspace.phaser.h" + +#include +#include +#include + +namespace subspace { + +// Requests to and responses from the server, as Phaser messages sent in +// protobuf wire format. The server, and clients in other languages, see the +// same bytes as from a protobuf client. +// +// A ServerWire serializes requests into its wire buffer and decodes +// responses. The Buffers policy decides where messages and wire bytes live: +// +// FixedServerWireBuffers holds them inside the object and makes no heap +// allocations. Requests and responses that don't fit fail with +// kResourceExhausted. NewRequest and NewResponse build messages in a single +// message buffer, so a new message replaces the previous one. +// +// DynamicServerWireBuffers grows its wire buffer to fit each request. Its +// messages are default constructed Phaser messages, which own their storage. +// +// Socket I/O stays with the caller. The length that precedes each message on +// the socket is the caller's to write, and Serialize leaves room for it just +// before the payload. +inline constexpr size_t kServerWireHeaderSize = sizeof(uint32_t); + +template class FixedServerWireBuffers { +public: + static constexpr size_t kMaxWireSize = kWireSize; + + absl::StatusOr NewRequest() { + return phaser::Request::TryCreateMutable(message_, sizeof(message_)); + } + + absl::StatusOr NewResponse() { + return phaser::Response::TryCreateMutable(message_, sizeof(message_)); + } + + // Room for the header and length bytes after it, or nullptr. + char *Wire(size_t length) { return length <= kWireSize ? wire_ : nullptr; } + +private: + alignas(8) char message_[kMessageSize]; + alignas(8) char wire_[kServerWireHeaderSize + kWireSize]; +}; + +class DynamicServerWireBuffers { +public: + char *Wire(size_t length) { + wire_.resize(kServerWireHeaderSize + length); + return wire_.data(); + } + +private: + std::vector wire_; +}; + +template class ServerWire { +public: + absl::StatusOr NewRequest() { return buffers_.NewRequest(); } + absl::StatusOr NewResponse() { + return buffers_.NewResponse(); + } + + // Serializes request into the wire buffer and returns its length. The + // bytes are at Payload(), with kServerWireHeaderSize bytes before them. + absl::StatusOr Serialize(const phaser::Request &request) { + if (request.AllocationFailed()) { + return absl::ResourceExhaustedError("Request is too large to build"); + } + const size_t length = request.SerializedSize(); + char *wire = buffers_.Wire(length); + if (wire == nullptr) { + return absl::ResourceExhaustedError("Request is too large to send"); + } + payload_ = wire + kServerWireHeaderSize; + ::phaser::ProtoBuffer out(payload_, length); + if (absl::Status status = request.Serialize(out); !status.ok()) { + return status; + } + return out.Size(); + } + + // The bytes from the last Serialize. + char *Payload() const { return payload_; } + + // Room for a response of length bytes, or nullptr if it doesn't fit. + char *ResponseBuffer(size_t length) { + char *wire = buffers_.Wire(length); + return wire == nullptr ? nullptr : wire + kServerWireHeaderSize; + } + + // Decodes length bytes of a response into response. + static absl::Status Decode(const char *data, size_t length, + phaser::Response &response) { + ::phaser::ProtoBuffer in(data, length); + return response.Deserialize(in); + } + +private: + Buffers buffers_; + char *payload_ = nullptr; +}; + +} // namespace subspace diff --git a/docs/android.md b/docs/android.md index dab8202a..fed70418 100644 --- a/docs/android.md +++ b/docs/android.md @@ -184,15 +184,18 @@ executable. The `android_libs/` approach works for `/data/local/tmp/` binaries. Subspace can also be cross-compiled for Android using CMake with the NDK toolchain. CMake fetches the same third-party dependencies with -`FetchContent`, but protobuf code generation requires a host-native `protoc` -that matches the protobuf version used by the Android build. +`FetchContent`, but code generation runs on the host. It needs a host-native +`protoc` that matches the protobuf version used by the Android build, and a +host-native phaser plugin (`protoc-gen-phaser`) for the C++ clients' server +messages. ```bash export ANDROID_NDK_HOME=/path/to/ndk -# Build a host protoc matching Subspace's protobuf dependency. +# Build a host protoc matching Subspace's protobuf dependency, and the phaser +# plugin. cmake -S . -B build/host-protoc -DCMAKE_BUILD_TYPE=Release -cmake --build build/host-protoc --target protoc --parallel +cmake --build build/host-protoc --target protoc protoc-gen-phaser --parallel cmake -S . -B build/android \ -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \ @@ -200,7 +203,8 @@ cmake -S . -B build/android \ -DANDROID_PLATFORM=android-28 \ -DANDROID_STL=c++_shared \ -DCMAKE_BUILD_TYPE=Release \ - -DPROTOC_EXECUTABLE="$PWD/build/host-protoc/_deps/protobuf-build/protoc" + -DPROTOC_EXECUTABLE="$PWD/build/host-protoc/_deps/protobuf-build/protoc" \ + -DPHASER_PLUGIN_EXECUTABLE="$PWD/build/host-protoc/_deps/phaser-build/protoc-gen-phaser" cmake --build build/android --parallel ``` @@ -240,6 +244,9 @@ Place the subspace source tree in your AOSP checkout (e.g., | `libsubspace_server` | static lib | Server implementation | | `subspace_server` | binary | Standalone server daemon | | `libsubspace_proto` | static lib | Protobuf message definitions | +| `libsubspace_phaser` | static lib | Phaser message definitions for the C++ clients | +| `libsubspace_asil_client` | static lib | ASIL client core (no exceptions, no RTTI) | +| `libsubspace_asil_phaser_connection` | static lib | ASIL client's server connection | | `libsubspace_jni` | shared lib | JNI bindings for Java clients | | `subspace-java` | java lib | Java client wrapper | | `subspace_java_client_test` | java binary | Device-side Java integration test | @@ -256,16 +263,20 @@ files with the `subspace_rpc` plugin via a `genrule` (see ### External Dependencies -Subspace requires two external libraries that must also be present in the AOSP -tree: +Subspace requires three external libraries that must also be present in the +AOSP tree: 1. **coroutines** (`external/coroutines/`) — https://github.com/dallison/coroutines 2. **cpp_toolbelt** (`external/cpp_toolbelt/`) — https://github.com/dallison/cpp_toolbelt - -Example Blueprint files for both are provided in -`external/coroutines/Android.bp.example` and -`external/cpp_toolbelt/Android.bp.example` within this repository. Copy these to -`Android.bp` in the respective source trees in your AOSP checkout. +3. **phaser** (`external/phaser/`) — https://github.com/dallison/phaser + +Example Blueprint files for all three are provided in +`external/coroutines/Android.bp.example`, +`external/cpp_toolbelt/Android.bp.example` and +`external/phaser/Android.bp.example` within this repository. Copy these to +`Android.bp` in the respective source trees in your AOSP checkout. The phaser +file defines the `libphaser_runtime` library and the `protoc-gen-phaser` host +plugin. ### AOSP Dependencies diff --git a/docs/asil-client.md b/docs/asil-client.md new file mode 100644 index 00000000..dd5b026c --- /dev/null +++ b/docs/asil-client.md @@ -0,0 +1,367 @@ +# ASIL Client + +The ASIL client in `asil_client/` is a Subspace client for safety-related +software. It publishes and reads messages on the same channels as the +standard C++, C, Python and Rust clients, using the same shared memory +protocol, so an ASIL process and a standard process can share a channel in +either direction. + +It works with any server. A server running a +[static channel config](../README.md#static-channel-config), where every +channel exists before any client connects and keeps a fixed layout, gives a +safety case the most predictable setup. + +## Design Rules + +- **C++17, the standard library and POSIX.** The core library has no other + dependencies and is compiled with `-fno-exceptions -fno-rtti`. The server + connection adds [phaser](https://github.com/dallison/phaser) and is built + the same way. +- **Errors are values.** Every operation that can fail returns + `subspace::asil::Error`. `ErrorString()` describes a code, and + `Client::LastServerError()` gives the server's reason for + `Error::kServerRejected`. +- **No heap allocation.** The client, publishers and subscribers allocate no + memory, and neither does `PhaserServerConnection` while it talks to the + server. +- **Resources are acquired when an object opens.** A publisher or + subscriber maps all of its channel's shared memory in `Open()`, and creates + shared memory only when an ASIL publisher is the first on a channel that + isn't static. Publishing and reading make no `mmap`, `shm_open` or other + shared memory calls. After `Open()` the server is involved only when a + publisher or subscriber refreshes its trigger file descriptors because + another process joined or left the channel, and on `Close()`. +- **Fixed capacities.** Channel names hold up to 255 characters and each + trigger list holds up to 1024 file descriptors. A request or response to + the server holds up to 16 KB in wire format. Exceeding any of these + returns `Error::kCapacityExceeded`. `max_active_messages` is at most 64. + A subscriber keeps the newest ordinal of every virtual channel, which makes + it about 8 KB, so keep subscribers in static storage or inside long-lived + objects. +- **Single threaded objects.** A `Client`, `Publisher` or `Subscriber` is used + by one thread at a time. The `ServerConnection` outlives the `Client`, and + the `Client` outlives its publishers and subscribers. + +## Scope + +The ASIL client shares channels with the standard clients for: + +- Unreliable and reliable publishers and subscribers. +- Virtual channels on a multiplexer, and subscribers to a whole multiplexer. +- Subscribers that hold several messages at once (`max_active_messages`). +- Subscriber queues: an ASIL publisher fills the queues of standard + subscribers that use them. +- Split buffers, including slots that a standard publisher's custom + allocator created. +- POSIX shared memory on macOS and QNX, `/dev/shm` on Linux, and the memfd + backend (always used on Android, and selected on Linux with + `SUBSPACE_LINUX_USE_MEMFD`). +- Checksums, user metadata, `kReadNext` and `kReadNewest`, dropped message + counting and activation messages. + +### Channel Buffers + +A publisher or subscriber maps one of the channel's message buffers, the +newest, when it opens. A server with a static channel config creates every +static channel's buffers when it starts, so ASIL publishers and subscribers +can open in any order. On a server that creates channels on demand, a +channel has buffers once its first publisher opens. Until then an ASIL +subscriber's `Open()` returns `Error::kNoBuffers`. An ASIL publisher that is +first on such a channel creates its buffer. + +ASIL publishers always use fixed-size slots, so a channel never resizes while +one is open. A standard publisher can resize a channel that only it +publishes on, which gives the channel a new buffer. An ASIL subscriber that +opened before the resize can't read the messages in the new buffer. +`ReadMessage` skips each of them and returns `Error::kBufferNotMapped`. An +ASIL publisher or subscriber that opens after the resize uses the new buffer. + +### Split Buffers + +A channel with split buffers keeps the message prefixes in one shared memory +object and each slot's payload in its own. The ASIL client maps all of them +in `Open()`, into an array of `SplitSlot` that the caller provides in +`split_slots`, with its length in `split_slot_capacity`. The array needs at +least the channel's `num_slots` entries, or `Open()` returns +`Error::kCapacityExceeded`. It must outlive the publisher or subscriber. +An ASIL publisher sets `use_split_buffers` and maps the channel's existing +buffers; it never creates split buffers. + +A static channel or multiplexer has split buffers when its config sets +`use_split_buffers`, and the server creates them. A `server_defined` +publisher learns from the server whether the channel has split buffers, and +needs `split_slots` when it does. On a server that creates channels on +demand, the first standard publisher creates them. + +A standard publisher can create its slots with a custom allocator. To map +those slots, set `split_allocator` to C functions that map and unmap a slot, +with a context pointer. `map` gets the slot's `SplitBufferInfo`: the +allocator's handle, sizes and the descriptor the creator registered, if any. +`Open()` calls `map` for each slot, and `Close()` calls `unmap`. Without +`split_allocator.map`, such a channel returns `Error::kUnsupported`. + +```cpp +asil::Error MapSlot(void *context, const asil::SplitBufferInfo &info, + asil::SplitBufferMapping &mapping) { + mapping.address = static_cast(context)->Find(info.handle); + return mapping.address != nullptr ? asil::Error::kOk + : asil::Error::kInvalidArgument; +} + +asil::SplitSlot slots[16]; +asil::SubscriberOptions options; +options.split_slots = slots; +options.split_slot_capacity = 16; +options.split_allocator.map = MapSlot; +options.split_allocator.context = &pool; +``` + +## Building + +Bazel: + +``` +bazelisk build //asil_client:asil_client //asil_client:phaser_connection +bazelisk test //asil_client:all +``` + +| Target | Contents | Dependencies | +|---|---|---| +| `//asil_client:asil_client` | Client, publisher, subscriber, shared memory | C++17, POSIX | +| `//asil_client:phaser_connection` | The standard server handshake | `asil_client`, `//common:server_wire` | +| `//common:server_wire` | Server request and response encoding, shared with the standard client | `//proto:subspace_phaser` | +| `//proto:subspace_phaser` | Phaser messages generated from `subspace.proto` | phaser runtime, Abseil, cpp_toolbelt | + +CMake builds the same libraries as `subspace_asil_client`, +`subspace_asil_phaser_connection` and `subspace_phaser`, with the tests +`asil_layout_test` and `asil_client_test`. Phaser is fetched with +`FetchContent`, and its `protoc-gen-phaser` plugin is built for the host. A +cross-compile needs a host plugin passed as `-DPHASER_PLUGIN_EXECUTABLE`; see +[the Android CMake build](android.md#building-with-cmake). + +Soong builds `libsubspace_asil_client`, `libsubspace_asil_phaser_connection` +and `libsubspace_phaser`, using the phaser modules from +`external/phaser/Android.bp.example`. + +The core library takes `SUBSPACE_MAX_CHANNELS` from the same build setting as +the server, because it sizes the shared system control block. + +## The Server Connection + +The client talks to the server through the abstract `ServerConnection` in +`asil_client/server_connection.h`. It has one call for each request the +client makes: `Init`, `CreatePublisher`, `CreateSubscriber`, `GetTriggers`, +`RemovePublisher` and `RemoveSubscriber`, plus `RegisterBuffer` and +`GetBuffer`, which pass buffer file descriptors between processes through the +server. `GetBuffer` fetches one buffer: a channel's single buffer for the +memfd backend, or the prefixes or one slot of a split buffer set, so that +each response stays small. + +`PhaserServerConnection` implements it with the server's standard protocol: +length-prefixed messages in protobuf wire format on the server's Unix socket, +with file descriptors passed by `SCM_RIGHTS`. The server receives the same +bytes it receives from the standard clients. Requests and responses are +encoded by `ServerWire` in `common/server_wire.h`, which the standard C++ +client uses too. The standard client gives it growable buffers. The ASIL +connection gives it fixed buffers inside the object: it builds each request +as a phaser message in a fixed buffer, serializes it to protobuf wire format, +and decodes the response into the same buffer, so it needs neither the +protobuf library nor the heap. The buffers take about 60 KB, so keep the +connection in static storage or inside a long-lived object. A system with +its own qualified transport can replace it with another implementation of +`ServerConnection`. + +## Example + +```cpp +#include "asil_client/client.h" +#include "asil_client/phaser_connection.h" + +#include + +namespace asil = subspace::asil; + +asil::PhaserServerConnection connection; + +asil::Error Run() { + if (asil::Error e = connection.Connect("/tmp/subspace"); + e != asil::Error::kOk) { + return e; + } + asil::Client client; + if (asil::Error e = client.Init(connection, "controller"); + e != asil::Error::kOk) { + return e; + } + + asil::PublisherOptions pub_options; + pub_options.slot_size = 256; + pub_options.num_slots = 8; + pub_options.checksum = true; + asil::Publisher publisher; + if (asil::Error e = + client.CreatePublisher("/vehicle/command", pub_options, publisher); + e != asil::Error::kOk) { + return e; + } + + asil::SubscriberOptions sub_options; + sub_options.checksum = true; + asil::Subscriber subscriber; + if (asil::Error e = + client.CreateSubscriber("/vehicle/state", sub_options, subscriber); + e != asil::Error::kOk) { + return e; + } + + void *buffer = publisher.Buffer(); + if (buffer == nullptr) { + return asil::Error::kNoSlot; + } + std::memcpy(buffer, "go", 2); + if (asil::Error e = publisher.Publish(2); e != asil::Error::kOk) { + return e; + } + + if (asil::Error e = subscriber.Wait(/*timeout_ms=*/100); + e == asil::Error::kTimeout) { + return asil::Error::kOk; + } else if (e != asil::Error::kOk) { + return e; + } + asil::Message msg; + for (;;) { + if (asil::Error e = subscriber.ReadMessage(msg); e != asil::Error::kOk) { + return e; + } + if (msg.length == 0) { + break; + } + // msg.data stays valid until the next ReadMessage or ReleaseMessage. + } + return asil::Error::kOk; +} +``` + +## Publishers + +`PublisherOptions` gives the slot size, number of slots, checksum size, +metadata size, subscriber queue arena size, split buffer setting and type. +`subscriber_queue_arena_size` must match the channel's other publishers. + +On a static channel the server defines the layout. The publisher sets +`server_defined` and leaves `slot_size`, `num_slots`, `checksum_size`, +`metadata_size`, `subscriber_queue_arena_size` and `use_split_buffers` at +zero. `Open()` returns `Error::kInvalidArgument` if any of them is set, and +`Error::kServerRejected` if the channel is static and `server_defined` isn't +set, or if `server_defined` is set and the channel isn't static. The +publisher then takes its layout from the server: + +```c++ +asil::PublisherOptions options; +options.server_defined = true; +options.type = "State"; +asil::Publisher pub; +asil::Error error = client.CreatePublisher("/vehicle/state", options, pub); +``` + +`Buffer()` returns the payload of the slot the publisher holds, and +`Metadata()` returns its metadata area. `Publish(size)` sends the message and +claims the next slot. It fills in an optional `PublishedMessage` with the +message's ordinal and timestamp. When `checksum` is set the publisher +computes the same CRC32 as the standard clients. When `activate` is set the +publisher sends the channel's activation message if no publisher has sent one. + +A publisher joining a channel whose slots a standard publisher has grown uses +the channel's newest buffer, so `SlotSize()` can be larger than the requested +size. + +### Reliable Publishers + +With `reliable` set, the publisher never overwrites a message that a +reliable subscriber hasn't read. It sends an activation message when it +opens, which every reliable subscriber holds until it reads further. +`Buffer()` returns null while the channel has no subscribers, and while every +slot holds a message a reliable subscriber still needs. `PollFd()` becomes +readable when a reliable subscriber releases a message, and +`Wait(timeout_ms)` polls it: + +```cpp +void *buffer = publisher.Buffer(); +while (buffer == nullptr) { + if (asil::Error e = publisher.Wait(/*timeout_ms=*/100); + e != asil::Error::kOk) { + return e; + } + buffer = publisher.Buffer(); +} +``` + +### Virtual Channels + +Set `mux` to the multiplexer's name to publish on one of its virtual +channels. The server assigns the virtual channel id unless `vchan_id` gives +one. `VirtualChannelId()` returns it. + +## Subscribers + +`ReadMessage(msg, mode)` fills in a `Message` with the payload, length, +ordinal, timestamp, slot, virtual channel id, metadata and the number of +messages dropped since the previous one. A length of 0 means there was +nothing to read. `PollFd()` is readable when messages may be waiting, and +`Wait(timeout_ms)` polls it. + +With `checksum` set, a message with a bad checksum returns +`Error::kChecksumMismatch`. With `pass_checksum_errors` also set, it is +delivered with `checksum_error` set. Activation messages are skipped unless +`pass_activation` is set. + + +### Holding Messages + +By default a subscriber holds one message, and reading the next releases it. +With `max_active_messages` set to more than 1, every message read stays valid +until it is released. `ReleaseMessage(msg)` releases one message and +`ReleaseMessage()` releases them all. When the subscriber holds +`max_active_messages` messages, `ReadMessage` returns +`Error::kActiveMessageLimit`. + +### Reliable Subscribers + +With `reliable` set, reliable publishers wait for the subscriber to read each +message. A reliable subscriber that holds one message keeps it until it has +read the next one, so a publisher can never take every slot. Reading with +`kReadNewest` marks the skipped messages as read, which lets reliable +publishers reuse their slots. + +### Virtual Channels and Multiplexers + +Set `mux` to subscribe to a virtual channel. Subscribing to the multiplexer +itself delivers the messages of all its virtual channels, with each message's +`vchan_id`. Each virtual channel has its own ordinals, so dropped messages +are counted per virtual channel. + +### Subscriber Queues + +An ASIL subscriber reads the channel's record of unread messages directly, +so it works on channels with subscriber queues without using one. ASIL +publishers add every message to the queues of the standard subscribers that +have them. + +## Testing + +`layout_test` compares every size, offset and constant in +`asil_client/shm_layout.h` with `common/channel.h`, checks that the checksum +matches the standard client's, and passes subscriber queue entries between +the ASIL client and the standard `InPlaceSlotQueue`. + +`asil_client_test` exchanges messages between the ASIL client and the +standard client in both directions. One server runs a static channel config, +for reliable channels, virtual channels, held messages, split buffers and +subscribers that open before any publisher. A second server creates channels +on demand, for channels created by ASIL publishers, resized channels, +subscriber queues, custom split buffer allocators and subscribers that open +before a channel has buffers. The +test also checks that setting up and closing a publisher and a subscriber +makes no heap allocations, and that a request too large for the wire buffer +returns `Error::kCapacityExceeded` and leaves the connection usable. diff --git a/docs/c-client.md b/docs/c-client.md index 7c9214ae..ee5d15f8 100644 --- a/docs/c-client.md +++ b/docs/c-client.md @@ -85,10 +85,18 @@ opts.metadata_size = 32; SubspacePublisher pub = subspace_create_publisher(client, "my_channel", opts); ``` +On a server with a [static channel config](../README.md#static-channel-config), +start with `subspace_publisher_options_server_defined()` instead. It sets +`server_defined` and leaves every layout option (`slot_size`, `num_slots`, +`fixed_size`, `checksum_size`, `metadata_size`, +`subscriber_queue_arena_size` and `use_split_buffers`) at zero, as the server +requires on a static channel. + Important publisher options: | Field | Purpose | | --- | --- | +| `server_defined` | Take the channel layout from the server's static channel config. Required on static channels and rejected on others. | | `slot_size`, `num_slots` | Initial payload slot size and fixed slot count. `slot_size` is a `SubspaceSlotSize`, which is `int32_t` unless the library and your code are both built with `SUBSPACE_64BIT_SLOT_SIZE`; see [Slot Sizes](slot-sizes.md#64-bit-slot-sizes). | | `local` | Keep the channel local to this server. | | `reliable` | Wait for free slots instead of overwriting unread messages. | diff --git a/docs/rust-client.md b/docs/rust-client.md index 39871ad1..53c66d87 100644 --- a/docs/rust-client.md +++ b/docs/rust-client.md @@ -180,10 +180,11 @@ let msg = subscriber | `max_outstanding_slot_leases` | 1 | Maximum unpublished slots owned through explicit leases. | | `notify_retirement_on_forced_reuse` | true | Emit retirement notifications when an unreliable publisher forcibly reuses an unread slot. | | `checksum` | false | Compute CRC32 checksums on published messages. | -| `checksum_size` | 4 | Bytes reserved for checksums (default: 4 for CRC32). | +| `checksum_size` | 0 | Bytes reserved for checksums; 0 means 4, for CRC32. | | `metadata_size` | 0 | Bytes reserved in each message prefix for user metadata. | | `use_split_buffers` | false | Store message prefixes separately from payload buffers using the built-in split shared-memory allocator. | | `split_buffers_over_bridge` | false | Ask receiving bridge servers to create their mirror publisher with split payload buffers. | +| `server_defined` | false | Take the layout from the server's [static channel config](../README.md#static-channel-config). Required on static channels, which reject `slot_size`, `num_slots`, `fixed_size`, `checksum_size`, `metadata_size`, `subscriber_queue_arena_size` and `use_split_buffers`. | | `split_buffer_callbacks` | none | Optional external allocator callbacks for split payload buffers. Publishers use `allocate`/`free`; subscribers use `map`/`unmap`. | All options use a builder pattern: diff --git a/external/phaser/Android.bp.example b/external/phaser/Android.bp.example new file mode 100644 index 00000000..7d853e8d --- /dev/null +++ b/external/phaser/Android.bp.example @@ -0,0 +1,59 @@ +// Android.bp for phaser (https://github.com/dallison/phaser). +// Place this file alongside the phaser source tree at external/phaser/ in +// your AOSP checkout. + +// Runtime library linked by phaser-generated code. +cc_library_static { + name: "libphaser_runtime", + srcs: [ + "phaser/runtime/md5.cc", + "phaser/runtime/message.cc", + "phaser/runtime/phaser_bank.cc", + ], + export_include_dirs: ["."], + cflags: [ + "-std=c++17", + "-Wall", + "-Wno-unused-parameter", + ], + static_libs: [ + "libcpp_toolbelt", + "libabsl", + ], + export_static_lib_headers: [ + "libcpp_toolbelt", + "libabsl", + ], + stl: "c++_shared", + min_sdk_version: "28", + compile_multilib: "64", +} + +// The protoc plugin. It runs on the build host during code generation. +cc_binary_host { + name: "protoc-gen-phaser", + srcs: [ + "phaser/compiler/enum_gen.cc", + "phaser/compiler/gen.cc", + "phaser/compiler/main.cc", + "phaser/compiler/message_gen.cc", + "phaser/options.proto", + ], + proto: { + type: "full", + include_dirs: ["external/protobuf/src"], + }, + local_include_dirs: ["."], + cflags: [ + "-Wall", + "-Wno-unused-parameter", + ], + cpp_std: "c++17", + cppflags: ["-fexceptions"], + rtti: true, + static_libs: [ + "libprotoc", + "libprotobuf-cpp-full", + "libabsl", + ], +} diff --git a/proto/BUILD.bazel b/proto/BUILD.bazel index ef45e13e..ff50834a 100644 --- a/proto/BUILD.bazel +++ b/proto/BUILD.bazel @@ -1,5 +1,6 @@ load("@protobuf//bazel:proto_library.bzl", "proto_library") load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library") +load("@phaser//phaser:phaser_library.bzl", "phaser_library") package(default_visibility = ["//visibility:public"]) @@ -16,3 +17,15 @@ cc_proto_library( deps = [":subspace_proto"], ) +# Phaser messages for subspace.proto in namespace subspace::phaser. They are +# built in caller-provided buffers without heap allocation and converted to and +# from protobuf wire format. +phaser_library( + name = "subspace_phaser", + add_namespace = "phaser", + copts = [ + "-fno-exceptions", + "-fno-rtti", + ], + deps = [":subspace_proto"], +) diff --git a/proto/CMakeLists.txt b/proto/CMakeLists.txt index 46036cc0..a8555564 100644 --- a/proto/CMakeLists.txt +++ b/proto/CMakeLists.txt @@ -93,3 +93,25 @@ target_link_libraries(subspace_proto PUBLIC protobuf::libprotobuf ) +# Phaser messages for the clients' server requests and responses. They are +# sent and received in protobuf wire format, so the server sees the same bytes +# as from any other client. Cross-compiles need a host plugin in +# PHASER_PLUGIN_EXECUTABLE. +if(CMAKE_CROSSCOMPILING AND NOT PHASER_PLUGIN_EXECUTABLE) + message(FATAL_ERROR + "Cross-compiling needs a host protoc-gen-phaser in " + "-DPHASER_PLUGIN_EXECUTABLE.") +endif() +set(SUBSPACE_PHASER_PROTOC) +if(CMAKE_CROSSCOMPILING) + set(SUBSPACE_PHASER_PROTOC PROTOC ${PROTOC_EXECUTABLE}) +endif() +phaser_add_library(subspace_phaser + PROTOS proto/subspace.proto + IMPORTS google/protobuf/any.proto + IMPORT_DIRS ${CMAKE_SOURCE_DIR} ${protobuf_SOURCE_DIR}/src + ADD_NAMESPACE phaser + COMPILE_OPTIONS -fno-exceptions -fno-rtti + ${SUBSPACE_PHASER_PROTOC} +) + diff --git a/proto/subspace.proto b/proto/subspace.proto index bcfc681b..888e9764 100644 --- a/proto/subspace.proto +++ b/proto/subspace.proto @@ -52,6 +52,11 @@ message CreatePublisherRequest { // Upper bound on the channel's slot size, already aligned. 0 means // unlimited. int64 max_slot_size = 23; + // The channel is in the server's static channel config, which defines its + // layout. Publishers on static channels must set this and leave + // num_slots, slot_size, is_fixed_size, checksum_size, metadata_size, + // subscriber_queue_arena_size and use_split_buffers unset. + bool server_defined = 24; } message CreatePublisherResponse { @@ -73,6 +78,13 @@ message CreatePublisherResponse { // Slots in the channel, which can exceed the requested num_slots when the // channel already exists. The CCB layout depends on it. int32 num_slots = 16; + // The rest of the channel's layout, which a server_defined publisher takes + // from here. + int64 slot_size = 17; + int32 checksum_size = 18; + int32 metadata_size = 19; + bool use_split_buffers = 20; + bool is_fixed_size = 21; } // This is used both to create a new subscriber and to reload @@ -212,6 +224,12 @@ message GetClientBuffersRequest { string channel_name = 1; uint64 session_id = 2; uint32 buffer_index = 3; + // If filter_slot is set, only the buffer matching is_prefix and slot_id is + // returned. This keeps responses small for channels with many split + // buffer slots. + bool filter_slot = 4; + bool is_prefix = 5; + uint32 slot_id = 6; } message GetClientBuffersResponse { @@ -647,3 +665,46 @@ message Telemetry { repeated Drop drops = 3; repeated Resize resizes = 4; } + +// A server given a StaticChannelConfig (subspace_server --channel_config, in +// protobuf text format) creates its channels before it accepts any clients. +// Clients can then use only these channels and the server's own +// /subspace/ChannelDirectory and /subspace/Statistics. The channels stay for +// the life of the server and their layout is fixed: publishers must be fixed +// size and match the configured slot size, number of slots, checksum size, +// metadata size and split buffer mode. The server creates each channel's +// buffers when it starts, so subscribers can map them before any publisher +// exists. +message StaticMultiplexer { + string name = 1; + int32 slot_size = 2; + int32 num_slots = 3; + string type = 4; + int32 checksum_size = 5; // 0 means the default of 4 bytes. + int32 metadata_size = 6; + // Message payloads are in shared memory of their own, one for each slot. + bool use_split_buffers = 7; +} + +message StaticChannel { + string name = 1; + // A channel on a multiplexer leaves slot_size, num_slots, type, + // checksum_size, metadata_size and use_split_buffers unset and takes them + // from the multiplexer. + int32 slot_size = 2; + int32 num_slots = 3; + string type = 4; + int32 checksum_size = 5; // 0 means the default of 4 bytes. + int32 metadata_size = 6; + // Name of an entry in StaticChannelConfig.multiplexers. + string mux = 7; + // Virtual channel id on the multiplexer. Only used when mux is set. + int32 vchan_id = 8; + // Message payloads are in shared memory of their own, one for each slot. + bool use_split_buffers = 9; +} + +message StaticChannelConfig { + repeated StaticMultiplexer multiplexers = 1; + repeated StaticChannel channels = 2; +} diff --git a/rust_client/src/client.rs b/rust_client/src/client.rs index 3d596de7..e5ddfcad 100644 --- a/rust_client/src/client.rs +++ b/rust_client/src/client.rs @@ -1209,7 +1209,25 @@ impl Client { ) -> Result { let mut client = self.inner.lock().unwrap(); - if opts.max_outstanding_slot_leases < 1 || opts.max_outstanding_slot_leases > opts.num_slots + if opts.server_defined { + if opts.slot_size != 0 + || opts.num_slots != 0 + || opts.fixed_size + || opts.checksum_size != 0 + || opts.metadata_size != 0 + || opts.subscriber_queue_arena_size != 0 + || opts.use_split_buffers + { + return Err(SubspaceError::InvalidArgument(format!( + "Publisher on {} is server defined, so it can't set the slot size, \ + number of slots, fixed size, checksum size, metadata size, \ + subscriber queue arena size or split buffers", + channel_name + ))); + } + // The server checks the leases against the channel's slots. + } else if opts.max_outstanding_slot_leases < 1 + || opts.max_outstanding_slot_leases > opts.num_slots { return Err(SubspaceError::InvalidArgument( "MaxOutstandingSlotLeases must be between 1 and NumSlots".into(), @@ -1263,6 +1281,7 @@ impl Client { } else { 0 }, + server_defined: opts.server_defined, }, )), }; @@ -1289,6 +1308,26 @@ impl Client { if pub_resp.num_slots > 0 { channel_opts.num_slots = pub_resp.num_slots; } + if opts.server_defined { + channel_opts.slot_size = pub_resp.slot_size; + channel_opts.fixed_size = pub_resp.is_fixed_size; + channel_opts.checksum_size = pub_resp.checksum_size; + channel_opts.metadata_size = pub_resp.metadata_size; + channel_opts.subscriber_queue_arena_size = pub_resp.subscriber_queue_arena_size; + channel_opts.use_split_buffers = pub_resp.use_split_buffers; + } + let slot_size = aligned64(channel_opts.slot_size); + let use_split_buffers = channel_opts.use_split_buffers; + let cs = if channel_opts.checksum_size > 0 { + channel_opts.checksum_size + } else { + 4 + }; + let ms = if channel_opts.metadata_size > 0 { + channel_opts.metadata_size + } else { + 0 + }; let mut pub_impl = PublisherImpl::new( channel_name.to_string(), channel_opts.num_slots, @@ -1301,24 +1340,11 @@ impl Client { String::from_utf8_lossy(&pub_resp.r#type).to_string(), channel_opts, ); - pub_impl.channel.use_split_buffers = opts.use_split_buffers; + pub_impl.channel.use_split_buffers = use_split_buffers; pub_impl.channel.split_buffer_callbacks = opts.split_buffer_callbacks.clone(); - - { - let cs = if opts.checksum_size > 0 { - opts.checksum_size - } else { - 4 - }; - let ms = if opts.metadata_size > 0 { - opts.metadata_size - } else { - 0 - }; - pub_impl.channel.checksum_size = cs; - pub_impl.channel.metadata_size = ms; - pub_impl.channel.prefix_size = compute_prefix_size(cs, ms); - } + pub_impl.channel.checksum_size = cs; + pub_impl.channel.metadata_size = ms; + pub_impl.channel.prefix_size = compute_prefix_size(cs, ms); let prot = ProtFlags::PROT_READ | ProtFlags::PROT_WRITE; pub_impl.channel.map( diff --git a/rust_client/src/options.rs b/rust_client/src/options.rs index c477c84a..d90a7aff 100644 --- a/rust_client/src/options.rs +++ b/rust_client/src/options.rs @@ -24,6 +24,8 @@ pub struct PublisherOptions { pub bridge: bool, pub for_tunnel: bool, pub fixed_size: bool, + /// See `set_server_defined`. + pub server_defined: bool, /// See `set_max_slot_size`. 0 means unlimited. pub max_slot_size: i64, pub channel_type: String, @@ -52,6 +54,7 @@ impl Default for PublisherOptions { bridge: false, for_tunnel: false, fixed_size: false, + server_defined: false, max_slot_size: 0, channel_type: String::new(), activate: false, @@ -61,7 +64,8 @@ impl Default for PublisherOptions { max_outstanding_slot_leases: 1, notify_retirement_on_forced_reuse: true, checksum: false, - checksum_size: 4, + // 0 means 4 bytes. + checksum_size: 0, metadata_size: 0, use_split_buffers: false, split_buffers_over_bridge: false, @@ -112,6 +116,16 @@ impl PublisherOptions { self } + /// The channel is in the server's static channel config, which defines + /// its layout. Publishers on static channels must set this and leave the + /// slot size, number of slots, fixed size, checksum size, metadata size, + /// subscriber queue arena size and split buffers unset. The publisher + /// takes them from the server. + pub fn set_server_defined(mut self, v: bool) -> Self { + self.server_defined = v; + self + } + /// Upper bound on how large the channel's slots may become. 0 (the /// default) means no limit. When set, the initial slot size must not /// exceed it, asking `get_message_buffer` for a larger buffer fails diff --git a/server/Android.bp b/server/Android.bp index 410318f3..e770d9c8 100644 --- a/server/Android.bp +++ b/server/Android.bp @@ -10,6 +10,7 @@ cc_library_static { "client_handler.cc", "server_channel.cc", "shadow_replicator.cc", + "static_config.cc", ], export_include_dirs: ["."], include_dirs: ["external/subspace"], diff --git a/server/BUILD.bazel b/server/BUILD.bazel index 50f0db9e..61df9b31 100644 --- a/server/BUILD.bazel +++ b/server/BUILD.bazel @@ -9,6 +9,7 @@ cc_library( "client_handler.cc", "server_channel.cc", "shadow_replicator.cc", + "static_config.cc", ], hdrs = [ "server.h", @@ -16,16 +17,20 @@ cc_library( "server_channel.h", "plugin.h", "shadow_replicator.h", + "static_config.h", ], deps = [ "//common:subspace_common", "//common/async:async", "//client:subspace_client", + "//proto:subspace_cc_proto", "@abseil-cpp//absl/container:flat_hash_map", "@abseil-cpp//absl/container:flat_hash_set", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", "@abseil-cpp//absl/strings:str_format", + "@protobuf//:protobuf", "@abseil-cpp//absl/flags:flag", "@abseil-cpp//absl/flags:parse", "@coroutines//co:co", @@ -61,6 +66,22 @@ cc_binary( }), ) +cc_test( + name = "static_config_test", + size = "small", + srcs = ["static_config_test.cc"], + deps = [ + ":server", + "//client:subspace_client", + "//client:test_fixture", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest", + "@coroutines//:co", + ], +) + cc_test( name = "server_test", size = "small", diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 0bb18283..c4945fc2 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -7,6 +7,8 @@ add_library(libserver STATIC server_channel.h shadow_replicator.cc shadow_replicator.h + static_config.cc + static_config.h server.cc server.h plugin.h diff --git a/server/client_handler.cc b/server/client_handler.cc index b42b0ad1..c2cc580c 100644 --- a/server/client_handler.cc +++ b/server/client_handler.cc @@ -4,6 +4,7 @@ #include "server/client_handler.h" #include "absl/strings/str_format.h" +#include "absl/strings/str_join.h" #include "client_handler.h" #include "server/server.h" #include @@ -11,6 +12,22 @@ namespace subspace { namespace { +// With a static channel config, clients can't create channels other than the +// server's own. Sets the response error and returns false for one they +// can't create. +template +bool StaticConfigAllowsNewChannel(const Server &server, + const std::string &channel_name, + Response *response) { + if (!server.HasStaticChannelConfig() || + Server::IsServerChannelName(channel_name)) { + return true; + } + response->set_error(absl::StrFormat( + "Channel %s isn't in the static channel config", channel_name)); + return false; +} + ClientBufferAllocatorKind FromProtoAllocator(ClientBufferAllocator allocator) { switch (allocator) { case CLIENT_BUFFER_ALLOCATOR_ANDROID_MEMFD: @@ -268,19 +285,7 @@ absl::Status ClientHandler::HandleRegisterClientBuffer( } fd = std::move(fds[size_t(req.fd_index())]); } - channel->RegisterClientBuffer(metadata, std::move(fd)); - server_->ForEachShadow([&](const std::unique_ptr &shadow) { - const auto matches = - channel->FindClientBuffers(metadata.session_id, metadata.buffer_index); - for (const RegisteredClientBuffer *buffer : matches) { - if (buffer->metadata.slot_id == metadata.slot_id && - buffer->metadata.is_prefix == metadata.is_prefix) { - shadow->SendRegisterClientBuffer(buffer->metadata, buffer->fd); - return; - } - } - shadow->SendRegisterClientBuffer(metadata); - }); + server_->RegisterClientBuffer(channel, std::move(metadata), std::move(fd)); return absl::OkStatus(); } @@ -325,6 +330,10 @@ void ClientHandler::HandleGetClientBuffers( } for (const RegisteredClientBuffer *buffer : channel->FindClientBuffers(req.session_id(), req.buffer_index())) { + if (req.filter_slot() && (buffer->metadata.is_prefix != req.is_prefix() || + buffer->metadata.slot_id != req.slot_id())) { + continue; + } ToProto(buffer->metadata, response->add_metadata()); if (buffer->fd.Valid()) { response->add_fd_indexes(static_cast(fds.size())); @@ -347,9 +356,76 @@ void ClientHandler::HandleInit(const subspace::InitRequest &req, } void ClientHandler::HandleCreatePublisher( - const subspace::CreatePublisherRequest &req, + const subspace::CreatePublisherRequest &request, subspace::CreatePublisherResponse *response, std::vector &fds) { + // A static channel's layout comes from the config, so its publishers set + // server_defined instead and the request is completed from the channel. + const ServerChannel *existing = server_->FindChannel(request.channel_name()); + const bool is_static = existing != nullptr && existing->IsStatic(); + if (is_static && !request.server_defined()) { + response->set_error(absl::StrFormat( + "Publishers on static channel %s must set server_defined", + request.channel_name())); + return; + } + if (!is_static && request.server_defined()) { + response->set_error(absl::StrFormat( + "Channel %s isn't in the static channel config, so its publishers " + "can't set server_defined", + request.channel_name())); + return; + } + subspace::CreatePublisherRequest server_defined_req; + if (is_static) { + std::vector layout_fields; + if (request.num_slots() != 0) { + layout_fields.push_back("num_slots"); + } + if (request.slot_size() != 0) { + layout_fields.push_back("slot_size"); + } + if (request.is_fixed_size()) { + layout_fields.push_back("fixed_size"); + } + if (request.checksum_size() != 0) { + layout_fields.push_back("checksum_size"); + } + if (request.metadata_size() != 0) { + layout_fields.push_back("metadata_size"); + } + if (request.subscriber_queue_arena_size() != 0) { + layout_fields.push_back("subscriber_queue_arena_size"); + } + if (request.use_split_buffers()) { + layout_fields.push_back("use_split_buffers"); + } + if (!layout_fields.empty()) { + response->set_error(absl::StrFormat( + "Static channel %s has a layout defined by the server; its " + "publishers can't set %s", + request.channel_name(), absl::StrJoin(layout_fields, ", "))); + return; + } + const ServerChannel *layout_channel = + existing->IsVirtual() + ? static_cast(existing)->GetMux() + : existing; + server_defined_req = request; + server_defined_req.set_num_slots(existing->NumSlots()); + server_defined_req.set_slot_size(existing->SlotSize()); + server_defined_req.set_is_fixed_size(true); + server_defined_req.set_checksum_size(existing->ChecksumSize()); + server_defined_req.set_metadata_size(existing->MetadataSize()); + server_defined_req.set_subscriber_queue_arena_size( + existing->SubscriberQueueArenaSize()); + server_defined_req.set_use_split_buffers( + layout_channel->HasSplitBufferOptions() && + layout_channel->GetSplitBufferOptions().use_split_buffers); + } + const subspace::CreatePublisherRequest &req = + is_static ? server_defined_req : request; + if (req.num_slots() <= 0 || req.slot_size() <= 0) { response->set_error("num_slots and slot_size must be greater than 0"); return; @@ -441,6 +517,10 @@ void ClientHandler::HandleCreatePublisher( absl::StrFormat("No such channel %s", req.channel_name())); return; } + if (channel == nullptr && + !StaticConfigAllowsNewChannel(*server_, req.channel_name(), response)) { + return; + } if (channel == nullptr) { server_->logger_.Log(toolbelt::LogLevel::kDebug, "Publisher %s is creating new channel %s with size " @@ -579,7 +659,9 @@ void ClientHandler::HandleCreatePublisher( } // Check consistency of publisher parameters. if (num_pubs > 0) { - if (req.is_fixed_size() != channel->IsFixedSize()) { + const ServerChannel::SharedPublisherTraits traits = + channel->GetSharedPublisherTraits(); + if (req.is_fixed_size() != traits.fixed_size) { response->set_error( absl::StrFormat("Inconsistent publisher parameters for channel %s: " "all publishers must be either fixed size or not", @@ -601,7 +683,7 @@ void ClientHandler::HandleCreatePublisher( } if (slot_size_changed) { if (slot_size_changed) { - if (channel->IsFixedSize()) { + if (traits.fixed_size) { // Fixed size channels cannot change size. response->set_error(absl::StrFormat( "Failed to add publisher to fixed size channel %s with different " @@ -620,7 +702,7 @@ void ClientHandler::HandleCreatePublisher( // Local subscribers don't count here: they make the channel local // without constraining publishers. - if (channel->HasLocalPublisher() != req.is_local()) { + if (traits.local != req.is_local()) { response->set_error( absl::StrFormat("Inconsistent publisher parameters for channel %s: " "all publishers must be either local or not", @@ -766,6 +848,13 @@ void ClientHandler::HandleCreatePublisher( response->set_subscriber_queue_arena_size( channel->SubscriberQueueArenaSize()); response->set_num_slots(channel->NumSlots()); + response->set_slot_size(channel->SlotSize()); + response->set_checksum_size(channel->ChecksumSize()); + response->set_metadata_size(channel->MetadataSize()); + response->set_use_split_buffers( + split_channel->HasSplitBufferOptions() && + split_channel->GetSplitBufferOptions().use_split_buffers); + response->set_is_fixed_size(req.is_fixed_size()); const SharedMemoryFds &channel_fds = channel->GetFds(); response->set_ccb_fd_index(0); @@ -876,6 +965,10 @@ void ClientHandler::HandleCreateSubscriber( return; } if (channel == nullptr) { + if (!StaticConfigAllowsNewChannel(*server_, req.channel_name(), + response)) { + return; + } // No channel exists, map an empty channel. server_->logger_.Log( toolbelt::LogLevel::kDebug, diff --git a/server/client_handler.h b/server/client_handler.h index 778baaba..294a5276 100644 --- a/server/client_handler.h +++ b/server/client_handler.h @@ -46,7 +46,7 @@ class ClientHandler { void HandleInit(const subspace::InitRequest &req, subspace::InitResponse *response, std::vector &fds); - void HandleCreatePublisher(const subspace::CreatePublisherRequest &req, + void HandleCreatePublisher(const subspace::CreatePublisherRequest &request, subspace::CreatePublisherResponse *response, std::vector &fds); void HandleCreateSubscriber(const subspace::CreateSubscriberRequest &req, diff --git a/server/main.cc b/server/main.cc index 13a29ad0..68bb1e15 100644 --- a/server/main.cc +++ b/server/main.cc @@ -5,6 +5,7 @@ #include "absl/flags/flag.h" #include "absl/flags/parse.h" #include "server.h" +#include "server/static_config.h" #include #include #include @@ -92,6 +93,10 @@ ABSL_FLAG(std::string, shadow_socket, "", "Primary shadow process Unix socket (empty = disabled)"); ABSL_FLAG(std::string, secondary_shadow_socket, "", "Secondary shadow process Unix socket (empty = disabled)"); +ABSL_FLAG(std::string, channel_config, "", + "StaticChannelConfig file in protobuf text format. The server " + "creates its channels at startup, fixes their layout and allows no " + "others (empty = channels are created on demand)"); // Look up this host's local vsock context id (CID) so we can advertise it to // bridge peers. Only meaningful on Linux with vsock support. @@ -271,6 +276,21 @@ int main(int argc, char **argv) { server->SetShadowSockets(absl::GetFlag(FLAGS_shadow_socket), absl::GetFlag(FLAGS_secondary_shadow_socket)); + if (const std::string &channel_config = absl::GetFlag(FLAGS_channel_config); + !channel_config.empty()) { + absl::StatusOr config = + subspace::ReadStaticChannelConfig(channel_config); + if (!config.ok()) { + fprintf(stderr, "%s\n", config.status().ToString().c_str()); + exit(1); + } + if (absl::Status status = server->SetStaticChannelConfig(*std::move(config)); + !status.ok()) { + fprintf(stderr, "%s\n", status.ToString().c_str()); + exit(1); + } + } + #if SUBSPACE_CORO_BACKEND == SUBSPACE_CORO_BACKEND_ASIO int num_asio_threads = absl::GetFlag(FLAGS_num_asio_threads); if (num_asio_threads < 1) { diff --git a/server/server.cc b/server/server.cc index d64ea278..2d4c7ce4 100644 --- a/server/server.cc +++ b/server/server.cc @@ -10,6 +10,7 @@ #include "client_handler.h" #include "common/split_buffer.h" #include "proto/subspace.pb.h" +#include "server/static_config.h" #include "toolbelt/clock.h" #include "toolbelt/hexdump.h" #include "toolbelt/sockets.h" @@ -41,6 +42,9 @@ bool ShouldClosePluginsOnShutdown() { return close_plugins_on_shutdown; } static constexpr int kTelemetrySlotSize = 1024; static constexpr int kTelemetryNumSlots = 32; +static constexpr char kChannelDirectoryChannel[] = "/subspace/ChannelDirectory"; +static constexpr char kStatisticsChannel[] = "/subspace/Statistics"; + static const ServerChannel *SplitBufferOptionsChannel( const ServerChannel *channel) { if (channel->IsVirtual()) { @@ -1006,6 +1010,12 @@ absl::Status Server::Run(int num_asio_threads) { s.ToString().c_str())); } + // Before any client is handled, so no client sees a static channel missing + // or as a placeholder. + if (absl::Status s = CreateStaticChannels(); !s.ok()) { + return s; + } + char hostname[256] = "local"; if (!local_) { if (gethostname(hostname, sizeof(hostname)) == -1) { @@ -1223,6 +1233,7 @@ Server::CreateMultiplexer(const std::string &channel_name, int64_t slot_size, *channel_id, channel_name, num_slots, subscriber_queue_size, subscriber_queue_arena_size, std::move(type), session_id_, logger_); channel->SetDebug(logger_.GetLogLevel() <= toolbelt::LogLevel::kVerboseDebug); + channel->SetLastKnownSlotSize(slot_size); // Establish the prefix layout before allocating. Allocation is what makes // the channel visible: it is immediately followed by replication to the // shadows, which copy the checksum and metadata sizes off the channel and @@ -1383,6 +1394,213 @@ Server::CreateChannel(const std::string &channel_name, int64_t slot_size, return channel; } +absl::Status Server::SetStaticChannelConfig(StaticChannelConfig config) { + if (absl::Status s = ValidateStaticChannelConfig(config); !s.ok()) { + return s; + } + static_config_ = std::move(config); + return absl::OkStatus(); +} + +bool Server::IsServerChannelName(const std::string &channel_name) { + return channel_name == kChannelDirectoryChannel || + channel_name == kStatisticsChannel; +} + +void Server::RegisterClientBuffer(ServerChannel *channel, + ClientBufferHandleMetadata metadata, + toolbelt::FileDescriptor fd) { + channel->RegisterClientBuffer(metadata, std::move(fd)); + ForEachShadow([&](const std::unique_ptr &shadow) { + const auto matches = + channel->FindClientBuffers(metadata.session_id, metadata.buffer_index); + for (const RegisteredClientBuffer *buffer : matches) { + if (buffer->metadata.slot_id == metadata.slot_id && + buffer->metadata.is_prefix == metadata.is_prefix) { + shadow->SendRegisterClientBuffer(buffer->metadata, buffer->fd); + return; + } + } + shadow->SendRegisterClientBuffer(metadata); + }); +} + +absl::Status Server::CreateStaticBuffers(ServerChannel *channel, + int64_t slot_size, + bool use_split_buffers) { + if (absl::Status s = channel->ValidateOrSetSplitBufferOptions( + {.use_split_buffers = use_split_buffers}, + /*set_if_missing=*/use_split_buffers, "static channel config"); + !s.ok()) { + return s; + } + if (channel->GetCcb()->num_buffers.load(std::memory_order_acquire) > 0) { + // Recovered from a shadow with its buffers. + return absl::OkStatus(); + } + // A publisher that is never registered with the channel creates the + // buffers through the same code as a client's first publisher. The + // buffers outlive it: named shared memory stays until the session is + // cleaned up, and the channel keeps the registered file descriptors. + details::PublisherImpl creator( + channel->Name(), channel->NumSlots(), channel->SubscriberQueueSize(), + channel->SubscriberQueueArenaSize(), channel->GetChannelId(), + /*publisher_id=*/-1, /*vchan_id=*/-1, session_id_, channel->Type(), + PublisherOptions() + .SetSlotSize(slot_size) + .SetNumSlots(channel->NumSlots()) + .SetFixedSize(true) + .SetUseSplitBuffers(use_split_buffers), + /*reload=*/nullptr, static_cast(getuid()), + static_cast(getgid())); + creator.SetChecksumSize(channel->ChecksumSize()); + creator.SetMetadataSize(channel->MetadataSize()); + creator.SetPrefixSize(channel->PrefixSize()); + creator.SetClientBufferRegistrationCallback( + [this, channel](const ClientBufferHandleMetadata &metadata, + const toolbelt::FileDescriptor *fd) { + RegisterClientBuffer(channel, metadata, + fd != nullptr ? *fd : toolbelt::FileDescriptor()); + return absl::OkStatus(); + }); + creator.SetClientBufferLookupCallback( + [channel](const std::string &, uint64_t session_id, + uint32_t buffer_index) + -> absl::StatusOr> { + std::vector buffers; + for (const RegisteredClientBuffer *buffer : + channel->FindClientBuffers(session_id, buffer_index)) { + buffers.push_back(*buffer); + } + return buffers; + }); + const SharedMemoryFds &fds = channel->GetFds(); + if (absl::Status s = creator.Map(SharedMemoryFds(fds.ccb, fds.bcb), scb_fd_); + !s.ok()) { + return s; + } + return creator.CreateOrAttachBuffers(slot_size); +} + +absl::Status Server::CreateStaticChannels() { + if (!static_config_.has_value()) { + return absl::OkStatus(); + } + // A server restarted from a shadow already has the channels; they must + // still be the ones configured. + auto check_recovered = [](const ServerChannel *channel, bool is_mux, + const std::string &mux, int vchan_id, + int64_t slot_size, int num_slots, + int32_t checksum_size, + int32_t metadata_size) -> absl::Status { + const bool matches = + !channel->IsPlaceholder() && channel->IsMux() == is_mux && + channel->IsVirtual() == !mux.empty() && + (mux.empty() || + (static_cast(channel)->GetMux()->Name() == + mux && + channel->GetVirtualChannelId() == vchan_id)) && + channel->SlotSize() == slot_size && channel->NumSlots() == num_slots && + channel->ChecksumSize() == checksum_size && + channel->MetadataSize() == metadata_size; + if (!matches) { + return absl::FailedPreconditionError(absl::StrFormat( + "Channel %s recovered from the shadow doesn't match the static " + "channel config", + channel->Name())); + } + return absl::OkStatus(); + }; + auto checksum_or_default = [](int32_t checksum_size) { + return checksum_size == 0 ? 4 : checksum_size; + }; + + absl::flat_hash_map muxes; + for (const StaticMultiplexer &m : static_config_->multiplexers()) { + muxes[m.name()] = &m; + const int64_t slot_size = Aligned(m.slot_size()); + const int32_t checksum_size = checksum_or_default(m.checksum_size()); + ServerChannel *mux = FindChannel(m.name()); + if (mux == nullptr) { + absl::StatusOr created = + CreateMultiplexer(m.name(), slot_size, m.num_slots(), 0, m.type(), + checksum_size, m.metadata_size()); + if (!created.ok()) { + return absl::InternalError( + absl::StrFormat("Failed to create static multiplexer %s: %s", + m.name(), created.status().ToString())); + } + mux = *created; + } else if (absl::Status s = check_recovered( + mux, /*is_mux=*/true, "", -1, slot_size, m.num_slots(), + checksum_size, m.metadata_size()); + !s.ok()) { + return s; + } + mux->SetStatic(true); + if (absl::Status s = + CreateStaticBuffers(mux, slot_size, m.use_split_buffers()); + !s.ok()) { + return absl::InternalError( + absl::StrFormat("Failed to create buffers for static multiplexer " + "%s: %s", + m.name(), s.ToString())); + } + } + + for (const StaticChannel &c : static_config_->channels()) { + const StaticMultiplexer *mux = + c.mux().empty() ? nullptr : muxes.at(c.mux()); + const int64_t slot_size = + Aligned(mux != nullptr ? mux->slot_size() : c.slot_size()); + const int num_slots = mux != nullptr ? mux->num_slots() : c.num_slots(); + const int32_t checksum_size = checksum_or_default( + mux != nullptr ? mux->checksum_size() : c.checksum_size()); + const int32_t metadata_size = + mux != nullptr ? mux->metadata_size() : c.metadata_size(); + const int vchan_id = mux != nullptr ? c.vchan_id() : -1; + ServerChannel *channel = FindChannel(c.name()); + if (channel == nullptr) { + // A virtual channel takes its layout from the multiplexer, which + // already has it. + absl::StatusOr created = + mux != nullptr + ? CreateChannel(c.name(), 0, 0, 0, c.mux(), vchan_id, "") + : CreateChannel(c.name(), slot_size, num_slots, 0, "", -1, + c.type(), /*hidden=*/false, + /*telemetry_target=*/{}, checksum_size, + metadata_size); + if (!created.ok()) { + return absl::InternalError( + absl::StrFormat("Failed to create static channel %s: %s", + c.name(), created.status().ToString())); + } + channel = *created; + } else if (absl::Status s = check_recovered( + channel, /*is_mux=*/false, c.mux(), vchan_id, slot_size, + num_slots, checksum_size, metadata_size); + !s.ok()) { + return s; + } + channel->SetStatic(true); + if (mux == nullptr) { + if (absl::Status s = + CreateStaticBuffers(channel, slot_size, c.use_split_buffers()); + !s.ok()) { + return absl::InternalError(absl::StrFormat( + "Failed to create buffers for static channel %s: %s", c.name(), + s.ToString())); + } + } + } + logger_.Log(toolbelt::LogLevel::kInfo, + "Static channel config: %d channels and %d multiplexers", + static_config_->channels_size(), + static_config_->multiplexers_size()); + SendChannelDirectory(); + return absl::OkStatus(); +} + uint64_t Server::GetVirtualMemoryUsage() const { uint64_t size = 0; for (const auto &channel : channels_) { @@ -2153,7 +2371,7 @@ void Server::RemoveChannel(ServerChannel *channel) { auto vchan = static_cast(it->second.get()); ChannelMultiplexer *mux = vchan->GetMux(); mux->RemoveVirtualChannel(vchan); - if (mux->IsEmpty()) { + if (mux->IsEmpty() && !mux->IsStatic()) { RemoveChannel(mux); } } @@ -2182,7 +2400,8 @@ void Server::RemoveAllUsersFor(ClientHandler *handler) { std::vector empty_channels; for (auto &channel : channels_) { channel.second->RemoveAllUsersFor(this, handler); - if (channel.second->IsEmpty() && !channel.second->IsTelemetryChannel()) { + if (channel.second->IsEmpty() && !channel.second->IsTelemetryChannel() && + !channel.second->IsStatic()) { empty_channels.push_back(channel.second.get()); } } @@ -2207,7 +2426,7 @@ void Server::ChannelDirectoryCoroutine(async::Context ctx) { constexpr int kDirectoryNumSlots = 32; absl::StatusOr channel_directory = client.CreatePublisher( - "/subspace/ChannelDirectory", kDirectorySlotSize, kDirectoryNumSlots, + kChannelDirectoryChannel, kDirectorySlotSize, kDirectoryNumSlots, PublisherOptions() .SetType("subspace.ChannelDirectory") .SetUseSplitBuffers(false)); @@ -2270,7 +2489,7 @@ void Server::StatisticsCoroutine(async::Context ctx) { constexpr int kStatsNumSlots = 32; absl::StatusOr pub = client.CreatePublisher( - "/subspace/Statistics", kStatsSlotSize, kStatsNumSlots, + kStatisticsChannel, kStatsSlotSize, kStatsNumSlots, PublisherOptions() .SetType("subspace.Statistics") .SetUseSplitBuffers(false)); @@ -3178,17 +3397,25 @@ void Server::BridgeReceiverCoroutine(async::Context ctx, if (bridge_ms < 0) { bridge_ms = 0; } - absl::StatusOr pub = client.CreatePublisher( - channel_name, subscribed.slot_size(), subscribed.num_slots(), - PublisherOptions() - .SetSubscriberQueueArenaSize( - subscribed.subscriber_queue_arena_size()) - .SetReliable(subscribed.reliable()) - .SetBridge(true) - .SetNotifyRetirement(subscribed.notify_retirement()) - .SetChecksumSize(bridge_cs) - .SetMetadataSize(bridge_ms) - .SetUseSplitBuffers(bridge_publisher_split_buffers)); + PublisherOptions bridge_options = PublisherOptions() + .SetReliable(subscribed.reliable()) + .SetBridge(true) + .SetNotifyRetirement( + subscribed.notify_retirement()); + // A static channel keeps the layout from this server's config. + if (const ServerChannel *local = FindChannel(channel_name); + local != nullptr && local->IsStatic()) { + bridge_options.SetServerDefined(true).SetUseSplitBuffers(false); + } else { + bridge_options.SetSlotSize(subscribed.slot_size()) + .SetNumSlots(subscribed.num_slots()) + .SetSubscriberQueueArenaSize(subscribed.subscriber_queue_arena_size()) + .SetChecksumSize(bridge_cs) + .SetMetadataSize(bridge_ms) + .SetUseSplitBuffers(bridge_publisher_split_buffers); + } + absl::StatusOr pub = + client.CreatePublisher(channel_name, bridge_options); if (!pub.ok()) { logger_.Log(toolbelt::LogLevel::kError, "Failed to create bridge publisher for %s: %s", diff --git a/server/server.h b/server/server.h index 28e3ec9b..bdbc3761 100644 --- a/server/server.h +++ b/server/server.h @@ -30,6 +30,7 @@ #include #include #include +#include #include namespace subspace { @@ -111,6 +112,15 @@ class Server { void SetShadowSocket(const std::string &socket_name); void SetShadowSockets(const std::string &primary, const std::string &secondary); + + // Call before Run(). Run() creates the configured channels before it + // accepts any clients, and from then on clients can use only those channels + // and the server's own. Fails if the config isn't valid. + absl::Status SetStaticChannelConfig(StaticChannelConfig config); + bool HasStaticChannelConfig() const { return static_config_.has_value(); } + // The channels the server publishes itself, which a static channel config + // doesn't list. + static bool IsServerChannelName(const std::string &channel_name); void SimulateCrash() { simulate_crash_ = true; for (auto &[name, ch] : channels_) { @@ -136,6 +146,12 @@ class Server { uint64_t GetSessionId() const { return session_id_; } + // Records a buffer that a publisher, or the server for a static channel, + // has created, and replicates it to the shadows. + void RegisterClientBuffer(ServerChannel *channel, + ClientBufferHandleMetadata metadata, + toolbelt::FileDescriptor fd); + absl::StatusOr CreateBridgeNotificationPipe(); struct BridgePortRange { @@ -299,6 +315,16 @@ class Server { absl::Status RecoverFromShadow(RecoveredState &state); + // Creates the static config's channels, or checks that channels recovered + // from a shadow match it, and marks them static. + absl::Status CreateStaticChannels(); + + // Creates the buffers of a static channel or multiplexer, as its first + // publisher would, so that subscribers can map them before any publisher + // exists. + absl::Status CreateStaticBuffers(ServerChannel *channel, int64_t slot_size, + bool use_split_buffers); + // Public iteration excludes internal telemetry transport channels. void ForeachChannel(std::function func); bool IsPublicChannel(const ServerChannel *channel) const { @@ -500,6 +526,7 @@ class Server { std::vector> plugins_; toolbelt::TriggerFd shutdown_trigger_fd_; std::string machine_name_; + std::optional static_config_; bool publish_server_channels_ = true; BridgePortRange bridge_port_range_; bool bridge_ports_fallback_to_ephemeral_ = false; diff --git a/server/server_channel.cc b/server/server_channel.cc index b5cfa031..eb9a9ec6 100644 --- a/server/server_channel.cc +++ b/server/server_channel.cc @@ -1156,7 +1156,7 @@ void ServerChannel::RemoveUser(Server *server, int user_id) { users_.erase(it); // The telemetry coroutine removes its hidden channel after its publisher is // fully destroyed. - if (IsEmpty() && !IsTelemetryChannel()) { + if (IsEmpty() && !IsTelemetryChannel() && !IsStatic()) { server->RemoveChannel(this); } server->SendChannelDirectory(); @@ -1638,6 +1638,18 @@ void ChannelMultiplexer::CountUsers(int &num_pubs, int &num_subs, num_tunnel_subs += total_tunnel_subs; } +ServerChannel::SharedPublisherTraits +ChannelMultiplexer::GetSharedPublisherTraits() const { + SharedPublisherTraits traits = ServerChannel::GetSharedPublisherTraits(); + for (const VirtualChannel *vchan : virtual_channels_) { + const SharedPublisherTraits v = + vchan->ServerChannel::GetSharedPublisherTraits(); + traits.fixed_size |= v.fixed_size; + traits.local |= v.local; + } + return traits; +} + void ChannelMultiplexer::CountCapacityUsage( int &max_active_messages, int &max_outstanding_slot_leases) const { ServerChannel::CountCapacityUsage(max_active_messages, diff --git a/server/server_channel.h b/server/server_channel.h index b2e1e782..67bc773d 100644 --- a/server/server_channel.h +++ b/server/server_channel.h @@ -238,6 +238,10 @@ class ServerChannel : public Channel { bool IsHidden() const { return hidden_; } bool IsTelemetryChannel() const { return !telemetry_target_.empty(); } const std::string &TelemetryTarget() const { return telemetry_target_; } + // A static channel comes from the server's static channel config. It + // keeps its layout and stays when it has no publishers or subscribers. + void SetStatic(bool v) { static_ = v; } + bool IsStatic() const { return static_; } absl::StatusOr AddPublisher(ClientHandler *handler, bool is_reliable, bool is_local, @@ -449,6 +453,16 @@ class ServerChannel : public Channel { bool IsReliable() const; bool IsFixedSize() const; + // Properties that every publisher sharing this channel's buffers must agree + // on. The virtual channels of a multiplexer all share its buffers. + struct SharedPublisherTraits { + bool fixed_size = false; + bool local = false; + }; + virtual SharedPublisherTraits GetSharedPublisherTraits() const { + return {IsFixedSize(), HasLocalPublisher()}; + } + bool HasSplitBufferOptions() const { return split_buffer_options_set_; } const SplitBufferOptions &GetSplitBufferOptions() const { return split_buffer_options_; @@ -534,6 +548,7 @@ class ServerChannel : public Channel { bool local_latched_ = false; bool skip_cleanup_ = false; bool hidden_ = false; + bool static_ = false; std::string telemetry_target_; int session_id_; mutable int64_t last_known_slot_size_ = 0; @@ -585,6 +600,7 @@ class ChannelMultiplexer : public ServerChannel { int &num_tunnel_subs) const override; void CountCapacityUsage(int &max_active_messages, int &max_outstanding_slot_leases) const override; + SharedPublisherTraits GetSharedPublisherTraits() const override; private: int next_vchan_id_ = 0; @@ -630,6 +646,9 @@ class VirtualChannel : public ServerChannel { mux_->CountUsers(num_pubs, num_subs, num_bridge_pubs, num_bridge_subs, num_tunnel_pubs, num_tunnel_subs); } + SharedPublisherTraits GetSharedPublisherTraits() const override { + return mux_->GetSharedPublisherTraits(); + } ChannelMultiplexer *GetMux() const { return mux_; } int GetVirtualChannelId() const override { return vchan_id_; } diff --git a/server/server_test.cc b/server/server_test.cc index 3b9018d8..91e6cb57 100644 --- a/server/server_test.cc +++ b/server/server_test.cc @@ -316,6 +316,28 @@ TEST_F(ServerTest, CreateSubscriberSuccess) { // CreatePublisher error paths // --------------------------------------------------------------------------- +TEST_F(ServerTest, ServerDefinedNeedsStaticChannel) { + RawConnection conn; + ASSERT_OK(conn.Connect(Socket())); + ASSERT_OK(conn.Init()); + + // Neither a new channel nor an existing one on a server without a static + // channel config has a layout that the server defines. + conn.CreatePublisher("server_defined_ch"); + for (const char *channel : {"server_defined_new", "server_defined_ch"}) { + subspace::Request req; + subspace::CreatePublisherRequest *cmd = req.mutable_create_publisher(); + cmd->set_channel_name(channel); + cmd->set_server_defined(true); + cmd->set_publisher_id(-1); + auto result = conn.Send(req); + ASSERT_OK(result); + EXPECT_THAT(result->first.create_publisher().error(), + ::testing::HasSubstr("can't set server_defined")) + << channel; + } +} + TEST_F(ServerTest, PubTypeMismatch) { RawConnection conn; ASSERT_OK(conn.Connect(Socket())); diff --git a/server/static_config.cc b/server/static_config.cc new file mode 100644 index 00000000..42b63e18 --- /dev/null +++ b/server/static_config.cc @@ -0,0 +1,158 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#include "server/static_config.h" + +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" +#include "absl/strings/match.h" +#include "absl/strings/str_format.h" +#include "common/channel.h" +#include "google/protobuf/text_format.h" +#include +#include + +namespace subspace { + +namespace { + +absl::Status ConfigError(const std::string &message) { + return absl::InvalidArgumentError("Static channel config: " + message); +} + +absl::Status ValidateName(const std::string &name, + absl::flat_hash_set &names) { + if (name.empty()) { + return ConfigError("every channel and multiplexer needs a name"); + } + if (absl::StartsWith(name, kServerChannelPrefix)) { + return ConfigError(absl::StrFormat( + "%s uses the server's reserved prefix %s", name, kServerChannelPrefix)); + } + if (!names.insert(name).second) { + return ConfigError(absl::StrFormat("%s is configured more than once", name)); + } + return absl::OkStatus(); +} + +absl::Status ValidateLayout(const std::string &name, int32_t slot_size, + int32_t num_slots, int32_t checksum_size, + int32_t metadata_size) { + if (slot_size <= 0 || num_slots <= 0) { + return ConfigError(absl::StrFormat( + "%s needs a slot_size and num_slots greater than 0, not %d and %d", + name, slot_size, num_slots)); + } + if (checksum_size < 0 || checksum_size > kMaxChecksumSize) { + return ConfigError( + absl::StrFormat("%s has checksum_size %d; it must be from 0 to %d", + name, checksum_size, kMaxChecksumSize)); + } + if (metadata_size < 0 || metadata_size > kMaxMetadataSize) { + return ConfigError( + absl::StrFormat("%s has metadata_size %d; it must be from 0 to %d", + name, metadata_size, kMaxMetadataSize)); + } + return absl::OkStatus(); +} + +} // namespace + +absl::Status ValidateStaticChannelConfig(const StaticChannelConfig &config) { + absl::flat_hash_set names; + // Virtual channel ids in use on each multiplexer. + absl::flat_hash_map> mux_vchan_ids; + + for (const StaticMultiplexer &mux : config.multiplexers()) { + if (absl::Status s = ValidateName(mux.name(), names); !s.ok()) { + return s; + } + if (absl::Status s = + ValidateLayout(mux.name(), mux.slot_size(), mux.num_slots(), + mux.checksum_size(), mux.metadata_size()); + !s.ok()) { + return s; + } + mux_vchan_ids[mux.name()]; + } + + for (const StaticChannel &channel : config.channels()) { + if (absl::Status s = ValidateName(channel.name(), names); !s.ok()) { + return s; + } + if (channel.mux().empty()) { + if (channel.vchan_id() != 0) { + return ConfigError(absl::StrFormat( + "%s has a vchan_id but no mux", channel.name())); + } + if (absl::Status s = ValidateLayout( + channel.name(), channel.slot_size(), channel.num_slots(), + channel.checksum_size(), channel.metadata_size()); + !s.ok()) { + return s; + } + continue; + } + auto ids = mux_vchan_ids.find(channel.mux()); + if (ids == mux_vchan_ids.end()) { + return ConfigError( + absl::StrFormat("%s uses mux %s, which isn't a configured " + "multiplexer", + channel.name(), channel.mux())); + } + if (channel.slot_size() != 0 || channel.num_slots() != 0 || + !channel.type().empty() || channel.checksum_size() != 0 || + channel.metadata_size() != 0 || channel.use_split_buffers()) { + return ConfigError(absl::StrFormat( + "%s is on mux %s and takes its slot_size, num_slots, type, " + "checksum_size, metadata_size and use_split_buffers from it; leave " + "them unset", + channel.name(), channel.mux())); + } + if (channel.vchan_id() < 0 || channel.vchan_id() >= kMaxVchanId) { + return ConfigError( + absl::StrFormat("%s has vchan_id %d; it must be from 0 to %d", + channel.name(), channel.vchan_id(), kMaxVchanId - 1)); + } + if (!ids->second.insert(channel.vchan_id()).second) { + return ConfigError(absl::StrFormat( + "%s reuses vchan_id %d on mux %s", channel.name(), + channel.vchan_id(), channel.mux())); + } + } + return absl::OkStatus(); +} + +absl::StatusOr +ParseStaticChannelConfig(const std::string &text) { + StaticChannelConfig config; + if (!google::protobuf::TextFormat::ParseFromString(text, &config)) { + return ConfigError("invalid protobuf text format"); + } + if (absl::Status s = ValidateStaticChannelConfig(config); !s.ok()) { + return s; + } + return config; +} + +absl::StatusOr +ReadStaticChannelConfig(const std::string &filename) { + std::ifstream in(filename); + if (!in) { + return absl::NotFoundError(absl::StrFormat( + "Static channel config: can't open %s", filename)); + } + std::stringstream text; + text << in.rdbuf(); + absl::StatusOr config = + ParseStaticChannelConfig(text.str()); + if (!config.ok()) { + return absl::Status(config.status().code(), + absl::StrFormat("%s: %s", filename, + config.status().message())); + } + return config; +} + +} // namespace subspace diff --git a/server/static_config.h b/server/static_config.h new file mode 100644 index 00000000..e3fe8d02 --- /dev/null +++ b/server/static_config.h @@ -0,0 +1,31 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#ifndef _xSERVERSTATIC_CONFIG_H +#define _xSERVERSTATIC_CONFIG_H + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "proto/subspace.pb.h" +#include + +namespace subspace { + +// Prefix of the channels the server creates for itself. A static channel +// config can't use it. +inline constexpr char kServerChannelPrefix[] = "/subspace/"; + +// Parses a StaticChannelConfig in protobuf text format and validates it. +absl::StatusOr +ParseStaticChannelConfig(const std::string &text); + +// Reads, parses and validates a StaticChannelConfig text format file. +absl::StatusOr +ReadStaticChannelConfig(const std::string &filename); + +absl::Status ValidateStaticChannelConfig(const StaticChannelConfig &config); + +} // namespace subspace + +#endif // _xSERVERSTATIC_CONFIG_H diff --git a/server/static_config_test.cc b/server/static_config_test.cc new file mode 100644 index 00000000..5cf55fb0 --- /dev/null +++ b/server/static_config_test.cc @@ -0,0 +1,488 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +// Tests for a server started with a static channel config: the configured +// channels exist before any client connects, they are the only channels +// clients can use, and their layout is fixed. + +#include "client/test_fixture.h" + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_format.h" +#include "proto/subspace.pb.h" +#include "server/static_config.h" +#include "toolbelt/sockets.h" +#include +#include +#include +#include +#include +#include + +namespace { + +using ::testing::HasSubstr; + +constexpr char kConfig[] = R"pb( + multiplexers { name: "/static/mux" slot_size: 128 num_slots: 8 type: "Mux" } + channels { name: "/static/a" slot_size: 256 num_slots: 8 type: "A" } + channels { name: "/static/meta" slot_size: 64 num_slots: 4 metadata_size: 16 } + channels { name: "/static/v1" mux: "/static/mux" vchan_id: 3 } + channels { name: "/static/v2" mux: "/static/mux" vchan_id: 7 } + channels { + name: "/static/split" + slot_size: 256 + num_slots: 4 + use_split_buffers: true + } + multiplexers { + name: "/static/split_mux" + slot_size: 128 + num_slots: 4 + use_split_buffers: true + } + channels { name: "/static/sv" mux: "/static/split_mux" vchan_id: 1 } +)pb"; + +subspace::PublisherOptions ServerDefined() { + return subspace::PublisherOptions().SetServerDefined(true); +} + +// Sends a request the way the clients do, bypassing the client library's own +// checks, and returns the server's response. +absl::StatusOr Transact(toolbelt::UnixSocket &socket, + const subspace::Request &req) { + std::vector buffer(sizeof(int32_t) + req.ByteSizeLong()); + char *payload = buffer.data() + sizeof(int32_t); + if (!req.SerializeToArray(payload, static_cast(req.ByteSizeLong()))) { + return absl::InternalError("Failed to serialize request"); + } + if (absl::StatusOr n = + socket.SendMessage(payload, req.ByteSizeLong()); + !n.ok()) { + return n.status(); + } + absl::StatusOr> received = + socket.ReceiveVariableLengthMessage(); + if (!received.ok()) { + return received.status(); + } + subspace::Response response; + if (!response.ParseFromArray(received->data(), + static_cast(received->size()))) { + return absl::InternalError("Failed to parse response"); + } + std::vector fds; + if (absl::Status status = socket.ReceiveFds(fds); !status.ok()) { + return status; + } + return response; +} + +void Publish(subspace::Publisher &pub, const std::string &text) { + absl::StatusOr buffer = pub.GetMessageBuffer(); + ASSERT_OK(buffer); + memcpy(*buffer, text.data(), text.size()); + ASSERT_OK(pub.PublishMessage(static_cast(text.size()))); +} + +class StaticConfigTest : public ::testing::Test { +public: + static void SetUpTestSuite() { +#if defined(__ANDROID__) + char socket_name_template[] = "/data/local/tmp/subspaceXXXXXX"; // NOLINT +#else + char socket_name_template[] = "/tmp/subspaceXXXXXX"; // NOLINT +#endif + ::close(mkstemp(&socket_name_template[0])); + socket_ = &socket_name_template[0]; + + (void)pipe(server_pipe_); + + server_ = std::make_unique( + engine_, socket_, "", 0, 0, + /*local=*/true, server_pipe_[1], /*initial_ordinal=*/1, + /*wait_for_clients=*/true); + absl::StatusOr config = + subspace::ParseStaticChannelConfig(kConfig); + ASSERT_OK(config); + ASSERT_OK(server_->SetStaticChannelConfig(*std::move(config))); + + server_thread_ = std::thread([]() { + absl::Status s = server_->Run(); + if (!s.ok()) { + fprintf(stderr, "Error running Subspace server: %s\n", + s.ToString().c_str()); + exit(1); + } + }); + + char buf[8]; + (void)::read(server_pipe_[0], buf, 8); + } + + static void TearDownTestSuite() { + server_->Stop(); + + char buf[8]; + (void)::read(server_pipe_[0], buf, 8); + server_thread_.join(); + server_->CleanupAfterSession(); + (void)remove(socket_.c_str()); + } + + void SetUp() override { + signal(SIGPIPE, SIG_IGN); + ASSERT_OK(client_.Init(socket_)); + } + + subspace::Client &Client() { return client_; } + static const std::string &Socket() { return socket_; } + +private: + inline static subspace::async::RuntimeEngine engine_; + inline static std::string socket_; + inline static int server_pipe_[2]; + inline static std::unique_ptr server_; + inline static std::thread server_thread_; + + subspace::Client client_; +}; + +TEST(StaticConfigParseTest, ParsesValidConfig) { + absl::StatusOr config = + subspace::ParseStaticChannelConfig(kConfig); + ASSERT_OK(config); + EXPECT_EQ(2, config->multiplexers_size()); + EXPECT_EQ(6, config->channels_size()); +} + +TEST(StaticConfigParseTest, RejectsInvalidConfigs) { + const std::vector> cases = { + {R"pb(channels { nme: "/x" })pb", "invalid protobuf text format"}, + {R"pb(channels { slot_size: 64 num_slots: 4 })pb", "needs a name"}, + {R"pb(channels { name: "/subspace/x" slot_size: 64 num_slots: 4 })pb", + "reserved prefix"}, + {R"pb(channels { name: "/x" slot_size: 64 num_slots: 4 } + channels { name: "/x" slot_size: 64 num_slots: 4 })pb", + "more than once"}, + {R"pb(multiplexers { name: "/x" slot_size: 64 num_slots: 4 } + channels { name: "/x" slot_size: 64 num_slots: 4 })pb", + "more than once"}, + {R"pb(channels { name: "/x" num_slots: 4 })pb", + "greater than 0"}, + {R"pb(channels { name: "/x" slot_size: 64 })pb", "greater than 0"}, + {R"pb(channels { name: "/x" slot_size: 64 num_slots: 4 + checksum_size: -1 })pb", + "checksum_size -1"}, + {R"pb(channels { name: "/x" slot_size: 64 num_slots: 4 + metadata_size: 100000 })pb", + "metadata_size 100000"}, + {R"pb(channels { name: "/x" slot_size: 64 num_slots: 4 vchan_id: 2 })pb", + "vchan_id but no mux"}, + {R"pb(channels { name: "/v" mux: "/m" })pb", + "isn't a configured multiplexer"}, + {R"pb(multiplexers { name: "/m" slot_size: 64 num_slots: 4 } + channels { name: "/v" mux: "/m" slot_size: 64 })pb", + "leave them unset"}, + {R"pb(multiplexers { name: "/m" slot_size: 64 num_slots: 4 } + channels { name: "/v" mux: "/m" use_split_buffers: true })pb", + "leave them unset"}, + {R"pb(multiplexers { name: "/m" slot_size: 64 num_slots: 4 } + channels { name: "/v" mux: "/m" vchan_id: 1023 })pb", + "vchan_id 1023"}, + {R"pb(multiplexers { name: "/m" slot_size: 64 num_slots: 4 } + channels { name: "/v1" mux: "/m" vchan_id: 1 } + channels { name: "/v2" mux: "/m" vchan_id: 1 })pb", + "reuses vchan_id 1"}, + }; + for (const auto &[text, error] : cases) { + absl::StatusOr config = + subspace::ParseStaticChannelConfig(text); + ASSERT_FALSE(config.ok()) << text; + EXPECT_THAT(config.status().message(), HasSubstr(error)) << text; + } +} + +TEST(StaticConfigParseTest, ReadMissingFile) { + absl::StatusOr config = + subspace::ReadStaticChannelConfig("/no/such/static_config.textproto"); + ASSERT_FALSE(config.ok()); + EXPECT_THAT(config.status().message(), HasSubstr("can't open")); +} + +TEST_F(StaticConfigTest, ChannelsExistBeforeAnyClient) { + absl::StatusOr info = + Client().GetChannelInfo("/static/a"); + ASSERT_OK(info); + EXPECT_EQ(256u, info->slot_size); + EXPECT_EQ(8, info->num_slots); + EXPECT_EQ("A", info->type); + EXPECT_EQ(0, info->num_publishers); + EXPECT_EQ(0, info->num_subscribers); + + absl::StatusOr vchan_info = + Client().GetChannelInfo("/static/v2"); + ASSERT_OK(vchan_info); + EXPECT_EQ(128u, vchan_info->slot_size); + EXPECT_EQ(8, vchan_info->num_slots); + EXPECT_EQ("Mux", vchan_info->type); +} + +TEST_F(StaticConfigTest, SubscriberBeforePublisher) { + absl::StatusOr sub = + Client().CreateSubscriber("/static/a"); + ASSERT_OK(sub); + EXPECT_EQ(8, sub->NumSlots()); + + absl::StatusOr pub = + Client().CreatePublisher("/static/a", ServerDefined()); + ASSERT_OK(pub); + Publish(*pub, "hello"); + + absl::StatusOr msg = sub->ReadMessage(); + ASSERT_OK(msg); + ASSERT_EQ(5, msg->length); + EXPECT_EQ("hello", + std::string(static_cast(msg->buffer), msg->length)); +} + +TEST_F(StaticConfigTest, SplitBuffersCreatedByServer) { + absl::StatusOr sub = + Client().CreateSubscriber("/static/split"); + ASSERT_OK(sub); + EXPECT_TRUE(sub->UsesSplitBuffers()); + + absl::StatusOr pub = + Client().CreatePublisher("/static/split", ServerDefined()); + ASSERT_OK(pub); + EXPECT_TRUE(pub->UsesSplitBuffers()); + EXPECT_EQ(256, pub->SlotSize()); + EXPECT_EQ(4, pub->NumSlots()); + for (int i = 0; i < 6; i++) { + std::string text = absl::StrFormat("split %d", i); + Publish(*pub, text); + absl::StatusOr msg = sub->ReadMessage(); + ASSERT_OK(msg); + EXPECT_EQ(text, + std::string(static_cast(msg->buffer), msg->length)); + } +} + +TEST_F(StaticConfigTest, SplitBufferVirtualChannel) { + absl::StatusOr sub = Client().CreateSubscriber( + "/static/sv", subspace::SubscriberOptions().SetMux("/static/split_mux")); + ASSERT_OK(sub); + EXPECT_TRUE(sub->UsesSplitBuffers()); + + absl::StatusOr pub = Client().CreatePublisher( + "/static/sv", ServerDefined().SetMux("/static/split_mux")); + ASSERT_OK(pub); + EXPECT_TRUE(pub->UsesSplitBuffers()); + Publish(*pub, "vchan"); + absl::StatusOr msg = sub->ReadMessage(); + ASSERT_OK(msg); + EXPECT_EQ("vchan", + std::string(static_cast(msg->buffer), msg->length)); +} + +TEST_F(StaticConfigTest, PublishersMustBeServerDefined) { + // A publisher that gives the configured layout itself is still rejected. + absl::StatusOr pub = Client().CreatePublisher( + "/static/a", 256, 8, subspace::PublisherOptions().SetFixedSize(true)); + ASSERT_FALSE(pub.ok()); + EXPECT_THAT(pub.status().message(), HasSubstr("must set server_defined")); + + // The client rejects layout options alongside server_defined. + for (const subspace::PublisherOptions &options : + {ServerDefined().SetSlotSize(256), ServerDefined().SetNumSlots(8), + ServerDefined().SetFixedSize(true), ServerDefined().SetChecksumSize(4), + ServerDefined().SetMetadataSize(16), + ServerDefined().SetSubscriberQueueArenaSize(4096), + ServerDefined().SetUseSplitBuffers(true)}) { + pub = Client().CreatePublisher("/static/a", options); + ASSERT_FALSE(pub.ok()); + EXPECT_EQ(absl::StatusCode::kInvalidArgument, pub.status().code()); + EXPECT_THAT(pub.status().message(), HasSubstr("is server defined")); + } +} + +TEST_F(StaticConfigTest, ServerRejectsLayoutFromServerDefinedPublishers) { + toolbelt::UnixSocket socket; + ASSERT_OK(socket.Connect(Socket())); + subspace::Request init; + init.mutable_init()->set_client_name("raw"); + ASSERT_OK(Transact(socket, init)); + + const std::vector< + std::pair> + cases = { + {"num_slots", + [](subspace::CreatePublisherRequest *r) { r->set_num_slots(8); }}, + {"slot_size", + [](subspace::CreatePublisherRequest *r) { r->set_slot_size(256); }}, + {"fixed_size", + [](subspace::CreatePublisherRequest *r) { + r->set_is_fixed_size(true); + }}, + {"checksum_size", + [](subspace::CreatePublisherRequest *r) { + r->set_checksum_size(4); + }}, + {"metadata_size", + [](subspace::CreatePublisherRequest *r) { + r->set_metadata_size(16); + }}, + {"subscriber_queue_arena_size", + [](subspace::CreatePublisherRequest *r) { + r->set_subscriber_queue_arena_size(4096); + }}, + {"use_split_buffers", + [](subspace::CreatePublisherRequest *r) { + r->set_use_split_buffers(true); + }}, + }; + for (const auto &[field, set] : cases) { + subspace::Request req; + subspace::CreatePublisherRequest *cmd = req.mutable_create_publisher(); + cmd->set_channel_name("/static/a"); + cmd->set_server_defined(true); + cmd->set_publisher_id(-1); + set(cmd); + absl::StatusOr response = Transact(socket, req); + ASSERT_OK(response); + EXPECT_THAT(response->create_publisher().error(), + HasSubstr("can't set " + field)) + << field; + } +} + +TEST_F(StaticConfigTest, PublisherTakesLayoutFromServer) { + absl::StatusOr pub = + Client().CreatePublisher("/static/meta", ServerDefined()); + ASSERT_OK(pub); + EXPECT_EQ(64, pub->SlotSize()); + EXPECT_EQ(4, pub->NumSlots()); + EXPECT_EQ(4, pub->ChecksumSize()); + EXPECT_EQ(16, pub->MetadataSize()); + EXPECT_FALSE(pub->UsesSplitBuffers()); + // The layout is fixed, so the publisher can't grow its slots. + EXPECT_FALSE(pub->GetMessageBuffer(128).ok()); + + // A second publisher is accepted after the first has created the + // channel's buffers. + absl::StatusOr first = + Client().CreatePublisher("/static/a", ServerDefined()); + ASSERT_OK(first); + Publish(*first, "x"); + absl::StatusOr second = + Client().CreatePublisher("/static/a", ServerDefined()); + ASSERT_OK(second); +} + +TEST_F(StaticConfigTest, UnconfiguredChannelsAreRejected) { + absl::StatusOr pub = + Client().CreatePublisher("/not/configured", ServerDefined()); + ASSERT_FALSE(pub.ok()); + EXPECT_THAT(pub.status().message(), + HasSubstr("isn't in the static channel config")); + + absl::StatusOr sub = + Client().CreateSubscriber("/not/configured"); + ASSERT_FALSE(sub.ok()); + EXPECT_THAT(sub.status().message(), + HasSubstr("isn't in the static channel config")); + + // A virtual channel on a configured multiplexer must be configured too. + pub = Client().CreatePublisher("/static/v9", + ServerDefined().SetMux("/static/mux")); + ASSERT_FALSE(pub.ok()); + EXPECT_THAT(pub.status().message(), + HasSubstr("isn't in the static channel config")); +} + +TEST_F(StaticConfigTest, ChannelsOutliveTheirUsers) { + { + absl::StatusOr pub = + Client().CreatePublisher("/static/a", ServerDefined()); + ASSERT_OK(pub); + absl::StatusOr sub = + Client().CreateSubscriber("/static/a"); + ASSERT_OK(sub); + Publish(*pub, "before"); + } + absl::StatusOr info = + Client().GetChannelInfo("/static/a"); + ASSERT_OK(info); + EXPECT_EQ(0, info->num_publishers); + EXPECT_EQ(0, info->num_subscribers); + EXPECT_EQ(256u, info->slot_size); + EXPECT_EQ(8, info->num_slots); + + absl::StatusOr pub = + Client().CreatePublisher("/static/a", ServerDefined()); + ASSERT_OK(pub); + absl::StatusOr sub = + Client().CreateSubscriber("/static/a"); + ASSERT_OK(sub); + Publish(*pub, "after"); + absl::StatusOr msg = + sub->ReadMessage(subspace::ReadMode::kReadNewest); + ASSERT_OK(msg); + EXPECT_EQ("after", + std::string(static_cast(msg->buffer), msg->length)); +} + +TEST_F(StaticConfigTest, VirtualChannels) { + absl::StatusOr sub = Client().CreateSubscriber( + "/static/v1", subspace::SubscriberOptions().SetMux("/static/mux")); + ASSERT_OK(sub); + EXPECT_EQ(3, sub->VirtualChannelId()); + + absl::StatusOr pub = Client().CreatePublisher( + "/static/v1", ServerDefined().SetMux("/static/mux")); + ASSERT_OK(pub); + EXPECT_EQ(128, pub->SlotSize()); + EXPECT_EQ(8, pub->NumSlots()); + EXPECT_EQ(3, pub->VirtualChannelId()); + Publish(*pub, "virtual"); + + absl::StatusOr msg = sub->ReadMessage(); + ASSERT_OK(msg); + EXPECT_EQ("virtual", + std::string(static_cast(msg->buffer), msg->length)); + + // Publishers on other virtual channels of the multiplexer share its + // buffers, so they are also fixed size. + absl::StatusOr other = Client().CreatePublisher( + "/static/v2", ServerDefined().SetMux("/static/mux")); + ASSERT_OK(other); + EXPECT_EQ(7, other->VirtualChannelId()); +} + +TEST_F(StaticConfigTest, ServerChannelsArePublished) { + for (const char *name : + {"/subspace/ChannelDirectory", "/subspace/Statistics"}) { + int num_publishers = 0; + for (int i = 0; i < 100 && num_publishers == 0; i++) { + absl::StatusOr info = + Client().GetChannelInfo(name); + if (info.ok()) { + num_publishers = info->num_publishers; + } + if (num_publishers == 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } + EXPECT_EQ(1, num_publishers) << name; + } +} + +} // namespace + +int main(int argc, char **argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}