Portable Object Adapter (POA)
A POA holds your servants (the objects that implement IDL interfaces) and routes incoming calls to them. You create one or more POAs, activate servants in them, and hand the resulting object references to clients.
A POA decides three things about the objects in it:
- Lifespan: reference counted, or alive until deactivated.
- Object ids: assigned by the POA, or chosen by you.
- Where servant methods run: on the transport thread, or on a thread or queue you choose.
An application often uses several POAs, for example one for cheap, latency-critical servants and one whose servants must run on the UI thread.
Creating a POA
C++
#include <nprpc/nprpc.hpp>
auto* rpc = nprpc::RpcBuilder()
.with_hostname("localhost")
.with_tcp(15000)
.build();
rpc->start_thread_pool(4);
auto* poa = rpc->create_poa()
.with_max_objects(128)
.with_lifespan(nprpc::PoaPolicy::Lifespan::Persistent)
.build();
| Builder method | Default | Purpose |
|---|---|---|
with_max_objects(n) |
32 | Objects active at once |
with_lifespan(...) |
Transient |
Transient: owned by the clients that hold it; deleted when the last one releases it. Persistent: lives until deactivated. |
with_object_id_policy(...) |
SystemGenerated |
Whether the POA or you assign object ids |
with_transport_affinity(...) |
AllowBlockTransport |
Whether methods may run on the transport thread |
with_dispatch_executor(...) |
none | Run methods on your own executor |
A transient object belongs to the client it was created for, so it can only
be activated inside a call, with that client's session:
activate_object(servant, flags, &nprpc::get_context()). Objects you publish
at startup belong in a persistent POA.
The runtime owns the POA; remove it with rpc->destroy_poa(poa), never
delete.
Swift
import NPRPC
let rpc = try RpcBuilder()
.withHostname("localhost")
.withTcp(15000)
.build()
try rpc.startThreadPool(4)
let poa = try rpc.createPoa(maxObjects: 128)
createPoa takes lifetime: (default .Persistent), idPolicy: (default
.systemGenerated) and dispatch: (default .inlineOnTransportThread; see
below).
Activating objects
Activation registers a servant and returns its ObjectId: the reference
clients use to reach it. The flags choose which transports may call it.
C++
class CalculatorImpl : public example::ICalculator_Servant {
public:
double Add(double a, double b) override { return a + b; }
};
auto oid = poa->activate_object(
new CalculatorImpl(),
nprpc::ObjectActivationFlags::tcp | nprpc::ObjectActivationFlags::shm |
nprpc::ObjectActivationFlags::ws);
The runtime calls the servant's destroy() when it is done with it, which
deletes it by default, so allocate servants with new. With the
UserSupplied id policy, use activate_object_with_id(id, servant, flags).
Swift
let oid = try poa.activateObject(CalculatorImpl(), flags: [.tcp, .shm, .ws])
// oid.urls lists where it can be reached, e.g. "tcp://…;mem://…;web://…"
Activation flags
| Flag | Transport |
|---|---|
tcp |
TCP |
shm |
Shared memory (same machine) |
ws / wss |
WebSocket |
http / https |
RPC over HTTP |
quic |
Native QUIC |
wt |
WebTransport |
privateSession |
Only the session that activated the object may call it |
Swift also has .allowAll and .networkOnly; C++ has all.
To publish an object, give its ObjectId to clients: through the nameserver
(Bind/Resolve), host.json for browsers (Rpc::add_to_host_json), or as a
string (ObjectId::to_string()).
Choosing where servant methods run
By default a servant method runs on the thread that received the call: the transport's I/O thread, or for shared memory the thread reading the ring. That is the lowest-latency option, and right for methods that finish in microseconds. A method that blocks, allocates heavily or touches UI state should run elsewhere, so the transport keeps serving other calls.
| Setup | Methods run on | Use for |
|---|---|---|
| Default | The transport thread | Cheap, latency-critical methods |
NeverBlockTransport affinity |
The runtime's thread pool | Methods that may block, with no thread requirement |
Swift dispatch: .main |
The main queue | UI servants |
Swift dispatch: .queue(q) |
Your serial queue | State owned by one queue |
Swift dispatch: .loop(executor) |
Your event-loop thread | A render loop, GLFW, epoll |
C++ with_dispatch_executor(ex) |
Wherever ex.post schedules work |
Your own executor |
With any of the non-default options, the transport hands the call off and immediately moves on. The method and its reply both run on the target.
C++
// Keep the transport free, run on the runtime's thread pool.
auto* offload = rpc->create_poa()
.with_transport_affinity(
nprpc::PoaPolicy::TransportAffinity::NeverBlockTransport)
.build();
// Or run on your own executor.
nprpc::DispatchExecutor ex;
ex.post = [](void* ctx, nprpc::DispatchExecutor::WorkFn fn, void* arg) {
static_cast<MyQueue*>(ctx)->push([fn, arg] { fn(arg); });
};
ex.is_running_on = [](void* ctx) {
return static_cast<MyQueue*>(ctx)->is_current_thread();
};
ex.ctx = &my_queue;
auto* poa = rpc->create_poa().with_dispatch_executor(ex).build();
post must not wait for the work to run. is_running_on lets the runtime
run work inline when it is already on your executor, instead of posting and
waiting for itself.
Swift
let uiPoa = try rpc.createPoa(maxObjects: 32, dispatch: .main)
final class DashboardImpl: DashboardServant, @unchecked Sendable {
override func updateTitle(title: String) throws {
label.stringValue = title // already on main
}
}
.queue must be a serial queue. A shared-memory client matches replies
to requests by order, so a concurrent queue could send them back out of order.
A thread with its own event loop (Swift)
.main and .queue cover everything GCD owns. A thread that runs its own
loop, blocking in glfwWaitEvents or epoll_wait and owning state only it
may touch, needs .loop with a PoaExecutor:
final class RenderLoop: PoaExecutor {
func post(_ work: @escaping @Sendable () -> Void) {
lock.lock(); pending.append(work); lock.unlock()
wakeTheLoop() // e.g. glfwPostEmptyEvent()
}
var isRunningOnExecutor: Bool { Thread.current === loopThread }
func drain() { // call once per loop iteration
lock.lock(); let work = pending; pending.removeAll(); lock.unlock()
for item in work { item() }
}
}
let poa = try rpc.createPoa(maxObjects: 8, dispatch: .loop(renderLoop))
Three rules, in order of how badly they fail when broken:
postmust wake the loop. Nothing else will tell it a call is waiting. An idle loop would otherwise never run the servant.- Drain in FIFO order, for the same reason
.queuemust be serial. isRunningOnExecutormust be accurate. It stops the runtime from posting to the loop it is already on and then waiting for a drain that cannot start until it returns.
Avoiding deadlocks
Do not make a blocking NPRPC call from a servant running on a queue if that
call needs the same queue to complete. For example, a .main servant must not
block main waiting for a reply that is also delivered on main. Use the async
form of the call instead.
docs/POA.md