From 26154351d2178b9466bc6259e0c5613aca7a94b1 Mon Sep 17 00:00:00 2001 From: Dave Allison Date: Thu, 8 Oct 2026 18:18:41 -0700 Subject: [PATCH 1/5] Add a static channel config to the server subspace_server --channel_config=FILE reads a StaticChannelConfig in protobuf text format and creates its channels and multiplexers before the server accepts any clients, so a subscriber that starts first gets the full channel layout instead of a placeholder. With a config loaded, clients can use only the configured channels and the server's own /subspace/ChannelDirectory and /subspace/Statistics. The configured channels stay when they have no users, and their layout is fixed: publishers must be fixed size and match the configured slot size, number of slots, checksum size and metadata size. Channels recovered from a shadow must match the config. A multiplexer with no publishers now reports its slot size. --- CHANGELOG.md | 9 + README.md | 33 ++++ proto/subspace.proto | 37 ++++ server/Android.bp | 1 + server/BUILD.bazel | 21 +++ server/CMakeLists.txt | 2 + server/client_handler.cc | 51 ++++++ server/main.cc | 20 +++ server/server.cc | 135 +++++++++++++- server/server.h | 15 ++ server/server_channel.cc | 2 +- server/server_channel.h | 5 + server/static_config.cc | 157 ++++++++++++++++ server/static_config.h | 31 ++++ server/static_config_test.cc | 335 +++++++++++++++++++++++++++++++++++ 15 files changed, 849 insertions(+), 5 deletions(-) create mode 100644 server/static_config.cc create mode 100644 server/static_config.h create mode 100644 server/static_config_test.cc diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d5e24d..04bf5fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 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 their layout + is fixed: publishers must be fixed size and match the configured slot + size, number of slots, checksum size and metadata size. +- A multiplexer with no publishers now reports its slot size. + ### 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/README.md b/README.md index ff7c8436..de4f2d4c 100644 --- a/README.md +++ b/README.md @@ -2008,6 +2008,38 @@ 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 before it accepts clients, so a +subscriber that starts first gets the full channel layout instead of a +placeholder. With a config loaded: + +- Clients can use only the configured channels and the server's own + `/subspace/ChannelDirectory` and `/subspace/Statistics`. 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 layout is fixed. Publishers must be fixed size (`SetFixedSize(true)`) + and match the configured slot size, number of slots, checksum size and + metadata size. +- A channel on a multiplexer takes its slot size, number of slots, type and + prefix layout 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. + ## Shadow Server (Crash Recovery) Subspace supports a **shadow process** that mirrors the server's channel, @@ -2083,6 +2115,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/proto/subspace.proto b/proto/subspace.proto index bcfc681b..8741ba4d 100644 --- a/proto/subspace.proto +++ b/proto/subspace.proto @@ -647,3 +647,40 @@ 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 and +// metadata size. +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 StaticChannel { + string name = 1; + // A channel on a multiplexer leaves slot_size, num_slots, type, + // checksum_size and metadata_size 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 StaticChannelConfig { + repeated StaticMultiplexer multiplexers = 1; + repeated StaticChannel channels = 2; +} 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..cbf733f6 100644 --- a/server/client_handler.cc +++ b/server/client_handler.cc @@ -11,6 +11,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: @@ -441,6 +457,37 @@ 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 && channel->IsStatic()) { + // Resizing happens in the publisher, so only a fixed size publisher keeps + // the configured layout. + if (!req.is_fixed_size()) { + response->set_error(absl::StrFormat( + "Publishers on static channel %s must be fixed size", + req.channel_name())); + return; + } + if (req.slot_size() != channel->SlotSize() || + req.num_slots() != channel->NumSlots()) { + response->set_error(absl::StrFormat( + "Static channel %s has %d slots of %d bytes, not %d slots of %d " + "bytes", + req.channel_name(), channel->NumSlots(), channel->SlotSize(), + req.num_slots(), req.slot_size())); + return; + } + if (cs != channel->ChecksumSize() || ms != channel->MetadataSize()) { + response->set_error(absl::StrFormat( + "Static channel %s has checksum_size %d and metadata_size %d, not " + "%d and %d", + req.channel_name(), channel->ChecksumSize(), channel->MetadataSize(), + cs, ms)); + return; + } + } if (channel == nullptr) { server_->logger_.Log(toolbelt::LogLevel::kDebug, "Publisher %s is creating new channel %s with size " @@ -876,6 +923,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/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..bbd51983 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,121 @@ 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; +} + +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); + } + + 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); + } + 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 +2279,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 +2308,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 +2334,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 +2397,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)); diff --git a/server/server.h b/server/server.h index 28e3ec9b..cfa7aeea 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_) { @@ -299,6 +309,10 @@ 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(); + // Public iteration excludes internal telemetry transport channels. void ForeachChannel(std::function func); bool IsPublicChannel(const ServerChannel *channel) const { @@ -500,6 +514,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..80f8ff59 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(); diff --git a/server/server_channel.h b/server/server_channel.h index b2e1e782..0a06b5d4 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, @@ -534,6 +538,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; diff --git a/server/static_config.cc b/server/static_config.cc new file mode 100644 index 00000000..2407e072 --- /dev/null +++ b/server/static_config.cc @@ -0,0 +1,157 @@ +// 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) { + return ConfigError(absl::StrFormat( + "%s is on mux %s and takes its slot_size, num_slots, type, " + "checksum_size and metadata_size 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..f6ac933c --- /dev/null +++ b/server/static_config_test.cc @@ -0,0 +1,335 @@ +// 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 "server/static_config.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 } +)pb"; + +subspace::PublisherOptions FixedSize() { + return subspace::PublisherOptions().SetFixedSize(true); +} + +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_; } + +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(1, config->multiplexers_size()); + EXPECT_EQ(4, 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" 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", 256, 8, FixedSize()); + 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, UnconfiguredChannelsAreRejected) { + absl::StatusOr pub = + Client().CreatePublisher("/not/configured", 64, 4, FixedSize()); + 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", 128, 8, + FixedSize().SetMux("/static/mux")); + ASSERT_FALSE(pub.ok()); + EXPECT_THAT(pub.status().message(), + HasSubstr("isn't in the static channel config")); +} + +TEST_F(StaticConfigTest, PublisherLayoutMustMatch) { + absl::StatusOr pub = Client().CreatePublisher( + "/static/a", 256, 8, subspace::PublisherOptions()); + ASSERT_FALSE(pub.ok()); + EXPECT_THAT(pub.status().message(), HasSubstr("must be fixed size")); + + for (auto [slot_size, num_slots] : + std::vector>{{512, 8}, {128, 8}, {256, 16}, + {256, 4}}) { + pub = Client().CreatePublisher("/static/a", slot_size, num_slots, + FixedSize()); + ASSERT_FALSE(pub.ok()) << slot_size << "/" << num_slots; + EXPECT_THAT(pub.status().message(), + HasSubstr("has 8 slots of 256 bytes")); + } + + pub = Client().CreatePublisher("/static/meta", 64, 4, FixedSize()); + ASSERT_FALSE(pub.ok()); + EXPECT_THAT(pub.status().message(), + HasSubstr("checksum_size 4 and metadata_size 16, not 4 and 0")); + pub = Client().CreatePublisher("/static/meta", 64, 4, + FixedSize().SetMetadataSize(16)); + ASSERT_OK(pub); + + // A matching second publisher is accepted after the first has created the + // channel's buffers. + absl::StatusOr first = + Client().CreatePublisher("/static/a", 256, 8, FixedSize()); + ASSERT_OK(first); + Publish(*first, "x"); + absl::StatusOr second = + Client().CreatePublisher("/static/a", 256, 8, FixedSize()); + ASSERT_OK(second); +} + +TEST_F(StaticConfigTest, ChannelsOutliveTheirUsers) { + { + absl::StatusOr pub = + Client().CreatePublisher("/static/a", 256, 8, FixedSize()); + 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", 256, 8, FixedSize()); + 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", 256, 8, FixedSize().SetMux("/static/mux")); + ASSERT_FALSE(pub.ok()); + EXPECT_THAT(pub.status().message(), HasSubstr("has 8 slots of 128 bytes")); + + pub = Client().CreatePublisher("/static/v1", 128, 8, + FixedSize().SetMux("/static/mux")); + ASSERT_OK(pub); + 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)); +} + +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(); +} From f06c6ae5e4375a19c8c9869e2e7c5f3b54eefa50 Mon Sep 17 00:00:00 2001 From: Dave Allison Date: Fri, 9 Oct 2026 11:27:53 -0700 Subject: [PATCH 2/5] Add the ASIL client asil_client/ is a C++17 client for safety-related software. It shares unreliable, fixed-size channels with the standard clients through the same shared memory protocol, using only the standard library and POSIX. It is built without exceptions or RTTI, returns an error code from every operation, and makes no heap allocations. - Publishers support checksums, metadata and activation messages. Subscribers support kReadNext and kReadNewest, dropped message counts, checksum checking and activation messages. - The server handshake is behind the ServerConnection interface. PhaserServerConnection implements the standard protocol with phaser messages in fixed buffers, sent and received in protobuf wire format, so the server sees the same bytes as from the other clients. - Bazel, CMake and Soong build phaser and generate subspace_phaser from subspace.proto. Android CMake cross-compiles take a host protoc-gen-phaser in PHASER_PLUGIN_EXECUTABLE. - Bazel moves to Abseil 20250814.1, protobuf 33.4, cpp_toolbelt 2.1.6 and phaser 2.1.3, with cpp_toolbelt and phaser pinned by git_override until they are in the Bazel Central Registry. CMake uses the same commits. - layout_test checks the ASIL client's view of shared memory against common/channel.h. asil_client_test exchanges messages with the standard client in both directions, checks that setup and teardown make no heap allocations, and checks that an oversized request leaves the connection usable. It is skipped on memfd builds, which the ASIL client doesn't support yet. --- .cursor/skills/subspace-builds/SKILL.md | 5 +- .github/workflows/ci.yml | 7 +- Android.bp | 58 ++ CHANGELOG.md | 18 + CMakeLists.txt | 16 +- MODULE.bazel | 28 +- README.md | 4 + asil_client/Android.bp | 36 ++ asil_client/BUILD.bazel | 88 +++ asil_client/CMakeLists.txt | 53 ++ asil_client/asil_client_test.cc | 691 ++++++++++++++++++++++++ asil_client/bitset.h | 101 ++++ asil_client/channel_memory.cc | 565 +++++++++++++++++++ asil_client/channel_memory.h | 114 ++++ asil_client/checksum.cc | 44 ++ asil_client/checksum.h | 19 + asil_client/client.cc | 44 ++ asil_client/client.h | 75 +++ asil_client/error.cc | 47 ++ asil_client/error.h | 50 ++ asil_client/layout_test.cc | 197 +++++++ asil_client/phaser_connection.cc | 537 ++++++++++++++++++ asil_client/phaser_connection.h | 76 +++ asil_client/publisher.cc | 408 ++++++++++++++ asil_client/publisher.h | 98 ++++ asil_client/server_connection.h | 111 ++++ asil_client/shm_layout.h | 236 ++++++++ asil_client/subscriber.cc | 396 ++++++++++++++ asil_client/subscriber.h | 111 ++++ asil_client/unique_fd.h | 80 +++ docs/android.md | 34 +- docs/asil-client.md | 206 +++++++ external/phaser/Android.bp.example | 59 ++ proto/BUILD.bazel | 13 + proto/CMakeLists.txt | 23 + 35 files changed, 4627 insertions(+), 21 deletions(-) create mode 100644 asil_client/Android.bp create mode 100644 asil_client/BUILD.bazel create mode 100644 asil_client/CMakeLists.txt create mode 100644 asil_client/asil_client_test.cc create mode 100644 asil_client/bitset.h create mode 100644 asil_client/channel_memory.cc create mode 100644 asil_client/channel_memory.h create mode 100644 asil_client/checksum.cc create mode 100644 asil_client/checksum.h create mode 100644 asil_client/client.cc create mode 100644 asil_client/client.h create mode 100644 asil_client/error.cc create mode 100644 asil_client/error.h create mode 100644 asil_client/layout_test.cc create mode 100644 asil_client/phaser_connection.cc create mode 100644 asil_client/phaser_connection.h create mode 100644 asil_client/publisher.cc create mode 100644 asil_client/publisher.h create mode 100644 asil_client/server_connection.h create mode 100644 asil_client/shm_layout.h create mode 100644 asil_client/subscriber.cc create mode 100644 asil_client/subscriber.h create mode 100644 asil_client/unique_fd.h create mode 100644 docs/asil-client.md create mode 100644 external/phaser/Android.bp.example 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 04bf5fdc..4cb601be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ size, number of slots, checksum size and metadata size. - A multiplexer with no publishers now reports its slot size. +### ASIL Client +- `asil_client/` is a new C++17 client for safety-related software. It + shares unreliable, fixed-size channels with the standard clients through + the same shared memory protocol, using only the standard library and + POSIX. It is built without exceptions or RTTI, returns an error code from + every operation, and 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`. +- Bazel, CMake and Soong build phaser and generate `subspace_phaser` from + `subspace.proto`. Android CMake cross-compiles take a host + `protoc-gen-phaser` in `PHASER_PLUGIN_EXECUTABLE`. +- 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..4c56c196 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,18 @@ 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 ASIL client talks to the server with phaser messages held in fixed +# buffers. 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 +269,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 de4f2d4c..4844e80c 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) @@ -2040,6 +2041,9 @@ placeholder. With a config loaded: - 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, 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..dab9dc9f --- /dev/null +++ b/asil_client/BUILD.bazel @@ -0,0 +1,88 @@ +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", + "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", + "//proto:subspace_phaser", + ], +) + +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..8bff0e59 --- /dev/null +++ b/asil_client/CMakeLists.txt @@ -0,0 +1,53 @@ +# 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 server connection needs the phaser messages, which proto/ skips when +# cross-compiling without a host phaser plugin. +if(NOT TARGET subspace_phaser) + message(STATUS "Skipping the ASIL server connection: subspace_phaser is not available.") + return() +endif() + +# 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..50d86e88 --- /dev/null +++ b/asil_client/asil_client_test.cc @@ -0,0 +1,691 @@ +// 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/subscriber.h" +#include "server/static_config.h" +#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/no_alloc" slot_size: 64 num_slots: 4 } + channels { name: "/asil/oversize" slot_size: 64 num_slots: 4 } +)pb"; + +subspace::PublisherOptions FixedSize() { + return subspace::PublisherOptions().SetFixedSize(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; +} + +class AsilClientTest : 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 { +#if ASIL_SHM_MODE == ASIL_SHM_MEMFD + GTEST_SKIP() << "The ASIL client does not support the memfd backend"; +#endif + 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")); + } + + subspace::Client &Std() { return std_client_; } + asil::Client &Asil() { return asil_client_; } + asil::PhaserServerConnection &Connection() { return connection_; } + +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 std_client_; + asil::PhaserServerConnection connection_; + asil::Client asil_client_; +}; + +TEST_F(AsilClientTest, AsilPublisherStandardSubscriber) { + absl::StatusOr sub = + Std().CreateSubscriber("/asil/to_std"); + ASSERT_OK(sub); + + asil::Publisher pub; + asil::PublisherOptions options = AsilPubOptions(256, 8); + 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", 256, 8, FixedSize()); + 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", AsilPubOptions(64, 4), 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", AsilPubOptions(64, 4), 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 = AsilPubOptions(128, 4); + 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", 128, 4, FixedSize().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; + asil::PublisherOptions options = AsilPubOptions(64, 8); + options.metadata_size = 16; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/meta", options, 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", 64, 8, FixedSize().SetMetadataSize(16)); + 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", 64, 8, FixedSize()); + 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", 64, 4, FixedSize()); + 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", AsilPubOptions(64, 4), 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 = AsilPubOptions(64, 4); + 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", AsilPubOptions(64, 4), 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", 64, 4, FixedSize()); + 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) { + asil::Publisher pub; + EXPECT_EQ(asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/reject", AsilPubOptions(128, 4), pub)); + EXPECT_THAT(Asil().LastServerError(), HasSubstr("has 4 slots of 64 bytes")); + EXPECT_FALSE(pub.IsOpen()); + + EXPECT_EQ(asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/unknown", AsilPubOptions(64, 4), 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)); + + EXPECT_EQ(asil::Error::kInvalidArgument, + Asil().CreatePublisher("/asil/reject", AsilPubOptions(0, 4), pub)); + EXPECT_EQ(asil::Error::kCapacityExceeded, + Asil().CreatePublisher(std::string(300, 'x').c_str(), + AsilPubOptions(64, 4), pub)); + + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/reject", AsilPubOptions(64, 4), pub)); + EXPECT_EQ(asil::Error::kAlreadyInitialized, + Asil().CreatePublisher("/asil/reject", AsilPubOptions(64, 4), pub)); + EXPECT_EQ(asil::Error::kMessageTooLarge, pub.Publish(65)); + + asil::Client uninitialized; + asil::Publisher other; + EXPECT_EQ(asil::Error::kNotInitialized, + uninitialized.CreatePublisher("/asil/reject", + AsilPubOptions(64, 4), other)); +} + +TEST_F(AsilClientTest, VirtualChannelsAreUnsupported) { + asil::Subscriber sub; + const asil::Error e = Asil().CreateSubscriber("/asil/v1", {}, sub); + EXPECT_NE(asil::Error::kOk, e); + EXPECT_FALSE(sub.IsOpen()); +} + +TEST_F(AsilClientTest, CloseRemovesFromServer) { + asil::Publisher pub; + asil::Subscriber sub; + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/close", AsilPubOptions(64, 4), 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", AsilPubOptions(64, 4), 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", AsilPubOptions(64, 4), 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)); +} + +} // 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..747d33ea --- /dev/null +++ b/asil_client/channel_memory.cc @@ -0,0 +1,565 @@ +// 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 + +namespace subspace { +namespace asil { +namespace internal { + +namespace { + +constexpr size_t kMaxCcbSize = size_t{1} << 30; + +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); +} + +#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 + +} // 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 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; +} + +Error ChannelMemory::Map(shm::SystemControlBlock *scb, int ccb_fd, int bcb_fd, + int num_slots, int32_t checksum_size, + int32_t metadata_size) { + if (ccb_ != nullptr) { + return Error::kAlreadyInitialized; + } + if (num_slots <= 0) { + // The channel has no layout yet. Static channels always have one. + return Error::kUnsupported; + } + const size_t ccb_size = shm::CcbSize(num_slots, 0); + 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); + ccb_size_ = ccb_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() { + if (buffer_ != nullptr) { + (void)::munmap(buffer_, buffer_size_); + buffer_ = nullptr; + buffer_size_ = 0; + slot_size_ = 0; + } + 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; + num_slots_ = 0; +} + +Error ChannelMemory::BufferName(const char *channel_name, uint64_t session_id, + char (&out)[kMaxChannelNameLength + 64]) const { + char sanitized[kMaxChannelNameLength + 1]; + if (Error e = CopyName(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(session_id), sanitized, 0); + if (n < 0 || static_cast(n) >= sizeof(out)) { + return Error::kCapacityExceeded; + } + return Error::kOk; +} + +Error ChannelMemory::MapBuffer(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 < 100; 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; + } + buffer_ = static_cast(p); + buffer_size_ = size; + slot_size_ = static_cast((size - prefixes) / + static_cast(num_slots_)); + return Error::kOk; +} + +Error ChannelMemory::CreateOrAttachBuffer(const char *channel_name, + uint64_t session_id, + int64_t slot_size, int32_t user_id, + int32_t group_id) { + if (ccb_ == nullptr) { + return Error::kNotInitialized; + } + if (buffer_ != nullptr) { + return Error::kAlreadyInitialized; + } + int num_buffers = ccb_->num_buffers.load(std::memory_order_relaxed); + if (num_buffers > 1) { + // The channel has been resized. + return Error::kUnsupported; + } + const uint64_t full_size = + static_cast(num_slots_) * + (static_cast(slot_size) + static_cast(prefix_size_)); + + char name[kMaxChannelNameLength + 64]; + if (Error e = BufferName(channel_name, session_id, name); e != Error::kOk) { + return e; + } + const bool root = ::getuid() == 0; + uint64_t size = full_size; +#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; + } + int fd = OpenShm(shm_name, O_RDWR | O_CREAT | O_EXCL); + if (fd >= 0) { + if (::ftruncate(fd, static_cast(full_size)) != 0 || + ::chmod(name, 0777) != 0 || + (root && ::chown(name, static_cast(user_id), + static_cast(group_id)) != 0)) { + (void)::close(fd); + return Error::kSharedMemoryError; + } + } else if (errno == EEXIST) { + fd = OpenShm(shm_name, O_RDWR); + struct stat st; + if (fd < 0 || ::stat(name, &st) != 0) { + if (fd >= 0) { + (void)::close(fd); + } + return Error::kSharedMemoryError; + } + size = static_cast(st.st_size); + } else { + return Error::kSharedMemoryError; + } +#elif ASIL_SHM_MODE == ASIL_SHM_LINUX + int fd = OpenShm(name, O_RDWR | O_CREAT | O_EXCL); + if (fd >= 0) { + char path[kMaxChannelNameLength + 80]; + (void)std::snprintf(path, sizeof(path), "/dev/shm/%s", name); + if (::ftruncate(fd, static_cast(full_size)) != 0 || + ::chmod(path, 0777) != 0 || + (root && ::chown(path, static_cast(user_id), + static_cast(group_id)) != 0)) { + (void)::close(fd); + return Error::kSharedMemoryError; + } + } else if (errno == EEXIST) { + fd = OpenShm(name, O_RDWR); + if (fd < 0) { + return Error::kSharedMemoryError; + } + size = 0; + for (int attempt = 0; attempt < 100 && size == 0; attempt++) { + struct stat st; + if (::fstat(fd, &st) != 0) { + (void)::close(fd); + return Error::kSharedMemoryError; + } + size = static_cast(st.st_size); + if (size == 0) { + SleepMillisecond(); + } + } + } else { + return Error::kSharedMemoryError; + } +#else + (void)root; + (void)size; + (void)user_id; + (void)group_id; + // Memfd buffers are passed between clients by the server. + return Error::kUnsupported; +#endif +#if ASIL_SHM_MODE != ASIL_SHM_MEMFD + const Error e = MapBuffer(fd, size, PROT_READ | PROT_WRITE); + (void)::close(fd); + if (e != Error::kOk) { + return e; + } + if (slot_size_ != slot_size) { + Unmap(); + return Error::kLayoutMismatch; + } + bcb_->sizes[0].store(size, std::memory_order_relaxed); + while (!ccb_->num_buffers.compare_exchange_strong( + num_buffers, 1, std::memory_order_release, std::memory_order_relaxed)) { + if (num_buffers > 1) { + Unmap(); + return Error::kUnsupported; + } + } + return Error::kOk; +#endif +} + +Error ChannelMemory::AttachBuffer(const char *channel_name, + uint64_t session_id) { + if (ccb_ == nullptr) { + return Error::kNotInitialized; + } + if (buffer_ != nullptr) { + return Error::kOk; + } + const int num_buffers = ccb_->num_buffers.load(std::memory_order_acquire); + if (num_buffers == 0) { + return Error::kOk; + } + if (num_buffers > 1) { + return Error::kUnsupported; + } + char name[kMaxChannelNameLength + 64]; + if (Error e = BufferName(channel_name, session_id, name); e != Error::kOk) { + return e; + } +#if ASIL_SHM_MODE == ASIL_SHM_POSIX + char shm_name[64]; + if (Error e = PosixShmName(name, shm_name); e != Error::kOk) { + return e; + } + const int fd = OpenShm(shm_name, O_RDONLY); + struct stat st; + if (fd < 0 || ::stat(name, &st) != 0) { + if (fd >= 0) { + (void)::close(fd); + } + return Error::kSharedMemoryError; + } +#elif ASIL_SHM_MODE == ASIL_SHM_LINUX + const int fd = OpenShm(name, O_RDONLY); + struct stat st; + if (fd < 0 || ::fstat(fd, &st) != 0) { + if (fd >= 0) { + (void)::close(fd); + } + return Error::kSharedMemoryError; + } +#else + return Error::kUnsupported; +#endif +#if ASIL_SHM_MODE != ASIL_SHM_MEMFD + Error e = Error::kOk; + if (st.st_size > 0) { + e = MapBuffer(fd, static_cast(st.st_size), PROT_READ); + } + (void)::close(fd); + return e; +#endif +} + +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 { + const int64_t stride = prefix_size_ + shm::Aligned64(slot_size_); + return reinterpret_cast(buffer_ + stride * slot->id); +} + +char *ChannelMemory::Payload(const shm::MessageSlot *slot) const { + 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_)); +} + +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, 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; + const uint64_t reliable_refs = + (ref >> shm::kReliableRefCountShift) & shm::kRefCountMask; + if (inc < 0 && refs == 0) { + return true; + } + refs = static_cast(static_cast(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..ae5bea78 --- /dev/null +++ b/asil_client/channel_memory.h @@ -0,0 +1,114 @@ +// 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 +#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); + +// Copies name into a fixed buffer. +Error CopyName(const char *name, char (&out)[kMaxChannelNameLength + 1]); + +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. + Error Map(shm::SystemControlBlock *scb, int ccb_fd, int bcb_fd, + int num_slots, int32_t checksum_size, int32_t metadata_size); + void Unmap(); + + // Publishers create the channel's buffer, or attach to the buffer another + // publisher created. The buffer's slot size must be slot_size. + Error CreateOrAttachBuffer(const char *channel_name, uint64_t session_id, + int64_t slot_size, int32_t user_id, + int32_t group_id); + // Subscribers attach to the buffer read-only once a publisher has made it. + // Returns kOk without a buffer when there isn't one yet. + Error AttachBuffer(const char *channel_name, uint64_t session_id); + bool HasBuffer() const { return buffer_ != nullptr; } + + int NumSlots() const { return num_slots_; } + 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; + 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; + + 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, 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(const char *channel_name, uint64_t session_id, + char (&out)[kMaxChannelNameLength + 64]) const; + Error MapBuffer(int fd, uint64_t size, int prot); + + shm::SystemControlBlock *scb_ = nullptr; + shm::ChannelControlBlock *ccb_ = nullptr; + shm::BufferControlBlock *bcb_ = nullptr; + size_t ccb_size_ = 0; + int num_slots_ = 0; + int32_t checksum_size_ = 4; + int32_t metadata_size_ = 0; + int32_t prefix_size_ = 64; + + char *buffer_ = nullptr; + uint64_t buffer_size_ = 0; + int64_t slot_size_ = 0; +}; + +} // 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..b7cef1c7 --- /dev/null +++ b/asil_client/client.h @@ -0,0 +1,75 @@ +// 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 unreliable, fixed-size channels, +// 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. Memory is allocated only by the +// ServerConnection while it talks to the server; publishing and reading +// messages allocate nothing. +// +// 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..edf14209 --- /dev/null +++ b/asil_client/error.cc @@ -0,0 +1,47 @@ +// 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"; + } + 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..fae48eae --- /dev/null +++ b/asil_client/error.h @@ -0,0 +1,50 @@ +// 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, +}; + +// 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..bc5295d0 --- /dev/null +++ b/asil_client/layout_test.cc @@ -0,0 +1,197 @@ +// 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/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(::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::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(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..71b9909c --- /dev/null +++ b/asil_client/phaser_connection.cc @@ -0,0 +1,537 @@ +// 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 length-prefixed request in wire_. +Error PhaserServerConnection::Send(size_t length) { + const uint32_t network_length = htonl(static_cast(length)); + std::memcpy(wire_, &network_length, sizeof(network_length)); + if (!SendFully(socket_.Get(), wire_, sizeof(network_length) + length)) { + return Error::kConnectionFailed; + } + return Error::kOk; +} + +// 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(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); + char *const data = wire_ + sizeof(uint32_t); + if (length <= kWireBufferSize) { + return ReceiveFully(socket_.Get(), data, length) ? Error::kOk + : Error::kConnectionFailed; + } + for (size_t remaining = length; remaining > 0;) { + const size_t chunk = + remaining < kWireBufferSize ? remaining : kWireBufferSize; + if (!ReceiveFully(socket_.Get(), data, 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. +template +Error PhaserServerConnection::Transact(Build build, Read read) { + if (!socket_.Valid()) { + return Error::kNotInitialized; + } + fds_.Clear(); + size_t length = 0; + { + absl::StatusOr request = + wire::Request::TryCreateMutable(message_, sizeof(message_)); + if (!request.ok()) { + return Error::kCapacityExceeded; + } + build(*request); + if (request->AllocationFailed()) { + return Error::kCapacityExceeded; + } + ::phaser::ProtoBuffer out(wire_ + sizeof(uint32_t), kWireBufferSize); + if (!request->Serialize(out).ok()) { + return Error::kCapacityExceeded; + } + length = out.Size(); + } + + Error e = Send(length); + if (e == Error::kOk) { + e = Receive(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::Response::TryCreateMutable(message_, sizeof(message_)); + if (!response.ok()) { + fds_.Clear(); + return Error::kCapacityExceeded; + } + ::phaser::ProtoBuffer in(static_cast(wire_ + sizeof(uint32_t)), + length); + if (absl::Status status = response->Deserialize(in); !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(true); + if (request.type != nullptr) { + cmd->set_type(View(request.type)); + } + cmd->set_vchan_id(-1); + cmd->set_checksum_size(request.checksum_size); + cmd->set_metadata_size(request.metadata_size); + 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(); + 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 = 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); + if (request.type != nullptr) { + cmd->set_type(View(request.type)); + } + cmd->set_max_active_messages(request.max_active_messages); + cmd->set_vchan_id(-1); + 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; + }); +} + +} // namespace asil +} // namespace subspace diff --git a/asil_client/phaser_connection.h b/asil_client/phaser_connection.h new file mode 100644 index 00000000..09dee295 --- /dev/null +++ b/asil_client/phaser_connection.h @@ -0,0 +1,76 @@ +// Copyright 2026 David Allison +// All Rights Reserved +// See LICENSE file for licensing information. + +#pragma once + +#include "asil_client/server_connection.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 buffers inside this object and +// converted to and from protobuf wire format, 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; + + const char *LastServerError() const override { return last_error_; } + + using ReceivedFds = FdList; + +private: + template + Error Transact(Build build, Read read); + Error Send(size_t length); + Error Receive(size_t &length); + Error ReceiveFds(); + Error Rejected(const char *message, size_t length); + + UniqueFd socket_; + ReceivedFds fds_; + char last_error_[256] = {}; + alignas(8) char wire_[sizeof(uint32_t) + kWireBufferSize]; + alignas(8) char message_[kMessageBufferSize]; +}; + +} // namespace asil +} // namespace subspace diff --git a/asil_client/publisher.cc b/asil_client/publisher.cc new file mode 100644 index 00000000..b59c495d --- /dev/null +++ b/asil_client/publisher.cc @@ -0,0 +1,408 @@ +// 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 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; } + +} // 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.slot_size <= 0 || options.num_slots <= 0 || + !ValidLayoutSize(options.checksum_size) || + !ValidLayoutSize(options.metadata_size)) { + return Error::kInvalidArgument; + } + if (Error e = internal::CopyName(channel_name, name_); e != Error::kOk) { + return e; + } + const int64_t slot_size = shm::Aligned64(options.slot_size); + + PublisherRequest request; + request.channel_name = name_; + request.slot_size = slot_size; + request.num_slots = options.num_slots; + request.type = options.type; + request.checksum_size = options.checksum_size; + request.metadata_size = options.metadata_size; + 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; + num_sub_updates_ = static_cast(reply.num_sub_updates); + checksum_ = options.checksum; + + Error e = Error::kOk; + if (reply.vchan_id != -1 || reply.subscriber_queue_arena_size != 0 || + channel_id_ < 0 || channel_id_ >= shm::kMaxChannels || + publisher_id_ < 0 || publisher_id_ >= shm::kMaxSlotOwners) { + e = Error::kUnsupported; + } + 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, + options.checksum_size, options.metadata_size); + } + if (e == Error::kOk) { + e = memory_.CreateOrAttachBuffer(name_, client.session_id_, slot_size, + client.user_id_, client.group_id_); + } + 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 (options.activate && !activations.IsSet(0)) { + slot_->message_size.store(1, std::memory_order_relaxed); + PublishSlot(slot_, /*is_activation=*/true, nullptr); + slot_ = FindFreeSlot(); + } + TriggerSubscribers(); + return Error::kOk; +} + +void Publisher::Reset() { + memory_.Unmap(); + subscriber_triggers_.Clear(); + retirement_triggers_.Clear(); + client_ = nullptr; + slot_ = nullptr; + channel_id_ = -1; + publisher_id_ = -1; +} + +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; +} + +bool Publisher::HoldSlot() { + if (slot_ == nullptr && client_ != nullptr) { + 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); + slot_ = 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)); + } +} + +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; +} + +shm::MessageSlot *Publisher::FindFreeSlot() { + shm::ChannelControlBlock *ccb = memory_.Ccb(); + BitsetView free_slots = memory_.FreeSlots(); + BitsetView retired_slots = memory_.RetiredSlots(); + const uint64_t owner = shm::kPubOwned | static_cast(publisher_id_); + 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(-1) == 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; + } + 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); + if (slot->refs.compare_exchange_weak(expected, owner, + std::memory_order_acquire, + std::memory_order_relaxed)) { + break; + } + if (++cas_retries >= kMaxCasRetries) { + if (retries-- == 0) { + return nullptr; + } + cas_retries = 0; + (void)::sched_yield(); + } + } + slot->ordinal.store(0, std::memory_order_relaxed); + slot->timestamp.store(0, std::memory_order_relaxed); + slot->vchan_id.store(-1, std::memory_order_relaxed); + SetSlotBuffer(slot); + shm::MessagePrefix *prefix = memory_.Prefix(slot); + prefix->flags = 0; + prefix->vchan_id = -1; + + memory_.Subscribers().Traverse( + [this, slot](int sub_id) { memory_.AvailableSlots(sub_id).Clear(slot->id); }); + + if (free_slot == -1 && retired_slot == -1) { + // A message that some subscriber hasn't seen is being overwritten. + memory_.TriggerRetirement(retirement_triggers_, slot->id); + } + return slot; +} + +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]--; + } + slot->buffer_index.store(0, std::memory_order_relaxed); + bcb->refs[0]++; +} + +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[0].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 = -1; + 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(0); + } + 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(-1); + + // 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 while the slot is still + // publisher owned. A subscriber can leave between the traversal and the + // set, so check membership again afterwards. + memory_.Subscribers().Traverse( + [this, slot](int sub_id) { + 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); + } + }, + 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, -1, 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(-1) != 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..4328ebac --- /dev/null +++ b/asil_client/publisher.h @@ -0,0 +1,98 @@ +// 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 +#include + +namespace subspace { +namespace asil { + +class Client; + +struct PublisherOptions { + // The channel layout. On a server with a static channel config these must + // match the configured channel. + 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. + bool activate = false; +}; + +struct PublishedMessage { + uint64_t ordinal = 0; + uint64_t timestamp = 0; +}; + +// An unreliable publisher for a fixed-size channel. It always holds a slot to +// write the next message into. +// +// 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 every slot is in use. + 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 every slot is in use. + 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); + + const char *Name() const { return name_; } + +private: + bool HoldSlot(); + Error RefreshTriggers(); + shm::MessageSlot *FindFreeSlot(); + 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_; + char name_[kMaxChannelNameLength + 1] = {}; + int32_t channel_id_ = -1; + int32_t publisher_id_ = -1; + uint16_t num_sub_updates_ = 0; + bool checksum_ = false; + shm::MessageSlot *slot_ = nullptr; + 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..9545c610 --- /dev/null +++ b/asil_client/server_connection.h @@ -0,0 +1,111 @@ +// 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, and to fetch trigger file descriptors when the +// set of publishers or subscribers on a channel changes. 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/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; +}; + +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; + UniqueFd ccb; + UniqueFd bcb; + // 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; +}; + +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; +}; + +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; + + // 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..253823e6 --- /dev/null +++ b/asil_client/shm_layout.h @@ -0,0 +1,236 @@ +// 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; + +// 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]; +}; + +// 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); +} + +inline size_t CcbSize(int num_slots, uint64_t subscriber_queue_arena_size) { + return SlotQueueIndexOffset(num_slots) + + static_cast( + Aligned64(static_cast(sizeof(AvailableSlotQueueIndex)))) + + 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/subscriber.cc b/asil_client/subscriber.cc new file mode 100644 index 00000000..749c644e --- /dev/null +++ b/asil_client/subscriber.cc @@ -0,0 +1,396 @@ +// 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. + +#include "asil_client/subscriber.h" + +#include "asil_client/checksum.h" +#include "asil_client/client.h" + +#include +#include +#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); +} + +} // namespace + +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 (Error e = internal::CopyName(channel_name, name_); e != Error::kOk) { + return e; + } + SubscriberRequest request; + request.channel_name = name_; + request.type = options.type; + request.max_active_messages = 1; + SubscriberReply reply; + reply.reliable_publisher_triggers = &reliable_publishers_; + reply.retirement_triggers = &retirement_triggers_; + if (Error e = client.connection_->CreateSubscriber(request, reply); + e != Error::kOk) { + Reset(); + return e; + } + client_ = &client; + options_ = options; + channel_id_ = reply.channel_id; + subscriber_id_ = reply.subscriber_id; + num_pub_updates_ = static_cast(reply.num_pub_updates); + trigger_ = static_cast(reply.trigger); + poll_ = static_cast(reply.poll); + + Error e = Error::kOk; + if (reply.vchan_id != -1 || reply.use_split_buffers || + reply.subscriber_queue_size != 0 || + reply.subscriber_queue_arena_size != 0 || channel_id_ < 0 || + channel_id_ >= shm::kMaxChannels || subscriber_id_ < 0 || + subscriber_id_ >= shm::kMaxSlotOwners) { + e = Error::kUnsupported; + } + if (e == Error::kOk) { + e = memory_.Map(client.scb_, reply.ccb.Get(), reply.bcb.Get(), + reply.num_slots, reply.checksum_size, reply.metadata_size); + } + if (e == Error::kOk) { + e = memory_.AttachBuffer(name_, client.session_id_); + } + if (e != Error::kOk) { + (void)Close(); + return e; + } + // Wake the reader for messages published before it joined. + internal::Trigger(trigger_.Get()); + return Error::kOk; +} + +void Subscriber::Reset() { + memory_.Unmap(); + reliable_publishers_.Clear(); + retirement_triggers_.Clear(); + trigger_.Reset(); + poll_.Reset(); + client_ = nullptr; + held_ = nullptr; + last_ordinal_ = 0; + channel_id_ = -1; + subscriber_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; + } + struct pollfd fd = {poll_.Get(), POLLIN, 0}; + for (;;) { + const int n = ::poll(&fd, 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; + } + } +} + +void Subscriber::ReleaseMessage() { + if (held_ != nullptr) { + ReleaseSlot(held_); + held_ = nullptr; + } +} + +void Subscriber::ReleaseSlot(shm::MessageSlot *slot) { + if (!Owners(slot).ClearWasSet(subscriber_id_)) { + return; + } + bool retired = false; + (void)memory_.AtomicIncRefCount( + slot, -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)); + } +} + +Error Subscriber::RefreshTriggers() { + const uint16_t updates = memory_.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)); + } +} + +// 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 ((s->refs.load(std::memory_order_acquire) & shm::kPubOwned) != 0) { + return; + } + const uint64_t ordinal = s->ordinal.load(std::memory_order_relaxed); + if (ordinal == 0 || + s->buffer_index.load(std::memory_order_relaxed) != 0) { + return; + } + 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 (memory_.AtomicIncRefCount( + best, 1, best_ordinal, + best->vchan_id.load(std::memory_order_relaxed), false, nullptr)) { + // Record ownership so that the server can release the reference if this + // process dies. + Owners(best).Set(subscriber_id_); + 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 ((s->refs.load(std::memory_order_acquire) & shm::kPubOwned) != 0) { + return; + } + const uint64_t ordinal = s->ordinal.load(std::memory_order_relaxed); + if (ordinal == 0 || + s->buffer_index.load(std::memory_order_relaxed) != 0) { + return; + } + 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 (memory_.AtomicIncRefCount( + best, 1, best_ordinal, + best->vchan_id.load(std::memory_order_relaxed), false, nullptr)) { + Owners(best).Set(subscriber_id_); + 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. +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); + available.Traverse([&](int i) { + if (i == newest->id) { + return; + } + shm::MessageSlot *s = memory_.Slot(i); + if ((s->refs.load(std::memory_order_acquire) & shm::kPubOwned) != 0) { + return; + } + const uint64_t ordinal = s->ordinal.load(std::memory_order_relaxed); + const uint64_t timestamp = s->timestamp.load(std::memory_order_relaxed); + if (ordinal == 0 || + !Before(timestamp, ordinal, newest_timestamp, newest_ordinal)) { + return; + } + const int vchan_id = s->vchan_id.load(std::memory_order_relaxed); + if (memory_.AtomicIncRefCount(s, 1, ordinal, vchan_id, false, nullptr)) { + available.Clear(i); + (void)memory_.AtomicIncRefCount(s, -1, ordinal, vchan_id, false, + nullptr); + } + }); +} + +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()); + ReleaseMessage(); + if (Error e = RefreshTriggers(); e != Error::kOk) { + return e; + } + if (Error e = memory_.AttachBuffer(name_, client_->session_id_); + e != Error::kOk) { + return e; + } + if (!memory_.HasBuffer()) { + return Error::kOk; + } + 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 shm::MessagePrefix *prefix = memory_.Prefix(slot); + 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 bool is_activation = (prefix->flags & shm::kMessageActivate) != 0; + + if (mode == ReadMode::kReadNewest) { + ClearOlder(slot); + } + available.Clear(slot->id); + slot->flags.fetch_or(shm::kMessageSeen, std::memory_order_relaxed); + + 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; + } + held_ = slot; + 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.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..f72a3da7 --- /dev/null +++ b/asil_client/subscriber.h @@ -0,0 +1,111 @@ +// 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 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; +}; + +enum class ReadMode { + kReadNext, + kReadNewest, +}; + +// A message in shared memory. The pointers stay valid until the next read, +// ReleaseMessage() or 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; + bool is_activation = false; + bool checksum_error = false; + // Messages missed since the previous read, for kReadNext. + uint32_t dropped = 0; + const void *metadata = nullptr; + size_t metadata_length = 0; +}; + +// An unreliable subscriber. It holds at most one message; reading the next +// message releases the previous one. +// +// A subscriber is not thread safe and must not outlive its Client. +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. If a publisher has joined since the last read this asks the + // server for its trigger fds first. + Error ReadMessage(Message &message, ReadMode mode = ReadMode::kReadNext); + // Releases the message from the last read. + void ReleaseMessage(); + + // 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); + + const char *Name() const { return name_; } + +private: + enum class Find { kFound, kNone }; + + Error RefreshTriggers(); + Find FindNext(shm::MessageSlot *&slot); + Find FindNewest(shm::MessageSlot *&slot); + void ClearOlder(const shm::MessageSlot *newest); + void ReleaseSlot(shm::MessageSlot *slot); + bool ChecksumValid(const shm::MessageSlot *slot, size_t length) const; + void TriggerReliablePublishers() const; + void Reset(); + + Client *client_ = nullptr; + internal::ChannelMemory memory_; + char name_[kMaxChannelNameLength + 1] = {}; + SubscriberOptions options_; + int32_t channel_id_ = -1; + int32_t subscriber_id_ = -1; + uint16_t num_pub_updates_ = 0; + UniqueFd trigger_; + UniqueFd poll_; + shm::MessageSlot *held_ = nullptr; + uint64_t last_ordinal_ = 0; + 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/docs/android.md b/docs/android.md index dab8202a..584cc7d3 100644 --- a/docs/android.md +++ b/docs/android.md @@ -184,15 +184,17 @@ 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 ASIL client's 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 +202,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 +243,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 ASIL client | +| `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 +262,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..1617086d --- /dev/null +++ b/docs/asil-client.md @@ -0,0 +1,206 @@ +# 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 is designed for 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. + +## 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. The server is involved when a client, publisher or subscriber is + set up or closed, and when a publisher or subscriber refreshes its trigger + file descriptors after another process joins or leaves the channel. +- **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`. +- **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 provides unreliable publishers and subscribers on +fixed-size, non-virtual channels: + +- Each channel has one buffer, which is what fixed-size channels use. +- A subscriber holds at most one message. Reading the next message releases + the previous one. +- Checksums, user metadata, `kReadNext` and `kReadNewest`, dropped message + counting and activation messages work with both clients. + +Reliable publishers and subscribers, virtual channels, subscriber queues, +split buffers and the memfd backend (always used on Android, and selected on +Linux with `SUBSPACE_LINUX_USE_MEMFD`) return `Error::kUnsupported`. +Channels created by standard publishers that resize their slots also have +more than one buffer and are rejected the same way. + +## 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`, `//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). Without one, the +cross-compile builds only the core library. + +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`. + +`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. The connection builds each +request as a phaser message in a fixed buffer inside the object, 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 and type. On a static channel these must match the config. + +`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. + +## Subscribers + +`ReadMessage(msg, mode)` fills in a `Message` with the payload, length, +ordinal, timestamp, slot, 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. + +## Testing + +`layout_test` compares every size, offset and constant in +`asil_client/shm_layout.h` with `common/channel.h`, and checks that the +checksum matches the standard client's. `asil_client_test` runs a server with +a static channel config and exchanges messages between the ASIL client and the +standard client in both directions. It 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. It is skipped on memfd builds. 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..db9e1bfd 100644 --- a/proto/CMakeLists.txt +++ b/proto/CMakeLists.txt @@ -93,3 +93,26 @@ target_link_libraries(subspace_proto PUBLIC protobuf::libprotobuf ) +# Phaser messages for the ASIL client's server connection. 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; without one the ASIL server connection is skipped. +if(CMAKE_CROSSCOMPILING AND NOT PHASER_PLUGIN_EXECUTABLE) + message(STATUS + "Skipping subspace_phaser: cross-compiling without " + "-DPHASER_PLUGIN_EXECUTABLE.") + return() +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} +) + From 3d78953dcc47cc7d125fcf860a9dfb55f8b959e3 Mon Sep 17 00:00:00 2001 From: Dave Allison Date: Fri, 9 Oct 2026 16:43:33 -0700 Subject: [PATCH 3/5] Map ASIL buffers at open and support split buffers The server now creates the message buffers of every static channel and multiplexer when it starts, through the same code as a standard first publisher, so subscribers can map them before any publisher exists. A static channel or multiplexer with use_split_buffers gets split buffers, which the server creates, and publishers whose split setting differs from the config are rejected. GetClientBuffersRequest can ask for one prefix or slot of a split buffer set. ASIL publishers and subscribers map all of a channel's shared memory in Open(), and publishing and reading make no shared memory calls. They map the channel's newest buffer; the resize, placeholder remap and 16-buffer table are gone. A subscriber on a channel without buffers gets kNoBuffers, and a message in a buffer created by a resize after the subscriber opened returns kBufferNotMapped. The ASIL client also supports split buffers, mapping each slot into an array the caller provides. Slots from a standard publisher's custom allocator are mapped through C map/unmap callbacks with a context. The ASIL client also gains reliable publishers and subscribers, virtual channels, multiple held messages, subscriber queues and the memfd backend, and the server checks fixed size and locality across the virtual channels of a multiplexer. --- CHANGELOG.md | 28 +- README.md | 16 +- asil_client/BUILD.bazel | 1 + asil_client/asil_client_test.cc | 781 +++++++++++++++++++++++++++++-- asil_client/channel_memory.cc | 639 ++++++++++++++++++------- asil_client/channel_memory.h | 96 +++- asil_client/client.h | 13 +- asil_client/error.cc | 6 + asil_client/error.h | 8 + asil_client/layout_test.cc | 83 +++- asil_client/phaser_connection.cc | 149 +++++- asil_client/phaser_connection.h | 5 +- asil_client/publisher.cc | 310 ++++++++++-- asil_client/publisher.h | 56 ++- asil_client/server_connection.h | 58 ++- asil_client/shm_layout.h | 62 ++- asil_client/split_buffer.h | 77 +++ asil_client/subscriber.cc | 271 +++++++---- asil_client/subscriber.h | 71 ++- docs/asil-client.md | 203 ++++++-- proto/subspace.proto | 20 +- server/client_handler.cc | 38 +- server/server.cc | 92 ++++ server/server.h | 12 + server/server_channel.cc | 12 + server/server_channel.h | 14 + server/static_config.cc | 5 +- server/static_config_test.cc | 78 ++- 28 files changed, 2722 insertions(+), 482 deletions(-) create mode 100644 asil_client/split_buffer.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb601be..73a78a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,24 @@ is fixed: publishers must be fixed size and match the configured slot size, number of slots, checksum size and metadata size. - 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. Publishers whose split buffer + setting differs from the config are rejected. +- `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 unreliable, fixed-size channels with the standard clients through - the same shared memory protocol, using only the standard library and + 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 @@ -23,6 +36,17 @@ 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`. - Bazel, CMake and Soong build phaser and generate `subspace_phaser` from `subspace.proto`. Android CMake cross-compiles take a host `protoc-gen-phaser` in `PHASER_PLUGIN_EXECUTABLE`. diff --git a/README.md b/README.md index 4844e80c..8cc73630 100644 --- a/README.md +++ b/README.md @@ -2023,9 +2023,10 @@ channels { name: "/sensors/lidar" mux: "/sensors" vchan_id: 0 } channels { name: "/sensors/radar" mux: "/sensors" vchan_id: 1 } ``` -The server creates every configured channel before it accepts clients, so a -subscriber that starts first gets the full channel layout instead of a -placeholder. With a config loaded: +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 @@ -2035,8 +2036,13 @@ placeholder. With a config loaded: - The layout is fixed. Publishers must be fixed size (`SetFixedSize(true)`) and match the configured slot size, number of slots, checksum size and metadata size. -- A channel on a multiplexer takes its slot size, number of slots, type and - prefix layout from the multiplexer and needs an explicit `vchan_id`. +- `use_split_buffers: true` gives a channel or multiplexer + [split buffers](docs/split-buffers.md), which the server creates. + Publishers must set `SetUseSplitBuffers(true)` on these channels and leave + it unset on the others. +- 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. diff --git a/asil_client/BUILD.bazel b/asil_client/BUILD.bazel index dab9dc9f..3c53448b 100644 --- a/asil_client/BUILD.bazel +++ b/asil_client/BUILD.bazel @@ -23,6 +23,7 @@ cc_library( "publisher.h", "server_connection.h", "shm_layout.h", + "split_buffer.h", "subscriber.h", "unique_fd.h", ], diff --git a/asil_client/asil_client_test.cc b/asil_client/asil_client_test.cc index 50d86e88..df5b659b 100644 --- a/asil_client/asil_client_test.cc +++ b/asil_client/asil_client_test.cc @@ -15,10 +15,13 @@ #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 @@ -67,8 +70,40 @@ constexpr char kConfig[] = R"pb( 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() { @@ -139,9 +174,11 @@ asil::PublisherOptions AsilPubOptions(int64_t slot_size, int32_t num_slots) { return options; } -class AsilClientTest : public ::testing::Test { +// 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: - static void SetUpTestSuite() { + void Start(const char *config) { #if defined(__ANDROID__) char socket_name_template[] = "/data/local/tmp/subspaceXXXXXX"; // NOLINT #else @@ -156,12 +193,14 @@ class AsilClientTest : public ::testing::Test { 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))); + if (config != nullptr) { + absl::StatusOr parsed = + subspace::ParseStaticChannelConfig(config); + ASSERT_OK(parsed); + ASSERT_OK(server_->SetStaticChannelConfig(*std::move(parsed))); + } - server_thread_ = std::thread([]() { + server_thread_ = std::thread([this]() { absl::Status s = server_->Run(); if (!s.ok()) { fprintf(stderr, "Error running Subspace server: %s\n", @@ -174,42 +213,73 @@ class AsilClientTest : public ::testing::Test { (void)::read(server_pipe_[0], buf, 8); } - static void TearDownTestSuite() { + 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()); } - void SetUp() override { -#if ASIL_SHM_MODE == ASIL_SHM_MEMFD - GTEST_SKIP() << "The ASIL client does not support the memfd backend"; -#endif - 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")); - } + 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_; } -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_; +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"); @@ -272,8 +342,7 @@ TEST_F(AsilClientTest, StandardPublisherAsilSubscriber) { TEST_F(AsilClientTest, AsilToAsilWithWait) { asil::Subscriber sub; - ASSERT_EQ(asil::Error::kOk, - Asil().CreateSubscriber("/asil/wait", {}, sub)); + ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/wait", {}, sub)); asil::Publisher pub; ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher("/asil/wait", AsilPubOptions(64, 4), pub)); @@ -292,8 +361,9 @@ TEST_F(AsilClientTest, AsilToAsilWithWait) { TEST_F(AsilClientTest, SubscriberJoinsAfterPublisher) { asil::Publisher pub; - ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher( - "/asil/late_sub", AsilPubOptions(64, 4), pub)); + ASSERT_EQ( + asil::Error::kOk, + Asil().CreatePublisher("/asil/late_sub", AsilPubOptions(64, 4), pub)); absl::StatusOr sub = Std().CreateSubscriber("/asil/late_sub"); @@ -327,8 +397,8 @@ 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)); + 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; @@ -406,8 +476,7 @@ TEST_F(AsilClientTest, Metadata) { TEST_F(AsilClientTest, ReadNewest) { asil::Subscriber sub; - ASSERT_EQ(asil::Error::kOk, - Asil().CreateSubscriber("/asil/newest", {}, sub)); + ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/newest", {}, sub)); absl::StatusOr pub = Std().CreatePublisher("/asil/newest", 64, 8, FixedSize()); ASSERT_OK(pub); @@ -475,8 +544,9 @@ TEST_F(AsilClientTest, StandardSubscriberSeesAsilDrops) { [&dropped](subspace::Subscriber *, int64_t n) { dropped += n; })); asil::Publisher pub; - ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher( - "/asil/drops_out", AsilPubOptions(64, 4), pub)); + ASSERT_EQ( + asil::Error::kOk, + Asil().CreatePublisher("/asil/drops_out", AsilPubOptions(64, 4), pub)); AsilPublish(pub, "first"); EXPECT_EQ("first", StdRead(*sub).text); @@ -565,19 +635,22 @@ TEST_F(AsilClientTest, HeldMessageIsNotOverwritten) { sub.ReleaseMessage(); asil::Message msg; - ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg, asil::ReadMode::kReadNewest)); + ASSERT_EQ(asil::Error::kOk, + sub.ReadMessage(msg, asil::ReadMode::kReadNewest)); EXPECT_EQ("overwrite 19", Text(msg)); } TEST_F(AsilClientTest, Rejections) { asil::Publisher pub; - EXPECT_EQ(asil::Error::kServerRejected, - Asil().CreatePublisher("/asil/reject", AsilPubOptions(128, 4), pub)); + EXPECT_EQ( + asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/reject", AsilPubOptions(128, 4), pub)); EXPECT_THAT(Asil().LastServerError(), HasSubstr("has 4 slots of 64 bytes")); EXPECT_FALSE(pub.IsOpen()); - EXPECT_EQ(asil::Error::kServerRejected, - Asil().CreatePublisher("/asil/unknown", AsilPubOptions(64, 4), pub)); + EXPECT_EQ( + asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/unknown", AsilPubOptions(64, 4), pub)); EXPECT_THAT(Asil().LastServerError(), HasSubstr("isn't in the static channel config")); @@ -600,15 +673,8 @@ TEST_F(AsilClientTest, Rejections) { asil::Client uninitialized; asil::Publisher other; EXPECT_EQ(asil::Error::kNotInitialized, - uninitialized.CreatePublisher("/asil/reject", - AsilPubOptions(64, 4), other)); -} - -TEST_F(AsilClientTest, VirtualChannelsAreUnsupported) { - asil::Subscriber sub; - const asil::Error e = Asil().CreateSubscriber("/asil/v1", {}, sub); - EXPECT_NE(asil::Error::kOk, e); - EXPECT_FALSE(sub.IsOpen()); + uninitialized.CreatePublisher("/asil/reject", AsilPubOptions(64, 4), + other)); } TEST_F(AsilClientTest, CloseRemovesFromServer) { @@ -683,6 +749,627 @@ TEST_F(AsilClientTest, OversizedRequestKeepsTheConnection) { 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(int64_t slot_size, + int32_t num_slots) { + asil::PublisherOptions options = AsilPubOptions(slot_size, num_slots); + 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(64, 4), 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", 64, 4, FixedSize().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(64, 4), 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(64, 4), 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", AsilPubOptions(128, 8), 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 = AsilPubOptions(128, 8); + 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", 128, 8, FixedSize().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", 64, 8, FixedSize()); + 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", 64, 4, FixedSize()); + ASSERT_OK(pub); + StdPublish(*pub, "early"); + ASSERT_EQ(asil::Error::kOk, sub.ReadMessage(msg)); + EXPECT_EQ("early", Text(msg)); +} + +asil::PublisherOptions SplitPubOptions(int64_t slot_size, int32_t num_slots, + asil::SplitSlot *slots) { + asil::PublisherOptions options = AsilPubOptions(slot_size, num_slots); + options.use_split_buffers = true; + options.split_slots = slots; + options.split_slot_capacity = num_slots; + 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(256, 4, pub_slots), 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", 256, 4, + FixedSize().SetUseSplitBuffers(true).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(128, 8, pub_slots); + 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()); + + asil::Publisher pub; + EXPECT_EQ( + asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/split_reject", AsilPubOptions(64, 4), pub)); + EXPECT_THAT(Asil().LastServerError(), HasSubstr("uses split buffers")); + EXPECT_EQ(asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/reject", + SplitPubOptions(64, 4, slots), pub)); + EXPECT_THAT(Asil().LastServerError(), HasSubstr("doesn't use split buffers")); +} + +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) { diff --git a/asil_client/channel_memory.cc b/asil_client/channel_memory.cc index 747d33ea..dfb38f95 100644 --- a/asil_client/channel_memory.cc +++ b/asil_client/channel_memory.cc @@ -9,9 +9,13 @@ #include #include #include +#include #include #include #include +#if ASIL_SHM_MODE == ASIL_SHM_MEMFD +#include +#endif namespace subspace { namespace asil { @@ -20,6 +24,8 @@ 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}; @@ -30,6 +36,11 @@ 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; @@ -76,6 +87,24 @@ Error CreateShadowFile(const char *shadow_file, uint64_t size) { } #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() { @@ -111,6 +140,22 @@ void ClearTrigger(int fd) { #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; @@ -123,17 +168,73 @@ Error CopyName(const char *name, char (&out)[kMaxChannelNameLength + 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) { + int32_t metadata_size, + uint64_t subscriber_queue_arena_size, + const BufferContext *context) { if (ccb_ != nullptr) { return Error::kAlreadyInitialized; } - if (num_slots <= 0) { - // The channel has no layout yet. Static channels always have one. - return Error::kUnsupported; + 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, 0); + const size_t ccb_size = shm::CcbSize(num_slots, subscriber_queue_arena_size); if (ccb_size > kMaxCcbSize) { return Error::kLayoutMismatch; } @@ -147,8 +248,8 @@ Error ChannelMemory::Map(shm::SystemControlBlock *scb, int ccb_fd, int bcb_fd, (void)::munmap(ccb, ccb_size); return Error::kLayoutMismatch; } - void *bcb = MapFd(bcb_fd, sizeof(shm::BufferControlBlock), - PROT_READ | PROT_WRITE); + void *bcb = + MapFd(bcb_fd, sizeof(shm::BufferControlBlock), PROT_READ | PROT_WRITE); if (bcb == MAP_FAILED) { (void)::munmap(ccb, ccb_size); return Error::kSharedMemoryError; @@ -156,7 +257,9 @@ Error ChannelMemory::Map(shm::SystemControlBlock *scb, int ccb_fd, int bcb_fd, 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; @@ -165,12 +268,7 @@ Error ChannelMemory::Map(shm::SystemControlBlock *scb, int ccb_fd, int bcb_fd, } void ChannelMemory::Unmap() { - if (buffer_ != nullptr) { - (void)::munmap(buffer_, buffer_size_); - buffer_ = nullptr; - buffer_size_ = 0; - slot_size_ = 0; - } + UnmapBuffers(); if (bcb_ != nullptr) { (void)::munmap(bcb_, sizeof(shm::BufferControlBlock)); bcb_ = nullptr; @@ -181,13 +279,41 @@ void ChannelMemory::Unmap() { ccb_size_ = 0; } scb_ = nullptr; + context_ = nullptr; + queue_arena_size_ = 0; num_slots_ = 0; } -Error ChannelMemory::BufferName(const char *channel_name, uint64_t session_id, +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(channel_name, sanitized); e != Error::kOk) { + if (Error e = CopyName(context_->channel_name, sanitized); e != Error::kOk) { return e; } for (char *p = sanitized; *p != '\0'; p++) { @@ -200,23 +326,59 @@ Error ChannelMemory::BufferName(const char *channel_name, uint64_t session_id, #else const char *format = "subspace_%llu_%s_%d"; #endif - const int n = - std::snprintf(out, sizeof(out), format, - static_cast(session_id), sanitized, 0); + 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::MapBuffer(int fd, uint64_t size, int prot) { +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 < 100; attempt++) { + for (int attempt = 0; attempt < kBufferWaitMilliseconds; attempt++) { p = MapFd(fd, static_cast(size), prot); if (p != MAP_FAILED || errno != EINVAL) { break; @@ -227,38 +389,112 @@ Error ChannelMemory::MapBuffer(int fd, uint64_t size, int prot) { if (p == MAP_FAILED) { return Error::kSharedMemoryError; } - buffer_ = static_cast(p); - buffer_size_ = size; + index_ = index; + base_ = static_cast(p); + size_ = size; slot_size_ = static_cast((size - prefixes) / static_cast(num_slots_)); return Error::kOk; } -Error ChannelMemory::CreateOrAttachBuffer(const char *channel_name, - uint64_t session_id, - int64_t slot_size, int32_t user_id, - int32_t group_id) { +// 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 (buffer_ != nullptr) { + if (index_ != -1) { return Error::kAlreadyInitialized; } - int num_buffers = ccb_->num_buffers.load(std::memory_order_relaxed); - if (num_buffers > 1) { - // The channel has been resized. - return Error::kUnsupported; + int index = -1; + if (Error e = NewestIndex(index); e != Error::kOk) { + return e; } - const uint64_t full_size = - static_cast(num_slots_) * - (static_cast(slot_size) + static_cast(prefix_size_)); + 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(channel_name, session_id, name); e != Error::kOk) { + 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; - uint64_t size = full_size; #if ASIL_SHM_MODE == ASIL_SHM_POSIX if (Error e = CreateShadowFile(name, full_size); e != Error::kOk) { return e; @@ -267,142 +503,193 @@ Error ChannelMemory::CreateOrAttachBuffer(const char *channel_name, if (Error e = PosixShmName(name, shm_name); e != Error::kOk) { return e; } - int fd = OpenShm(shm_name, O_RDWR | O_CREAT | O_EXCL); - if (fd >= 0) { - if (::ftruncate(fd, static_cast(full_size)) != 0 || - ::chmod(name, 0777) != 0 || - (root && ::chown(name, static_cast(user_id), - static_cast(group_id)) != 0)) { - (void)::close(fd); - return Error::kSharedMemoryError; - } - } else if (errno == EEXIST) { - fd = OpenShm(shm_name, O_RDWR); - struct stat st; - if (fd < 0 || ::stat(name, &st) != 0) { - if (fd >= 0) { - (void)::close(fd); - } - return Error::kSharedMemoryError; - } - size = static_cast(st.st_size); - } else { + 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; } -#elif ASIL_SHM_MODE == ASIL_SHM_LINUX - int fd = OpenShm(name, O_RDWR | O_CREAT | O_EXCL); - if (fd >= 0) { - char path[kMaxChannelNameLength + 80]; - (void)std::snprintf(path, sizeof(path), "/dev/shm/%s", name); - if (::ftruncate(fd, static_cast(full_size)) != 0 || - ::chmod(path, 0777) != 0 || - (root && ::chown(path, static_cast(user_id), - static_cast(group_id)) != 0)) { - (void)::close(fd); - return Error::kSharedMemoryError; - } - } else if (errno == EEXIST) { - fd = OpenShm(name, O_RDWR); - if (fd < 0) { - return Error::kSharedMemoryError; - } - size = 0; - for (int attempt = 0; attempt < 100 && size == 0; attempt++) { - struct stat st; - if (::fstat(fd, &st) != 0) { - (void)::close(fd); - return Error::kSharedMemoryError; - } - size = static_cast(st.st_size); - if (size == 0) { - SleepMillisecond(); - } +#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); } - } else { - return Error::kSharedMemoryError; + return e; } -#else - (void)root; - (void)size; - (void)user_id; - (void)group_id; - // Memfd buffers are passed between clients by the server. - return Error::kUnsupported; #endif -#if ASIL_SHM_MODE != ASIL_SHM_MEMFD - const Error e = MapBuffer(fd, size, PROT_READ | PROT_WRITE); - (void)::close(fd); - if (e != Error::kOk) { - return e; + 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 (slot_size_ != slot_size) { - Unmap(); - return Error::kLayoutMismatch; + if (index_ != -1) { + return Error::kAlreadyInitialized; } - bcb_->sizes[0].store(size, std::memory_order_relaxed); - while (!ccb_->num_buffers.compare_exchange_strong( - num_buffers, 1, std::memory_order_release, std::memory_order_relaxed)) { - if (num_buffers > 1) { - Unmap(); - return Error::kUnsupported; + 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::kOk; -#endif + return Error::kSharedMemoryError; } -Error ChannelMemory::AttachBuffer(const char *channel_name, - uint64_t session_id) { +Error ChannelMemory::AttachSplitBuffers(bool writable, SplitSlot *slots, + int32_t capacity, + const SplitBufferAllocator &allocator) { if (ccb_ == nullptr) { return Error::kNotInitialized; } - if (buffer_ != nullptr) { - return Error::kOk; + if (index_ != -1) { + return Error::kAlreadyInitialized; } - const int num_buffers = ccb_->num_buffers.load(std::memory_order_acquire); - if (num_buffers == 0) { - return Error::kOk; + if (slots == nullptr) { + return Error::kInvalidArgument; } - if (num_buffers > 1) { - return Error::kUnsupported; + if (capacity < num_slots_) { + return Error::kCapacityExceeded; } - char name[kMaxChannelNameLength + 64]; - if (Error e = BufferName(channel_name, session_id, name); e != Error::kOk) { + int index = -1; + if (Error e = NewestIndex(index); e != Error::kOk) { return e; } -#if ASIL_SHM_MODE == ASIL_SHM_POSIX - char shm_name[64]; - if (Error e = PosixShmName(name, shm_name); e != Error::kOk) { + 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; } - const int fd = OpenShm(shm_name, O_RDONLY); - struct stat st; - if (fd < 0 || ::stat(name, &st) != 0) { - if (fd >= 0) { - (void)::close(fd); - } + 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; } -#elif ASIL_SHM_MODE == ASIL_SHM_LINUX - const int fd = OpenShm(name, O_RDONLY); - struct stat st; - if (fd < 0 || ::fstat(fd, &st) != 0) { - if (fd >= 0) { - (void)::close(fd); + 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; } -#else - return Error::kUnsupported; -#endif -#if ASIL_SHM_MODE != ASIL_SHM_MEMFD - Error e = Error::kOk; - if (st.st_size > 0) { - e = MapBuffer(fd, static_cast(st.st_size), PROT_READ); + void *p = MapFd(reply.fd.Get(), static_cast(payload_size), prot); + if (p == MAP_FAILED) { + return Error::kSharedMemoryError; } - (void)::close(fd); - return e; -#endif + slot.mapping.address = p; + slot.mapping.size = payload_size; + return Error::kOk; } shm::MessageSlot *ChannelMemory::Slot(int id) const { @@ -412,11 +699,22 @@ shm::MessageSlot *ChannelMemory::Slot(int id) const { } shm::MessagePrefix *ChannelMemory::Prefix(const shm::MessageSlot *slot) const { - const int64_t stride = prefix_size_ + shm::Aligned64(slot_size_); - return reinterpret_cast(buffer_ + stride * slot->id); + 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_; } @@ -447,6 +745,23 @@ shm::AvailableSlotQueueIndex *ChannelMemory::QueueIndex() const { 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 (;;) { @@ -456,11 +771,10 @@ int ChannelMemory::NumSubscribers(int vchan_id) const { 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); + 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; } @@ -473,12 +787,13 @@ uint64_t ChannelMemory::CleanupGeneration(int vchan_id) const { if (vchan_id == -1) { return mux_generation; } - return mux_generation + ccb_->subscriber_cleanup_generation[vchan_id + 1].load( - std::memory_order_acquire); + return mux_generation + + ccb_->subscriber_cleanup_generation[vchan_id + 1].load( + std::memory_order_acquire); } -bool ChannelMemory::AtomicIncRefCount(shm::MessageSlot *slot, int inc, - uint64_t ordinal, int vchan_id, +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; @@ -489,19 +804,24 @@ bool ChannelMemory::AtomicIncRefCount(shm::MessageSlot *slot, int inc, if ((ref & shm::kPubOwned) != 0) { return false; } - const uint64_t ref_ordinal = (ref >> shm::kOrdinalShift) & shm::kOrdinalMask; + 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; - const uint64_t reliable_refs = + 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) { @@ -514,8 +834,7 @@ bool ChannelMemory::AtomicIncRefCount(shm::MessageSlot *slot, int inc, std::memory_order_acq_rel, std::memory_order_relaxed)) { if (retire && refs == 0 && reliable_refs == 0 && - retired_refs >= - static_cast(NumSubscribers(ref_vchan_id)) && + retired_refs >= static_cast(NumSubscribers(ref_vchan_id)) && RetiredSlots().SetWasClear(slot->id) && retired != nullptr) { *retired = true; } diff --git a/asil_client/channel_memory.h b/asil_client/channel_memory.h index ae5bea78..3593016a 100644 --- a/asil_client/channel_memory.h +++ b/asil_client/channel_memory.h @@ -11,6 +11,7 @@ #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 @@ -30,9 +31,28 @@ 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; @@ -40,22 +60,38 @@ class ChannelMemory { ChannelMemory(const ChannelMemory &) = delete; ChannelMemory &operator=(const ChannelMemory &) = delete; - // Maps the CCB and BCB. The SCB is mapped by the client. - Error Map(shm::SystemControlBlock *scb, int ccb_fd, int bcb_fd, - int num_slots, int32_t checksum_size, int32_t metadata_size); + // 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(); - - // Publishers create the channel's buffer, or attach to the buffer another - // publisher created. The buffer's slot size must be slot_size. - Error CreateOrAttachBuffer(const char *channel_name, uint64_t session_id, - int64_t slot_size, int32_t user_id, - int32_t group_id); - // Subscribers attach to the buffer read-only once a publisher has made it. - // Returns kOk without a buffer when there isn't one yet. - Error AttachBuffer(const char *channel_name, uint64_t session_id); - bool HasBuffer() const { return buffer_ != nullptr; } + 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_; } @@ -67,6 +103,8 @@ class ChannelMemory { } 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; @@ -75,14 +113,17 @@ class ChannelMemory { 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, int inc, uint64_t ordinal, - int vchan_id, bool retire, bool *retired); + 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); @@ -91,22 +132,37 @@ class ChannelMemory { void TriggerRetirement(const TriggerFdList &triggers, int32_t slot_id) const; private: - Error BufferName(const char *channel_name, uint64_t session_id, - char (&out)[kMaxChannelNameLength + 64]) const; - Error MapBuffer(int fd, uint64_t size, int prot); + 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; - char *buffer_ = nullptr; - uint64_t buffer_size_ = 0; + // 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 diff --git a/asil_client/client.h b/asil_client/client.h index b7cef1c7..054c0b85 100644 --- a/asil_client/client.h +++ b/asil_client/client.h @@ -4,12 +4,13 @@ // A Subspace client for safety-related software. // -// The client publishes and reads messages on unreliable, fixed-size channels, -// 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. Memory is allocated only by the -// ServerConnection while it talks to the server; publishing and reading -// messages allocate nothing. +// 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"); diff --git a/asil_client/error.cc b/asil_client/error.cc index edf14209..8b672716 100644 --- a/asil_client/error.cc +++ b/asil_client/error.cc @@ -39,6 +39,12 @@ const char *ErrorString(Error error) { 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"; } diff --git a/asil_client/error.h b/asil_client/error.h index fae48eae..58290ad8 100644 --- a/asil_client/error.h +++ b/asil_client/error.h @@ -41,6 +41,14 @@ enum class Error : int32_t { 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. diff --git a/asil_client/layout_test.cc b/asil_client/layout_test.cc index bc5295d0..7320a6ef 100644 --- a/asil_client/layout_test.cc +++ b/asil_client/layout_test.cc @@ -5,6 +5,7 @@ // 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" @@ -22,7 +23,7 @@ namespace { namespace shm = ::subspace::asil::shm; #define EXPECT_SAME_OFFSET(type, field) \ - EXPECT_EQ(offsetof(::subspace::type, field), offsetof(shm::type, field)) \ + EXPECT_EQ(offsetof(::subspace::type, field), offsetof(shm::type, field)) \ << #type "::" #field TEST(LayoutTest, Constants) { @@ -35,10 +36,13 @@ TEST(LayoutTest, Constants) { 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::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); @@ -142,6 +146,9 @@ TEST(LayoutTest, CcbRegions) { 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)); @@ -151,9 +158,9 @@ TEST(LayoutTest, CcbRegions) { 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)); + EXPECT_EQ( + shm::PrefixSize(checksum_size, metadata_size), + ::subspace::Channel::ComputePrefixSize(checksum_size, metadata_size)); } } } @@ -169,6 +176,70 @@ TEST(LayoutTest, RefsBitField) { } } +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}) { diff --git a/asil_client/phaser_connection.cc b/asil_client/phaser_connection.cc index 71b9909c..f2e20dc5 100644 --- a/asil_client/phaser_connection.cc +++ b/asil_client/phaser_connection.cc @@ -160,6 +160,38 @@ Error PhaserServerConnection::Send(size_t length) { 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(size_t &length) { @@ -190,8 +222,8 @@ Error PhaserServerConnection::Receive(size_t &length) { // 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))]; + alignas( + struct cmsghdr) char control[CMSG_SPACE(kMaxFdsPerMessage * sizeof(int))]; int32_t received = 0; bool overflow = false; for (;;) { @@ -259,8 +291,9 @@ Error PhaserServerConnection::ReceiveFds() { } // 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) { +Error PhaserServerConnection::Transact(Build build, Read read, int send_fd) { if (!socket_.Valid()) { return Error::kNotInitialized; } @@ -284,6 +317,9 @@ Error PhaserServerConnection::Transact(Build build, Read read) { } Error e = Send(length); + if (e == Error::kOk && send_fd >= 0) { + e = SendFd(send_fd); + } if (e == Error::kOk) { e = Receive(length); } @@ -353,12 +389,19 @@ Error PhaserServerConnection::CreatePublisher(const PublisherRequest &request, cmd->set_slot_size(request.slot_size); cmd->set_num_slots(request.num_slots); cmd->set_is_fixed_size(true); + cmd->set_is_reliable(request.reliable); if (request.type != nullptr) { cmd->set_type(View(request.type)); } - cmd->set_vchan_id(-1); + 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); @@ -385,6 +428,10 @@ Error PhaserServerConnection::CreatePublisher(const PublisherRequest &request, 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); }, @@ -406,11 +453,15 @@ Error PhaserServerConnection::CreateSubscriber(const SubscriberRequest &request, 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); - cmd->set_vchan_id(-1); + 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) { @@ -450,7 +501,9 @@ Error PhaserServerConnection::CreateSubscriber(const SubscriberRequest &request, } if (Error e = FillFdList( fds_, sub.reliable_pub_trigger_fd_indexes_size(), - [&](size_t i) { return sub.reliable_pub_trigger_fd_indexes(i); }, + [&](size_t i) { + return sub.reliable_pub_trigger_fd_indexes(i); + }, reply.reliable_publisher_triggers); e != Error::kOk) { return e; @@ -533,5 +586,89 @@ Error PhaserServerConnection::RemoveSubscriber(const char *channel_name, }); } +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 index 09dee295..f195576f 100644 --- a/asil_client/phaser_connection.h +++ b/asil_client/phaser_connection.h @@ -52,6 +52,8 @@ class PhaserServerConnection : public ServerConnection { 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_; } @@ -59,8 +61,9 @@ class PhaserServerConnection : public ServerConnection { private: template - Error Transact(Build build, Read read); + Error Transact(Build build, Read read, int send_fd = -1); Error Send(size_t length); + Error SendFd(int fd); Error Receive(size_t &length); Error ReceiveFds(); Error Rejected(const char *message, size_t length); diff --git a/asil_client/publisher.cc b/asil_client/publisher.cc index b59c495d..c87aa579 100644 --- a/asil_client/publisher.cc +++ b/asil_client/publisher.cc @@ -3,8 +3,8 @@ // See LICENSE file for licensing information. // The publishing side of the shared memory protocol. This follows -// PublisherImpl::FindFreeSlotUnreliable and -// PublisherImpl::ActivateSlotAndGetAnother in client/publisher.cc. +// PublisherImpl::FindFreeSlotUnreliable, PublisherImpl::FindFreeSlotReliable +// and PublisherImpl::ActivateSlotAndGetAnother in client/publisher.cc. #include "asil_client/publisher.h" @@ -21,6 +21,16 @@ 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, @@ -33,12 +43,18 @@ Error Publisher::Open(Client &client, const char *channel_name, } if (options.slot_size <= 0 || options.num_slots <= 0 || !ValidLayoutSize(options.checksum_size) || - !ValidLayoutSize(options.metadata_size)) { + !ValidLayoutSize(options.metadata_size) || 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; + } const int64_t slot_size = shm::Aligned64(options.slot_size); PublisherRequest request; @@ -48,6 +64,11 @@ Error Publisher::Open(Client &client, const char *channel_name, 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_; @@ -59,24 +80,40 @@ Error Publisher::Open(Client &client, const char *channel_name, 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_; Error e = Error::kOk; - if (reply.vchan_id != -1 || reply.subscriber_queue_arena_size != 0 || - channel_id_ < 0 || channel_id_ >= shm::kMaxChannels || - publisher_id_ < 0 || publisher_id_ >= shm::kMaxSlotOwners) { - e = Error::kUnsupported; + 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)) { + 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, - options.checksum_size, options.metadata_size); + options.checksum_size, options.metadata_size, + reply.subscriber_queue_arena_size, &buffer_context_); } if (e == Error::kOk) { - e = memory_.CreateOrAttachBuffer(name_, client.session_id_, slot_size, - client.user_id_, client.group_id_); + e = options.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(); @@ -90,10 +127,12 @@ Error Publisher::Open(Client &client, const char *channel_name, } BitsetView activations(&memory_.Ccb()->activation_tracker, shm::kMaxVchanId + 1); - if (options.activate && !activations.IsSet(0)) { + 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_ = FindFreeSlot(); + slot_ = reliable_ ? nullptr : FindFreeSlot(); } TriggerSubscribers(); return Error::kOk; @@ -103,10 +142,13 @@ 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() { @@ -114,16 +156,35 @@ Error Publisher::Close() { return Error::kOk; } // The server releases the slot this publisher holds. - const Error e = - client_->connection_->RemovePublisher(name_, publisher_id_); + 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 && client_ != nullptr) { - slot_ = FindFreeSlot(); + 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; } @@ -158,14 +219,14 @@ Error Publisher::Publish(size_t message_size, PublishedMessage *published) { } slot_->message_size.store(message_size, std::memory_order_relaxed); PublishSlot(slot_, /*is_activation=*/false, published); - slot_ = FindFreeSlot(); + // 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; + const uint16_t updates = memory_.Counters(channel_id_).num_sub_updates; if (updates == num_sub_updates_) { return Error::kOk; } @@ -185,6 +246,10 @@ void Publisher::TriggerSubscribers() const { } } +shm::MessageSlot *Publisher::FindFreeSlot() { + return reliable_ ? FindFreeSlotReliable() : FindFreeSlotUnreliable(); +} + int Publisher::FindRetiredSlotToReuse(bool oldest_first) const { BitsetView retired = memory_.RetiredSlots(); if (!oldest_first) { @@ -208,11 +273,22 @@ int Publisher::FindRetiredSlotToReuse(bool oldest_first) const { return oldest; } -shm::MessageSlot *Publisher::FindFreeSlot() { +// 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(); - const uint64_t owner = shm::kPubOwned | static_cast(publisher_id_); constexpr int kMaxCasRetries = 1000; int retries = memory_.NumSlots() * 1000; int cas_retries = 0; @@ -223,7 +299,7 @@ shm::MessageSlot *Publisher::FindFreeSlot() { slot = nullptr; free_slot = -1; retired_slot = -1; - const bool no_subscribers = memory_.NumSubscribers(-1) == 0; + 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)) { @@ -260,14 +336,7 @@ shm::MessageSlot *Publisher::FindFreeSlot() { } continue; } - 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); - if (slot->refs.compare_exchange_weak(expected, owner, - std::memory_order_acquire, - std::memory_order_relaxed)) { + if (ClaimSlot(slot)) { break; } if (++cas_retries >= kMaxCasRetries) { @@ -278,22 +347,149 @@ shm::MessageSlot *Publisher::FindFreeSlot() { (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(-1, 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 = -1; + prefix->vchan_id = vchan_id_; - memory_.Subscribers().Traverse( - [this, slot](int sub_id) { memory_.AvailableSlots(sub_id).Clear(slot->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 (free_slot == -1 && retired_slot == -1) { - // A message that some subscriber hasn't seen is being overwritten. + if (forced_reuse) { memory_.TriggerRetirement(retirement_triggers_, slot->id); } - return slot; } void Publisher::SetSlotBuffer(shm::MessageSlot *slot) { @@ -303,15 +499,17 @@ void Publisher::SetSlotBuffer(shm::MessageSlot *slot) { bcb->refs[old_index].load(std::memory_order_relaxed) > 0) { bcb->refs[old_index]--; } - slot->buffer_index.store(0, std::memory_order_relaxed); - bcb->refs[0]++; + 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[0].fetch_add(1); + 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); @@ -322,7 +520,7 @@ void Publisher::PublishSlot(shm::MessageSlot *slot, bool is_activation, prefix->message_size = message_size; prefix->ordinal = ordinal; prefix->timestamp = timestamp; - prefix->vchan_id = -1; + prefix->vchan_id = vchan_id_; prefix->checksum_size = static_cast(memory_.ChecksumSize()); prefix->metadata_size = static_cast(memory_.MetadataSize()); prefix->flags = 0; @@ -331,7 +529,8 @@ void Publisher::PublishSlot(shm::MessageSlot *slot, bool is_activation, 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(0); + BitsetView(&ccb->activation_tracker, shm::kMaxVchanId + 1) + .Set(vchan_id_ + 1); } if (checksum_) { prefix->flags |= shm::kMessageHasChecksum; @@ -350,7 +549,7 @@ void Publisher::PublishSlot(shm::MessageSlot *slot, bool is_activation, std::memcpy(&prefix->checksum, &crc, sizeof(crc)); } - const uint64_t cleanup_generation = memory_.CleanupGeneration(-1); + 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. @@ -358,11 +557,18 @@ void Publisher::PublishSlot(shm::MessageSlot *slot, bool is_activation, memory_.QueueIndex()->active_publishers[publisher_id_]; active_publishers.fetch_add(1, std::memory_order_seq_cst); - // Mark the message available to every subscriber while the slot is still - // publisher owned. A subscriber can leave between the traversal and the - // set, so check membership again afterwards. + // 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](int sub_id) { + [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); @@ -373,6 +579,11 @@ void Publisher::PublishSlot(shm::MessageSlot *slot, bool is_activation, } 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); @@ -384,12 +595,13 @@ void Publisher::PublishSlot(shm::MessageSlot *slot, bool is_activation, } // Releasing publisher ownership makes the message readable. - slot->refs.store(shm::BuildRefsBitField(ordinal, -1, 0), + 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(-1) != cleanup_generation && + if (!is_activation && + memory_.CleanupGeneration(vchan_id_) != cleanup_generation && memory_.TryRetireSlot(slot)) { memory_.TriggerRetirement(retirement_triggers_, slot->id); } diff --git a/asil_client/publisher.h b/asil_client/publisher.h index 4328ebac..90701f3c 100644 --- a/asil_client/publisher.h +++ b/asil_client/publisher.h @@ -7,6 +7,7 @@ #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 @@ -26,8 +27,27 @@ struct PublisherOptions { const char *type = nullptr; // Put a checksum in every message. bool checksum = false; - // Publish an activation message if the channel hasn't been activated. + // 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. They must exist already: the server + // creates them for a static channel, otherwise a standard publisher does. + // 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 { @@ -35,8 +55,10 @@ struct PublishedMessage { uint64_t timestamp = 0; }; -// An unreliable publisher for a fixed-size channel. It always holds a slot to -// write the next message into. +// 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 { @@ -52,13 +74,15 @@ class Publisher { Error Close(); bool IsOpen() const { return client_ != nullptr; } - // Where to write the next message, or null if every slot is in use. + // 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 every slot is in use. + // none or no slot is available. void *Metadata(); size_t MetadataSize() const { return static_cast(memory_.MetadataSize()); @@ -69,12 +93,27 @@ class Publisher { // 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, @@ -84,12 +123,19 @@ class Publisher { 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_; }; diff --git a/asil_client/server_connection.h b/asil_client/server_connection.h index 9545c610..40e47cad 100644 --- a/asil_client/server_connection.h +++ b/asil_client/server_connection.h @@ -5,8 +5,9 @@ // 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, and to fetch trigger file descriptors when the -// set of publishers or subscribers on a channel changes. Messages move +// 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. @@ -15,6 +16,7 @@ #include "asil_client/error.h" #include "asil_client/shm_layout.h" +#include "asil_client/split_buffer.h" #include "asil_client/unique_fd.h" #include @@ -39,6 +41,12 @@ struct PublisherRequest { 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; }; struct PublisherReply { @@ -50,6 +58,8 @@ struct PublisherReply { uint64_t subscriber_queue_arena_size = 0; 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; @@ -59,6 +69,10 @@ 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 { @@ -88,6 +102,39 @@ struct TriggersReply { 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; @@ -103,6 +150,13 @@ class ServerConnection { 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; }; diff --git a/asil_client/shm_layout.h b/asil_client/shm_layout.h index 253823e6..6301fc8d 100644 --- a/asil_client/shm_layout.h +++ b/asil_client/shm_layout.h @@ -58,6 +58,7 @@ 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; @@ -74,9 +75,9 @@ 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); + ordinal == 0 + ? 0 + : ((static_cast(vchan_id) & kVchanIdMask) << kVchanIdShift); return ((ordinal & kOrdinalMask) << kOrdinalShift) | vchan_bits | ((retired_refs & kRetiredRefsMask) << kRetiredRefsShift); } @@ -192,25 +193,51 @@ struct BufferControlBlock { 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)))); + 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)))); + 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)))) + + static_cast( + Aligned64(static_cast(SizeofBitset(num_slots)))) + SizeofBitset(num_slots) * static_cast(sub_id); } @@ -218,17 +245,22 @@ inline size_t SlotQueueIndexOffset(int num_slots) { return AvailableSlotsOffset(num_slots, kMaxSlotOwners); } -inline size_t CcbSize(int num_slots, uint64_t subscriber_queue_arena_size) { +// 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)))) + + 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)); + return static_cast( + Aligned64(static_cast(offsetof(MessagePrefix, checksum)) + + checksum_size + metadata_size)); } } // namespace shm 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 index 749c644e..4070c1f7 100644 --- a/asil_client/subscriber.cc +++ b/asil_client/subscriber.cc @@ -5,16 +5,15 @@ // 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. +// 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 #include -#include namespace subspace { namespace asil { @@ -36,8 +35,23 @@ 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) { @@ -46,43 +60,50 @@ Error Subscriber::Open(Client &client, const char *channel_name, 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; } - SubscriberRequest request; - request.channel_name = name_; - request.type = options.type; - request.max_active_messages = 1; + 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(request, reply); + if (Error e = client.connection_->CreateSubscriber(BuildRequest(), reply); e != Error::kOk) { Reset(); return e; } client_ = &client; - options_ = options; 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 (reply.vchan_id != -1 || reply.use_split_buffers || - reply.subscriber_queue_size != 0 || - reply.subscriber_queue_arena_size != 0 || channel_id_ < 0 || - channel_id_ >= shm::kMaxChannels || subscriber_id_ < 0 || - subscriber_id_ >= shm::kMaxSlotOwners) { - e = Error::kUnsupported; - } - if (e == Error::kOk) { - e = memory_.Map(client.scb_, reply.ccb.Get(), reply.bcb.Get(), - reply.num_slots, reply.checksum_size, reply.metadata_size); + 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) { - e = memory_.AttachBuffer(name_, client.session_id_); + // A channel without a layout has no buffers either. + e = reply.num_slots > 0 ? MapChannel(reply) : Error::kNoBuffers; } if (e != Error::kOk) { (void)Close(); @@ -93,6 +114,22 @@ Error Subscriber::Open(Client &client, const char *channel_name, 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(); @@ -100,10 +137,13 @@ void Subscriber::Reset() { trigger_.Reset(); poll_.Reset(); client_ = nullptr; - held_ = nullptr; - last_ordinal_ = 0; + num_held_ = 0; + for (uint64_t &ordinal : last_ordinals_) { + ordinal = 0; + } channel_id_ = -1; subscriber_id_ = -1; + vchan_id_ = -1; } Error Subscriber::Close() { @@ -111,8 +151,7 @@ Error Subscriber::Close() { return Error::kOk; } ReleaseMessage(); - const Error e = - client_->connection_->RemoveSubscriber(name_, subscriber_id_); + const Error e = client_->connection_->RemoveSubscriber(name_, subscriber_id_); Reset(); return e; } @@ -121,35 +160,40 @@ Error Subscriber::Wait(int timeout_ms) { if (client_ == nullptr) { return Error::kNotInitialized; } - struct pollfd fd = {poll_.Get(), POLLIN, 0}; - for (;;) { - const int n = ::poll(&fd, 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; - } - } + 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() { - if (held_ != nullptr) { - ReleaseSlot(held_); - held_ = nullptr; + 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, -1, slot->ordinal.load(std::memory_order_relaxed), + slot, options_.reliable, -1, + slot->ordinal.load(std::memory_order_relaxed), slot->vchan_id.load(std::memory_order_relaxed), /*retire=*/true, &retired); if (retired) { @@ -157,10 +201,15 @@ void Subscriber::ReleaseSlot(shm::MessageSlot *slot) { 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 = memory_.Counters(channel_id_).num_pub_updates; + const uint16_t updates = client_->scb_->counters[channel_id_].num_pub_updates; if (updates == num_pub_updates_) { return Error::kOk; } @@ -180,6 +229,29 @@ void Subscriber::TriggerReliablePublishers() const { } } +// 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_); @@ -189,14 +261,10 @@ Subscriber::Find Subscriber::FindNext(shm::MessageSlot *&slot) { uint64_t best_timestamp = 0; available.Traverse([&](int i) { shm::MessageSlot *s = memory_.Slot(i); - if ((s->refs.load(std::memory_order_acquire) & shm::kPubOwned) != 0) { + if (!Visible(s)) { return; } const uint64_t ordinal = s->ordinal.load(std::memory_order_relaxed); - if (ordinal == 0 || - s->buffer_index.load(std::memory_order_relaxed) != 0) { - return; - } const uint64_t timestamp = s->timestamp.load(std::memory_order_relaxed); if (best == nullptr || Before(timestamp, ordinal, best_timestamp, best_ordinal)) { @@ -208,12 +276,7 @@ Subscriber::Find Subscriber::FindNext(shm::MessageSlot *&slot) { if (best == nullptr) { return Find::kNone; } - if (memory_.AtomicIncRefCount( - best, 1, best_ordinal, - best->vchan_id.load(std::memory_order_relaxed), false, nullptr)) { - // Record ownership so that the server can release the reference if this - // process dies. - Owners(best).Set(subscriber_id_); + if (Claim(best, best_ordinal)) { slot = best; return Find::kFound; } @@ -231,14 +294,10 @@ Subscriber::Find Subscriber::FindNewest(shm::MessageSlot *&slot) { uint64_t best_timestamp = 0; available.Traverse([&](int i) { shm::MessageSlot *s = memory_.Slot(i); - if ((s->refs.load(std::memory_order_acquire) & shm::kPubOwned) != 0) { + if (!Visible(s)) { return; } const uint64_t ordinal = s->ordinal.load(std::memory_order_relaxed); - if (ordinal == 0 || - s->buffer_index.load(std::memory_order_relaxed) != 0) { - return; - } const uint64_t timestamp = s->timestamp.load(std::memory_order_relaxed); if (best == nullptr || Before(best_timestamp, best_ordinal, timestamp, ordinal)) { @@ -250,10 +309,7 @@ Subscriber::Find Subscriber::FindNewest(shm::MessageSlot *&slot) { if (best == nullptr) { return Find::kNone; } - if (memory_.AtomicIncRefCount( - best, 1, best_ordinal, - best->vchan_id.load(std::memory_order_relaxed), false, nullptr)) { - Owners(best).Set(subscriber_id_); + if (Claim(best, best_ordinal)) { slot = best; return Find::kFound; } @@ -263,34 +319,44 @@ Subscriber::Find Subscriber::FindNewest(shm::MessageSlot *&slot) { // 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. +// 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 ((s->refs.load(std::memory_order_acquire) & shm::kPubOwned) != 0) { + 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 (ordinal == 0 || - !Before(timestamp, ordinal, newest_timestamp, newest_ordinal)) { + 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, 1, ordinal, vchan_id, false, nullptr)) { + if (memory_.AtomicIncRefCount(s, options_.reliable, 1, ordinal, vchan_id, + false, nullptr)) { available.Clear(i); - (void)memory_.AtomicIncRefCount(s, -1, ordinal, vchan_id, false, - nullptr); + 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, @@ -318,53 +384,70 @@ Error Subscriber::ReadMessage(Message &message, ReadMode mode) { return Error::kNotInitialized; } internal::ClearTrigger(poll_.Get()); - ReleaseMessage(); - if (Error e = RefreshTriggers(); e != Error::kOk) { - return e; + 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 = memory_.AttachBuffer(name_, client_->session_id_); - e != Error::kOk) { + if (Error e = RefreshTriggers(); e != Error::kOk) { return e; } - if (!memory_.HasBuffer()) { - return Error::kOk; - } 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); + 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 shm::MessagePrefix *prefix = memory_.Prefix(slot); 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 bool is_activation = (prefix->flags & shm::kMessageActivate) != 0; + 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(shm::kMessageSeen, std::memory_order_relaxed); + 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; + 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); + 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; + if (ordinal > last_ordinal) { + last_ordinal = ordinal; } const bool checksum_error = options_.checksum && (prefix->flags & shm::kMessageHasChecksum) != 0 && @@ -374,12 +457,16 @@ Error Subscriber::ReadMessage(Message &message, ReadMode mode) { message = Message(); return Error::kChecksumMismatch; } - held_ = slot; + 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) { diff --git a/asil_client/subscriber.h b/asil_client/subscriber.h index f72a3da7..9d11ef07 100644 --- a/asil_client/subscriber.h +++ b/asil_client/subscriber.h @@ -17,6 +17,9 @@ 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. @@ -26,6 +29,22 @@ struct SubscriberOptions { 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 { @@ -33,26 +52,30 @@ enum class ReadMode { kReadNewest, }; -// A message in shared memory. The pointers stay valid until the next read, -// ReleaseMessage() or Close(). +// 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, for kReadNext. + // 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; }; -// An unreliable subscriber. It holds at most one message; reading the next -// message releases the previous one. +// A subscriber to a fixed-size channel. // -// A subscriber is not thread safe and must not outlive its Client. +// 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; @@ -67,42 +90,68 @@ class Subscriber { bool IsOpen() const { return client_ != nullptr; } // Reads a message. Returns kOk with message.length == 0 when there is no - // message. If a publisher has joined since the last read this asks the - // server for its trigger fds first. + // 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 the message from the last read. + // 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_; - shm::MessageSlot *held_ = nullptr; - uint64_t last_ordinal_ = 0; + 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_; }; diff --git a/docs/asil-client.md b/docs/asil-client.md index 1617086d..47cfe6be 100644 --- a/docs/asil-client.md +++ b/docs/asil-client.md @@ -6,9 +6,10 @@ 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 is designed for 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. +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 @@ -22,33 +23,97 @@ layout. `Error::kServerRejected`. - **No heap allocation.** The client, publishers and subscribers allocate no memory, and neither does `PhaserServerConnection` while it talks to the - server. The server is involved when a client, publisher or subscriber is - set up or closed, and when a publisher or subscriber refreshes its trigger - file descriptors after another process joins or leaves the channel. + 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`. + 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 provides unreliable publishers and subscribers on -fixed-size, non-virtual channels: - -- Each channel has one buffer, which is what fixed-size channels use. -- A subscriber holds at most one message. Reading the next message releases - the previous one. +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 work with both clients. + 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. Publishers must then use +split buffers, and on any other static channel they must not. 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`. -Reliable publishers and subscribers, virtual channels, subscriber queues, -split buffers and the memfd backend (always used on Android, and selected on -Linux with `SUBSPACE_LINUX_USE_MEMFD`) return `Error::kUnsupported`. -Channels created by standard publishers that resize their slots also have -more than one buffer and are rejected the same way. +```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 @@ -85,7 +150,11 @@ the server, because it sizes the shared system control block. 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`. +`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, @@ -174,6 +243,7 @@ asil::Error Run() { `PublisherOptions` gives the slot size, number of slots, checksum size, metadata size and type. On a static channel these must match the config. +`subscriber_queue_arena_size` must match the channel's other publishers. `Buffer()` returns the payload of the slot the publisher holds, and `Metadata()` returns its metadata area. `Publish(size)` sends the message and @@ -182,25 +252,96 @@ 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, 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. +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`, and checks that the -checksum matches the standard client's. `asil_client_test` runs a server with -a static channel config and exchanges messages between the ASIL client and the -standard client in both directions. It 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. It is skipped on memfd builds. +`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/proto/subspace.proto b/proto/subspace.proto index 8741ba4d..29498d17 100644 --- a/proto/subspace.proto +++ b/proto/subspace.proto @@ -212,6 +212,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 { @@ -653,8 +659,10 @@ message Telemetry { // 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 and -// metadata size. +// 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; @@ -662,13 +670,15 @@ message StaticMultiplexer { 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 and metadata_size unset and takes them from the - // multiplexer. + // 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; @@ -678,6 +688,8 @@ message StaticChannel { 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 { diff --git a/server/client_handler.cc b/server/client_handler.cc index cbf733f6..9ffc57b5 100644 --- a/server/client_handler.cc +++ b/server/client_handler.cc @@ -284,19 +284,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(); } @@ -341,6 +329,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())); @@ -487,6 +479,18 @@ void ClientHandler::HandleCreatePublisher( cs, ms)); return; } + const ServerChannel *layout_channel = + channel->IsVirtual() + ? static_cast(channel)->GetMux() + : channel; + const bool split = layout_channel->HasSplitBufferOptions() && + layout_channel->GetSplitBufferOptions().use_split_buffers; + if (req.use_split_buffers() != split) { + response->set_error(absl::StrFormat( + "Static channel %s %s split buffers", req.channel_name(), + split ? "uses" : "doesn't use")); + return; + } } if (channel == nullptr) { server_->logger_.Log(toolbelt::LogLevel::kDebug, @@ -626,7 +630,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", @@ -648,7 +654,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 " @@ -667,7 +673,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", diff --git a/server/server.cc b/server/server.cc index bbd51983..e1d9b892 100644 --- a/server/server.cc +++ b/server/server.cc @@ -1407,6 +1407,81 @@ bool Server::IsServerChannelName(const std::string &channel_name) { 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(); @@ -1463,6 +1538,14 @@ absl::Status Server::CreateStaticChannels() { 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()) { @@ -1500,6 +1583,15 @@ absl::Status Server::CreateStaticChannels() { 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", diff --git a/server/server.h b/server/server.h index cfa7aeea..bdbc3761 100644 --- a/server/server.h +++ b/server/server.h @@ -146,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 { @@ -313,6 +319,12 @@ class Server { // 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 { diff --git a/server/server_channel.cc b/server/server_channel.cc index 80f8ff59..eb9a9ec6 100644 --- a/server/server_channel.cc +++ b/server/server_channel.cc @@ -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 0a06b5d4..67bc773d 100644 --- a/server/server_channel.h +++ b/server/server_channel.h @@ -453,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_; @@ -590,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; @@ -635,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/static_config.cc b/server/static_config.cc index 2407e072..42b63e18 100644 --- a/server/static_config.cc +++ b/server/static_config.cc @@ -103,10 +103,11 @@ absl::Status ValidateStaticChannelConfig(const StaticChannelConfig &config) { } if (channel.slot_size() != 0 || channel.num_slots() != 0 || !channel.type().empty() || channel.checksum_size() != 0 || - channel.metadata_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 and metadata_size from it; leave them unset", + "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) { diff --git a/server/static_config_test.cc b/server/static_config_test.cc index f6ac933c..431e875a 100644 --- a/server/static_config_test.cc +++ b/server/static_config_test.cc @@ -10,6 +10,7 @@ #include "absl/status/status.h" #include "absl/status/statusor.h" +#include "absl/strings/str_format.h" #include "server/static_config.h" #include #include @@ -28,6 +29,19 @@ constexpr char kConfig[] = R"pb( 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 FixedSize() { @@ -107,8 +121,8 @@ TEST(StaticConfigParseTest, ParsesValidConfig) { absl::StatusOr config = subspace::ParseStaticChannelConfig(kConfig); ASSERT_OK(config); - EXPECT_EQ(1, config->multiplexers_size()); - EXPECT_EQ(4, config->channels_size()); + EXPECT_EQ(2, config->multiplexers_size()); + EXPECT_EQ(6, config->channels_size()); } TEST(StaticConfigParseTest, RejectsInvalidConfigs) { @@ -139,6 +153,9 @@ TEST(StaticConfigParseTest, RejectsInvalidConfigs) { {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"}, @@ -198,6 +215,56 @@ TEST_F(StaticConfigTest, SubscriberBeforePublisher) { 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", 256, 4, FixedSize().SetUseSplitBuffers(true)); + ASSERT_OK(pub); + EXPECT_TRUE(pub->UsesSplitBuffers()); + 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", 128, 4, + FixedSize().SetMux("/static/split_mux").SetUseSplitBuffers(true)); + ASSERT_OK(pub); + 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, RejectsMismatchedSplitBuffers) { + absl::StatusOr unsplit = + Client().CreatePublisher("/static/split", 256, 4, FixedSize()); + ASSERT_FALSE(unsplit.ok()); + EXPECT_THAT(unsplit.status().message(), HasSubstr("uses split buffers")); + + absl::StatusOr split = Client().CreatePublisher( + "/static/a", 256, 8, FixedSize().SetUseSplitBuffers(true)); + ASSERT_FALSE(split.ok()); + EXPECT_THAT(split.status().message(), + HasSubstr("doesn't use split buffers")); +} + TEST_F(StaticConfigTest, UnconfiguredChannelsAreRejected) { absl::StatusOr pub = Client().CreatePublisher("/not/configured", 64, 4, FixedSize()); @@ -307,6 +374,13 @@ TEST_F(StaticConfigTest, VirtualChannels) { 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", 128, 8, FixedSize().SetMux("/static/mux")); + ASSERT_OK(other); + EXPECT_EQ(7, other->VirtualChannelId()); } TEST_F(StaticConfigTest, ServerChannelsArePublished) { From bdccd144a016d08ed7ba17e6f363b9afad660553 Mon Sep 17 00:00:00 2001 From: Dave Allison Date: Sat, 10 Oct 2026 14:48:35 -0700 Subject: [PATCH 4/5] Share phaser server encoding between the C++ clients The standard C++ client now builds its server requests and responses as phaser messages, like the ASIL client. ServerWire in common/server_wire.h serializes requests and decodes responses for both, with growable buffers for the standard client and fixed buffers for the ASIL connection, which still makes no heap allocations. The bytes on the socket are unchanged. CMake now requires PHASER_PLUGIN_EXECUTABLE when cross-compiling, since the standard client needs subspace_phaser. --- CHANGELOG.md | 6 +- CMakeLists.txt | 6 +- asil_client/BUILD.bazel | 2 +- asil_client/CMakeLists.txt | 7 -- asil_client/phaser_connection.cc | 46 +++---- asil_client/phaser_connection.h | 14 +-- client/Android.bp | 2 + client/BUILD.bazel | 2 + client/CMakeLists.txt | 1 + client/client.cc | 209 ++++++++++++++++--------------- client/client.h | 14 +-- common/BUILD.bazel | 12 ++ common/server_wire.h | 115 +++++++++++++++++ docs/android.md | 5 +- docs/asil-client.md | 23 ++-- proto/CMakeLists.txt | 13 +- 16 files changed, 307 insertions(+), 170 deletions(-) create mode 100644 common/server_wire.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a78a4d..cbbcb0dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,12 @@ 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 take a host + `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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c56c196..4891443d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -167,8 +167,10 @@ FetchContent_MakeAvailable(protobuf) # These targets can be used directly without find_package # --- External Dependency: phaser (using FetchContent for native CMake) --- -# The ASIL client talks to the server with phaser messages held in fixed -# buffers. Phaser uses the Abseil, cpp_toolbelt and protobuf fetched above. +# 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 diff --git a/asil_client/BUILD.bazel b/asil_client/BUILD.bazel index 3c53448b..d673e5d9 100644 --- a/asil_client/BUILD.bazel +++ b/asil_client/BUILD.bazel @@ -54,7 +54,7 @@ cc_library( ], deps = [ ":asil_client", - "//proto:subspace_phaser", + "//common:server_wire", ], ) diff --git a/asil_client/CMakeLists.txt b/asil_client/CMakeLists.txt index 8bff0e59..e1692392 100644 --- a/asil_client/CMakeLists.txt +++ b/asil_client/CMakeLists.txt @@ -25,13 +25,6 @@ target_link_libraries(asil_layout_test PUBLIC gtest_main ) -# The server connection needs the phaser messages, which proto/ skips when -# cross-compiling without a host phaser plugin. -if(NOT TARGET subspace_phaser) - message(STATUS "Skipping the ASIL server connection: subspace_phaser is not available.") - return() -endif() - # The handshake with the server, using phaser messages in fixed buffers. add_library(subspace_asil_phaser_connection STATIC phaser_connection.cc diff --git a/asil_client/phaser_connection.cc b/asil_client/phaser_connection.cc index f2e20dc5..02b0c5da 100644 --- a/asil_client/phaser_connection.cc +++ b/asil_client/phaser_connection.cc @@ -150,11 +150,12 @@ Error PhaserServerConnection::Rejected(const char *message, size_t length) { return Error::kServerRejected; } -// Sends the length-prefixed request in wire_. +// 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(wire_, &network_length, sizeof(network_length)); - if (!SendFully(socket_.Get(), wire_, sizeof(network_length) + length)) { + std::memcpy(header, &network_length, sizeof(network_length)); + if (!SendFully(socket_.Get(), header, kServerWireHeaderSize + length)) { return Error::kConnectionFailed; } return Error::kOk; @@ -194,22 +195,24 @@ Error PhaserServerConnection::SendFd(int fd) { // 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(size_t &length) { +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); - char *const data = wire_ + sizeof(uint32_t); - if (length <= kWireBufferSize) { - return ReceiveFully(socket_.Get(), data, length) ? Error::kOk - : Error::kConnectionFailed; + 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(), data, chunk)) { + if (!ReceiveFully(socket_.Get(), scratch, chunk)) { return Error::kConnectionFailed; } remaining -= chunk; @@ -300,28 +303,27 @@ Error PhaserServerConnection::Transact(Build build, Read read, int send_fd) { fds_.Clear(); size_t length = 0; { - absl::StatusOr request = - wire::Request::TryCreateMutable(message_, sizeof(message_)); + // 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); - if (request->AllocationFailed()) { + absl::StatusOr serialized = wire_.Serialize(*request); + if (!serialized.ok()) { return Error::kCapacityExceeded; } - ::phaser::ProtoBuffer out(wire_ + sizeof(uint32_t), kWireBufferSize); - if (!request->Serialize(out).ok()) { - return Error::kCapacityExceeded; - } - length = out.Size(); + 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(length); + e = Receive(data, length); } if (e == Error::kOk || e == Error::kCapacityExceeded) { // The descriptors follow even a response that did not fit. @@ -340,15 +342,13 @@ Error PhaserServerConnection::Transact(Build build, Read read, int send_fd) { return e; } - absl::StatusOr response = - wire::Response::TryCreateMutable(message_, sizeof(message_)); + absl::StatusOr response = wire_.NewResponse(); if (!response.ok()) { fds_.Clear(); return Error::kCapacityExceeded; } - ::phaser::ProtoBuffer in(static_cast(wire_ + sizeof(uint32_t)), - length); - if (absl::Status status = response->Deserialize(in); !status.ok()) { + if (absl::Status status = wire_.Decode(data, length, *response); + !status.ok()) { fds_.Clear(); return status.code() == absl::StatusCode::kResourceExhausted ? Error::kCapacityExceeded diff --git a/asil_client/phaser_connection.h b/asil_client/phaser_connection.h index f195576f..e017f8f6 100644 --- a/asil_client/phaser_connection.h +++ b/asil_client/phaser_connection.h @@ -5,6 +5,7 @@ #pragma once #include "asil_client/server_connection.h" +#include "common/server_wire.h" #include #include @@ -14,10 +15,10 @@ 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 buffers inside this object and -// converted to and from protobuf wire format, so the handshake makes no heap -// allocations. A request or response that does not fit fails with -// kCapacityExceeded. +// 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. @@ -64,15 +65,14 @@ class PhaserServerConnection : public ServerConnection { Error Transact(Build build, Read read, int send_fd = -1); Error Send(size_t length); Error SendFd(int fd); - Error Receive(size_t &length); + 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] = {}; - alignas(8) char wire_[sizeof(uint32_t) + kWireBufferSize]; - alignas(8) char message_[kMessageBufferSize]; + ServerWire> wire_; }; } // namespace asil 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..cc0a96cf 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); @@ -446,29 +448,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); }; @@ -485,7 +487,7 @@ ClientImpl::CreatePublisher(const std::string &channel_name, 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)); }, @@ -601,12 +603,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 +616,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 +628,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 +1735,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 +1749,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 +1764,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 +1830,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 +1843,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 +1873,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 +1886,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 +1967,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 +1981,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 +1993,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 +2008,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 +2036,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 +2057,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 +2081,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 +2133,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 +2153,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 +2168,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,10 +2224,9 @@ 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); @@ -2253,7 +2255,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 +2278,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 +2298,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 +2332,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 +2368,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 +2392,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 +2414,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 +2461,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 +2478,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 +2514,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 +2523,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 +2539,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 +2560,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 +2576,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/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 584cc7d3..fed70418 100644 --- a/docs/android.md +++ b/docs/android.md @@ -186,7 +186,8 @@ Subspace can also be cross-compiled for Android using CMake with the NDK toolchain. CMake fetches the same third-party dependencies with `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 ASIL client's messages. +host-native phaser plugin (`protoc-gen-phaser`) for the C++ clients' server +messages. ```bash export ANDROID_NDK_HOME=/path/to/ndk @@ -243,7 +244,7 @@ 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 ASIL client | +| `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 | diff --git a/docs/asil-client.md b/docs/asil-client.md index 47cfe6be..74591dc0 100644 --- a/docs/asil-client.md +++ b/docs/asil-client.md @@ -127,7 +127,8 @@ 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`, `//proto:subspace_phaser` | +| `//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`, @@ -135,8 +136,7 @@ CMake builds the same libraries as `subspace_asil_client`, `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). Without one, the -cross-compile builds only the core library. +[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 @@ -159,13 +159,16 @@ 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. The connection builds each -request as a phaser message in a fixed buffer inside the object, 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`. +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 diff --git a/proto/CMakeLists.txt b/proto/CMakeLists.txt index db9e1bfd..a8555564 100644 --- a/proto/CMakeLists.txt +++ b/proto/CMakeLists.txt @@ -93,15 +93,14 @@ target_link_libraries(subspace_proto PUBLIC protobuf::libprotobuf ) -# Phaser messages for the ASIL client's server connection. 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; without one the ASIL server connection is skipped. +# 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(STATUS - "Skipping subspace_phaser: cross-compiling without " + message(FATAL_ERROR + "Cross-compiling needs a host protoc-gen-phaser in " "-DPHASER_PLUGIN_EXECUTABLE.") - return() endif() set(SUBSPACE_PHASER_PROTOC) if(CMAKE_CROSSCOMPILING) From e94bdaee975d0dfad193cf2c9f0dfc9fc33b3af7 Mon Sep 17 00:00:00 2001 From: Dave Allison Date: Mon, 12 Oct 2026 16:03:11 -0700 Subject: [PATCH 5/5] Require server_defined publishers on static channels A static channel's layout comes from the server's config, so its publishers now set a server_defined option instead of the options that shape the shared memory. The server rejects a publisher on a static channel that leaves out server_defined or sets num_slots, slot_size, fixed_size, checksum_size, metadata_size, subscriber_queue_arena_size or use_split_buffers, and rejects server_defined on any other channel. CreatePublisherResponse now reports the channel's slot size, checksum size, metadata size, split buffer setting and fixed size, and a server_defined publisher takes its layout from them. The C++, C, Python, Rust and ASIL clients have the option and check it before asking the server. The C++ and Rust default checksum_size is now 0, which means 4 bytes, and a bridge publisher on a static channel is server defined. --- CHANGELOG.md | 21 +++- README.md | 24 +++- asil_client/asil_client_test.cc | 183 ++++++++++++++++----------- asil_client/phaser_connection.cc | 7 +- asil_client/publisher.cc | 41 ++++-- asil_client/publisher.h | 19 ++- asil_client/server_connection.h | 8 ++ c_client/subspace.cc | 10 +- c_client/subspace.h | 9 ++ client/client.cc | 52 ++++++-- client/options.h | 16 ++- client/python/client.cc | 7 ++ docs/asil-client.md | 25 +++- docs/c-client.md | 8 ++ docs/rust-client.md | 3 +- proto/subspace.proto | 12 ++ rust_client/src/client.rs | 62 ++++++--- rust_client/src/options.rs | 16 ++- server/client_handler.cc | 116 +++++++++++------ server/client_handler.h | 2 +- server/server.cc | 30 +++-- server/server_test.cc | 22 ++++ server/static_config_test.cc | 209 +++++++++++++++++++++---------- 23 files changed, 643 insertions(+), 259 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbbcb0dc..38739165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,21 @@ - `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 their layout - is fixed: publishers must be fixed size and match the configured slot - size, number of slots, checksum size and metadata size. + 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 @@ -19,8 +31,7 @@ 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. Publishers whose split buffer - setting differs from the config are rejected. + 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`. diff --git a/README.md b/README.md index 8cc73630..26280185 100644 --- a/README.md +++ b/README.md @@ -2033,13 +2033,27 @@ loaded: 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 layout is fixed. Publishers must be fixed size (`SetFixedSize(true)`) - and match the configured slot size, number of slots, checksum size and - metadata size. +- 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. - Publishers must set `SetUseSplitBuffers(true)` on these channels and leave - it unset on the others. - 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`. diff --git a/asil_client/asil_client_test.cc b/asil_client/asil_client_test.cc index df5b659b..bd7c5476 100644 --- a/asil_client/asil_client_test.cc +++ b/asil_client/asil_client_test.cc @@ -110,6 +110,11 @@ 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); @@ -174,6 +179,13 @@ asil::PublisherOptions AsilPubOptions(int64_t slot_size, int32_t 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 { @@ -286,7 +298,7 @@ TEST_F(AsilClientTest, AsilPublisherStandardSubscriber) { ASSERT_OK(sub); asil::Publisher pub; - asil::PublisherOptions options = AsilPubOptions(256, 8); + asil::PublisherOptions options = StaticPubOptions(); options.type = "T"; ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher("/asil/to_std", options, pub)); @@ -324,7 +336,7 @@ TEST_F(AsilClientTest, StandardPublisherAsilSubscriber) { EXPECT_EQ(0u, msg.length); absl::StatusOr pub = - Std().CreatePublisher("/asil/from_std", 256, 8, FixedSize()); + Std().CreatePublisher("/asil/from_std", ServerDefined()); ASSERT_OK(pub); StdPublish(*pub, "alpha"); StdPublish(*pub, "beta"); @@ -345,7 +357,7 @@ TEST_F(AsilClientTest, AsilToAsilWithWait) { ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/wait", {}, sub)); asil::Publisher pub; ASSERT_EQ(asil::Error::kOk, - Asil().CreatePublisher("/asil/wait", AsilPubOptions(64, 4), pub)); + Asil().CreatePublisher("/asil/wait", StaticPubOptions(), pub)); // Drain the trigger set when the subscriber opened. asil::Message msg; @@ -361,9 +373,8 @@ TEST_F(AsilClientTest, AsilToAsilWithWait) { TEST_F(AsilClientTest, SubscriberJoinsAfterPublisher) { asil::Publisher pub; - ASSERT_EQ( - asil::Error::kOk, - Asil().CreatePublisher("/asil/late_sub", AsilPubOptions(64, 4), pub)); + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/late_sub", StaticPubOptions(), pub)); absl::StatusOr sub = Std().CreateSubscriber("/asil/late_sub"); @@ -382,7 +393,7 @@ TEST_F(AsilClientTest, ChecksumToStandard) { "/asil/sum_to_std", subspace::SubscriberOptions().SetChecksum(true)); ASSERT_OK(sub); asil::Publisher pub; - asil::PublisherOptions options = AsilPubOptions(128, 4); + asil::PublisherOptions options = StaticPubOptions(); options.checksum = true; ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher("/asil/sum_to_std", options, pub)); @@ -406,7 +417,7 @@ TEST_F(AsilClientTest, ChecksumFromStandard) { lenient, lenient_sub)); absl::StatusOr pub = Std().CreatePublisher( - "/asil/sum_from_std", 128, 4, FixedSize().SetChecksum(true)); + "/asil/sum_from_std", ServerDefined().SetChecksum(true)); ASSERT_OK(pub); StdPublish(*pub, "good"); @@ -440,10 +451,8 @@ TEST_F(AsilClientTest, Metadata) { ASSERT_OK(std_sub); asil::Publisher asil_pub; - asil::PublisherOptions options = AsilPubOptions(64, 8); - options.metadata_size = 16; ASSERT_EQ(asil::Error::kOk, - Asil().CreatePublisher("/asil/meta", options, asil_pub)); + 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"); @@ -452,8 +461,8 @@ TEST_F(AsilClientTest, Metadata) { 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", 64, 8, FixedSize().SetMetadataSize(16)); + absl::StatusOr std_pub = + Std().CreatePublisher("/asil/meta", ServerDefined()); ASSERT_OK(std_pub); absl::StatusOr buffer = std_pub->GetMessageBuffer(); ASSERT_OK(buffer); @@ -478,7 +487,7 @@ TEST_F(AsilClientTest, ReadNewest) { asil::Subscriber sub; ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/newest", {}, sub)); absl::StatusOr pub = - Std().CreatePublisher("/asil/newest", 64, 8, FixedSize()); + Std().CreatePublisher("/asil/newest", ServerDefined()); ASSERT_OK(pub); StdPublish(*pub, "old"); StdPublish(*pub, "older"); @@ -501,7 +510,7 @@ TEST_F(AsilClientTest, AsilSubscriberCountsDrops) { ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/drops_in", {}, sub)); absl::StatusOr pub = - Std().CreatePublisher("/asil/drops_in", 64, 4, FixedSize()); + Std().CreatePublisher("/asil/drops_in", ServerDefined()); ASSERT_OK(pub); StdPublish(*pub, "first"); asil::Message msg; @@ -544,9 +553,8 @@ TEST_F(AsilClientTest, StandardSubscriberSeesAsilDrops) { [&dropped](subspace::Subscriber *, int64_t n) { dropped += n; })); asil::Publisher pub; - ASSERT_EQ( - asil::Error::kOk, - Asil().CreatePublisher("/asil/drops_out", AsilPubOptions(64, 4), pub)); + ASSERT_EQ(asil::Error::kOk, + Asil().CreatePublisher("/asil/drops_out", StaticPubOptions(), pub)); AsilPublish(pub, "first"); EXPECT_EQ("first", StdRead(*sub).text); @@ -577,7 +585,7 @@ TEST_F(AsilClientTest, Activation) { Asil().CreateSubscriber("/asil/activate", {}, asil_sub)); asil::Publisher pub; - asil::PublisherOptions options = AsilPubOptions(64, 4); + asil::PublisherOptions options = StaticPubOptions(); options.activate = true; ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher("/asil/activate", options, pub)); @@ -604,7 +612,7 @@ TEST_F(AsilClientTest, ManyMessagesThroughFewSlots) { ASSERT_OK(std_sub); asil::Publisher pub; ASSERT_EQ(asil::Error::kOk, - Asil().CreatePublisher("/asil/cycle", AsilPubOptions(64, 4), pub)); + Asil().CreatePublisher("/asil/cycle", StaticPubOptions(), pub)); for (int i = 0; i < 200; i++) { const std::string text = "message " + std::to_string(i); @@ -621,7 +629,7 @@ TEST_F(AsilClientTest, HeldMessageIsNotOverwritten) { asil::Subscriber sub; ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/hold", {}, sub)); absl::StatusOr pub = - Std().CreatePublisher("/asil/hold", 64, 4, FixedSize()); + Std().CreatePublisher("/asil/hold", ServerDefined()); ASSERT_OK(pub); StdPublish(*pub, "held"); asil::Message held; @@ -641,16 +649,15 @@ TEST_F(AsilClientTest, HeldMessageIsNotOverwritten) { } 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(128, 4), pub)); - EXPECT_THAT(Asil().LastServerError(), HasSubstr("has 4 slots of 64 bytes")); + 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", AsilPubOptions(64, 4), pub)); + EXPECT_EQ(asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/unknown", StaticPubOptions(), pub)); EXPECT_THAT(Asil().LastServerError(), HasSubstr("isn't in the static channel config")); @@ -658,30 +665,57 @@ TEST_F(AsilClientTest, Rejections) { EXPECT_EQ(asil::Error::kServerRejected, Asil().CreateSubscriber("/asil/unknown", {}, sub)); - EXPECT_EQ(asil::Error::kInvalidArgument, - Asil().CreatePublisher("/asil/reject", AsilPubOptions(0, 4), pub)); + // 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(), - AsilPubOptions(64, 4), pub)); + StaticPubOptions(), pub)); ASSERT_EQ(asil::Error::kOk, - Asil().CreatePublisher("/asil/reject", AsilPubOptions(64, 4), pub)); + 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", AsilPubOptions(64, 4), pub)); + 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", AsilPubOptions(64, 4), - 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", AsilPubOptions(64, 4), pub)); + Asil().CreatePublisher("/asil/close", StaticPubOptions(), pub)); ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/close", {}, sub)); absl::StatusOr info = Std().GetChannelInfo("/asil/close"); @@ -701,7 +735,7 @@ TEST_F(AsilClientTest, CloseRemovesFromServer) { // The channel can be used again. ASSERT_EQ(asil::Error::kOk, - Asil().CreatePublisher("/asil/close", AsilPubOptions(64, 4), pub)); + Asil().CreatePublisher("/asil/close", StaticPubOptions(), pub)); ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/close", {}, sub)); AsilPublish(pub, "again"); asil::Message msg; @@ -722,7 +756,7 @@ TEST_F(AsilClientTest, HandshakeMakesNoHeapAllocations) { allocations = 0; counting_allocations = true; const asil::Error pub_error = - Asil().CreatePublisher("/asil/no_alloc", AsilPubOptions(64, 4), pub); + 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(); @@ -765,9 +799,8 @@ int FillReliable(asil::Publisher &pub, const std::string &prefix, int first) { return n - first; } -asil::PublisherOptions ReliablePubOptions(int64_t slot_size, - int32_t num_slots) { - asil::PublisherOptions options = AsilPubOptions(slot_size, num_slots); +asil::PublisherOptions ReliablePubOptions() { + asil::PublisherOptions options = StaticPubOptions(); options.reliable = true; return options; } @@ -777,9 +810,9 @@ TEST_F(AsilClientTest, ReliableAsilPublisherStandardSubscriber) { "/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(64, 4), 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. @@ -806,7 +839,7 @@ TEST_F(AsilClientTest, StandardReliablePublisherAsilSubscriber) { Asil().CreateSubscriber("/asil/rel_from_std", options, sub)); EXPECT_TRUE(sub.IsReliable()); absl::StatusOr pub = Std().CreatePublisher( - "/asil/rel_from_std", 64, 4, FixedSize().SetReliable(true)); + "/asil/rel_from_std", ServerDefined().SetReliable(true)); ASSERT_OK(pub); int published = 0; @@ -849,9 +882,9 @@ TEST_F(AsilClientTest, ReliableReadNewestFreesSkippedSlots) { 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(64, 4), pub)); + ASSERT_EQ( + asil::Error::kOk, + Asil().CreatePublisher("/asil/rel_newest", ReliablePubOptions(), pub)); ASSERT_EQ(3, FillReliable(pub, "n", 0)); asil::Message msg; @@ -871,9 +904,8 @@ TEST_F(AsilClientTest, ReliableReadNewestFreesSkippedSlots) { TEST_F(AsilClientTest, ReliablePublisherWaitsForSubscriber) { asil::Publisher pub; - ASSERT_EQ( - asil::Error::kOk, - Asil().CreatePublisher("/asil/rel_late", ReliablePubOptions(64, 4), pub)); + ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher( + "/asil/rel_late", ReliablePubOptions(), pub)); EXPECT_EQ(nullptr, pub.Buffer()); absl::StatusOr sub = Std().CreateSubscriber( @@ -885,9 +917,8 @@ TEST_F(AsilClientTest, ReliablePublisherWaitsForSubscriber) { TEST_F(AsilClientTest, VirtualChannels) { asil::Publisher rejected; - EXPECT_EQ( - asil::Error::kServerRejected, - Asil().CreatePublisher("/asil/v1", AsilPubOptions(128, 8), rejected)); + EXPECT_EQ(asil::Error::kServerRejected, + Asil().CreatePublisher("/asil/v1", StaticPubOptions(), rejected)); asil::Subscriber mux_sub; ASSERT_EQ(asil::Error::kOk, @@ -902,14 +933,14 @@ TEST_F(AsilClientTest, VirtualChannels) { "/asil/v1", subspace::SubscriberOptions().SetMux("/asil/mux")); ASSERT_OK(v1_sub); - asil::PublisherOptions v1_options = AsilPubOptions(128, 8); + 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", 128, 8, FixedSize().SetMux("/asil/mux")); + absl::StatusOr v2_pub = + Std().CreatePublisher("/asil/v2", ServerDefined().SetMux("/asil/mux")); ASSERT_OK(v2_pub); AsilPublish(v1_pub, "a1"); @@ -949,7 +980,7 @@ TEST_F(AsilClientTest, MultipleActiveMessages) { ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/multi", options, sub)); absl::StatusOr pub = - Std().CreatePublisher("/asil/multi", 64, 8, FixedSize()); + Std().CreatePublisher("/asil/multi", ServerDefined()); ASSERT_OK(pub); for (const char *text : {"a", "b", "c", "d"}) { StdPublish(*pub, text); @@ -992,19 +1023,19 @@ TEST_F(AsilClientTest, SubscriberOpensBeforeAnyPublisher) { EXPECT_EQ(0u, msg.length); absl::StatusOr pub = - Std().CreatePublisher("/asil/early_sub", 64, 4, FixedSize()); + 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)); } -asil::PublisherOptions SplitPubOptions(int64_t slot_size, int32_t num_slots, - asil::SplitSlot *slots) { - asil::PublisherOptions options = AsilPubOptions(slot_size, num_slots); - options.use_split_buffers = true; +// 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 = num_slots; + options.split_slot_capacity = capacity; return options; } @@ -1031,7 +1062,7 @@ TEST_F(AsilClientTest, SplitAsilPublisherStandardSubscriber) { asil::Publisher pub; ASSERT_EQ(asil::Error::kOk, Asil().CreatePublisher("/asil/split_to_std", - SplitPubOptions(256, 4, pub_slots), pub)) + SplitPubOptions(pub_slots, 4), pub)) << Asil().LastServerError(); EXPECT_GE(pub.SlotSize(), 256); for (const asil::SplitSlot &slot : pub_slots) { @@ -1063,8 +1094,7 @@ TEST_F(AsilClientTest, SplitStandardPublisherAsilSubscriber) { ASSERT_EQ(asil::Error::kOk, Asil().CreateSubscriber("/asil/split_from_std", options, sub)); absl::StatusOr pub = Std().CreatePublisher( - "/asil/split_from_std", 256, 4, - FixedSize().SetUseSplitBuffers(true).SetChecksum(true)); + "/asil/split_from_std", ServerDefined().SetChecksum(true)); ASSERT_OK(pub); asil::Message msg; @@ -1087,7 +1117,7 @@ TEST_F(AsilClientTest, SplitVirtualChannel) { EXPECT_EQ(4, sub.VirtualChannelId()); asil::SplitSlot pub_slots[8]; - asil::PublisherOptions pub_options = SplitPubOptions(128, 8, pub_slots); + asil::PublisherOptions pub_options = SplitPubOptions(pub_slots, 8); pub_options.mux = "/asil/split_mux"; asil::Publisher pub; ASSERT_EQ(asil::Error::kOk, @@ -1110,15 +1140,16 @@ TEST_F(AsilClientTest, SplitRejections) { 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::kServerRejected, - Asil().CreatePublisher("/asil/split_reject", AsilPubOptions(64, 4), pub)); - EXPECT_THAT(Asil().LastServerError(), HasSubstr("uses split buffers")); - EXPECT_EQ(asil::Error::kServerRejected, - Asil().CreatePublisher("/asil/reject", - SplitPubOptions(64, 4, slots), pub)); - EXPECT_THAT(Asil().LastServerError(), HasSubstr("doesn't use split buffers")); + 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) { diff --git a/asil_client/phaser_connection.cc b/asil_client/phaser_connection.cc index 02b0c5da..81cacc49 100644 --- a/asil_client/phaser_connection.cc +++ b/asil_client/phaser_connection.cc @@ -388,7 +388,8 @@ Error PhaserServerConnection::CreatePublisher(const PublisherRequest &request, 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(true); + 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)); @@ -420,6 +421,10 @@ Error PhaserServerConnection::CreatePublisher(const PublisherRequest &request, 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; diff --git a/asil_client/publisher.cc b/asil_client/publisher.cc index c87aa579..5395b69b 100644 --- a/asil_client/publisher.cc +++ b/asil_client/publisher.cc @@ -41,10 +41,18 @@ Error Publisher::Open(Client &client, const char *channel_name, if (!client.Initialized()) { return Error::kNotInitialized; } - if (options.slot_size <= 0 || options.num_slots <= 0 || - !ValidLayoutSize(options.checksum_size) || - !ValidLayoutSize(options.metadata_size) || options.vchan_id < -1 || - options.vchan_id >= shm::kMaxVchanId) { + 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) { @@ -55,11 +63,11 @@ Error Publisher::Open(Client &client, const char *channel_name, e != Error::kOk) { return e; } - const int64_t slot_size = shm::Aligned64(options.slot_size); - PublisherRequest request; request.channel_name = name_; - request.slot_size = slot_size; + 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; @@ -91,22 +99,35 @@ Error Publisher::Open(Client &client, const char *channel_name, 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)) { + (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, - options.checksum_size, options.metadata_size, + checksum_size, metadata_size, reply.subscriber_queue_arena_size, &buffer_context_); } if (e == Error::kOk) { - e = options.use_split_buffers + e = use_split_buffers ? memory_.AttachSplitBuffers( /*writable=*/true, options.split_slots, options.split_slot_capacity, options.split_allocator) diff --git a/asil_client/publisher.h b/asil_client/publisher.h index 90701f3c..14e91122 100644 --- a/asil_client/publisher.h +++ b/asil_client/publisher.h @@ -18,8 +18,14 @@ namespace asil { class Client; struct PublisherOptions { - // The channel layout. On a server with a static channel config these must - // match the configured channel. + // 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. @@ -40,10 +46,11 @@ struct PublisherOptions { // 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. They must exist already: the server - // creates them for a static channel, otherwise a standard publisher does. - // split_slots has room for the channel's num_slots slot mappings, and the - // allocator maps slots that a custom allocator created. + // 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; diff --git a/asil_client/server_connection.h b/asil_client/server_connection.h index 40e47cad..80a84767 100644 --- a/asil_client/server_connection.h +++ b/asil_client/server_connection.h @@ -47,6 +47,9 @@ struct PublisherRequest { 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 { @@ -56,6 +59,11 @@ struct PublisherReply { 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. 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/client.cc b/client/client.cc index cc0a96cf..ec13b0af 100644 --- a/client/client.cc +++ b/client/client.cc @@ -434,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"); } @@ -483,6 +494,15 @@ 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(), @@ -507,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)); @@ -534,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; @@ -2228,24 +2250,30 @@ 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 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/docs/asil-client.md b/docs/asil-client.md index 74591dc0..dd5b026c 100644 --- a/docs/asil-client.md +++ b/docs/asil-client.md @@ -88,9 +88,10 @@ 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. Publishers must then use -split buffers, and on any other static channel they must not. On a server -that creates channels on demand, the first standard publisher creates them. +`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, @@ -245,9 +246,25 @@ asil::Error Run() { ## Publishers `PublisherOptions` gives the slot size, number of slots, checksum size, -metadata size and type. On a static channel these must match the config. +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 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/proto/subspace.proto b/proto/subspace.proto index 29498d17..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 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/client_handler.cc b/server/client_handler.cc index 9ffc57b5..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 @@ -355,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; @@ -453,45 +521,6 @@ void ClientHandler::HandleCreatePublisher( !StaticConfigAllowsNewChannel(*server_, req.channel_name(), response)) { return; } - if (channel != nullptr && channel->IsStatic()) { - // Resizing happens in the publisher, so only a fixed size publisher keeps - // the configured layout. - if (!req.is_fixed_size()) { - response->set_error(absl::StrFormat( - "Publishers on static channel %s must be fixed size", - req.channel_name())); - return; - } - if (req.slot_size() != channel->SlotSize() || - req.num_slots() != channel->NumSlots()) { - response->set_error(absl::StrFormat( - "Static channel %s has %d slots of %d bytes, not %d slots of %d " - "bytes", - req.channel_name(), channel->NumSlots(), channel->SlotSize(), - req.num_slots(), req.slot_size())); - return; - } - if (cs != channel->ChecksumSize() || ms != channel->MetadataSize()) { - response->set_error(absl::StrFormat( - "Static channel %s has checksum_size %d and metadata_size %d, not " - "%d and %d", - req.channel_name(), channel->ChecksumSize(), channel->MetadataSize(), - cs, ms)); - return; - } - const ServerChannel *layout_channel = - channel->IsVirtual() - ? static_cast(channel)->GetMux() - : channel; - const bool split = layout_channel->HasSplitBufferOptions() && - layout_channel->GetSplitBufferOptions().use_split_buffers; - if (req.use_split_buffers() != split) { - response->set_error(absl::StrFormat( - "Static channel %s %s split buffers", req.channel_name(), - split ? "uses" : "doesn't use")); - return; - } - } if (channel == nullptr) { server_->logger_.Log(toolbelt::LogLevel::kDebug, "Publisher %s is creating new channel %s with size " @@ -819,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); 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/server.cc b/server/server.cc index e1d9b892..2d4c7ce4 100644 --- a/server/server.cc +++ b/server/server.cc @@ -3397,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_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_test.cc b/server/static_config_test.cc index 431e875a..5cf55fb0 100644 --- a/server/static_config_test.cc +++ b/server/static_config_test.cc @@ -11,7 +11,9 @@ #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 @@ -44,8 +46,39 @@ constexpr char kConfig[] = R"pb( channels { name: "/static/sv" mux: "/static/split_mux" vchan_id: 1 } )pb"; -subspace::PublisherOptions FixedSize() { - return subspace::PublisherOptions().SetFixedSize(true); +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) { @@ -106,6 +139,7 @@ class StaticConfigTest : public ::testing::Test { } subspace::Client &Client() { return client_; } + static const std::string &Socket() { return socket_; } private: inline static subspace::async::RuntimeEngine engine_; @@ -204,7 +238,7 @@ TEST_F(StaticConfigTest, SubscriberBeforePublisher) { EXPECT_EQ(8, sub->NumSlots()); absl::StatusOr pub = - Client().CreatePublisher("/static/a", 256, 8, FixedSize()); + Client().CreatePublisher("/static/a", ServerDefined()); ASSERT_OK(pub); Publish(*pub, "hello"); @@ -221,10 +255,12 @@ TEST_F(StaticConfigTest, SplitBuffersCreatedByServer) { ASSERT_OK(sub); EXPECT_TRUE(sub->UsesSplitBuffers()); - absl::StatusOr pub = Client().CreatePublisher( - "/static/split", 256, 4, FixedSize().SetUseSplitBuffers(true)); + 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); @@ -242,9 +278,9 @@ TEST_F(StaticConfigTest, SplitBufferVirtualChannel) { EXPECT_TRUE(sub->UsesSplitBuffers()); absl::StatusOr pub = Client().CreatePublisher( - "/static/sv", 128, 4, - FixedSize().SetMux("/static/split_mux").SetUseSplitBuffers(true)); + "/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); @@ -252,22 +288,103 @@ TEST_F(StaticConfigTest, SplitBufferVirtualChannel) { std::string(static_cast(msg->buffer), msg->length)); } -TEST_F(StaticConfigTest, RejectsMismatchedSplitBuffers) { - absl::StatusOr unsplit = - Client().CreatePublisher("/static/split", 256, 4, FixedSize()); - ASSERT_FALSE(unsplit.ok()); - EXPECT_THAT(unsplit.status().message(), HasSubstr("uses split buffers")); - - absl::StatusOr split = Client().CreatePublisher( - "/static/a", 256, 8, FixedSize().SetUseSplitBuffers(true)); - ASSERT_FALSE(split.ok()); - EXPECT_THAT(split.status().message(), - HasSubstr("doesn't use split buffers")); +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", 64, 4, FixedSize()); + Client().CreatePublisher("/not/configured", ServerDefined()); ASSERT_FALSE(pub.ok()); EXPECT_THAT(pub.status().message(), HasSubstr("isn't in the static channel config")); @@ -279,52 +396,17 @@ TEST_F(StaticConfigTest, UnconfiguredChannelsAreRejected) { HasSubstr("isn't in the static channel config")); // A virtual channel on a configured multiplexer must be configured too. - pub = Client().CreatePublisher("/static/v9", 128, 8, - FixedSize().SetMux("/static/mux")); + 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, PublisherLayoutMustMatch) { - absl::StatusOr pub = Client().CreatePublisher( - "/static/a", 256, 8, subspace::PublisherOptions()); - ASSERT_FALSE(pub.ok()); - EXPECT_THAT(pub.status().message(), HasSubstr("must be fixed size")); - - for (auto [slot_size, num_slots] : - std::vector>{{512, 8}, {128, 8}, {256, 16}, - {256, 4}}) { - pub = Client().CreatePublisher("/static/a", slot_size, num_slots, - FixedSize()); - ASSERT_FALSE(pub.ok()) << slot_size << "/" << num_slots; - EXPECT_THAT(pub.status().message(), - HasSubstr("has 8 slots of 256 bytes")); - } - - pub = Client().CreatePublisher("/static/meta", 64, 4, FixedSize()); - ASSERT_FALSE(pub.ok()); - EXPECT_THAT(pub.status().message(), - HasSubstr("checksum_size 4 and metadata_size 16, not 4 and 0")); - pub = Client().CreatePublisher("/static/meta", 64, 4, - FixedSize().SetMetadataSize(16)); - ASSERT_OK(pub); - - // A matching second publisher is accepted after the first has created the - // channel's buffers. - absl::StatusOr first = - Client().CreatePublisher("/static/a", 256, 8, FixedSize()); - ASSERT_OK(first); - Publish(*first, "x"); - absl::StatusOr second = - Client().CreatePublisher("/static/a", 256, 8, FixedSize()); - ASSERT_OK(second); -} - TEST_F(StaticConfigTest, ChannelsOutliveTheirUsers) { { absl::StatusOr pub = - Client().CreatePublisher("/static/a", 256, 8, FixedSize()); + Client().CreatePublisher("/static/a", ServerDefined()); ASSERT_OK(pub); absl::StatusOr sub = Client().CreateSubscriber("/static/a"); @@ -340,7 +422,7 @@ TEST_F(StaticConfigTest, ChannelsOutliveTheirUsers) { EXPECT_EQ(8, info->num_slots); absl::StatusOr pub = - Client().CreatePublisher("/static/a", 256, 8, FixedSize()); + Client().CreatePublisher("/static/a", ServerDefined()); ASSERT_OK(pub); absl::StatusOr sub = Client().CreateSubscriber("/static/a"); @@ -360,13 +442,10 @@ TEST_F(StaticConfigTest, VirtualChannels) { EXPECT_EQ(3, sub->VirtualChannelId()); absl::StatusOr pub = Client().CreatePublisher( - "/static/v1", 256, 8, FixedSize().SetMux("/static/mux")); - ASSERT_FALSE(pub.ok()); - EXPECT_THAT(pub.status().message(), HasSubstr("has 8 slots of 128 bytes")); - - pub = Client().CreatePublisher("/static/v1", 128, 8, - FixedSize().SetMux("/static/mux")); + "/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"); @@ -378,7 +457,7 @@ TEST_F(StaticConfigTest, VirtualChannels) { // Publishers on other virtual channels of the multiplexer share its // buffers, so they are also fixed size. absl::StatusOr other = Client().CreatePublisher( - "/static/v2", 128, 8, FixedSize().SetMux("/static/mux")); + "/static/v2", ServerDefined().SetMux("/static/mux")); ASSERT_OK(other); EXPECT_EQ(7, other->VirtualChannelId()); }