v0.48.16

thevoiceguy/siphon-aiv0.48.16Aug 13, 2026by github-actions[bot]

Upgrading?

AI Summary

Maintenance release focusing on session timer logic, including metrics for failed refresh loops and fixing CSeq collisions between refreshes and BYEs.

Key Highlights

  • Added metrics `siphon_ai_session_refresh_total` and `siphon_ai_session_refresh_stopped_total`.
  • Fixed session refresh loop to stop and report when the dialog is gone or exhausted.
  • Fixed CSeq collision between session refresh re-INVITEs and teardown BYEs.
  • Refreshes now run a 5-second guard ahead of the deadline.

Full Release Notes

### Fixed

- **An outbound session-refresh loop that gives up now says so** (issue #484). The refresh added in 0.48.14 works — live-verified, CSeq advancing 2→3→4 against a callee imposing `Session-Expires: 90;refresher=uac` — but it had no failure signal whatsoever: a rejected or timed-out refresh logged a `warn!` and the loop ticked on at the same cadence forever, with no metric and nothing to alert on. The call still ended correctly at the armed expiry, so the *outcome* was right while the *reason* was invisible — "the callee is refusing our refreshes" and "the callee hung up" looked identical until the deadline passed. Ported from upstream's fix for the same gap in its own timer (siphon-rs #98 / #93):
  - **A non-2xx answer is a failure, not health.** `422 Session Interval Too Small` and `503` come back as `Ok(response)`, so they were previously indistinguishable from a successful refresh while the session was quietly left to expire.
  - **`408`/`481` is terminal on the first occurrence**, whatever the threshold — the peer is saying the dialog does not exist and no number of retries brings it back (RFC 3261 §12.2.1.2); continuing just burns CSeqs against a dead dialog.
  - **Two new counters.** `siphon_ai_session_refresh_total{result=ok|rejected|failed}` scores every attempt, and `siphon_ai_session_refresh_stopped_total{reason=dialog_gone|exhausted|unresolvable}` fires when the loop stops while the call is still up — **that one is the alertable signal**, since every increment means nothing is keeping the session alive. Like upstream, giving up does not BYE the call: RFC 4028 §10 suggests the refresher tear it down, but that is the deployment's decision, so the loop stops and reports while the armed expiry ends the call at its deadline.
  - **Dead dialogs are identified by the dialog's state, not the response code.** `408`/`481` never arrive as a code — `apply_in_dialog_response` maps them to `Err` and terminates the dialog on the way through — so the first cut of this change matched `Some(408) | Some(481)` and was dead code that live traffic never hit: a real `481` came back as `Err("Received 481 for in-dialog …")`, scored an ordinary failure, and the loop retried a dialog the peer had already declared dead. Reading the state (as upstream does, for the same reason) also catches an owner that hung up while a refresh was in flight. Verified live: a callee answering the refresh `481` now stops after exactly one attempt instead of two.
  - **The give-up threshold is 2, not upstream's default of 3, because 3 can never be reached.** Refreshes run at `Session-Expires/2` while the expiry sits a full `Session-Expires` past the last success, so exactly two attempts fit inside a dying session regardless of the negotiated value — a third would mean the loop never announces giving up, which is the silence this change exists to remove. A test pins the arithmetic so a future "let's be more robust" bump fails loudly instead of quietly restoring it. When this was written the second attempt landed *on* the deadline, so `exhausted` lost the race to the expiry and never fired — but #490, in this same release, moves every tick a five-second guard earlier, which puts the last attempt ten seconds clear and makes `exhausted` reachable on a 90 s timer. `dialog_gone` remains the branch that fires deterministically (terminal on the first `408`/`481`), and any non-`ok` `siphon_ai_session_refresh_total` is still the earliest signal; `docs/DEPLOY.md` carries the corrected version of this.
  - **Not** switched to upstream's `CallHandle::start_session_timer`, which is what #484 originally proposed on an incorrect reading of siphon-rs #96. That fix stopped the upstream timer refreshing a private clone, but it still refreshes `CallHandle::dialog` — and this daemon does not use that dialog on an outbound leg. `OutboundCall::dialog` is a snapshot taken at answer time and moved into the leg's own `shared_dialog`, which every in-dialog request (REFER, hold/resume, park, teardown BYE) resolves from and commits back to. Switching would put refreshes on one dialog and the BYE on another: the duplicate-CSeq BYE a record-routing carrier answers `408`, which is issue #353 again. Unifying them is a real refactor of `DialogSource::Direct` — a synchronous `parking_lot::Mutex` becoming the upstream async lock, across hold, resume, REFER, park and teardown — and stays tracked in #484.

- **The session-refresh re-INVITE and the teardown BYE no longer go out on the same CSeq** (issue #490). On an outbound leg whose refreshes are being rejected, the two were measured 564 µs apart carrying `CSeq: 3 INVITE` and `CSeq: 3 BYE` in one dialog — two different requests, one sequence number, which RFC 3261 §12.2.1.1 forbids. A peer entitled to reject that BYE leaves the leg (and its billing) up at the far end: the leak #480 armed the expiry to close, reopened at the moment the expiry fires. Reproduced 3/3 against the shipped 0.48.15 binary; SIPp answers the colliding BYE anyway (it matches on Call-ID), which is why the 0.48.14 verification did not see it. Two independent halves:
  - **The consumed CSeq is now published before the request leaves, not after it is answered.** `DialogControl::commit` writes the advance back post-response, so during the round-trip the shared dialog still read the *pre-request* number and anything else on the leg picked it up — the teardown BYE did. `DialogSource::reserve_cseq` now takes the number on the shared dialog up front; it never runs the sequence backwards, so two in-flight requests get two distinct numbers. Applied to the refresh loop, to hold/resume (inside `send_reinvite`), and to REFER, all of which had the same window — the session timer just made it deterministic rather than a coincidence. Inbound legs never had it: upstream re-inserts the advanced dialog into the shared `DialogManager` before the send, so the number is published already.
  - **No refresh tick lands on the deadline it protects.** Ticks recurred at exactly `Session-Expires/2` while the armed expiry sat at `Session-Expires` past the last *successful* refresh, so the second tick after any failure coincided with the teardown by construction. Refreshes now run a five-second guard ahead of the half-way point, which puts twice the guard between the last attempt and the deadline. Refreshing early is always safe (RFC 4028 §10 — the peer restarts its timer from whenever the refresh lands), and the gap is also the window in which a rejected refresh gets to be logged before the call ends: `exhausted` is now reachable on a 90 s timer instead of losing the photo finish to the expiry. `docs/DEPLOY.md`'s metric row said so and has been corrected.
  - Every refresh attempt now logs its `cseq`, so this class of defect is legible in the field rather than only in a packet capture.

---

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