blob: 28317f4a46b63004e5f559977aa88a4879fc0075 [file]
// Copyright 2026 The Pigweed Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
#include "pw_rpc2/client.h"
#include <cstddef>
#include <optional>
#include <utility>
#include <variant>
#include "echo_pb/echo.rpc.pwpb.h"
#include "pw_allocator/testing.h"
#include "pw_assert/check.h"
#include "pw_async2/dispatcher_for_test.h"
#include "pw_async2/try.h"
#include "pw_async2/value_future.h"
#include "pw_rpc2/internal/handshake.h"
#include "pw_rpc2/internal/packet.h"
#include "pw_rpc2/internal/test_utils.h"
#include "pw_thread/test_thread_context.h"
#include "pw_thread/thread.h"
#include "pw_transport/transport.h"
#include "pw_unit_test/framework.h"
namespace pw::rpc2 {
namespace {
class MockConnector : public transport::ReliableDatagramConnector {
public:
MockConnector() = default;
transport::ReliableDatagramConnector::ConnectFuture Connect() override {
return connect_provider_.Get();
}
void ResolveConnect(Result<transport::ReliableDatagramSocket> res) {
connect_provider_.Resolve(std::move(res));
}
private:
async2::ValueProvider<Result<transport::ReliableDatagramSocket>>
connect_provider_;
};
class ClientFutureTestTask : public async2::Task {
public:
explicit ClientFutureTestTask(ClientFuture future)
: fut_(std::move(future)) {}
async2::Poll<> DoPend(async2::Context& cx) override {
auto poll = fut_.Pend(cx);
if (poll.IsPending()) {
return async2::Pending();
}
result_ = std::move(*poll);
return async2::Ready();
}
std::optional<Result<Client>>& result() { return result_; }
const std::optional<Result<Client>>& result() const { return result_; }
~ClientFutureTestTask() override {
Deregister();
// A `Client` must be closed before it is destroyed. If nothing took
// this one, close it here.
if (result_.has_value() && result_->ok()) {
auto closed = (*result_)->Close();
}
}
private:
ClientFuture fut_;
std::optional<Result<Client>> result_;
};
// Awaits a `ControlFuture` on the dispatcher. A future only makes progress
// while a task is pending it, so tests cannot simply run the dispatcher and
// inspect the future.
class ControlTask : public async2::Task {
public:
explicit ControlTask(ControlFuture&& future)
: async2::Task(PW_ASYNC_TASK_NAME("ControlTask")),
future_(std::move(future)) {}
async2::Poll<> DoPend(async2::Context& cx) override {
PW_TRY_READY_ASSIGN(Status status, future_.Pend(cx));
status_ = status;
return async2::Ready();
}
bool done() const { return status_.has_value(); }
Status status() const { return status_.value_or(Status::Unknown()); }
private:
ControlFuture future_;
std::optional<Status> status_;
};
// Pumps the handshake against a mock connection and returns the connected
// client. `task` must outlive the returned client's creation, but is
// deregistered before returning.
Client ConnectClient(async2::DispatcherForTest& dispatcher,
Allocator& allocator,
transport::ReliableDatagramSocket connection,
test::MockConnection& raw_connection,
std::optional<ClientFutureTestTask>& task) {
task.emplace(Client::Create(dispatcher, allocator, std::move(connection)));
dispatcher.Post(*task);
dispatcher.RunUntilStalled();
Buf response =
Buf::Allocate(allocator, internal::HandshakePacket::kWireSizeBytes);
PW_CHECK(!response.empty());
auto encoded =
internal::HandshakePacket(internal::HandshakePacket::Type::kSynAck)
.Encode(std::move(response));
PW_CHECK(encoded.ok());
raw_connection.SetNextRead(std::move(*encoded));
dispatcher.RunUntilStalled();
PW_CHECK(task->result().has_value());
PW_CHECK(task->result()->ok());
Client client = std::move(task->result()->value());
task->Deregister();
return client;
}
TEST(ClientFutureTest, CreateFromTransportSequentialConnectAndHandshake) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
MockConnector transport;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
auto fut = Client::Create(dispatcher, allocator, transport);
ClientFutureTestTask task(std::move(fut));
dispatcher.Post(task);
// Poll 1: Awaiting transport connection.
dispatcher.RunUntilStalled();
EXPECT_FALSE(task.result().has_value());
// Resolve transport connection. Now ClientFuture should move to handshake
// stage and send HandshakePacket.
transport.ResolveConnect(conn);
dispatcher.RunUntilStalled();
EXPECT_FALSE(task.result().has_value());
EXPECT_EQ(raw_conn->commit_count(), 1u);
// Provide server handshake response.
Buf resp =
Buf::Allocate(allocator, internal::HandshakePacket::kWireSizeBytes);
ASSERT_FALSE(resp.empty());
auto hs_pkt =
internal::HandshakePacket(internal::HandshakePacket::Type::kSynAck);
auto enc_res = hs_pkt.Encode(std::move(resp));
ASSERT_TRUE(enc_res.ok());
raw_conn->SetNextRead(std::move(enc_res.value()));
// Poll 2: ClientFuture completes handshake (sends ACK) and yields handshaked
// Client.
dispatcher.RunUntilStalled();
ASSERT_TRUE(task.result().has_value());
ASSERT_TRUE(task.result()->ok());
EXPECT_EQ(raw_conn->commit_count(), 2u);
Client client = std::move(task.result()->value());
EXPECT_TRUE(client.is_open());
// Check that the client can create calls after handshake completes
auto call = internal::CallAccess::CreateCall(client);
EXPECT_NE(call, nullptr);
call = nullptr;
test::CloseClient(client, dispatcher);
task.Deregister();
}
TEST(ClientFutureTest, CreateFromConnectionDirectHandshake) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
auto fut = Client::Create(dispatcher, allocator, conn);
ClientFutureTestTask task(std::move(fut));
dispatcher.Post(task);
// Poll 1: Awaiting handshake response.
dispatcher.RunUntilStalled();
EXPECT_FALSE(task.result().has_value());
EXPECT_EQ(raw_conn->commit_count(), 1u);
// Provide server handshake response.
Buf resp =
Buf::Allocate(allocator, internal::HandshakePacket::kWireSizeBytes);
ASSERT_FALSE(resp.empty());
auto hs_pkt =
internal::HandshakePacket(internal::HandshakePacket::Type::kSynAck);
auto enc_res = hs_pkt.Encode(std::move(resp));
ASSERT_TRUE(enc_res.ok());
raw_conn->SetNextRead(std::move(enc_res.value()));
// Poll 2: Completes handshake and yields ready Client.
dispatcher.RunUntilStalled();
ASSERT_TRUE(task.result().has_value());
ASSERT_TRUE(task.result()->ok());
EXPECT_EQ(raw_conn->commit_count(), 2u);
Client client = std::move(task.result()->value());
EXPECT_TRUE(client.is_open());
test::CloseClient(client, dispatcher);
task.Deregister();
}
TEST(ClientFutureTest, CreateFromTransportConnectFailure) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
MockConnector transport;
auto fut = Client::Create(dispatcher, allocator, transport);
ClientFutureTestTask task(std::move(fut));
dispatcher.Post(task);
dispatcher.RunUntilStalled();
EXPECT_FALSE(task.result().has_value());
// Fail transport connection.
transport.ResolveConnect(Status::Unavailable());
dispatcher.RunUntilStalled();
ASSERT_TRUE(task.result().has_value());
EXPECT_FALSE(task.result()->ok());
EXPECT_EQ(task.result()->status(), Status::Unavailable());
task.Deregister();
}
TEST(ClientFutureTest, CreateFromConnectionHandshakeFailure) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
auto fut = Client::Create(dispatcher, allocator, conn);
ClientFutureTestTask task(std::move(fut));
dispatcher.Post(task);
dispatcher.RunUntilStalled();
EXPECT_FALSE(task.result().has_value());
// Provide corrupt handshake packet response.
Buf resp = Buf::Allocate(allocator, 4);
ASSERT_FALSE(resp.empty());
raw_conn->SetNextRead(std::move(resp));
dispatcher.RunUntilStalled();
ASSERT_TRUE(task.result().has_value());
EXPECT_FALSE(task.result()->ok());
EXPECT_EQ(task.result()->status(), Status::DataLoss());
task.Deregister();
}
TEST(ClientTest, CallIdAllocationAndCopyMoveSemantics) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
auto fut = Client::Create(dispatcher, allocator, conn);
ClientFutureTestTask task(std::move(fut));
dispatcher.Post(task);
dispatcher.RunUntilStalled();
EXPECT_FALSE(task.result().has_value());
pw::Buf resp =
pw::Buf::Allocate(allocator, internal::HandshakePacket::kWireSizeBytes);
auto hs_pkt =
internal::HandshakePacket(internal::HandshakePacket::Type::kSynAck);
auto enc_res = hs_pkt.Encode(std::move(resp));
ASSERT_TRUE(enc_res.ok());
raw_conn->SetNextRead(std::move(enc_res.value()));
dispatcher.RunUntilStalled();
ASSERT_TRUE(task.result().has_value() && task.result()->ok());
EXPECT_EQ(raw_conn->commit_count(), 2u);
Client client = std::move(task.result()->value());
// Moving empties the source.
Client moved_client(std::move(client));
EXPECT_TRUE(moved_client.is_open());
EXPECT_FALSE(client.is_open()); // NOLINT(bugprone-use-after-move)
// Copying keeps both clients open and referring to the same connection.
Client copied_client(moved_client);
EXPECT_TRUE(copied_client.is_open());
EXPECT_TRUE(moved_client.is_open());
// ServiceClients store a Client and expose it via client(), and every copy
// draws call IDs from the one underlying connection. A ServiceClient is only
// constructed through a derived service client.
class TestServiceClient : public ServiceClient {
public:
TestServiceClient(const Client& client, uint32_t service_id)
: ServiceClient(client, service_id) {}
};
TestServiceClient ref1(copied_client, 1);
TestServiceClient ref2 = ref1;
EXPECT_TRUE(ref1.is_open());
EXPECT_TRUE(ref1.client().is_open());
auto call1 = internal::CallAccess::CreateCall(ref1.client());
auto call2 = internal::CallAccess::CreateCall(ref2.client());
auto call3 = internal::CallAccess::CreateCall(copied_client);
ASSERT_NE(call1, nullptr);
ASSERT_NE(call2, nullptr);
ASSERT_NE(call3, nullptr);
EXPECT_NE(call1->call_id(), call2->call_id());
EXPECT_NE(call2->call_id(), call3->call_id());
// Drop the calls before closing the client.
call1 = nullptr;
call2 = nullptr;
call3 = nullptr;
test::CloseClient(ref1.client(), dispatcher);
EXPECT_FALSE(moved_client.is_open());
EXPECT_FALSE(copied_client.is_open());
EXPECT_FALSE(ref2.is_open());
task.Deregister();
}
TEST(MockPeerTest, SkipsHandshakeEntirely) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto peer = test::MakeMockPeer(dispatcher, allocator);
// Client::Create() would have written a kSyn here and blocked awaiting a
// kSynAck (see CreateFromConnectionDirectHandshake above, which expects
// commit_count 1 then 2). MakeMockPeer writes nothing at all, so the
// first recorded packet is whatever the first real call emits.
dispatcher.RunUntilStalled();
EXPECT_EQ(peer.connection().commit_count(), 0u);
EXPECT_EQ(peer.connection().written_packet_count(), 0u);
}
TEST(MockPeerTest, IsImmediatelyUsable) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto peer = test::MakeMockPeer(dispatcher, allocator);
const Client& client = peer.client();
// No handshake had to be pumped for the client to allocate calls.
EXPECT_TRUE(client.is_open());
auto call = internal::CallAccess::CreateCall(client);
EXPECT_NE(call, nullptr);
}
TEST(MockPeerTest, ObservesNoCallsBeforeAnyCall) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto peer = test::MakeMockPeer(dispatcher, allocator);
EXPECT_TRUE(peer.pending_calls().empty());
}
// The typed payload path is backend-agnostic: `MockPeer` only ever calls the
// generic `Size`/`Serialize`/`Deserialize` dispatchers, and the pwpb
// `SerializerFor` specialization arrives via the generated header included
// above. These tests pin that down against a real generated client.
using EchoRequest = echo::pwpb::EchoRequest::Message;
using EchoResponse = echo::pwpb::EchoResponse::Message;
class EchoUnaryTask : public async2::Task {
public:
using Future =
echo::pwpb::EchoService::UnaryCallFuture<EchoRequest, EchoResponse>;
explicit EchoUnaryTask(Future fut) : fut_(std::move(fut)) {}
async2::Poll<> DoPend(async2::Context& cx) override {
auto poll = fut_.Pend(cx);
if (poll.IsPending()) {
return async2::Pending();
}
result_ = std::move(*poll);
return async2::Ready();
}
const std::optional<Result<EchoResponse>>& result() const { return result_; }
private:
Future fut_;
std::optional<Result<EchoResponse>> result_;
};
TEST(MockPeerTest, DecodesTypedRequestAndSendsTypedResponse) {
// Allocator first: the dispatcher destroys still-posted tasks living in it.
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto peer = test::MakeMockPeer(dispatcher, allocator);
echo::pwpb::EchoService::Client client(peer.client());
EchoUnaryTask task(client.EchoUnary(EchoRequest{.val = 42}));
dispatcher.Post(task);
dispatcher.RunUntilStalled();
// Read side: the recorded request payload decodes back to the message.
auto call = peer.ExpectInvocation<echo::pwpb::EchoService::EchoUnary>();
auto decoded = call.request();
ASSERT_TRUE(decoded.ok());
EXPECT_EQ(decoded->val, 42u);
// The call is still open until the peer answers it.
EXPECT_FALSE(task.result().has_value());
// Send side: a typed response is serialized and delivered to the client.
call.Finish(EchoResponse{.val = 99});
ASSERT_TRUE(task.result().has_value());
ASSERT_TRUE(task.result()->ok());
EXPECT_EQ(task.result()->value().val, 99u);
task.Deregister();
}
TEST(MockPeerTest, ClaimsEachInvocationExactlyOnce) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto peer = test::MakeMockPeer(dispatcher, allocator);
echo::pwpb::EchoService::Client client(peer.client());
EchoUnaryTask first(client.EchoUnary(EchoRequest{.val = 1}));
dispatcher.Post(first);
peer.ExpectInvocation<echo::pwpb::EchoService::EchoUnary>().Finish(
EchoResponse{.val = 11});
ASSERT_TRUE(first.result().has_value());
EXPECT_EQ(first.result()->value().val, 11u);
// The first call is claimed, so the second ExpectInvocation() sees only the
// new one rather than tripping on a stale request.
EchoUnaryTask second(client.EchoUnary(EchoRequest{.val = 2}));
dispatcher.Post(second);
peer.ExpectInvocation<echo::pwpb::EchoService::EchoUnary>().Finish(
EchoResponse{.val = 22});
ASSERT_TRUE(second.result().has_value());
EXPECT_EQ(second.result()->value().val, 22u);
EXPECT_TRUE(peer.pending_calls().empty());
first.Deregister();
second.Deregister();
}
TEST(MockPeerTest, TypedDecodeFailsOnGarbagePayload) {
// Decoding a valid payload as the wrong shape must report an error rather
// than silently yielding a default-constructed message.
const std::byte garbage[] = {std::byte{0xFF}, std::byte{0xFF}};
EXPECT_FALSE(test::PayloadAs<EchoRequest>(garbage).ok());
}
// Closing a client is the only way to release its connection.
TEST(ClientTest, CloseMarksClientClosedAndResolves) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
std::optional<ClientFutureTestTask> connect_task;
Client client =
ConnectClient(dispatcher, allocator, conn, *raw_conn, connect_task);
ASSERT_TRUE(client.is_open());
// `Close()` hands the close request off to the dispatcher immediately, so
// `is_open()` becomes false as soon as `Close()` returns.
ControlTask close_task(client.Close());
EXPECT_FALSE(client.is_open());
dispatcher.Post(close_task);
dispatcher.RunUntilStalled();
EXPECT_TRUE(close_task.done());
EXPECT_EQ(close_task.status(), OkStatus());
}
// Closing an empty client is a no-op rather than an error, so moved-from and
// already-closed clients can be closed again unconditionally.
TEST(ClientTest, CloseOnAnEmptyClientResolvesImmediately) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
std::optional<ClientFutureTestTask> connect_task;
Client client =
ConnectClient(dispatcher, allocator, conn, *raw_conn, connect_task);
test::CloseClient(client, dispatcher);
ASSERT_FALSE(client.is_open());
ControlTask close_task(client.Close());
dispatcher.Post(close_task);
dispatcher.RunUntilStalled();
EXPECT_TRUE(close_task.done());
EXPECT_EQ(close_task.status(), OkStatus());
}
// Closing tears down the connection, which aborts every call riding on it.
TEST(ClientTest, CloseCancelsInFlightCalls) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
std::optional<ClientFutureTestTask> connect_task;
Client client =
ConnectClient(dispatcher, allocator, conn, *raw_conn, connect_task);
echo::pwpb::EchoService::Client stub{client};
EchoUnaryTask call(stub.EchoUnary(EchoRequest{.val = 7}));
dispatcher.Post(call);
dispatcher.RunUntilStalled();
ASSERT_FALSE(call.result().has_value());
test::CloseClient(client, dispatcher);
ASSERT_TRUE(call.result().has_value());
EXPECT_EQ(call.result()->status(), Status::Cancelled());
call.Deregister();
}
// `Close()` and `CloseBlocking()` are the only client operations that may run
// off the dispatcher thread; the blocking form is for threads that are not
// driving the dispatcher themselves.
TEST(ClientTest, BlockingCloseRunsFromAnotherThread) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
std::optional<ClientFutureTestTask> connect_task;
Client client =
ConnectClient(dispatcher, allocator, conn, *raw_conn, connect_task);
pw::thread::test::TestThreadContext context;
pw::Thread thread(context.options(), [&] { client.CloseBlocking(); });
dispatcher.AllowBlocking();
dispatcher.RunToCompletion();
thread.join();
EXPECT_FALSE(client.is_open());
}
TEST(ClientTest, DroppingOneOfSeveralHandlesKeepsConnectionOpen) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
std::optional<ClientFutureTestTask> connect_task;
std::optional<Client> client =
ConnectClient(dispatcher, allocator, conn, *raw_conn, connect_task);
echo::pwpb::EchoService::Client stub{*client};
EchoUnaryTask call(stub.EchoUnary(EchoRequest{.val = 7}));
dispatcher.Post(call);
dispatcher.RunUntilStalled();
ASSERT_FALSE(call.result().has_value());
// Destroying one Client keeps the connection open for the remaining handles
// (such as `stub`).
client.reset();
dispatcher.RunUntilStalled();
EXPECT_TRUE(stub.is_open());
EXPECT_FALSE(raw_conn->is_closed());
EXPECT_FALSE(call.result().has_value());
test::CloseClient(stub.client(), dispatcher);
ASSERT_TRUE(call.result().has_value());
EXPECT_EQ(call.result()->status(), Status::Cancelled());
call.Deregister();
}
// Once no handle can reach the connection, nothing could ever close it, so
// dropping the last handle closes it.
TEST(ClientTest, DroppingLastHandleClosesConnection) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
std::optional<ClientFutureTestTask> connect_task;
std::optional<Client> client =
ConnectClient(dispatcher, allocator, conn, *raw_conn, connect_task);
std::optional<echo::pwpb::EchoService::Client> stub(std::in_place, *client);
EchoUnaryTask call(stub->EchoUnary(EchoRequest{.val = 7}));
dispatcher.Post(call);
dispatcher.RunUntilStalled();
ASSERT_FALSE(call.result().has_value());
client.reset();
dispatcher.RunUntilStalled();
EXPECT_FALSE(raw_conn->is_closed());
EXPECT_FALSE(call.result().has_value());
// `stub` holds the last handle.
stub.reset();
dispatcher.RunUntilStalled();
EXPECT_TRUE(raw_conn->is_closed());
ASSERT_TRUE(call.result().has_value());
EXPECT_EQ(call.result()->status(), Status::Cancelled());
call.Deregister();
}
TEST(ClientFutureTest, DestroyedUnpendedFromConnectionClosesConnection) {
allocator::test::AllocatorForTest<16384> allocator;
async2::DispatcherForTest dispatcher;
auto [conn, raw_conn] = test::MakeMockConnection(allocator);
{
auto fut = Client::Create(dispatcher, allocator, conn);
EXPECT_TRUE(fut.is_pendable());
}
EXPECT_TRUE(raw_conn->is_closed());
}
} // namespace
} // namespace pw::rpc2