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.

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, &reg)?;   // already running

Register 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, &registry), from_xml_path(path, &registry) — 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

  1. Start — Composition::new / new_with_telemetry / from_xml* starts the Runtime immediately (worker reactors, accept loop, storage pool), before any bindings exist.
  2. Add — Composition::listen_tcp / Runtime::add_tcp_listener → (SocketAddr, BindingId), applied immediately against the live Runtime. Port 0 yields an ephemeral local address.
  3. Run — accept loop registers the mio listener; each accept round-robins onto a worker reactor (affinity for life).
  4. Remove — Composition::remove_binding / Runtime::remove_binding. The accept loop deregisters the listener; existing connections keep affinity until close.
  5. Shutdown — Composition::shutdown / Runtime::shutdown tears down workers, accept loop, and storage. Best-effort on Composition: if a registered service factory kept its own Arc<Runtime> clone (from runtime()), that clone keeps the Runtime alive past this call — same as the standalone protocol examples, which never call Runtime::shutdown and just drop their Arc<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, &reg, Some(hook))?;

See runtime-options.md and telemetry.md.

Limitations

See also