Composition
Crate hopf-core: Composition is the canonical builder that starts a Runtime and applies listen/dial bindings. Declarative XML (via tractrix) desugars into the same builder. Handler names resolve through a closed CompositionRegistry — not reflective DI.
Contents
Builder API
use hopf_core::{Composition, RuntimeConfig, TcpConnectorConfig, TcpListenerConfig};
let mut comp = Composition::new(RuntimeConfig {
worker_threads: 4,
..Default::default()
})?; // Runtime starts here
comp.listen_tcp(TcpListenerConfig::new(listen_addr, factory))?;
comp.dial_tcp(TcpConnectorConfig::new(peer_addr, dial_factory))?;
let primary = comp.primary_addr();
// later: comp.remove_binding(id); comp.shutdown();| Method | Effect |
|---|---|
Composition::new(RuntimeConfig) |
Starts the Runtime immediately, no telemetry |
Composition::new_with_telemetry(RuntimeConfig, Option<Arc<dyn TelemetryHook>>) |
Starts the Runtime immediately, with a telemetry hook |
runtime() -> &Arc<Runtime> |
Accessor — build Arc<Runtime>-dependent protocol services before adding bindings |
listen_tcp(TcpListenerConfig) -> io::Result<BindingId> |
Apply a listen binding now |
dial_tcp(TcpConnectorConfig) -> io::Result<()> |
Apply a dial now |
from_xml / from_xml_str / from_xml_path (and _with_telemetry siblings) |
Parse XML and apply it against a fresh Runtime — returns an already-running Composition |
primary_addr() / remove_binding(BindingId) / shutdown(self) |
Inspect / tear down the running composition |
There is no separate build() step — a Composition is running as soon as it's constructed (Runtime::start before the script runs). This is also what lets a registered listener close over Arc<Runtime> — required by any protocol service that offloads work to the storage pool (SMTP local delivery, POP3, IMAP, ...).
CompositionRegistry
handler="…" in XML (and conceptual names in docs) is not a Service or HTTP Stream handler name. It resolves a closed map of connection-level HandlerFactory values (Fn() -> Box<dyn ProtocolHandler>).
use std::sync::Arc;
use hopf_core::{Composition, CompositionRegistry, ProtocolHandler};
let mut reg = CompositionRegistry::new();
reg.register("echo", Arc::new(|| Box::new(Echo) as Box<dyn ProtocolHandler>));
// HTTP: register CleartextHttpEndpoint / AlpnHttpEndpoint under a short name.
let xml = r#"<composition worker-threads="2">
<listen-tcp addr="127.0.0.1:0" handler="echo"/>
</composition>"#;
let comp = Composition::from_xml_str(xml, ®)?; // already runningRegister pre-wired stacks in Rust before load. Unknown handler names fail parse with CompositionXmlError. Only self-contained handlers can be registered here — the registry is built and resolved before from_xml* starts the Runtime, so a factory that needs Arc<Runtime> (any storage-offloading protocol service — SMTP local delivery, POP3, IMAP, ...) can't go through XML. Wire those with the Rust builder instead, using Composition::runtime() after new().
XML (from_xml)
Declarative files use elements and attributes only (no character data). Naming mirrors the Rust builder. Parsing is entirely through tractrix.
<composition worker-threads="4" storage-threads="4">
<listen-tcp addr="127.0.0.1:0" handler="echo"
max-net-in="1048576" max-net-out="4194304"
idle-timeout-ms="60000">
<allow cidr="10.0.0.0/8"/>
<deny cidr="192.0.2.0/24"/>
<rate-limit per-source="100" window-ms="1000" global="0"/>
</listen-tcp>
<dial-tcp addr="127.0.0.1:8080" handler="echo"/>
</composition>| XML | Rust |
|---|---|
<composition> |
Composition::new(RuntimeConfig) |
worker-threads |
RuntimeConfig::worker_threads |
storage-threads |
StorageConfig::threads |
<listen-tcp> / <dial-tcp> |
listen_tcp / dial_tcp |
handler |
Lookup in CompositionRegistry |
<allow> / <deny> / <rate-limit> |
ACL / accept rate limit on that listen |
Also: Composition::from_xml(bytes, ®istry), from_xml_path(path, ®istry) — both return an already-running Composition, no separate build step.
XML attribute map
<composition>
| Attribute | Type | Maps to |
|---|---|---|
worker-threads |
usize |
RuntimeConfig::worker_threads (0 → auto) |
storage-threads |
usize |
StorageConfig::threads |
<listen-tcp> / <dial-tcp>
| Attribute | Type | Maps to | Default (Rust) |
|---|---|---|---|
addr |
socket addr string | bind / peer | required |
handler |
registry name | HandlerFactory |
required |
max-net-in |
usize |
buffer cap | 1 MiB |
max-net-out |
usize |
buffer cap | 4 MiB |
idle-timeout-ms |
u64 ms |
idle_timeout |
unset / None |
connect-timeout-ms |
u64 ms |
connect_timeout (<dial-tcp> only) |
unset / None |
Children of <listen-tcp>
| Element | Attributes | Maps to |
|---|---|---|
<allow> |
cidr |
PeerAcl.allow |
<deny> |
cidr |
PeerAcl.deny (deny wins) |
<rate-limit> |
per-source, window-ms, global |
AcceptRateLimit (0 = unlimited axis) |
TLS / secure attributes are rejected in v1. Wire TLS inside the registered factory or use the Rust builder (TcpListenerConfig::with_tls).
Telemetry is not expressed in XML v1 — pass it to Composition::from_xml_with_telemetry / from_xml_str_with_telemetry / from_xml_path_with_telemetry instead of the plain from_xml* entry points, since it must be known before the Runtime starts.
Binding lifecycle
- Start —
Composition::new/new_with_telemetry/from_xml*starts the Runtime immediately (worker reactors, accept loop, storage pool), before any bindings exist. - Add —
Composition::listen_tcp/Runtime::add_tcp_listener→(SocketAddr, BindingId), applied immediately against the live Runtime. Port0yields an ephemeral local address. - Run — accept loop registers the mio listener; each accept round-robins onto a worker reactor (affinity for life).
- Remove —
Composition::remove_binding/Runtime::remove_binding. The accept loop deregisters the listener; existing connections keep affinity until close. - Shutdown —
Composition::shutdown/Runtime::shutdowntears down workers, accept loop, and storage. Best-effort onComposition: if a registered service factory kept its ownArc<Runtime>clone (fromruntime()), that clone keeps the Runtime alive past this call — same as the standalone protocol examples, which never callRuntime::shutdownand just drop theirArc<Runtime>at process exit.
Trust policies and DNS caches attach at the protocol layer (e.g. HTTP BasicAuthFactory, DnsResolver), not as core Composition fields — core stays free of HTTP/auth types.
Telemetry
use hopf_core::Composition;
// hook: Arc<dyn TelemetryHook> from hopf-otel TelemetryPipeline::hook()
let comp = Composition::from_xml_str_with_telemetry(xml, ®, Some(hook))?;See runtime-options.md and telemetry.md.
Limitations
- Closed registry only — no dynamic plugin loading.
- Registry factories can't depend on
Arc<Runtime>— they're resolved beforefrom_xml*starts the Runtime. Storage-offloading protocol services (SMTP local delivery, POP3, IMAP, ...) compose via the Rust builder instead. - XML v1 has no TLS / secure / telemetry attributes.
idle_timeoutis accepted and stored but not yet enforced on the datapath — see runtime-options.md.- QUIC / H3 bindings are not Composition XML elements; use
listen_h3/RuntimeQuicExtin Rust.