External Clients
Every example so far has lived inside a single graph: operators wired with
add_flow(), compiled into one plan, run by one process. This walkthrough
crosses the process boundary. The external_clients example ships two
small standalone clients — a sensor daemon that publishes IMU samples
into a running graph, and a recorder that consumes the graph’s exported
control result — and neither of them is an operator or contains a graph. The
C++ variants link only against holoscan::core; the Python variants import
the matching holoscan.channel facade.
The runnable source lives at public/examples/external_clients/ in the SDK
tree. Both clients have C++ and Python variants with the same command line and
catalog handoff.
What makes this work is a deliberately narrow contract: the graph process
serializes its endpoint catalog to a JSON file, and each external client
loads that file and attaches to one named endpoint through the
ChannelRuntime client API. Nothing else is shared — no compiled plan, no
in-memory objects, no transport handles. Because cross-process attachment is
asynchronous, both clients wrap their I/O in small, principled retry loops,
and those loops are the real lesson of this page.
Developer preview. Interprocess channels and the standalone client API are a developer preview: cross-process payloads carry bounds (for example, cross-process tensors — host-pageable, pinned-host, or CUDA-device backed — are always read-only on import), and the API surface may evolve in upcoming releases. Verify against the headers in your installed SDK before relying on details.
- How a graph hands its boundary to another process as a single JSON catalog file
- How a standalone process attaches with
ChannelRuntime,create_publisher, andcreate_subscriber - Why attach is asynchronous, and how to write a deadline-bounded retry loop around it
- Which outcomes are safe to retry — and why an indeterminate publication never is
- How to close a stream so that end-of-stream is actually observed
Two processes, one artifact
When you compile() a graph that declares import_topic / export_topic
boundaries, the resulting plan carries an endpoint catalog: a description
of every boundary endpoint — its name, payload identity, and queue policy.
The graph process saves that catalog to disk and passes the path to its
peers (via argv, an environment variable, or however you launch them):
The catalog file is the entire contract. Either side may start first, a peer built from a different binary can attach as long as it loads the same catalog, and a wrong or stale catalog fails at attach time — with a typed error, not a hang. When the boundary changes, regenerate and re-share the file.
Each endpoint in the catalog has a deployment-stable name, which the graph side assigns when declaring the topic:
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.
Attaching: catalog in, endpoint out
Both clients open identically. Load the catalog, create a ChannelRuntime,
and attach to the named endpoint — every step returns
holoscan::expected, so a standalone client gets the same errors-as-values
discipline as an operator. From sensor_daemon:
The recorder differs by one line — create_subscriber<std::uint32_t>
instead of create_publisher. Note what is absent: no transport
configuration, no broker address, no serialization setup. The catalog
already pins all of that; the client only names which endpoint it wants.
create_publisher succeeding does not mean a peer is connected.
Cross-process attachment completes asynchronously, in the background, which
is why everything that follows is a loop.
The sensor daemon: retry what is provably safe
The daemon publishes a finite stream of uint32_t IMU samples, each stamped
with a caller-owned capture timestamp. Before publishing, it waits (with a
deadline) for at least one subscriber to appear, polling
update_connections() to refresh the connection snapshot and
has_subscribers() to check it:
The publish loop is where the example earns its keep. emit_captured(value, capture_timestamp_ns) returns a positive recipient count on success. The
daemon treats that success as delivery. Everything else follows one rule —
retry only outcomes that provably did not publish:
Never retry an indeterminate publication. The pre-submit kNotReady and
kBackpressured errors prove that the sample was not delivered. Leave the
retry loop on any other error and handle it according to that error’s
contract. In particular, kPublicationPartial and
kPublicationIndeterminate consume publication authority and are terminal
for that sample because retrying may duplicate a delivery.
The daemon also calls update_connections() at the top of every retry
iteration and propagates any refresh failure instead of looping blindly — a
dead transport should end the loop, not spin it until the deadline.
Ordered end-of-stream
A finite stream is not finished when the last sample goes out; it is
finished when downstream has observed end-of-stream. The daemon ends with a
close() loop shaped like the publish loop:
The same retry rule applies: kNotReady proves the close did not take
effect and the publisher is still usable, so retrying is safe. Any other
close failure consumes the publisher and is terminal. Only after the close
succeeds does the daemon report completion — samples published, total
recipient deliveries, and the catalog digest (catalog->digest(), a
normalized fingerprint that both sides can log to confirm they loaded the
same contract).
The recorder: wait until a sample arrives
The consuming side is event-driven. wait_and_receive_sample() blocks on the
endpoint’s transport event lane and covers both waits at once: it absorbs the
kNotReady the transport reports while it is still connecting and while no
sample is pending. The recorder repeats that bounded wait until it observes
remote kClosed; it does not treat one wake or one received value as stream
completion. Its optional sample limit is the explicit exception for graphs that
intentionally keep producing. From result_recorder:
The deadline is an absolute coordinate rather than a per-attempt duration, so a
spurious wake cannot silently restart the budget. kNotReady never escapes the
call; a subscriber that is still connecting simply keeps waiting. Because the
catalog always admits event wake, the recorder sets no option to get this — a
publication wakes the recorder from a kernel wait instead of finding it mid-sleep.
The wait is event-driven but not purely so. Transport notification is best effort — its result is not reported to the publisher — so a blocked wait also re-checks readiness once a second as a bounded fallback, and readiness that arrives on a clock-health lane rather than a data lane is discovered on that fallback rather than by a wake. An idle recorder therefore costs one wakeup per second rather than nothing at all: far below the 2 ms poll loop it replaced, but not zero, and enough to keep a fully idle process out of the deepest sleep states. Treat the one-second figure as an implementation bound, not a contract.
A received sample carries its payload in sample->data and public
SampleMetadata in sample->metadata — the publication sequence, the
producer’s capture_timestamp_ns, and the source_clock_id naming the
clock domain that timestamp belongs to. The recorder prints these alongside
the catalog digest:
Metadata is the public record only: transport-level lifecycle evidence
(such as producer-epoch history) lives in runtime traces, not in
SampleMetadata.
After the sensor daemon’s ordered publisher close, an input-driven finite graph
drains every accepted sample, closes eligible downstream outputs, and lets the
recorder observe remote kClosed. Only after that remote terminal observation
does the recorder close its local subscriber. The local close releases client
resources; it neither publishes EOS nor helps the graph complete. A graph with
an independent trigger, another unbounded producer, or unused admitted
external-restart capacity remains live according to that authored authority.
For such an unbounded graph, pass a positive max-samples value. The recorder
then returns successfully after receiving that many values, reports that remote
EOS was not observed, and closes only its local subscriber. This bounded mode
does not claim that the producer or graph completed.
After the recorder has finished with the local endpoint, it closes the subscriber explicitly and checks the result:
This is local consumer cleanup only. It discards any unread local samples,
causes later checked receive operations on that facade to return kClosed,
and sends no EOS into the graph. Publisher close() remains the only ordered
producer-EOS operation. If a separate client thread is polling or waiting,
request its cancellation and join it before calling subscriber close();
calls on one subscriber are externally serialized.
Running the pair
Both programs take the catalog path and the endpoint name on the command line; the daemon additionally accepts a sample count, period, and first value:
The Python variants use the same arguments:
They use hs.channel.Runtime, infer uint32 from the graph-produced catalog,
and raise the corresponding hs.errors exception on failure. Passing
timeout= to Python emit() implements the same one-deadline retry rule as
the explicit C++ loop. The optional --async recorder uses
hs.channel.aio.Subscriber with the catalog’s native readiness descriptor and
asserts that no Python helper thread appears.
Omit max-samples for a finite input-driven graph so the recorder demonstrates
data-before-EOS ordering. Supply a positive limit for an independently
triggered or otherwise unbounded graph. For a deployed camera_imu_control
graph, whose camera remains clock-driven, record one control result with:
These commands require a separately running deployed graph that owns the
catalog endpoints. camera_imu_control --emit-artifacts only writes the
deployment bundle; it does not start that graph or its Channel services. The baseline release
does not ship an author-facing graph-only launcher for this manual
three-process recipe, so use the no-argument camera/IMU reference executable
for an end-to-end smoke test. Once the deployed graph is running, launch either
client in any order — the recorder’s wait and the daemon’s retry loops absorb
the startup race. These
intentionally small acceptance companions are independently tested
attach-and-retry templates, not a general device-adapter or recording product.
The source tree also includes a finite import-relay-export graph in C++ and
Python plus python/run_clients.py. The example CTests use that launcher to
run the Python sensor and recorder as separate processes against each graph
language. A third CTest runs the async recorder against the C++ graph. The
sensor’s ordered close drains through the relay, the recorder observes remote
EOS, and the graph completes naturally.
Recap
- The serialized endpoint catalog is the only thing the graph process
and an external client share:
save_jsonon one side,load_jsonon the other, attach by deployment-stable endpoint name. - Graphs use ports and
add_flow(); standalone processes useChannelRuntimewithcreate_publisher/create_subscriber. Never mix the two vocabularies. Python uses the matchinghs.channel.Runtimefacade and infers catalog payloads without a type argument. - Attach is asynchronous. On the publish side, wrap
emit_capturedandclosein deadline-bounded retry loops, refreshing withupdate_connections(). On the subscribe side, preferwait_and_receive_samplewith an absolute deadline over areceive_samplepoll loop. - Retry only provably-unpublished outcomes — pre-submit
kNotReadyorkBackpressured. Partial and indeterminate publications are terminal. For publisherclose(), retry onlykNotReady; every other close failure is terminal. Leave other emit errors for code-specific handling. - Publisher
close()publishes ordered EOS. In a finite input-driven graph, accepted data drains before EOS reaches exports and the run completes naturally. A recorder sample limit bounds intentionally unbounded graphs but is not EOS. Subscriberclose()only retires the local consumer endpoint; cancel and join its client thread before local close. - A finite stream completes only after an ordered end-of-stream
close()succeeds and the graph’s retained work drains; log the catalog digest on both sides to confirm a shared contract.
Next: the full client API — Tensor payloads, producer restart semantics, and the retryable-error taxonomy — is covered in Standalone channel clients.