Scope note:
- this document describes the current C++ hierarchical transport pattern
- the
child_transport/parent_transportlifetime model is a C++ runtime pattern, not a cross-language Canopy guarantee - see C++ Status, Rust Status, and JavaScript Status for implementation scope
This document describes the circular dependency architecture and safe disconnection protocol used by hierarchical transports in the current C++ implementation.
- Local Transport (
rpc::local) - In-process parent/child zones - SGX Enclave Transport - Host/enclave communication
- Blocking DLL Transport (
rpc::dynamic_library) - In-process DLL child zones in blocking builds - Host-Scheduled Coroutine DLL Transport (
rpc::libcoro_host_scheduled_dynamic_library) - In-process DLL child zones sharing the host scheduler - DLL-Scheduled Coroutine DLL Transport (
rpc::libcoro_dll_scheduled_dynamic_library) - In-process DLL child zones with a DLL-owned scheduler - Any transport where a parent zone creates and manages a child zone
rpc::ipc_transport is intentionally not part of this document. It is a
process-owning rpc::stream_transport::transport, not necessarily a hierarchical
child_transport / parent_transport pair.
Hierarchical transports implement an intentional circular dependency to manage zone lifetime across boundaries.
The naming can be confusing because it's from each zone's perspective:
- child_transport - Lives in parent zone, gateway TO child zone
- parent_transport - Lives in child zone, gateway TO parent zone
Think of it as: "I am a transport to reach my parent/child"
Parent Zone (zone 1):
└─ child_transport
└─ child_: stdex::member_ptr<parent_transport> (points to child zone)
Child Zone (zone 2):
└─ child_service
└─ parent_transport_: std::shared_ptr<parent_transport>
└─ parent_transport
└─ parent_: stdex::member_ptr<child_transport> (points back to parent zone)
rpc::child_service(child zone) holdsstd::shared_ptr<parent_transport>parent_transport(child zone) holdsstdex::member_ptr<child_transport>(parent zone)child_transport(parent zone) holdsstdex::member_ptr<parent_transport>(child zone)
This creates a circular reference that keeps both zones alive as long as references exist in either direction.
The critical safety mechanism: when calls cross zone boundaries, stack-based shared_ptr protects transport lifetime.
// In child_transport (parent zone), calling into child zone:
CORO_TASK(int) child_transport::outbound_send(...) {
auto child = child_.get_nullable(); // Stack-based shared_ptr<parent_transport>
if (!child) {
CO_RETURN rpc::error::ZONE_NOT_FOUND();
}
// child shared_ptr on stack keeps parent_transport alive during entire call
CO_RETURN CO_AWAIT child->inbound_send(...);
// When stack unwinds, parent_transport can safely destruct
}Even if child_service releases its last reference to parent_transport during an active call from the parent zone:
- The stack-based
shared_ptrkeepsparent_transportalive - No use-after-free on the call stack
- Transport destructs naturally when the stack unwinds
This is the key insight that makes the pattern safe.
When a child zone is being destroyed, the circular references must be broken in a coordinated way.
- Trigger:
child_servicedestructor runs (child zone shutting down) - Set Status: Calls
parent_transport->set_status(DISCONNECTED)on its own transport - Propagate:
parent_transport::set_status()override propagates disconnect to parent zone - Parent Breaks:
child_transport::on_child_disconnected()breaks itschild_reference - Child Breaks:
parent_transportbreaks itsparent_reference - Cleanup: Circular dependency resolved, both transports can destruct
void parent_transport::set_status(rpc::transport_status status) {
// Call base class to update status
rpc::transport::set_status(status);
// If disconnecting, notify parent zone to break circular reference
if (status == rpc::transport_status::DISCONNECTED) {
auto parent = parent_.get_nullable();
if (parent) {
// Notify parent zone's child_transport to break its child_ reference
parent->on_child_disconnected();
}
// Break our reference to parent
parent_.reset();
}
}void child_transport::on_child_disconnected() {
// Break circular reference when child zone disconnects
// Safe because stack-based shared_ptr in outbound_* methods keeps parent_transport alive
child_.reset();
}- Zone Boundaries Respected:
child_serviceonly touches its ownparent_transport - Status Propagation: Disconnect notification crosses zone boundary via override
- Stack Protection: Active calls protected by stack-based
shared_ptr - Natural Cleanup: Transport destructs when stack unwinds and refs drop to zero
- Thread Safe:
stdex::member_ptrusesshared_mutexfor concurrent access
stdex::member_ptr provides thread-safe access to the circular references:
get_nullable(): Acquiresshared_lock(concurrent reads allowed)reset(): Acquiresunique_lock(exclusive write)
Multiple threads can safely:
- Call
outbound_*methods (concurrent reads viaget_nullable()) - Break references during shutdown (exclusive write via
reset())
Each hierarchical transport implements this pattern:
- Direct in-process function calls
- No serialization overhead
- Immediate CONNECTED status
- See
documents/transports/local.md
- Crosses SGX enclave boundary
- Uses ECALL/OCALL mechanisms
- Serialization required for boundary crossing
- See
documents/transports/sgx.md
- Loads a shared object at runtime via
dlopen/LoadLibrary - Boundary crossed via C function pointers (
canopy_dll_*entry points) RTLD_LOCALkeeps DLL symbols isolated from the host symbol tabledlclosedeferred toon_destination_count_zero— never called while DLL code is on the stack- Non-coroutine builds only
- See
documents/transports/dynamic_library.md
- Loads a shared object into the current process in coroutine builds
- Uses direct
coro::taskfunction pointers and the host scheduler - Defers
dlcloseuntil the host scheduler threads that executed DLL code have stopped - Preserves the same parent/child lifetime pattern as other hierarchical transports
- Loads a shared object into the current process in coroutine builds
- Uses begin/complete callback entry points and a scheduler owned by the DLL runtime
- Can shut down its DLL scheduler as part of transport teardown before
dlclose - Preserves the same parent/child lifetime pattern as other hierarchical transports
- See
documents/transports/dynamic_library.md
auto child_transport = std::make_shared<rpc::local::child_transport>(
"child_name",
parent_service);
child_transport->set_child_entry_point<i_example_parent, i_example_child>(
[](const rpc::shared_ptr<i_example_parent>& parent_interface,
std::shared_ptr<rpc::child_service> child_service)
-> CORO_TASK(rpc::service_connect_result<i_example_child>) {
// Initialize child zone
auto child_interface = rpc::make_shared<example_child_impl>(child_service, parent_interface);
CO_RETURN rpc::service_connect_result<i_example_child>{
rpc::error::OK(),
std::move(child_interface)};
});
rpc::shared_ptr<i_example_parent> parent_ptr;
auto ret = CO_AWAIT parent_service->connect_to_zone<i_example_parent, i_example_child>(
"child_name", child_transport, parent_ptr);
if (ret.error_code != rpc::error::OK())
{
CO_RETURN ret.error_code;
}
auto child_ptr = ret.output_interface;The child zone destructs naturally when all references are released:
- Release all proxies to child zone objects
child_servicedestructs (last reference holder)- Disconnection protocol runs automatically
- Circular references broken
- Transports destruct
Enable telemetry to visualize the circular dependency lifecycle:
- Green highlighting shows deletion/destruction events
- Transport ref counts tracked per zone pair
- Status changes visible in timeline
Problem: Transport destructing during active call
- Cause: Circular reference broken too early
- Fix: Ensure stack-based
shared_ptrin alloutbound_*methods
Problem: Transport never destructs (leak)
- Cause: Circular reference not broken on disconnect
- Fix: Verify
set_status()override andon_child_disconnected()called
Problem: Negative ref counts in telemetry
- Cause: Mismatched add_ref/release (often relay operations)
- Fix: Ensure relay operations (options=3) skip ref counting
- canopy-gj2: Implementation of circular dependency fix
- canopy-w6l: Telemetry ref counting for relay operations
c++/rpc/include/rpc/internal/member_ptr.h- Thread-safe pointer wrapperc++/rpc/include/rpc/internal/transport.h- Base transport classc++/rpc/include/rpc/internal/service.h-rpc::root_serviceandrpc::child_service