v0.48.14

thevoiceguy/siphon-aiv0.48.14Aug 12, 2026by github-actions[bot]

Upgrading?

AI Summary

Maintenance release introducing outbound session timer support and fixing metrics visibility.

Key Highlights

  • Added outbound session timer support (RFC 4028) for legs where the callee nominates the refresher.
  • Fixed `siphon_ai_outbound_audio_frames_dropped_total` metric to be visible at zero from startup.
  • Corrected neural VAD memory cost documentation.
  • Bumped `siphon-rs` to handle session timer refreshes on the correct dialog clone.

New Features

  • Outbound session timer support (RFC 4028).
  • Zero-baseline metrics publishing for unlabeled counters.

Full Release Notes

### Changed

- **Bumped siphon-rs `9ae6ce2cee5a` → `b71a75f1eacf`**, picking up two fixes to issues this project filed: **#91** keys the ingress rate-limit warning throttle by source IP (a process-wide static meant a multi-source flood named one peer and hid the rest, siphon-ai's siphon-rs#90), and **#94** schedules UAC session refreshes at `Session-Expires/2` instead of `max(90, se/2)` with an immediate first tick (siphon-rs#92). No glue changes were needed. Verified with the full workspace suite plus the SIPp signalling regression: 35 of 38 scenarios pass, and the same three — `outbound_uas_answer`, `attended_transfer`, `barge_in_pause` — fail identically on `main` before the bump, so they are pre-existing on this host (two need capabilities/services the box doesn't grant; the harness also collides with a running daemon on `:9091` until pointed elsewhere). **Two of those three were the harness, not the host** — see the bump below.

- **Bumped siphon-rs `b71a75f1eacf` → `b9f5a3bf66f2`**, closing out the session-timer defects this project's #477 work surfaced upstream: **siphon-rs #96** makes `CallHandle::start_session_timer` refresh the caller's *own* dialog instead of a private clone (siphon-rs#95 — the clone advanced a CSeq that never reached the owner, so a later BYE reused a consumed one and drew a 408 from a record-routing carrier), and **siphon-rs #98** stops a doomed refresh loop instead of retrying forever, reporting `Idle / Healthy / Failing{consecutive} / Stopped{reason}` on a new `session_timer_state()` watch channel, treating 408/481 as terminal on first occurrence and finally counting non-2xx responses (422, 503) as failures rather than health (siphon-rs#93).
  - **Neither changes what this daemon does on the wire.** Nothing here calls `start_session_timer`: the outbound refresh added in this same release drives `DialogControl` directly, precisely because of the clone bug #96 has now fixed. Both land as a correctness floor under a future switch to the upstream timer — worth making for #98's failure signal, which the hand-rolled task lacks entirely, but a behaviour change to every outbound leg and so deliberately not folded into a pin bump. Tracked in **#484**.
  - Verified with the full workspace suite (**1,145 tests, 0 failures** across 57 binaries), `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings` (clean), and the SIPp signalling regression: **37 of 38 scenarios pass**, with `barge_in_pause` the only failure and failing identically on `main` before the bump (the call hangs with `frames_echoed=0` and is force-terminated at the 30 s drain timeout).
  - **Correction to the entry above:** `outbound_uas_answer` and `attended_transfer` were never host-capability failures. Every auxiliary phase spawns its own echo WS server from `examples/echo-ws-server-python/.venv`, falls back to system `python3` when that venv is absent, and that interpreter has no `websockets` module — so the server died on import, the daemon logged `cause=WsDisconnect`, and the scenario failed for reasons having nothing to do with SIP. Creating the venv the runner documents takes the suite from 35/38 to 37/38. The `:9091` collision with a running daemon is real, and is in `configs/local-dev.toml`'s `[observability].http_listen` as well as the runner's own port constants; `SIPHON_AI_CONFIG` overrides the former.

### Fixed

- **Outbound legs now honour an RFC 4028 timer the callee imposes** (issue #477). Inbound calls have had full session-timer handling for releases — a negotiation policy, and a `SessionExpired` event that reclaims the call. Outbound legs had none of it: `outbound.rs` and `outbound_service.rs` contained no session-timer code at all, so a callee that answered with `Session-Expires` and then stopped refreshing left SiphonAI holding the leg, and its billing, open indefinitely. Reproduced against a UAS that answers `Session-Expires: 90;refresher=uac`: before, the call was still up 140 s later with nothing sent; now it is torn down at the 90 s deadline (`RFC 4028 session expired; tearing down call`, CDR cause `local_shutdown`).
  - **SiphonAI now refreshes when the callee nominates it** (`refresher=uac`), at half the interval, with the armed expiry left underneath as the backstop for a refresh that fails — a rejected or failed refresh therefore ends the call at the deadline instead of silently pretending the session is alive. Verified live: a callee imposing `Session-Expires: 90;refresher=uac` gets refreshes at 45/90/135 s with the CSeq advancing 2→3→4, and the call outlives the deadline that previously killed it. Refreshes are driven through `DialogControl` (resolve → send → commit) rather than upstream's `CallHandle::start_session_timer`, which refreshes a *clone* of the dialog: the CSeq it advances never reaches ours, so the teardown BYE would reuse a consumed CSeq and get 408'd by a record-routing carrier (the issue #353 failure).
  - **Still no `Supported: timer` on outbound INVITEs.** That is deliberate: advertising support would let a *compliant* callee legally nominate us as refresher (RFC 4028 §7.1), which is a behavioural change to every outbound call rather than only the SBC-shaped ones that nominate us today — and it needs an upstream API that can set the caller-ID and extra headers on the same INVITE, which `invite_with_from`/`invite_with_headers` cannot do. Reading the callee's `Session-Expires`, arming an expiry against it, and refreshing when we are nominated anyway gets the protection without that change.
  - The expiry is the *same* `SessionTimerManager` the inbound path uses, shared via a new `SessionTimers` handle rather than a second manager — `subscribe()` is one-shot upstream, so a second manager would mean a second fan-out task and one of them silently receiving nothing. Parsing is upstream's `SessionExpires`, which enforces the 90 s floor, so a bogus tiny value cannot arm a timer that tears down healthy calls.

### Documentation

- **Documented the RFC 4028 session-timer knobs and what SiphonAI does with them** (issue #478). `[sip].min_session_expires_secs` and `[sip].preferred_session_expires_secs` have been accepted, validated and wired into the negotiation policy for several releases while appearing nowhere in `docs/CONFIG.md` — an operator had no way to learn they exist. Both now have rows, including the parts that only show up in production: an offer below Min-SE is rejected `422 Session Interval Too Small`, a *refresh* below Min-SE is also 422 but leaves the existing dialog and timer running so the peer can retry, and a configured value under the RFC floor of 90 fails startup.
  - The behaviour around them is now written down too. **On an inbound call SiphonAI always nominates the caller as refresher**, overriding `refresher=uas` if the peer asks for it, because it does not refresh an inbound leg itself — there is no setting that changes this. (Outbound legs are a separate matter: per the entry above, a callee that nominates *us* gets refreshed.) The operator-facing consequence is that a peer which negotiates a timer and then stops refreshing **loses the call at the deadline by design**, showing up as a clean teardown with one log line (`RFC 4028 session expired; tearing down call`), a CDR cause of `local_shutdown`, and a duration suspiciously close to the negotiated `Session-Expires`. "Calls die at exactly N seconds" is now searchable.

### Fixed

- **`siphon_ai_outbound_audio_frames_dropped_total` is now published at zero from startup** (issue #474). `describe_counter!` registers the `# HELP` text, not the series — a counter does not exist in `/metrics` until something increments it. For a counter whose *healthy* value is zero and whose every movement is the alert, that makes "nothing was dropped" and "this build has no instrumentation" render identically, and the transition an operator watches for is the one they cannot see. Measured on 0.48.13: a clean one-call run exported 59 `siphon_ai_*` series and the playout counter was **absent**; a WS server bursting 100 frames on connect (so the 200 ms playout window must evict, PROTOCOL.md §5.5) made it appear at **85** alongside the "faster than realtime" warning. The counter was always correct — it was just invisible while the news was good. This is why the Playout SLO in `test-harness/load/LOAD_TEST_PLAN.md` §4 has been recorded as *unverified, not passed* on both the 0.48.10 and 0.48.13 tier-1 load runs, while every other §4 SLO was measured.
  - Published via a new `metrics::publish_zero_baselines()` at recorder install, the same treatment `siphon_ai_sip_rate_limited_total` already gets (#464). Deliberately limited to **unlabeled** counters: publishing a labeled series means inventing label values before anything has happened — `siphon_ai_room_frames_dropped_total` would need a `stage` × `side` matrix — which trades one wrong signal for another. Labeled drop counters stay with the code that owns their label space, as the HEP sampler does.
  - **Metric names at the emit sites now come from the constants that declare them.** `media-glue` and `sip-glue` were spelling 12 metric names as string literals — including the playout counter above — because they depended on the `metrics` facade but not on `siphon-ai-telemetry`, so the constants were out of reach. `room.rs` had gone further and re-declared four names locally under a comment asking the reader to keep them in step by hand. A rename in `telemetry` would have left these crates emitting the old name with nothing failing to compile — the same class of silent drift as the missing series. Both crates now take a **`siphon-ai-telemetry` dependency for the name constants**: the edge `core` already has for `DIALOGS_ACTIVE`, with `telemetry` depending on neither, so there is no cycle and no new external dependency.

### Documentation

- **Corrected the neural-VAD memory cost in `docs/CONFIG.md`** (issue #472). The row told integrators to "budget ~3× the CPU per call and **~2× the memory per call**" against energy VAD, and then to size for a knee near 200 concurrent calls. The memory ratio was measured at **25** concurrent and does not survive being applied at 200: energy's per-call figure there is mostly fixed overhead divided by a small N (0.76 MB/call) rather than a marginal cost, while neural's is a genuine per-call model instantiation that barely moves with concurrency. Using the same formula on the 200-concurrent column of the original data gives **7.5×**, not 2×; re-measured on 0.48.13 it is 6.5× at 25 concurrent and **8.8× at 200**. Sizing a 200-call node off "2×" budgets ~120 MB where ~400 MB is needed. The row now quotes the marginal delta (**~1.9 MB/call**) and totals at a stated concurrency instead of a ratio, since a ratio between a cost that amortises and one that does not is meaningless without naming the N. The CPU half of the claim was re-measured and is correct (2.9×). `test-harness/load/RESULTS-0.48.10.md` §7.2, which CONFIG.md cites as its source and which contradicted itself on this point ("~6× the per-call memory" in the narrative against "~1.6 vs ~0.79" in the bullet), is corrected in place.

---

**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.14` (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