v0.48.11
thevoiceguy/siphon-aiv0.48.11Aug 10, 2026by github-actions[bot]
Upgrading?
AI Summary
Maintenance release adding configurable SIP rate limiting and fixing HEP metrics visibility.
Key Highlights
- Added configurable `[sip].udp_rate_limit_pps` and `[sip].stream_rate_limit_fps`.
- Fixed HEP metrics (`siphon_ai_hep_packets_sent_total`, `siphon_ai_hep_packets_dropped_total`) to be exported.
- Fixed default log filter to not silently mute crates.
- Fixed hold/resume re-INVITE backoff jitter to follow RFC roles.
New Features
- Configurable SIP ingress rate limiting.
- HEP metrics visibility in the `/metrics` endpoint.
Full Release Notes
### Added
- **The per-source SIP ingress rate limit is now configurable and counted** (issue #459, upstream siphon-rs #89). A hard-coded `UDP_RATE_LIMIT_PPS = 200` in `sip-transport` dropped inbound SIP per source IP with **no metric, no config knob, and a warning that carried no count** — and disabling `[sip.admission]` did not disable it, because it sits a layer below. New `[sip].udp_rate_limit_pps` (inbound datagrams/sec) and `[sip].stream_rate_limit_fps` (post-framing SIP messages/sec over TCP/TLS/WS), both defaulting to **200 to preserve existing behaviour exactly**, both accepting `0` to disable, and both validated at config load — the upstream token bucket rejects out-of-range values only when it is built, which is after the listeners are up. Drops are exported as `siphon_ai_sip_rate_limited_total{transport}`, pre-registered at zero for every transport so an alert never has to tolerate an absent series. The source IP is deliberately not a label (unbounded, and spoofable on UDP); the throttled `sip_transport` WARN names the peer and now carries a cumulative `dropped_total`. Applied once at startup, so **a config reload cannot change either limit** — and if a packet somehow beat the setter, the daemon warns that the configured value did not take effect rather than running silently at a different cap. Verified live: at the default, 120 cps from one source dropped **2279 packets, all counted**, where the same load previously vanished into a bare warning; raising the limit to 5000 took the identical load to **zero** drops; `0` disables. Note the calls still completed in all three cases — SIP retransmission masks the drops as added setup latency rather than failures, which is why this went unnoticed for so long.
- Wiring this up required siphon-ai to implement `sip_observe::TransportMetrics` (new `crates/telemetry/src/transport.rs`) and install it with `set_transport_metrics`. The upstream hook is **defaulted to a no-op**, so an embedder that installs nothing — as siphon-ai did until now — gets the `NoopTransportMetrics` fallback and sees nothing at all. Only `on_rate_limited` is implemented; the other hooks are deliberate no-ops, because siphon-ai already derives that signal at the call and dialog layers with better attribution, and counting raw packets twice would add cardinality without answering anything in DEV_PLAN §11.8.
- **HEP shipping now exports metrics, and a dead collector is no longer silent** (issue #460). `siphon_ai_hep_packets_sent_total` and `siphon_ai_hep_packets_dropped_total{reason="queue_full"}` were documented in five places — including `crates/telemetry/src/hep.rs`'s own module doc, which claimed "the drop counter is surfaced via metrics" — and existed nowhere: `/metrics` carried 53 `siphon_ai_*` series and not one HEP series, so HEP.md's numbered troubleshooting runbook sent operators to two metrics that could never appear. Neither needed an upstream change; `hep_rs::UdpHepSink` already exposes `sent()` and `drops()`, and siphon-ai simply never read them. A sampler mirrors both into the registry every 10 s (`absolute`, not `increment` — the upstream values are monotonic totals, so mirroring them cannot drift the way a locally-computed delta would across a missed tick), publishes once at build so **both series exist from startup rather than registering lazily on first use**, and publishes a final time *after* the shutdown drain so end-of-life CDR chunks and close-race drops land in the last scrape. The counters carry no `{type}` label because SIP chunks are emitted by `sip-hep` and RTCP/QoS by `forge-hep` — neither passes through siphon-ai, so there is no local call site to attribute them at.
### Fixed
- **The default log filter no longer silently discards whole crates' warnings** (found while fixing #460). `DEFAULT` in `bins/siphon-ai/src/main.rs` was an allowlist with no global level — `siphon_ai=info,...,forge=warn` — and `EnvFilter` drops every *unlisted* target outright. `hep_rs` was not listed, so upstream's throttled "HEP UDP send failed ... Connection refused" warning, which is the daemon's only signal that a Homer collector is unreachable, never reached a sink; the same omission muted 38 `warn!`/`error!` sites across `siphon_ai_telemetry`, `siphon_ai_cdr`, `siphon_ai_http`, `siphon_ai_recording`, `siphon_ai_audit`, `siphon_ai_quality` and `siphon_ai_stir_shaken`. The directive now leads with a bare `warn` floor and lifts the first-party crates to `info` on top of it, which makes the filter fail-safe: a new crate, or any dependency, can no longer be muted by omission — only turned *up* by naming it. The upstream `sip_*` / `forge*` entries are gone as redundant, since the floor puts them exactly where their old `=warn` directives did. Two tests drive a real subscriber and assert on what it emitted, rather than pattern-matching the directive string — the string is what regressed, so it can't be the oracle. Note the precedence is replace, not merge: a `RUST_LOG` narrowing to one target drops the floor with it.
- **Corrected what the HEP docs promise, in `HEP.md`, `CONFIG.md`, `DEPLOY.md` and `DEV_PLAN.md`** (issue #460). `siphon_ai_hep_collector_up` is **not** implemented and is no longer claimed anywhere: `sent()` counts wire successes and `drops()` counts queue-full, so a *refused* send is counted in neither, and there is nothing to derive the gauge from without a send-failure counter in `hep-rs`. Also struck: the `{type}` labels, the `collector_down` / `encode_error` drop reasons, and `siphon_ai_hep_send_duration_seconds`, all specified in DEV_PLAN and never built. HEP.md's runbook is rewritten to start from the startup line and the `hep_rs::udp` warning before reaching for metrics, and DEV_PLAN's R12 mitigation now describes what exists instead of what was planned. One correction is measured rather than reasoned: **`siphon_ai_hep_packets_sent_total` does not go flat when the collector dies.** The sink writes to a *connected* UDP socket, so the refusal surfaces as `ECONNREFUSED` on the *following* send — one send consumes the queued ICMP error and the next finds the queue empty and succeeds. Against a dead collector the counter kept climbing at almost exactly half rate (35 of ~70 attempted), which no threshold can distinguish from a quiet period; the docs now say to alert on the log line and explain why.
- **`docs/CONFIG.md` no longer under-prices neural VAD by ~6×** (issue #461). The `vad = "neural"` row quoted "~60–80 µs per 32 ms audio window per call" — 0.19–0.25% of a core — as the only sizing figure available to an integrator. Measured on 4 vCPU at 8 kHz, away from saturation so contention couldn't inflate it, the cost is **+1.47% of a core per call**, about 470 µs per window. The row now carries that figure, the previously undocumented **~1.6 MB of RSS per call** (against energy's ~0.79 — and it is per call, not a one-time model load), the fact that the model loads on the **first call** rather than at startup so an idle daemon's RSS says nothing about its footprint, and a sizing rule: on 4 vCPU the knee lands near 200 concurrent rather than the 250+ energy sustains. Absolute numbers are CPU-dependent, so the row says to re-measure on your own hardware.
- **The 491-glare backoff for hold/resume re-INVITEs now follows RFC 3261 §14.1's role split** (issue #454). Both call directions retried after a fixed 250 ms, which sits inside the *non-owner's* 0–2 s band — correct for inbound legs (SiphonAI doesn't own the Call-ID there, verified live on 0.48.10), wrong for outbound legs, where SiphonAI **is** the Call-ID owner and the RFC prescribes 2.1–4.0 s. The two bands are disjoint on purpose — after a collision one side is guaranteed to re-offer first, so the glare cannot repeat — and on the outbound path that guarantee was forfeited: a peer retrying fast could collide a second time and spend the once-only retry, failing the hold (`hold_failed`; the call stays in its prior media state, so the blast radius was one unheld call — low severity, but the collision-avoidance guarantee is the whole point of the scheme). The backoff is now chosen from the role's band — outbound 2.1–4.0 s, inbound 0–2 s — and **randomised within it**, also per the RFC: the previous fixed value meant two SiphonAI instances glaring at each other would retry in permanent lockstep. Jitter comes from std's OS-seeded `RandomState` (no new dependency; desynchronisation is all §14.1's randomness buys). A unit test pins both bands, their disjointness, and that the value actually varies. The functional test plan's HOLD-04 row, which expected 2.1–4.0 s unconditionally, now states the per-role bands.
### Documentation
- **Published tier-1 load & capacity results for 0.48.10, including §7.2's feature-cost deltas** (`test-harness/load/RESULTS-0.48.10.md`, follow-up to #456). 4 vCPU, G.711, plaintext loopback: **≥250 concurrent calls** and **≥75 cps** with every quality SLO held — both floors rather than measured ceilings, since the ramp ended where the plan said to stop with two-thirds of the CPU idle and the rate ramp ended on the *generator's* headroom, not the bridge's. Per-call CPU **falls** with concurrency (1.10% → 0.52% of a core), fds are exactly `12 + 3 × calls`, threads never leave 5, and 375 calls offered against a 250 cap shed exactly 125 with `503` and no brown-out for admitted traffic. §7.2 prices the features one at a time at a fixed 200-concurrent reference point: **HEP is free** (+1%), **recording costs +81%** CPU/call and is the only variant to lose packets (0.169%, MOS floor 3.530 — inside SLO, but "recording is free" is not supportable), and **neural VAD costs +191%** CPU/call and ~6× the memory, moving the knee from ≥250 to below 200 on this hardware. Two findings came out of the run and are filed but unfixed: **#458** (RSS grows ~6–10 KB per *completed call* — tracks cumulative calls, not concurrency and not frames; ~60–100 MB/day at 10k calls/day) and **#459** (an undocumented hard-coded 200 pps per-source SIP cap that drops silently, and that disabling `[sip.admission]` does not disable). The run also corrected the plan where it was unfalsifiable or wrong: the §4/§5 setup-latency SLO moved 250 ms → 200 ms (`SDP_NEGOTIATE_BUCKETS` tops out at a finite `0.2`, so a 250 ms bar could be satisfied but never falsified), §6.3's "RSS within 10% of idle" criterion was replaced with the two tests that actually separate pooling from a leak, three metric names were corrected, and `rtp_packet_loss_ratio`, `ws_connect_seconds` and `outbound_audio_frames_dropped_total` are recorded as **not exported** — the last of which leaves §4's Playout SLO with no data source, so it is published as unverified rather than passed. §7.2 itself no longer demands a full re-ramp per feature, and now says to take the marginal cost at low concurrency: neural VAD reads +1.03 %/core per call at 200 concurrent but +1.47 at 25, and its first-audio p95 of 198 ms at 200 concurrent is saturation (18 ms at 25), not an inherent cost of the model. Both references to HEP drop metrics are struck — see #460.
- **The load-harness README now runs against `configs/soak.toml`, which is sized to its own plan** (follow-up to #456). The README's run commands still started the daemon with `configs/local-dev.toml`, which fails the harness three ways: it listens on **5070** while every SIPp command targets **5060** (the documented soak could not even connect), its 100-port RTP range caps at 50 concurrent calls, and its default 60 s inactivity watchdog reaps the burst's signalling-only calls at the one-minute mark. The run commands now use `configs/soak.toml` — and `soak.toml`'s RTP range widens `[40000, 41100]` → `[40000, 42000]` to match LOAD_TEST_PLAN.md §1.1's own sizing rule (2 × target concurrency plus teardown headroom; §11's human reference call is call number 501). Prerequisites gain the `ulimit -n 65536` step (each call ≈ 4 fds — the 500-burst blows through the default 1024), and the plan's §1.1 table and §1.3 now name `soak.toml` as the shipped baseline. Verified live: daemon on `soak.toml`, echo server, one SIPp call through 5060, clean CDR.
---
**Artifacts**
- Static musl binaries for `x86_64` and `aarch64` (`.tar.gz`).
- Debian packages (`.deb`) for `amd64` and `arm64` — `apt install ./siphon-ai_*_amd64.deb`.
- `SHA256SUMS` — verify with `sha256sum -c SHA256SUMS`.
- `*-sbom.cdx.json` — CycloneDX SBOM.
- `SHA256SUMS.cosign.bundle` — cosign keyless signature over the checksums.
- Container: `ghcr.io/thevoiceguy/siphon-ai:v0.48.11` (multi-arch, cosign-signed).
Verify the checksums signature:
```
cosign verify-blob --bundle SHA256SUMS.cosign.bundle \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'release.yml@' SHA256SUMS
```
Related Repositories
farion1231/cc-switch★ 147,255
A cross-platform desktop All-in-One assistant for Claude Code, Codex, OpenCode, OpenClaw, Grok Build & Hermes Agent. Only official website: ccswitch.io
rustdesk/rustdesk★ 126,548
An open-source remote desktop application designed for self-hosting, as an alternative to TeamViewer.
tauri-apps/tauri★ 112,097
Build smaller, faster, and more secure desktop and mobile applications with a web frontend.
ruvnet/RuView★ 99,061
π RuView turns commodity WiFi signals into real-time spatial intelligence, vital sign monitoring, and presence detection — all without a single pixel of video.
oven-sh/bun★ 96,339
Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one