Milestone 3

This commit is contained in:
Joey Yakimowich-Payne 2026-01-29 17:47:31 -07:00
commit 691eb327d0
6 changed files with 507 additions and 24 deletions

10
PLAN.md
View file

@ -25,11 +25,11 @@
- [x] Performance tests: instructions: create/destroy 100 virtual sinks and 100 virtual sources in a tight loop; measure wall time and ensure it stays within the target budget. - [x] Performance tests: instructions: create/destroy 100 virtual sinks and 100 virtual sources in a tight loop; measure wall time and ensure it stays within the target budget.
- [ ] Milestone 3 - Link management API - [ ] Milestone 3 - Link management API
- [ ] Implement link creation via link-factory (load libpipewire-module-link-factory and call pw_core_create_object with link.input.* and link.output.* props; see src/modules/module-link-factory.c, src/examples/internal.c, src/tools/pw-link.c). - [x] Implement link creation via link-factory (load libpipewire-module-link-factory and call pw_core_create_object with link.input.* and link.output.* props; see src/modules/module-link-factory.c, src/examples/internal.c, src/tools/pw-link.c).
- [ ] Support linking by node+port names and by object IDs; add object.linger and link.passive options. - [x] Support linking by node+port names and by object IDs; add object.linger and link.passive options.
- [ ] Add link deletion and link reconciliation (auto-remove stale links when endpoints vanish). - [x] Add link deletion and link reconciliation (auto-remove stale links when endpoints vanish).
- [ ] Tests to add (non-happy path/edge cases): instructions: link to non-existent port; link output-to-output or input-to-input; remove node while link is initializing; create two links to same port and validate policy behavior. - [x] Tests to add (non-happy path/edge cases): instructions: link to non-existent port; link output-to-output or input-to-input; remove node while link is initializing; create two links to same port and validate policy behavior.
- [ ] Performance tests: instructions: create 200 links between existing ports; measure create+destroy time and verify subsecond target where possible. - [x] Performance tests: instructions: create 200 links between existing ports; measure create+destroy time and verify subsecond target where possible.
- [ ] Milestone 4 - Persistence and "ephemeral source" policy - [ ] Milestone 4 - Persistence and "ephemeral source" policy
- [ ] Implement persistence (JSON or TOML) for: virtual nodes, links, and per-app routing rules. Persist on change; load on startup. - [ ] Implement persistence (JSON or TOML) for: virtual nodes, links, and per-app routing rules. Persist on change; load on startup.

View file

@ -152,6 +152,11 @@ class Client {
Status RemoveNode(NodeId node); Status RemoveNode(NodeId node);
Result<Link> CreateLink(PortId output, PortId input, const LinkOptions& options); Result<Link> CreateLink(PortId output, PortId input, const LinkOptions& options);
Result<Link> CreateLinkByName(std::string_view output_node,
std::string_view output_port,
std::string_view input_node,
std::string_view input_port,
const LinkOptions& options);
Status RemoveLink(LinkId link); Status RemoveLink(LinkId link);
Result<RuleId> AddRouteRule(const RouteRule& rule); Result<RuleId> AddRouteRule(const RouteRule& rule);

View file

@ -22,6 +22,12 @@ Registry snapshot + add/remove events (milestone 1):
./build/warppipe_perf --mode registry --count 1000 --events 100 ./build/warppipe_perf --mode registry --count 1000 --events 100
``` ```
Link creation + removal (milestone 3):
```
./build/warppipe_perf --mode links --links 200
./build/warppipe_perf --mode links --links 200 --batch 50
```
Optional format and loopback: Optional format and loopback:
``` ```
./build/warppipe_perf --mode create-destroy --count 200 --type sink --rate 48000 --channels 2 ./build/warppipe_perf --mode create-destroy --count 200 --type sink --rate 48000 --channels 2

View file

@ -1,8 +1,10 @@
#include <chrono> #include <chrono>
#include <algorithm>
#include <cstdint> #include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <iomanip> #include <iomanip>
#include <iostream> #include <iostream>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@ -18,6 +20,8 @@ struct Options {
std::string target; std::string target;
uint32_t count = 200; uint32_t count = 200;
uint32_t events = 100; uint32_t events = 100;
uint32_t links = 200;
uint32_t batch = 0;
uint32_t rate = 48000; uint32_t rate = 48000;
uint32_t channels = 2; uint32_t channels = 2;
}; };
@ -37,10 +41,12 @@ bool ParseUInt(const char* value, uint32_t* out) {
void PrintUsage() { void PrintUsage() {
std::cout << "warppipe_perf usage:\n" std::cout << "warppipe_perf usage:\n"
<< " --mode create-destroy|registry\n" << " --mode create-destroy|registry|links\n"
<< " --type sink|source|both\n" << " --type sink|source|both\n"
<< " --count N (default 200, per-type when --type both)\n" << " --count N (default 200, per-type when --type both)\n"
<< " --events N (registry mode, default 100)\n" << " --events N (registry mode, default 100)\n"
<< " --links N (links mode, default 200)\n"
<< " --batch N (links mode, batch size)\n"
<< " --rate N (default 48000)\n" << " --rate N (default 48000)\n"
<< " --channels N (default 2)\n" << " --channels N (default 2)\n"
<< " --target <node-name> (loopback target, optional)\n"; << " --target <node-name> (loopback target, optional)\n";
@ -61,6 +67,14 @@ bool ParseArgs(int argc, char* argv[], Options* options) {
if (!ParseUInt(argv[++i], &options->events)) { if (!ParseUInt(argv[++i], &options->events)) {
return false; return false;
} }
} else if (arg == "--links" && i + 1 < argc) {
if (!ParseUInt(argv[++i], &options->links)) {
return false;
}
} else if (arg == "--batch" && i + 1 < argc) {
if (!ParseUInt(argv[++i], &options->batch)) {
return false;
}
} else if (arg == "--rate" && i + 1 < argc) { } else if (arg == "--rate" && i + 1 < argc) {
if (!ParseUInt(argv[++i], &options->rate)) { if (!ParseUInt(argv[++i], &options->rate)) {
return false; return false;
@ -80,7 +94,7 @@ bool ParseArgs(int argc, char* argv[], Options* options) {
if (options->type != "sink" && options->type != "source" && options->type != "both") { if (options->type != "sink" && options->type != "source" && options->type != "both") {
return false; return false;
} }
if (options->mode != "create-destroy" && options->mode != "registry") { if (options->mode != "create-destroy" && options->mode != "registry" && options->mode != "links") {
return false; return false;
} }
return true; return true;
@ -94,6 +108,23 @@ double ToMillis(std::chrono::steady_clock::duration duration) {
return std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(duration).count(); return std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(duration).count();
} }
std::optional<warppipe::PortId> FindPort(warppipe::Client* client,
warppipe::NodeId node,
bool want_input) {
for (int attempt = 0; attempt < 50; ++attempt) {
auto ports = client->ListPorts(node);
if (ports.ok()) {
for (const auto& port : ports.value) {
if (port.is_input == want_input) {
return port.id;
}
}
}
usleep(5000);
}
return std::nullopt;
}
} // namespace } // namespace
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
@ -226,6 +257,96 @@ int main(int argc, char* argv[]) {
return 0; return 0;
} }
if (options.mode == "links") {
const uint32_t pair_count = options.links;
const uint32_t batch = options.batch == 0 ? pair_count : options.batch;
double total_create_ms = 0.0;
double total_remove_ms = 0.0;
size_t total_links = 0;
for (uint32_t start_index = 0; start_index < pair_count; start_index += batch) {
const uint32_t batch_count = std::min(batch, pair_count - start_index);
std::vector<warppipe::NodeId> sinks;
std::vector<warppipe::NodeId> sources;
sinks.reserve(batch_count);
sources.reserve(batch_count);
for (uint32_t i = 0; i < batch_count; ++i) {
uint32_t index = start_index + i;
std::string sink_name = prefix + "-sink-" + std::to_string(index);
auto sink = client.value->CreateVirtualSink(sink_name, node_options);
if (!sink.ok()) {
std::cerr << "create sink failed at " << index << ": " << sink.status.message << "\n";
break;
}
sinks.push_back(sink.value.node);
std::string source_name = prefix + "-source-" + std::to_string(index);
auto source = client.value->CreateVirtualSource(source_name, node_options);
if (!source.ok()) {
std::cerr << "create source failed at " << index << ": " << source.status.message << "\n";
break;
}
sources.push_back(source.value.node);
}
const size_t pair_limit = std::min(sinks.size(), sources.size());
std::vector<warppipe::LinkId> links;
links.reserve(pair_limit);
std::vector<warppipe::PortId> out_ports;
std::vector<warppipe::PortId> in_ports;
out_ports.reserve(pair_limit);
in_ports.reserve(pair_limit);
for (size_t i = 0; i < pair_limit; ++i) {
auto out_port = FindPort(client.value.get(), sources[i], false);
auto in_port = FindPort(client.value.get(), sinks[i], true);
if (!out_port || !in_port) {
std::cerr << "port lookup failed at " << (start_index + i) << "\n";
break;
}
out_ports.push_back(*out_port);
in_ports.push_back(*in_port);
}
auto create_start = std::chrono::steady_clock::now();
for (size_t i = 0; i < out_ports.size() && i < in_ports.size(); ++i) {
auto link = client.value->CreateLink(out_ports[i], in_ports[i], warppipe::LinkOptions{});
if (!link.ok()) {
std::cerr << "link failed at " << (start_index + i) << ": " << link.status.message << "\n";
break;
}
links.push_back(link.value.id);
}
auto create_end = std::chrono::steady_clock::now();
for (const auto& link_id : links) {
client.value->RemoveLink(link_id);
}
auto remove_end = std::chrono::steady_clock::now();
for (const auto& node : sources) {
client.value->RemoveNode(node);
}
for (const auto& node : sinks) {
client.value->RemoveNode(node);
}
total_links += links.size();
total_create_ms += ToMillis(create_end - create_start);
total_remove_ms += ToMillis(remove_end - create_end);
}
const double total_ms = total_create_ms + total_remove_ms;
std::cout << "link_count=" << total_links << "\n"
<< "link_create_ms=" << std::fixed << std::setprecision(2) << total_create_ms << "\n"
<< "link_remove_ms=" << total_remove_ms << "\n"
<< "link_total_ms=" << total_ms << "\n"
<< "link_batch=" << batch << "\n";
return 0;
}
PrintUsage(); PrintUsage();
return 2; return 2;
} }

View file

@ -6,6 +6,7 @@
#include <utility> #include <utility>
#include <pipewire/keys.h> #include <pipewire/keys.h>
#include <pipewire/link.h>
#include <pipewire/pipewire.h> #include <pipewire/pipewire.h>
#include <spa/param/audio/format-utils.h> #include <spa/param/audio/format-utils.h>
@ -76,6 +77,58 @@ struct StreamData {
uint32_t rate = kDefaultRate; uint32_t rate = kDefaultRate;
}; };
struct LinkProxy {
pw_proxy* proxy = nullptr;
spa_hook listener{};
pw_thread_loop* loop = nullptr;
bool done = false;
bool failed = false;
std::string error;
uint32_t id = SPA_ID_INVALID;
};
void LinkProxyBound(void* data, uint32_t global_id) {
auto* link = static_cast<LinkProxy*>(data);
if (!link) {
return;
}
link->id = global_id;
link->done = true;
if (link->loop) {
pw_thread_loop_signal(link->loop, false);
}
}
void LinkProxyRemoved(void* data) {
auto* link = static_cast<LinkProxy*>(data);
if (!link) {
return;
}
link->done = true;
if (link->loop) {
pw_thread_loop_signal(link->loop, false);
}
}
void LinkProxyError(void* data, int, int res, const char* message) {
auto* link = static_cast<LinkProxy*>(data);
if (!link) {
return;
}
link->failed = true;
link->error = message ? message : spa_strerror(res);
if (link->loop) {
pw_thread_loop_signal(link->loop, false);
}
}
static const pw_proxy_events kLinkProxyEvents = {
PW_VERSION_PROXY_EVENTS,
.bound = LinkProxyBound,
.removed = LinkProxyRemoved,
.error = LinkProxyError,
};
void StreamProcess(void* data) { void StreamProcess(void* data) {
auto* stream_data = static_cast<StreamData*>(data); auto* stream_data = static_cast<StreamData*>(data);
if (!stream_data || !stream_data->stream) { if (!stream_data || !stream_data->stream) {
@ -187,6 +240,7 @@ struct Client::Impl {
std::unordered_map<uint32_t, PortInfo> ports; std::unordered_map<uint32_t, PortInfo> ports;
std::unordered_map<uint32_t, Link> links; std::unordered_map<uint32_t, Link> links;
std::unordered_map<uint32_t, std::unique_ptr<StreamData>> virtual_streams; std::unordered_map<uint32_t, std::unique_ptr<StreamData>> virtual_streams;
std::unordered_map<uint32_t, std::unique_ptr<LinkProxy>> link_proxies;
Status ConnectLocked(); Status ConnectLocked();
void DisconnectLocked(); void DisconnectLocked();
@ -270,6 +324,7 @@ void Client::Impl::RegistryGlobalRemove(void* data, uint32_t id) {
std::lock_guard<std::mutex> lock(impl->cache_mutex); std::lock_guard<std::mutex> lock(impl->cache_mutex);
impl->virtual_streams.erase(id); impl->virtual_streams.erase(id);
impl->link_proxies.erase(id);
auto node_it = impl->nodes.find(id); auto node_it = impl->nodes.find(id);
if (node_it != impl->nodes.end()) { if (node_it != impl->nodes.end()) {
impl->nodes.erase(node_it); impl->nodes.erase(node_it);
@ -519,7 +574,10 @@ Result<uint32_t> Client::Impl::CreateVirtualStreamLocked(std::string_view name,
stream_data->node_id = node_id; stream_data->node_id = node_id;
stream_data->ready = true; stream_data->ready = true;
virtual_streams.emplace(node_id, std::move(stream_data)); {
std::lock_guard<std::mutex> lock(cache_mutex);
virtual_streams.emplace(node_id, std::move(stream_data));
}
return {Status::Ok(), node_id}; return {Status::Ok(), node_id};
} }
@ -575,7 +633,21 @@ Status Client::Impl::ConnectLocked() {
} }
void Client::Impl::DisconnectLocked() { void Client::Impl::DisconnectLocked() {
for (auto& entry : virtual_streams) { std::unordered_map<uint32_t, std::unique_ptr<LinkProxy>> links;
std::unordered_map<uint32_t, std::unique_ptr<StreamData>> streams;
{
std::lock_guard<std::mutex> lock(cache_mutex);
links.swap(link_proxies);
streams.swap(virtual_streams);
}
for (auto& entry : links) {
LinkProxy* link = entry.second.get();
if (link && link->proxy) {
pw_proxy_destroy(link->proxy);
link->proxy = nullptr;
}
}
for (auto& entry : streams) {
StreamData* stream_data = entry.second.get(); StreamData* stream_data = entry.second.get();
if (stream_data && stream_data->stream) { if (stream_data && stream_data->stream) {
pw_stream_disconnect(stream_data->stream); pw_stream_disconnect(stream_data->stream);
@ -583,7 +655,6 @@ void Client::Impl::DisconnectLocked() {
stream_data->stream = nullptr; stream_data->stream = nullptr;
} }
} }
virtual_streams.clear();
if (registry_listener_attached) { if (registry_listener_attached) {
spa_hook_remove(&registry_listener); spa_hook_remove(&registry_listener);
registry_listener_attached = false; registry_listener_attached = false;
@ -772,28 +843,214 @@ Status Client::RemoveNode(NodeId node) {
} }
pw_thread_loop_lock(impl_->thread_loop); pw_thread_loop_lock(impl_->thread_loop);
auto it = impl_->virtual_streams.find(node.value); std::unique_ptr<StreamData> owned_stream;
if (it == impl_->virtual_streams.end()) { {
pw_thread_loop_unlock(impl_->thread_loop); std::lock_guard<std::mutex> lock(impl_->cache_mutex);
return Status::Error(StatusCode::kNotFound, "node not managed by warppipe"); auto it = impl_->virtual_streams.find(node.value);
if (it == impl_->virtual_streams.end()) {
pw_thread_loop_unlock(impl_->thread_loop);
return Status::Error(StatusCode::kNotFound, "node not managed by warppipe");
}
owned_stream = std::move(it->second);
impl_->virtual_streams.erase(it);
} }
StreamData* stream_data = it->second.get(); if (owned_stream && owned_stream->stream) {
if (stream_data && stream_data->stream) { pw_stream_disconnect(owned_stream->stream);
pw_stream_disconnect(stream_data->stream); pw_stream_destroy(owned_stream->stream);
pw_stream_destroy(stream_data->stream); owned_stream->stream = nullptr;
stream_data->stream = nullptr;
} }
impl_->virtual_streams.erase(it);
pw_thread_loop_unlock(impl_->thread_loop); pw_thread_loop_unlock(impl_->thread_loop);
return Status::Ok(); return Status::Ok();
} }
Result<Link> Client::CreateLink(PortId, PortId, const LinkOptions&) { Result<Link> Client::CreateLink(PortId output, PortId input, const LinkOptions& options) {
return {Status::Error(StatusCode::kNotImplemented, "create link not implemented"), {}}; Status status = impl_->EnsureConnected();
if (!status.ok()) {
return {status, {}};
}
if (output.value == 0 || input.value == 0) {
return {Status::Error(StatusCode::kInvalidArgument, "invalid port id"), {}};
}
pw_thread_loop_lock(impl_->thread_loop);
PortInfo out_port;
PortInfo in_port;
{
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
auto out_it = impl_->ports.find(output.value);
if (out_it == impl_->ports.end()) {
pw_thread_loop_unlock(impl_->thread_loop);
return {Status::Error(StatusCode::kNotFound, "output port not found"), {}};
}
auto in_it = impl_->ports.find(input.value);
if (in_it == impl_->ports.end()) {
pw_thread_loop_unlock(impl_->thread_loop);
return {Status::Error(StatusCode::kNotFound, "input port not found"), {}};
}
out_port = out_it->second;
in_port = in_it->second;
for (const auto& entry : impl_->links) {
const Link& link = entry.second;
if (link.output_port.value == output.value && link.input_port.value == input.value) {
pw_thread_loop_unlock(impl_->thread_loop);
return {Status::Error(StatusCode::kInvalidArgument, "link already exists"), {}};
}
}
}
if (out_port.is_input || !in_port.is_input) {
pw_thread_loop_unlock(impl_->thread_loop);
return {Status::Error(StatusCode::kInvalidArgument, "port directions do not match"), {}};
}
pw_properties* props = pw_properties_new(nullptr, nullptr);
if (!props) {
pw_thread_loop_unlock(impl_->thread_loop);
return {Status::Error(StatusCode::kInternal, "failed to allocate link properties"), {}};
}
pw_properties_setf(props, PW_KEY_LINK_OUTPUT_PORT, "%u", output.value);
pw_properties_setf(props, PW_KEY_LINK_INPUT_PORT, "%u", input.value);
if (options.passive) {
pw_properties_set(props, PW_KEY_LINK_PASSIVE, "true");
}
if (options.linger) {
pw_properties_set(props, PW_KEY_OBJECT_LINGER, "true");
}
pw_proxy* proxy = reinterpret_cast<pw_proxy*>(
pw_core_create_object(impl_->core,
"link-factory",
PW_TYPE_INTERFACE_Link,
PW_VERSION_LINK,
&props->dict,
0));
pw_properties_free(props);
if (!proxy) {
pw_thread_loop_unlock(impl_->thread_loop);
return {Status::Error(StatusCode::kUnavailable, "failed to create link"), {}};
}
auto link_proxy = std::make_unique<LinkProxy>();
link_proxy->proxy = proxy;
link_proxy->loop = impl_->thread_loop;
pw_proxy_add_listener(proxy, &link_proxy->listener, &kLinkProxyEvents, link_proxy.get());
int wait_attempts = 0;
while (link_proxy->id == SPA_ID_INVALID && !link_proxy->failed && wait_attempts < 3) {
int wait_res = pw_thread_loop_timed_wait(impl_->thread_loop, kSyncWaitSeconds);
if (wait_res == -ETIMEDOUT) {
break;
}
++wait_attempts;
}
if (link_proxy->failed) {
std::string error = link_proxy->error.empty() ? "link creation failed" : link_proxy->error;
pw_proxy_destroy(proxy);
pw_thread_loop_unlock(impl_->thread_loop);
return {Status::Error(StatusCode::kUnavailable, std::move(error)), {}};
}
if (link_proxy->id == SPA_ID_INVALID) {
pw_proxy_destroy(proxy);
pw_thread_loop_unlock(impl_->thread_loop);
return {Status::Error(StatusCode::kTimeout, "timed out waiting for link id"), {}};
}
Link link;
link.id = LinkId{link_proxy->id};
link.output_port = output;
link.input_port = input;
{
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
impl_->link_proxies.emplace(link_proxy->id, std::move(link_proxy));
impl_->links[link.id.value] = link;
}
pw_thread_loop_unlock(impl_->thread_loop);
return {Status::Ok(), link};
} }
Status Client::RemoveLink(LinkId) { Result<Link> Client::CreateLinkByName(std::string_view output_node,
return Status::Error(StatusCode::kNotImplemented, "remove link not implemented"); std::string_view output_port,
std::string_view input_node,
std::string_view input_port,
const LinkOptions& options) {
Status status = impl_->EnsureConnected();
if (!status.ok()) {
return {status, {}};
}
if (output_node.empty() || output_port.empty() || input_node.empty() || input_port.empty()) {
return {Status::Error(StatusCode::kInvalidArgument, "node or port name missing"), {}};
}
std::optional<PortId> out_id;
std::optional<PortId> in_id;
{
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
for (const auto& entry : impl_->ports) {
const PortInfo& port = entry.second;
auto node_it = impl_->nodes.find(port.node.value);
if (node_it == impl_->nodes.end()) {
continue;
}
const NodeInfo& node = node_it->second;
if (port.is_input) {
if (node.name == input_node && port.name == input_port) {
in_id = port.id;
}
} else {
if (node.name == output_node && port.name == output_port) {
out_id = port.id;
}
}
if (out_id && in_id) {
break;
}
}
}
if (!out_id || !in_id) {
return {Status::Error(StatusCode::kNotFound, "matching ports not found"), {}};
}
return CreateLink(*out_id, *in_id, options);
}
Status Client::RemoveLink(LinkId link) {
Status status = impl_->EnsureConnected();
if (!status.ok()) {
return status;
}
pw_thread_loop_lock(impl_->thread_loop);
bool removed = false;
{
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
auto it = impl_->link_proxies.find(link.value);
if (it != impl_->link_proxies.end()) {
if (it->second && it->second->proxy) {
pw_proxy_destroy(it->second->proxy);
}
impl_->link_proxies.erase(it);
impl_->links.erase(link.value);
removed = true;
}
}
if (!removed && impl_->registry) {
int res = pw_registry_destroy(impl_->registry, link.value);
if (res < 0) {
pw_thread_loop_unlock(impl_->thread_loop);
return Status::Error(StatusCode::kNotFound, "link not found");
}
removed = true;
}
pw_thread_loop_unlock(impl_->thread_loop);
return removed ? Status::Ok() : Status::Error(StatusCode::kNotFound, "link not found");
} }
Result<RuleId> Client::AddRouteRule(const RouteRule&) { Result<RuleId> Client::AddRouteRule(const RouteRule&) {

View file

@ -216,3 +216,97 @@ TEST_CASE("autoconnect reconnects after forced disconnect") {
auto nodes_after = result.value->ListNodes(); auto nodes_after = result.value->ListNodes();
REQUIRE(nodes_after.ok()); REQUIRE(nodes_after.ok());
} }
TEST_CASE("link creation validates ports and directions") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
warppipe::NodeInfo node;
node.id = warppipe::NodeId{600001};
node.name = "warppipe-link-node";
node.media_class = "Audio/Sink";
REQUIRE(result.value->Test_InsertNode(node).ok());
warppipe::PortInfo out_port;
out_port.id = warppipe::PortId{600002};
out_port.node = node.id;
out_port.name = "out";
out_port.is_input = false;
REQUIRE(result.value->Test_InsertPort(out_port).ok());
warppipe::PortInfo in_port;
in_port.id = warppipe::PortId{600003};
in_port.node = node.id;
in_port.name = "in";
in_port.is_input = true;
REQUIRE(result.value->Test_InsertPort(in_port).ok());
auto invalid = result.value->CreateLink(warppipe::PortId{0}, in_port.id, warppipe::LinkOptions{});
REQUIRE_FALSE(invalid.ok());
REQUIRE(invalid.status.code == warppipe::StatusCode::kInvalidArgument);
auto missing_out = result.value->CreateLink(warppipe::PortId{123456}, in_port.id, warppipe::LinkOptions{});
REQUIRE_FALSE(missing_out.ok());
REQUIRE(missing_out.status.code == warppipe::StatusCode::kNotFound);
auto missing_in = result.value->CreateLink(out_port.id, warppipe::PortId{123457}, warppipe::LinkOptions{});
REQUIRE_FALSE(missing_in.ok());
REQUIRE(missing_in.status.code == warppipe::StatusCode::kNotFound);
auto mismatch = result.value->CreateLink(in_port.id, out_port.id, warppipe::LinkOptions{});
REQUIRE_FALSE(mismatch.ok());
REQUIRE(mismatch.status.code == warppipe::StatusCode::kInvalidArgument);
}
TEST_CASE("CreateLinkByName validates missing names") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
auto invalid = result.value->CreateLinkByName("", "out", "node", "in", warppipe::LinkOptions{});
REQUIRE_FALSE(invalid.ok());
REQUIRE(invalid.status.code == warppipe::StatusCode::kInvalidArgument);
}
TEST_CASE("duplicate links are rejected") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
warppipe::NodeInfo node;
node.id = warppipe::NodeId{600101};
node.name = "warppipe-link-dup";
node.media_class = "Audio/Sink";
REQUIRE(result.value->Test_InsertNode(node).ok());
warppipe::PortInfo out_port;
out_port.id = warppipe::PortId{600102};
out_port.node = node.id;
out_port.name = "out";
out_port.is_input = false;
REQUIRE(result.value->Test_InsertPort(out_port).ok());
warppipe::PortInfo in_port;
in_port.id = warppipe::PortId{600103};
in_port.node = node.id;
in_port.name = "in";
in_port.is_input = true;
REQUIRE(result.value->Test_InsertPort(in_port).ok());
auto first = result.value->CreateLink(out_port.id, in_port.id, warppipe::LinkOptions{});
if (!first.ok()) {
SUCCEED("Link factory unavailable");
return;
}
auto second = result.value->CreateLink(out_port.id, in_port.id, warppipe::LinkOptions{});
REQUIRE_FALSE(second.ok());
REQUIRE(second.status.code == warppipe::StatusCode::kInvalidArgument);
}