Skip to content

Multi-Path Bonding

bilbycast-edge supports multi-path packet bonding across N heterogeneous IP links — 5G + Starlink + fibre, two LTE SIMs, QUIC + UDP, whatever you can reach over the public internet. A bonded hop sits between two edges and carries any inner protocol (SRT, RTMP, RTSP, ST 2110), aggregating the paths into a single reliable flow with frame-accurate failover on IDR boundaries.

bilbycast-edge ships three bonding options. Pick by topology:

ScenarioUse
Two SRT legs to the same SRT receiverlibsrt socket groups (Broadcast or Backup) — configured on the SRT output redundancy block
Two RIST legs to a RIST receiverRIST native SMPTE 2022-7 bonding
N ≥ 2 heterogeneous links carrying any inner protocol, with IDR-frame duplicationBonded input / output type (this page)

The bonded type doesn’t replace the native two — it covers the heterogeneous case they don’t. In particular, it lets you bond an SRT flow, an RTMP flow, or an ST 2110 flow over any mix of UDP / QUIC / RIST legs without the inner protocol having to know anything about bonding.

A bonded hop rides between two edges:

[Source device] ── SRT / RTMP / RTSP / ST 2110 / … ──►
Edge A
│ flow: source_input → bonded_output
[bond paths: UDP / QUIC / RIST × N]
Edge B
│ flow: bonded_input → destination_output
── SRT / RTMP / RTSP / ST 2110 / … ──►
[Destination]

Each edge runs one flow. The bonded_output on edge A and the bonded_input on edge B are peered by matching bond_flow_id.

Each packet is framed with a 12-byte bond header carrying a 32-bit sequence number, a path ID, and a priority hint. The scheduler on the sender decides which path(s) each packet rides; the receiver reorders across paths using a sequence-keyed reassembly buffer and NACKs lost packets back to the sender.

The default adaptive scheduler reads inside the outbound MPEG-TS stream, detects H.264 and HEVC NAL boundaries, and tags SPS / PPS / IDR frames as Critical priority. Critical packets are duplicated across the two best paths; everything else rides a single path chosen by each leg’s measured capacity (see Adaptive capacity scheduling). The older media_aware scheduler does the same NAL tagging but splits purely by live RTT with no capacity discovery — adaptive supersedes it and is the edge default.

The result: even under severe asymmetry — say a 200 ms-RTT Starlink leg sharing load with 30 ms-RTT 5G — every IDR frame arrives unconditionally on the best paths even if the slower leg drops packets. Non-IDR frames go where they’re cheapest, so you don’t pay 2× bandwidth for the whole stream.

Each bond leg uses one of three per-path transports:

TransportWhenFeatures
UDPSimplest, broadest device supportBidirectional. Plaintext. Use when the NAT allows it and you don’t need per-leg TLS
QUIC (RFC 9221 DATAGRAM)Need per-leg TLS 1.3Bidirectional. ALPN bilbycast-bond. Self-signed mode for trusted LAN / loopback, PEM mode for production
RIST (VSF TR-06-1 Simple Profile)Want per-leg ARQ + RTT that the bond layer can layer on top ofUnidirectional. Role (sender / receiver) matches the bonded input/output side

Paths are independent — you can mix (e.g. one QUIC leg for the trusted fibre path, one UDP leg for the LTE SIM).

Each bond leg can run direct (edge-to-edge over its uplink) or through a relay — independently, in any combination. A relayed leg is just an existing native-UDP IP tunnel loopback-bridged onto the leg: the leg’s local socket targets 127.0.0.1:<port>, the tunnel carries it to the far edge, and the relay forwards [tunnel_id][encrypted payload] opaquely. The bond’s ARQ, FEC, reordering, and capacity scheduling all run end-to-end edge↔edge — the relay never sees, terminates, or combines bond traffic (there is no “bond bridge”; the relay is a generic per-path forwarder).

This is what lets a bond work when both ends are behind NAT: each relayed leg is its own native-UDP tunnel and both edges dial out to the relay, so neither end needs to accept inbound connections. A direct leg, by contrast, is asymmetric — the receiving (destination) edge must be reachable. Mix freely: some legs direct, others via a relay, each leg able to use a different relay with a primary + backup for failover.

Per-leg relay routing (relay choice, backup relay, and the leg’s uplink / NIC pin) is provisioned from the manager’s Bonded-Link wizard or the Tunnels page — not by hand-editing the leg’s loopback address in the bonded-output form, where such legs are shown locked with a “Via relay (managed tunnel)” note.

{
"id": "bond-in-0",
"name": "From field unit",
"type": "bonded",
"bond_flow_id": 42,
"paths": [ { ... }, ... ],
"hold_ms": 500,
"nack_delay_ms": 30,
"max_nack_retries": 8,
"keepalive_ms": 200
}
FieldTypeDefaultMeaning
bond_flow_idu32requiredBond-layer flow ID. Must match the sender end
pathsarrayrequired, ≥1Paths to bind on (see Path transport blocks)
hold_msu32500Reassembly hold time — how long a gap is held before declaring loss
nack_delay_msu3230Base NACK delay after detecting a gap. Gives natural out-of-order arrivals a chance to fill before an ARQ round-trip
max_nack_retriesu328Max NACK retries per gap before giving up
keepalive_msu32200Keepalive interval — drives per-path RTT / liveness
{
"id": "bond-out-0",
"name": "To headend",
"type": "bonded",
"active": true,
"bond_flow_id": 42,
"paths": [ { ... }, ... ],
"scheduler": "media_aware",
"retransmit_capacity": 8192,
"keepalive_ms": 200,
"program_number": null
}
FieldTypeDefaultMeaning
bond_flow_idu32requiredMust match the receiver end
pathsarrayrequired, ≥1Paths to transmit across
schedulerenumadaptiveround_robin, weighted_rtt, media_aware, or adaptive (see Scheduler options)
congestionobjectOptional tuning for the adaptive scheduler’s per-leg capacity controller (see Adaptive capacity scheduling)
encryption_keystring (64 hex)Optional 32-byte ChaCha20-Poly1305 AEAD key applied per-datagram on the UDP legs (QUIC legs are already TLS). Both ends must share it
retransmit_capacityusize8192Sender retransmit buffer capacity (packets). Must exceed send_rate_pps × max_nack_round_trip_seconds
keepalive_msu32200Keepalive interval
path_mtuu321500Smallest IP-layer path MTU across the bond’s legs, [576, 9000]. The sender re-chunks outbound MPEG-TS at 188-byte boundaries into datagrams that fit this MTU after every per-datagram overhead, so no leg emits an IP-fragmented datagram. Lower it for constrained cellular / satellite bearers — see Fitting datagrams to the path MTU. Sender-side only
program_numberu16Optional MPTS → SPTS filter applied before bonding

Three more optional resilience blocks ship on the bonded output and are off by default: fec (bond-wide interleaved-XOR repair), per-path fec (per-leg XOR or Reed-Solomon, adaptive parity), redundancy (replicate packets across the N best legs), and equalization + max_bonding_latency_ms (time-align heterogeneous legs so high-jitter satellite + cellular legs aggregate in order instead of head-of-line blocking). They are surfaced in the manager UI’s bonded-output editor.

Each entry in paths has a common shell plus a transport block:

{
"id": 0,
"name": "lte-0",
"weight_hint": 1,
"transport": { "type": "udp|rist|quic", ... }
}
FieldTypeDefaultMeaning
idu8requiredPath identifier. Unique within paths. Echoed in NACKs so the sender knows which path to fault
namestringrequiredOperator-visible label ("lte-0", "starlink", …)
weight_hintu321Scheduler weight hint. Higher = more traffic at steady state. weighted_rtt / media_aware combine this with live RTT
transportobjectrequiredPer-leg protocol (below)

UDP path (bidirectional, simplest):

{ "type": "udp", "bind": "10.0.0.1:5000", "remote": "203.0.113.5:6000", "interface": "wwan0" }

Sender: remote required, bind optional (ephemeral if omitted). Receiver: bind required, remote ignored.

interface (optional, 1–15 chars) pins egress to a specific NIC (e.g. "wwan0", "eth0"). Critical when multiple paths share a destination IP — without pinning, the kernel routing table collapses them onto the same default route and the bond is cosmetic. Linux prefers SO_BINDTODEVICE (a hard TX + RX device bind), which needs CAP_NET_RAW (grant with sudo setcap cap_net_raw+ep /path/to/bilbycast-edge or a systemd AmbientCapabilities=CAP_NET_RAW line; the edge itself does not need root). Without CAP_NET_RAW the edge automatically falls back to the unprivileged IP_UNICAST_IF egress hint — the leg still leaves the right NIC, but the hint is TX-only (it doesn’t device-bind the receive side), so on a multi-homed host with overlapping subnets the strict SO_BINDTODEVICE path is still preferred. macOS / FreeBSD use IP_BOUND_IF and are unprivileged. Omit the field to let the kernel decide (or to use source-IP binding plus ip rule policy routing instead).

RIST path (unidirectional at the bond layer; per-leg ARQ from the RIST protocol itself):

{
"type": "rist",
"role": "sender",
"remote": "203.0.113.5:8000",
"local_bind": null,
"buffer_ms": 1000
}

role must be sender or receiver and should match the bonded input/output side. buffer_ms is the RIST jitter/retransmit buffer (default 1000 ms). RIST uses port P for RTP and P+1 for RTCP — both must be reachable.

QUIC path (TLS 1.3 + DATAGRAM extension, full-duplex):

{
"type": "quic",
"role": "client",
"addr": "203.0.113.5:7000",
"server_name": "edge-b.example.com",
"tls": { "mode": "self_signed" }
}
SubfieldMeaning
role"client" (dial) or "server" (accept)
addrClient: remote host:port. Server: local bind ip:port
server_nameClient SNI / ALPN. Ignored on server role
tls.mode"self_signed" (dev / loopback / trusted LAN) or "pem"

PEM mode:

{
"mode": "pem",
"cert_chain_path": "/etc/bilbycast/bond.crt",
"private_key_path": "/etc/bilbycast/bond.key",
"client_trust_root_path": null
}

ALPN bilbycast-bond is negotiated automatically; other protocols on the same UDP port (HTTP/3, bilbycast-relay tunnels) stay isolated.

ValueBehaviour
round_robinEqual-weight rotation. Fine when path health is near-identical (two matched fibre legs)
weighted_rttRTT-weighted rotation — sends more traffic to lower-RTT paths. Critical-priority packets (set by upstream tagging, rare without media awareness) are duplicated across the two lowest-RTT paths
media_awareweighted_rtt plus NAL walking: detects H.264 and HEVC IDR frames (H.264 types 5/7/8; HEVC 19/20/21/32/33/34) inside the outbound TS and duplicates them across the two best paths. Non-IDR frames go single-path. Legacy — superseded by adaptive
adaptive (default)The same NAL walking + IDR duplication as media_aware, plus a per-leg capacity-aware congestion controller. Each leg discovers its usable bitrate from delivered-rate / loss / RTT-inflation feedback and is filled to (but not past) that capacity, so the split is proportional to measured capacity and a saturated leg spills to one with headroom. The right policy for a heterogeneous cellular + satellite bond. See Adaptive capacity scheduling

On an 84/16 traffic split (5G vs Starlink in testing), the adaptive scheduler delivers zero lost gaps under 200 ms RTT and 3% loss on the Starlink leg because every IDR rides the best paths.

adaptive (the default) runs a closed-loop congestion controller per leg. The receiver echoes per-path byte counters + measured jitter in its keepalive ack (~5 Hz); the sender differences successive acks into a windowed delivered bitrate + loss fraction and feeds the controller. Each leg probes its capacity estimate up while clean, backs off toward the delivered rate the moment loss or queue-building delay appears, and the per-leg token buckets split traffic proportionally to the discovered capacities. RTT alone no longer drives the split — the old weighted_rtt behaviour over-drove a low-RTT cellular link past its capacity while starving a high-RTT satellite link.

Clean jittery cellular legs are no longer pinned

Section titled “Clean jittery cellular legs are no longer pinned”

A clean but RTT-jittery radio leg (5G or Starlink, whose smoothed RTT sits tens of milliseconds above its own windowed minimum even at zero loss) used to read as permanently congested and stayed parked at min_rate_kbps while the bond dropped packets for want of its capacity. The controller now discovers capacity from delivered rate versus estimate, not RTT: when a leg delivers ≥ 85 % of its current estimate and loss is below loss_low_pct, it is treated as capacity-limited and the estimate probes up (slow-start style, so a starved leg reaches its real capacity in about a second instead of crawling). RTT inflation from normal radio jitter no longer masquerades as congestion. Loss is the safety bound — the instant probing up induces real loss the leg backs off, so discovery cannot run away. ARQ retransmits are charged their real size against the token bucket, so a NACK storm can’t self-amplify on a leg that is already dropping.

Every field is optional; unset falls back to the broadcast-tuned default. Set them under the bonded output’s congestion object.

FieldTypeDefaultMeaning
min_rate_kbpsu32250Floor a leg’s capacity estimate never drops below
start_rate_kbpsu32Starting estimate before measurement for a unit-weight leg
loss_low_pctf320.5Loss below which a leg is “clean” and probes up
loss_high_pctf32Loss at/above which a leg backs off hard
delay_inflation_msu32RTT inflation over a leg’s own minimum treated as queue-building congestion. With delay_inflation_auto set, this is the floor of the auto-derived threshold
delay_inflation_autobooltrue on edge bonded outputsDerive the queue-build delay threshold per leg from its own windowed baseline RTT (a bufferbloated cellular leg gets a proportionally looser threshold; a terrestrial leg keeps the tight delay_inflation_ms floor). The library default is off; the edge enables it because its headline bond is heterogeneous cellular + satellite. Set false to force a fixed threshold
burst_msu32Token-bucket burst depth, in milliseconds of capacity
probe_cap_multf642.0Evidence bound — a leg’s estimate never exceeds delivered × probe_cap_mult (suspended while the leg is the clean bottleneck so the bound can’t re-pin the leg the bond must grow into)
rtt_min_window_msu6410000Window over which each leg’s minimum-RTT baseline is tracked (BBR-style); a route change that shifts the floor ages out instead of reading as permanent congestion
jitter_demote_msu32150Smoothed interarrival jitter above which a leg is demoted from carrying unique media (it keeps carrying redundancy / FEC copies). Re-admits when jitter recovers. 0 disables demotion

By default the bond emits 1316-byte (7 × 188) TS datagrams — the classic SRT / standard-ethernet size derived from path_mtu’s 1500 default. On a constrained path that is a problem: a datagram larger than a leg’s real IP-layer MTU gets IP-fragmented, and cellular CGNAT bearers routinely drop IP fragments and black-hole PMTU discovery (no ICMP fragmentation needed comes back). The leg then reports state=alive with a healthy RTT yet delivers only ~10–15 % of its bytes — video absent or heavily pixelated — because every oversized datagram (e.g. a whole I-frame) is lost wholesale and unrecoverably.

Set path_mtu on the bonded output to the smallest IP-layer MTU across the legs. The sender re-chunks outbound MPEG-TS at 188-byte packet boundaries into datagrams that fit path_mtu after every per-datagram overhead — IP/UDP (28 B v4, or 48 B when a leg dials an IPv6 literal or a hostname), relay / native-UDP tunnel framing (16 or 44 B), the RIST/RTP carrier (12 B), the bond header (12 B, or 16 B with equalization), the AEAD envelope (29 B when encryption_key is set), plus FEC repair headroom when fec or per-leg FEC is on. No leg emits a fragmented datagram, so any residual loss is per-small-datagram and recoverable by the bond’s ARQ + FEC.

  • Default 1500 → 1316-byte (7 × 188) datagrams.
  • A measured ~1000-byte cellular bearer → 752-byte (4 × 188) datagrams.

Measure the constrained leg with a DF (don’t-fragment) ping sweep — the largest size that still gets a reply, plus 28 bytes for the IP + ICMP header, is that path’s MTU:

Terminal window
ping -M do -s 1472 <peer> # 1472 + 28 = 1500; shrink -s until replies stop fragmenting

path_mtu is sender-side only — the bonded input reassembles in bond-sequence order regardless of datagram size, so nothing changes on the receiver. Payloads that are not 188-aligned (non-TS essence) cannot be re-chunked and are sent whole, flagged via the oversize_payloads stat and a bond_payload_exceeds_mtu warning event. See Cellular Modem Bonding Path → Fit the datagram size to the cellular MTU for the field-measurement recipe on a live SIM.

Source: SRT listener on edge A. Destination: SRT caller pulling from edge B. Bond over two UDP paths (e.g. two SIMs on a mobile router).

Edge A (sender side):

{
"inputs": [{
"id": "cam-in",
"name": "Camera SRT",
"type": "srt",
"mode": "listener",
"local_addr": "0.0.0.0:9000"
}],
"outputs": [{
"id": "bond-out",
"name": "Bond to Edge B",
"type": "bonded",
"bond_flow_id": 42,
"scheduler": "media_aware",
"paths": [
{ "id": 0, "name": "sim-a", "transport": { "type": "udp", "remote": "203.0.113.5:5000" }},
{ "id": 1, "name": "sim-b", "transport": { "type": "udp", "remote": "203.0.113.5:5001" }}
]
}],
"flows": [{
"id": "feed",
"name": "Camera feed",
"input_ids": ["cam-in"],
"output_ids": ["bond-out"]
}]
}

Edge B (receiver side):

{
"inputs": [{
"id": "bond-in",
"name": "Bond from Edge A",
"type": "bonded",
"bond_flow_id": 42,
"paths": [
{ "id": 0, "name": "sim-a", "transport": { "type": "udp", "bind": "0.0.0.0:5000" }},
{ "id": 1, "name": "sim-b", "transport": { "type": "udp", "bind": "0.0.0.0:5001" }}
]
}],
"outputs": [{
"id": "srt-out",
"name": "To studio",
"type": "srt",
"mode": "listener",
"local_addr": "0.0.0.0:9999"
}],
"flows": [{
"id": "feed",
"name": "Camera feed",
"input_ids": ["bond-in"],
"output_ids": ["srt-out"]
}]
}

bond_flow_id must match on both sides. Path id values within each paths array must also match (path 0 on the sender is path 0 on the receiver — NACKs use this identifier to target the right leg).

QUIC + UDP hybrid (trusted primary + LTE secondary)

Section titled “QUIC + UDP hybrid (trusted primary + LTE secondary)”

One QUIC leg for the trusted primary path (with TLS), one raw UDP leg for the LTE secondary:

"paths": [
{
"id": 0, "name": "fibre",
"transport": {
"type": "quic", "role": "client",
"addr": "203.0.113.5:7000",
"server_name": "edge-b.example.com",
"tls": { "mode": "pem",
"cert_chain_path": "/etc/bilbycast/bond.crt",
"private_key_path": "/etc/bilbycast/bond.key" }
}
},
{
"id": 1, "name": "lte",
"transport": { "type": "udp", "remote": "203.0.113.5:5000" }
}
]

The QUIC leg gets TLS end-to-end; the UDP leg is plaintext — wrap an encrypted inner protocol (SRT-encrypted TS) if confidentiality is required on the LTE leg.

Section titled “Three-path heterogeneous bonding (5G + Starlink + fibre)”

With media_aware scheduling and very different RTTs, IDR frames ride the two fastest paths; non-IDR traffic rides a single path weighted by live RTT.

"paths": [
{ "id": 0, "name": "fibre", "weight_hint": 4, "transport": { "type": "udp", "remote": "host:5000" }},
{ "id": 1, "name": "5g", "weight_hint": 2, "transport": { "type": "udp", "remote": "host:5001" }},
{ "id": 2, "name": "starlink", "weight_hint": 1, "transport": { "type": "udp", "remote": "host:5002" }}
]

The manager UI covers the same config surface as the JSON schema above. Use the UI for operational day-to-day work; use JSON for version-controlled deployments.

Create the bonded receiver (input) on Edge B

Section titled “Create the bonded receiver (input) on Edge B”
  1. Navigate to Edge B → Config → Inputs.
  2. Click Add Input and set:
    • Type: Bonded (multi-path aggregation — UDP / QUIC / RIST).
    • Bond Flow ID: a number you choose. Must match the sender. Any positive u32 is fine — pick something memorable per flow (e.g. 42 for the camera feed, 43 for audio).
    • Hold (ms): 500 is the default. Raise it for high-RTT paths, lower to reduce end-to-end latency.
    • NACK delay (ms): 30 is a sensible default.
    • Max NACK retries: 8 is fine; a gap that fails 8 retransmits is declared lost.
    • Keepalive (ms): 200 (drives per-path RTT and liveness).
  3. In the Paths list, add one row per leg:
    • Name is operator-visible (lte-0, starlink, fibre).
    • Transport is UDP, QUIC, or RIST. Choose UDP unless you need TLS (use QUIC) or per-leg ARQ / jitter tolerance (use RIST).
    • For UDP receiver: fill Bind (e.g. 0.0.0.0:5000).
    • For QUIC server: fill Bind + choose TLS mode. Self-signed is fine for trusted LANs / loopback; PEM mode for production.
    • For RIST receiver: set Role to Receiver and fill Local bind. RIST uses the port you configure for RTP and port+1 for RTCP — both must be reachable.
  4. Save.

Create the bonded sender (output) on Edge A

Section titled “Create the bonded sender (output) on Edge A”
  1. Edge A → Config → Outputs → Add Output.
  2. Set:
    • Type: Bonded.
    • Bond Flow ID: must equal the receiver’s.
    • Scheduler: Adaptive is the default — walks H.264 / HEVC NAL units and duplicates IDR frames across the two best paths, and discovers each leg’s usable capacity so the split tracks measured bandwidth (right for heterogeneous cellular + satellite bonds). Use Media-aware for the legacy RTT-only split, Weighted RTT for non-video data where IDR detection is a no-op, or Round Robin when all paths are near-identical.
    • Retransmit buffer (packets): 8192 default. Must exceed send_rate_pps × worst_NACK_round_trip_seconds.
    • Keepalive (ms): 200.
    • Program number (optional): set to down-select a single program from an MPTS input before bonding.
  3. Paths: mirror the receiver’s paths with matching id numbers.
    • For UDP sender: fill Remote (203.0.113.5:5000).
    • For QUIC client: fill Remote, Server name (for SNI), TLS mode (match what the receiver accepts).
    • For RIST sender: set Role to Sender and fill Remote.
  4. Save, then in Flows create a flow with your source input and this bonded output as output_ids.

A bonded input or output renders an expanded status card with:

  • Aggregate headerup / degraded / idle pill, role, scheduler, flow ID, path count.
  • Sender aggregate rowpackets_sent, retransmits, duplicated (IDR frames that rode two paths), dropped_no_path (bond hard-fail indicator).
  • Receiver aggregate rowpackets_received, delivered, gaps_recovered, gaps_lost, duplicates, reassembly_overflow.
  • Per-path table — one row per leg with a liveness pill (alive / dead), RTT, loss percentage, traffic-share bar, packets / bytes, NACKs, retransmits, keepalives.

The topology view shows only the aggregate state (up / degraded / idle). Deep per-path inspection lives on the Node Detail page.

  • “bond UI helper missing — reload the page” in a config form means the bonding helper script failed to load. Hard-refresh (Cmd/Ctrl+Shift+R). If it persists, the manager is serving an older build.
  • Receiver shows idle, sender shows packets_dropped_no_path — no path has handshaken. Check firewall / NAT on every leg; the first packet on a UDP path triggers peer discovery on the receiver, so if nothing ever reaches the receiver you’re stuck in idle.
  • gaps_lost climbing steadily — either hold_ms is too low for the worst path’s RTT, or a path has saturated and is dropping packets faster than ARQ can repair. Check per-path loss_fraction.
  • One path stays dead but others work — keepalive isn’t making it through. Check the bind / remote addresses and firewall; a dead path is excluded from the scheduler without affecting the bond as a whole.

Every bonded input or output carries a bond_stats field with aggregate and per-path metrics.

Aggregate fields:

FieldSideMeaning
stateboth"up", "degraded", or "idle"
flow_idbothMatches bond_flow_id
roleboth"sender" or "receiver"
schedulersender"round_robin", "weighted_rtt", "media_aware", or "adaptive"
packets_sent / bytes_sentsender
packets_retransmittedsenderCount of ARQ retransmits
packets_duplicatedsenderPackets intentionally duplicated (IDR frames on two paths)
packets_dropped_no_pathsenderScheduler couldn’t dispatch — bond is hard-failed
packets_received / bytes_receivedreceiver
packets_deliveredreceiverPackets delivered to the application after reassembly
gaps_recoveredreceiverGaps filled by ARQ or a second path
gaps_lostreceiverGaps that exceeded hold_ms — packet loss
duplicates_receivedreceiverDuplicates absorbed by reassembly
reassembly_overflowreceiverSequence space exceeded buffer — tune hold_ms down or fix a path

Per-path fields (one entry per leg):

id, name, transport, state ("alive" or "dead"), rtt_ms, jitter_us, loss_fraction, throughput_bps, queue_depth, packets_sent, bytes_sent, packets_received, bytes_received, nacks_sent, nacks_received, retransmits_sent, retransmits_received, keepalives_sent, keepalives_received.

Prometheus counters (labels: flow_id, output_id, leg_role, path_id, path_name, transport):

bilbycast_edge_bond_rtt_ms
bilbycast_edge_bond_loss_fraction
bilbycast_edge_bond_path_packets_sent
bilbycast_edge_bond_path_packets_received
bilbycast_edge_bond_path_retransmits_sent
bilbycast_edge_bond_path_nacks_sent
bilbycast_edge_bond_path_nacks_received
bilbycast_edge_bond_path_keepalives_sent
bilbycast_edge_bond_path_dead
bilbycast_edge_bond_gaps_recovered
bilbycast_edge_bond_gaps_lost
bilbycast_edge_bond_packets_duplicated

Events — category bond, severity info / warning / critical. Path-up / path-down transitions fire as info / warning; bond-idle (no alive paths) fires as critical.

  • hold_ms — tune to the worst path’s expected RTT × 2 plus a margin for jitter. Too low and gaps_lost climbs from late arrivals; too high and end-to-end latency grows.
  • nack_delay_ms — comparable to the median path RTT. Lower retries faster; higher gives natural reordering a chance.
  • retransmit_capacity — must exceed send_rate_pps × max_nack_round_trip_seconds. At 10 kpps and a worst-case 500 ms NACK round-trip that’s ≥ 5000. The 8192 default is fine for typical broadcast bitrates.
  • keepalive_ms — faster keepalives detect dead paths sooner but consume more bandwidth. 200 ms is a reasonable default.
  • Scheduler choiceadaptive is the right default for video flows carried over MPEG-TS, especially heterogeneous cellular + satellite bonds. Use round_robin for bonded non-video data (e.g. bulk file transfers) where IDR detection is a no-op. With adaptive, weight_hint only seeds each leg’s initial capacity prior — the controller then discovers the real capacity, so you no longer hand-shape the split.
  • SRT paths are deferred. UDP / QUIC / RIST are supported today; SRT as a per-leg transport (with libsrt’s own ARQ and encryption per-path) is planned but not yet shipped.
  • Confidentiality is per-path. QUIC legs are TLS-encrypted; RIST legs are plaintext; UDP legs are plaintext unless you set encryption_key (per-datagram ChaCha20-Poly1305 AEAD, shared by both ends). For a relayed leg the carrying tunnel adds its own edge-to-edge encryption. Otherwise wrap an already-encrypted inner protocol (SRT-encrypted TS).
  • Topology view shows aggregate state only. The Node Detail page has full per-path tables; the topology view only shows up / degraded / idle.