Interprocess Channels
Developer preview. Cross-process channels currently support scalar, POD, self-contained-schema, and Tensor payloads; cross-process Tensors may be host-pageable, pinned-host, or CUDA-device backed (with bounded staging where placement requires it) and always arrive read-only. A restart-capable external import may accept at most one replacement producer incarnation, including raw-Tensor and descriptor-backed Tensor schema imports, and there is no author-facing launcher for multi-process worker isolation yet. The API surface may evolve in upcoming releases — verify against the current headers before relying on it.
Sometimes your pipeline does not end at the edge of one process. A sensor
daemon runs as its own binary, a recorder outlives the graph it observes, or a
deployment simply insists that producer and consumer never share an address
space. Interprocess channels let a compiled Holoscan graph exchange samples
with another operating-system process over the same holomq:// topics you
already use for external topics
— the graph side stays declarative, and the peer process attaches from the
outside as a standalone client.
The whole mechanism rests on one idea: the two processes share exactly one artifact — a serialized endpoint catalog. Everything else in this page follows from that.
You will learn
- Why the serialized endpoint catalog is the only cross-process contract
- How the graph side binds automatically via
import_topic/export_topicwhile the standalone side attaches explicitly withChannelRuntime - The mandatory retry pattern for asynchronous attach (
kNotReady/kBackpressured) - How generation-fenced producer restart works, and why the restart budget
ends in
kResourceExhausted - What cross-process Tensors can and cannot do today
One graph, one vocabulary. Inside a graph, operators communicate through
typed ports (Input<T>/Output<T>) wired with add_flow() — the runtime
materializes the channels for you. The ChannelRuntime client API exists
only for standalone processes outside any graph. Never mix the two: using
ChannelRuntime inside an operator bypasses the compiled plan and its
guarantees.
The catalog is the whole contract
When you compile() a graph that imports or exports holomq:// topics, the
accepted plan carries a ChannelEndpointCatalog describing every boundary
endpoint: payload identity, queue policy, capacities, and process groups. That
catalog is serializable — save_json() writes it on one side,
ChannelEndpointCatalog::load_json() reads it on the other — and it is the
only thing the two processes share:
- The peer process inherits nothing else — no compiled plan, no in-memory C++ objects, no transport handles. A differently built peer binary can attach as long as it loads the same catalog bytes.
- Either process may start first; the canonical catalog bytes round-trip identically, so startup order does not matter.
- “What crosses the boundary” becomes a single inspectable JSON artifact you can check into deployment configuration, diff, and audit.
Two consequences are worth internalizing. First, locality is a compile decision: each accepted cross-process edge gets one deterministic dedicated service, so the boundary is replay-stable and independent of which peer attaches first. Second, a wrong or stale catalog fails at attach, not at compile — if you change the boundary (payload type, endpoint name, queue policy), regenerate and re-share the catalog.
The graph side: automatic binding
The graph never writes “channel” code. It declares the boundary with
import_topic / export_topic exactly as on any external-topics page, then
saves the plan’s catalog for the peer:
This graph-side declaration is illustrative;
camera_imu_control supplies the shipping graph-boundary use, while
external_clients supplies the standalone peer loops.
That is the entire graph-side story: declare the topic, compile, save the catalog, run. Binding, producer-restart fencing, and Tensor transport all happen under the hood.
The standalone side: explicit client, asynchronous attach
The peer is an ordinary binary. It loads the catalog, creates a
ChannelRuntime, and attaches a publisher or subscriber by endpoint name —
the same client surface described on
Standalone channel clients:
For a descriptor-backed schema, attach with
create_tensor_schema_publisher<Payload>() or
create_tensor_schema_subscriber<Payload>(); publish the FlatBuffer metadata
and complete Tensor inventory together, and receive them with
receive_tensors(). Descriptor-schema publishers support the same capture,
frame, trace, and validity metadata as the scalar catalog surface, while the
resolved sample or bundle exposes the received sample_metadata. The
descriptor_tensor_external_topic
example contains both the Graph process and standalone process for this case.
Cross-process attach is asynchronous, and there is no “wait until
connected” helper. emit_captured() returns kNotReady or
kBackpressured until the peer connects, and receive_sample() returns
kNotReady — the retry loop is how the connection is established. Code
that treats the first kNotReady as a hard failure will never connect.
Wrap every publish and receive in a deadline-bounded retry loop over the retryable codes, in the usual errors-as-values style:
A subscriber is symmetric: runtime->create_subscriber<T>(*catalog, name),
then poll receive_sample() in the same deadline loop, retrying only on
kNotReady. The external_clients example ships both halves — a
sensor_daemon publisher and a recorder subscriber — each taking the
catalog path and endpoint name on the command line.
Endpoint shutdown is asymmetric by design. Publisher close() publishes
ordered producer EOS. Subscriber close() only retires that process’s local
consumer endpoint: it sends no EOS, marks no graph port terminal, and supplies
no natural-completion evidence. It may discard unread local samples, while
already returned owning values keep their normal lifetime. Stop and join any
thread using the subscriber before closing it; do not race close against a
receive or wait. Later checked operations on the consumed facade return
kClosed.
For a finite input-driven graph, producer EOS propagates through each eligible
Operator only after earlier accepted data and retained work drain. Graph
exports then publish ordered terminal control, so a live standalone subscriber
receives final data before remote kClosed. The RunSession becomes naturally
kCompleted only when every contributing finite input path is terminal and no
callback, selector claim, publication, Tensor loan, or qualified device use
remains. Fan-in waits for every input required by its selector; fan-out closes
each required arm independently. An independently triggered Operator treats
its input as a side input, and an unrelated unbounded producer keeps the run
live.
A required external export subscriber must remain attached through remote
kClosed. If it disappears after accepting data and terminal drain observes no
peer, Runtime fails the run instead of treating local retirement as delivered
EOS or retrying indefinitely. There is no reconnection grace inside terminal
drain; any replacement must attach before that abandoned obligation is
observed.
An import admitted with allow_external_restart behaves differently: EOS
fences one producer incarnation but does not close downstream outputs while a
replacement incarnation remains available. Final EOS propagates after that
finite replacement capacity is exhausted. Peer disappearance, silence,
subscriber close, and timeout never substitute for this explicit protocol.
Retry only kNotReady and kBackpressured. Leave the loop on any other
code and handle it according to that code’s contract. In particular, never
retry kPublicationPartial or kPublicationIndeterminate: those outcomes
consume publication authority and may already have delivered the sample.
Producer restart: generation-fenced rebind
When an admitted reset or lifecycle restart touches an external import,
Runtime may rebind it to a later producer incarnation. This includes scalar,
POD, ordinary schema, raw-Tensor, and descriptor-backed Tensor schema imports.
Before detaching
the old binding, preflight requires both the old incarnation’s accepted
nonzero token and that same incarnation’s explicit end-of-stream (the
publisher’s close()). A replacement producer need not already be present.
After preflight succeeds, Runtime replaces the import’s subscriber attachment
so a later producer incarnation can attach through the same catalog. Peer
absence alone is deliberately never enough: rebinding on silence would let old
and new producer data silently mix. The incarnation token is transport/runtime
evidence, not a public application type; applications observe only the typed
retry and terminal outcomes below. There is no separate rebind API to call —
rebind is part of the admitted reset or restart:
kBackpressured— the old peer (or a retained claim) is still live; keep retrying.kNotSupported— either the old incarnation’s accepted token or its explicit end-of-stream is missing, or the compiled import does not admit rebind (for example, its admitted generation capacity is below two). Advisory absence is insufficient by design.kResourceExhausted— the bounded generation capacity is used up. This is terminal.
The restart budget is one. Restart-capable imports have generation
capacity two—one active producer plus one replacement—so a producer may
restart exactly once against a given boundary. Raw-Tensor and
descriptor-backed Tensor schema imports use the same budget and additionally
require all retained backing claims to quiesce before cutover. A further
rebind attempt returns kResourceExhausted before any destructive
mutation, and the slot is not reusable in that run; an in-graph lifecycle
restart that therefore cannot publish ends failed-stopped. Once an accepted
cutover’s detach begins, a partial failure terminalizes the session rather
than exposing a half-rebound stream. Design supervision-and-relaunch
policies around these budgets; see
Restart control for the
in-graph counterpart.
Generation fencing applies to external imports only. Internal cross-process peers are fixed by the compiled plan and do not restart independently — that is a deliberate consequence of locality being a compile decision.
Tensors across processes
Cross-process Tensor transport uses the same loan/write/commit/emit flow as
Tensors in depth:
the producer loans a slot, fills it, commits, and emits; the receiving process
materializes a Core Tensor and keeps the exported buffer alive until the
last claim retires. Three facts shape the surface today:
- Three memory kinds travel. Host-pageable, pinned-host, and CUDA-device Tensors cross the process boundary, with bounded staging where the declared placement requires it.
- A CUDA device must be present. Creating a catalog Tensor endpoint requires an available CUDA device (virtual memory management) in the process — even for host-pageable backing.
- The received Tensor is read-only. A consumer that must mutate the data has to copy it first.
No author-facing multi-process launcher yet. The runtime can isolate
process groups internally — run_async() fails closed on a plan that spans
multiple process groups rather than silently running them co-located — but
there is no application-author API to launch or orchestrate per-group
workers. That launcher is deployment infrastructure planned for a future
release. Today, write the far side of every process boundary as a
standalone client, exactly as shown above.
Recap
- The serialized endpoint catalog (
save_json/load_json) is the only cross-process contract — the peer inherits no plan, objects, or handles. - The graph side is automatic (
import_topic/export_topic); the standalone side is explicit (ChannelRuntime+create_publisher/create_subscriber). Never mix the two vocabularies. - Attach is asynchronous: wrap emit/receive in deadline retry loops over
kNotReady/kBackpressured— the loop is the connection handshake. - Producer restart for external imports is generation-fenced (the old
incarnation’s accepted token plus that incarnation’s explicit EOS) with a
budget of one. A missing fence fact or compiled no-rebind policy is
kNotSupported, a live peer or retained claim iskBackpressured, and a spent generation budget is terminalkResourceExhaustedbefore mutation. Raw-Tensor and descriptor-backed Tensor schema imports share this behavior and wait for retained backing claims to quiesce before cutover. - Cross-process Tensors may be host-pageable, pinned-host, or CUDA-device backed, always arrive read-only, and need a CUDA device present to create catalog Tensor endpoints; there is no author-facing multi-process worker launcher yet.
Next: Standalone channel clients covers the external client API in full, including subscriber metadata and clean shutdown.