v0.49.8

thevoiceguy/siphon-aiv0.49.8Aug 21, 2026by github-actions[bot]

Upgrading?

AI Summary

Fixes `[[register]]` blocks on ephemeral ports and implements proper product branding for `User-Agent` and `Server` headers, changing what the node says on the wire.

Key Highlights

  • `[[register]]` on ephemeral ports now fails at config load instead of dying silently.
  • `User-Agent` and `Server` headers now use product name/version instead of crate names.
  • SIPp harness improved to fail fast on port collisions instead of reporting false failures.

Breaking Changes

  • Changes wire protocol headers (Server and User-Agent values). Operators matching on these headers need to update.

Full Release Notes

`[sip].user_agent` finally does what this repo has documented it doing
since it was added, a `[[register]]` block can no longer be silently
dead, and the SIPp harness stops reporting its own port collisions as
product regressions. Three siphon-rs releases are absorbed along the
way, two of them cut for fixes this project filed.

**Upgrade note: this changes what your node says on the wire**, so it
wants a restart to take effect. No config change is required (the new
validation only refuses a combination that never worked), no protocol
change (WS stays version `"1"`), and no CDR change (still v8).

### On the wire: one change to `User-Agent` / `Server`

Four entries below touch these two headers, which reads like four
changes. For anyone who deploys **releases**, it is exactly one:

| | 0.49.7 and every release before it | 0.49.8 |
|---|---|---|
| `Server` on responses | `siphon-rs/0.1.0` | **`siphon-ai/0.49.8`** |
| `User-Agent` on requests | `siphon-rs/0.1.0` | **`siphon-ai/0.49.8`** |
| with `[sip].user_agent` set | *ignored* — still `siphon-rs/0.1.0` | **the configured value**, both headers |

`siphon-rs/0.1.0` was a version number that never corresponded to
anything: it was hardcoded upstream before any release existed, and
this daemon took it because `[sip].user_agent` was wired to nothing.
The intermediate values you will see in the entries below —
`sip-uas/0.3.0`, `sip-uac/0.5.0`, `sip-uac/0.6.0` — existed only in
unreleased builds between the dependency bumps; no tagged siphon-ai
ever shipped them.

**What to check before upgrading:** anything that matches on either
header — a carrier-side rule, a Homer search, a log or capture filter,
an SBC's UA-based routing. If you would rather keep a stable string
across this transition, set `[sip].user_agent` to whatever you were
matching on; it is honoured now.

### Fixed

- **A `[[register]]` block on an ephemeral SIP port is now refused at config load, instead of dying quietly at runtime.** The Contact a registrar routes calls back through is built from the *configured* `[sip].listen` port — `[node].public_address` replaces only the host — so `listen = "0.0.0.0:0"` produced `sip:user@0.0.0.0:0`, which is not a valid SIP URI. Until siphon-rs v2026.08.20.1 that panicked the registration's drive task; after it, the task ends with a `warn!` and that registration is simply dead on a daemon whose `/ready`, metrics and other registrations all look fine. CLAUDE.md §4.6 puts this class of check at load time, so `siphon-ai check` and startup both refuse it now, naming the block and quoting the listen. An ephemeral bind with no `[[register]]` blocks is untouched — nothing needs advertising.

- **The other half of `[sip].user_agent`: requests now name this product too** — closes [#539](https://github.com/thevoiceguy/siphon-ai/issues/539). The response half shipped in the entry below; requests kept saying `sip-uac/<version>` because every request builder in `sip-uac`'s `UserAgentClient` pushed a crate constant and `UACConfig::user_agent` was consumed only as an SDP session name — no configuration from here could reach the header. [siphon-rs#116](https://github.com/thevoiceguy/siphon-rs/pull/116), opened from this project, threads the configured token into those builders; the pin bump below picks it up. A REGISTER now carries `User-Agent: siphon-ai/<version>` (or whatever `[sip].user_agent` says), asserted against a fake registrar in `bins/siphon-ai/tests/startup.rs` — the test that shipped `#[ignore]`d with the first half, now live.
  - **Operator note:** requests move from `sip-uac/0.5.0` to `siphon-ai/<version>` on a node that sets nothing. With the `Server` change in the previous release and the two token changes before it, anything matching on either header wants checking once, now, rather than release by release.

- **`[sip].user_agent` was documented to brand the `User-Agent` and `Server` headers and was wired to nothing** ([#539](https://github.com/thevoiceguy/siphon-ai/issues/539)). The compiled value reached exactly one call site — `sip_local_uri()`, where a helper ignored its argument and returned the literal `"siphon"` — so the key parsed, validated, and did nothing, with no warning to say so. Every response this daemon sent carried the SIP stack's own default instead: `siphon-rs/0.1.0` historically, `sip-uas/<version>` since the v2026.08.19 bump above.
  - **Responses are fixed.** `[sip].user_agent` now reaches the `Server` header, and an unset key means **`siphon-ai/<version>`** — this product and its own version, rather than whichever stack crate emitted the message. It applies to responses the UAS synthesizes (100 Trying, 405, 481, 501, the OPTIONS 200) and to the ones the routing handler builds and fills itself (the trunk 403, the no-route 404 / 488).
  - **Requests are not, and cannot be from here.** `sip-uac`'s request builders — `create_register`, `create_options`, `create_invite_with_from`, `create_invite_with_body`, `create_reinvite`, `create_update`, `create_publish`, `create_subscribe` — each push the crate constant `DEFAULT_USER_AGENT` onto `User-Agent` directly. `UACConfig.user_agent` exists but is consumed only as an SDP session name, so REGISTER, INVITE, REFER and BYE still say `sip-uac/<version>` no matter what is configured here. That is the same shape of bug one layer down, and it needs a siphon-rs change; #539 stays open for it, with an `#[ignore]`d test in `bins/siphon-ai/tests/startup.rs` stating the intended behaviour.
  - The dead `extract_user_part()` helper is gone. `sip_local_uri()` uses the `siphon` user-part directly, which is what it always produced — a `User-Agent` is product info, not a user, and routing one through the other is what disguised the dead code as a live path.
  - **Operator note:** a node that never set this key moves from `sip-uas/0.3.0` to `siphon-ai/<version>` on responses. Combined with the bump above, that is two changes to the same header in one release for anyone matching on it.

- **The SIPp harness reported a daemon that never started as six failing scenarios** ([#541](https://github.com/thevoiceguy/siphon-ai/issues/541)). #533 made every port the harness binds overridable and added a preflight — but it probed only what the *auxiliary* phases bind. The **main** phase runs `$SIPHON_AI_CONFIG` (`configs/local-dev.toml` by default), whose `[observability] http_listen` is a literal the preflight never read. So on a box with a daemon already on `:9091`, the documented `AUX_OBS_PORT=9591 ./run-all.sh` moved the auxiliary phases off the collision and left the main phase sitting on it: its daemon exited with "Address already in use" and its first six scenarios — `basic_call_then_bye` through `reinvite_unsupported_codec_488` — reported as scenario failures while every auxiliary phase passed. That is the exact failure mode #533 set out to end, one port short of ending it; it cost a red 7-of-40 run while verifying the siphon-rs bump below.
  - **The main phase now gets what every other phase already had:** its config is copied with the `[observability]` and `[admin]` listeners rewritten onto the harness's own ports before the daemon sees it. One `AUX_OBS_PORT` override covers the whole run, and `SIPHON_AI_CONFIG` goes back to meaning "which config to run" rather than "how to dodge a port". Only lines that already exist are rewritten, so a config that declares no listener still declares none.
  - **A daemon that dies during startup now aborts the run**, printing the config path, the log path and the log's last 15 lines — and keeping the rewritten copy on disk to inspect. Whatever the cause (a port taken, a config the daemon rejects, a bad build), the answer is in that log, not in sipp output. Verified by pointing the harness at a config with an invalid `[sip].listen`: exit 2, no scenario run, cause printed.
  - Re-run on the box that produced the red run, with the production daemon still holding `:9091`: **39 of 40**, the one failure being `barge_in_pause`, which needs a `setcap` this host does not grant.

### Changed

- **Bumped siphon-rs `v2026.08.20` → [`v2026.08.20.1`](https://github.com/thevoiceguy/siphon-rs/releases/tag/v2026.08.20.1)** — sip-uac 0.6.0 → 0.7.0, sip-uas 0.3.0 → 0.4.0, every other crate unchanged. Carries [siphon-rs#119](https://github.com/thevoiceguy/siphon-rs/pull/119) for its [#118](https://github.com/thevoiceguy/siphon-rs/issues/118), both filed from this project while bumping onto v2026.08.20: the integrated builders' `local_uri` / `contact_uri` parsed with `.ok()` and discarded the failure, so a rejected URI was indistinguishable from an unset one. `build()` then either reported `local_uri is required` for a URI that *was* supplied, or synthesized a default Contact and panicked unwrapping that parse.
  - **It was reachable here.** The registration UAC's Contact is built from the *configured* `[sip].listen`, not the bound address, so `listen = "0.0.0.0:0"` plus a `[[register]]` block made `sip:user@0.0.0.0:0` — unparseable, silently dropped, and then a panic in the default-Contact path that killed that registration's task. It now surfaces as an error naming the block.
  - **Breaking upstream, so the four builder sites here take a `?`**: the UAS, the transfer UAC, each gateway UAC, and the per-`[[register]]` UAC. Each names itself in the error (`UAS contact_uri: …`, `[[register]] cucm local_uri: …`) rather than passing the bare upstream message up.

- **Bumped siphon-rs `v2026.08.19` → [`v2026.08.20`](https://github.com/thevoiceguy/siphon-rs/releases/tag/v2026.08.20)** — sip-uac 0.5.0 → 0.6.0, every other crate unchanged. Carries [siphon-rs#116](https://github.com/thevoiceguy/siphon-rs/pull/116) (see above). Upstream's `UacError` gains two variants, which is breaking there; nothing here matches on that type, so no code moved. `cargo check --workspace --all-targets` needed no glue changes.
  - **The pins are now tags, not revs.** siphon-rs's `RELEASING.md` asks consumers to pin tags, and it answers the objection that kept us on revs — *"Tags are immutable. Never move or delete a pushed tag; if a tagged release is bad, cut a new one."* A tag also names what the release notes and the changelog name, where a twelve-hex-digit rev names nothing, and `Cargo.lock` still records the exact commit either way. All thirteen `sip-*` entries move together, as they must: different refs of one repo are different sources to Cargo, which duplicates shared crates and produces impossible type mismatches.

- **Bumped siphon-rs `99da91f599e2` → `9b7b238fd11f`** — [v2026.08.19](https://github.com/thevoiceguy/siphon-rs/releases/tag/v2026.08.19), upstream's **first tagged release**, carrying [siphon-rs#111](https://github.com/thevoiceguy/siphon-rs/pull/111), [#112](https://github.com/thevoiceguy/siphon-rs/pull/112) and [#113](https://github.com/thevoiceguy/siphon-rs/pull/113). Upstream `main` is two commits further on, both docs-only (a changelog entry and a `RELEASING.md`), so the tag is the clean point to sit on. We still pin the **rev**, not the tag: a tag is a movable ref, and reproducibility is the whole reason these are pinned.
  - **#111** gives the crates real version numbers for the first time — `sip-uac` 0.5.0, `sip-uas` 0.3.0, `sip-core` 0.7.7, and so on, where everything had read 0.1.x since the repo began. No code change; the numbers are what #113 consumes.
  - **#112** is README prose. No code.
  - **#113 changes what this daemon puts on the wire.** It derives the default `User-Agent` / `Server` product tokens from those crate versions instead of the hardcoded `"siphon-rs/0.1.0"` frozen there before any release. Upstream reasons that embedders setting `user_agent` themselves are unaffected — **we are not such an embedder**: neither the `IntegratedUAS` nor either `IntegratedUAC` in `runtime.rs` passes a config, so all three take the stack default. Live-probed on the bumped build, a `Server` header that read `siphon-rs/0.1.0` now reads **`sip-uas/0.3.0`**, and UAC requests carry **`User-Agent: sip-uac/0.5.0`**. Accurate at last, but still the crate rather than the product — `[sip].user_agent` is documented to brand these headers and is in fact dead code ([#539](https://github.com/thevoiceguy/siphon-ai/issues/539)). Fixing that is what will make this token read `siphon-ai/<version>`; this bump only moves it off a number that was never true.
  - **Operator note:** anything matching on that token — a carrier-side rule, a Homer search, a log grep — wants updating before this ships.
  - Verified per CLAUDE.md §7.5: `cargo check --workspace --all-targets` needed **no glue changes**; workspace suite **1,283 passed / 0 failed**; clippy `-D warnings` and `cargo fmt` clean; SIPp signalling **39 of 40**, the single failure (`barge_in_pause`) failing **identically on `main` before the bump** in a control run on the same box with the same config, so it is this host, not the bump; and HEP3 emission re-checked end to end against a stub collector — request and response both captured, payload chunks intact — because `sip-hep` moves with the bump and `test-harness/hep-collector-stub/` is still an empty scaffold. Lockfile movement is confined to the siphon-rs crates: **zero crates.io entries touched**.

### Documentation

- **`test-harness/load/RESULTS-0.49.7-ring-ab.md` — the SIP-ladder ring's memory cost, measured.** `RESULTS-0.49.5-sip-ring.md` recorded it as unquantified because a call-based comparison could not resolve it: the expected signal is 1–2 MB against run-to-run RSS variance larger than that, and at 203 concurrent that rig loses 19 % of its calls to the WS server, so the two arms would not carry the same load. Isolating the ring instead — synthetic SIP from loopback, no media, no WS, no call setup, identical flood in both arms so transaction memory cancels — puts it at **~1.9 MB at a realistic 200-concurrent shape** (about 2.5 % of that node's 77 MB), **~17 MB with the pending bound saturated at its per-call cap**, and a computed **~55 MB** ceiling at the bounds that needs 512 concurrent calls each having exchanged 64+ messages. Per-message cost is roughly `1.34 × payload + 285 B`, the slope being allocator size-class rounding. Carries `abrun.sh`, `ringflood.py` and the two configs into the harness.

- **The SIP-ladder ring's documented worst case was an order of magnitude low.** `DESIGN_SIP_LADDER.md` §3.2 published `50 calls × 64 messages × ~1.5 KB ≈ 4.8 MB` — arithmetic over the *completed* window only, dropping the `MAX_PENDING` (256) and `MAX_LIVE` (512) populations the same section introduces thirteen lines earlier. The bound the code actually guarantees, and states correctly in `sip_ring.rs`, is `(256 + 512 + cap_calls) × cap_messages` entries: **~55 MB**, not 4.8. Corrected, and the measured figures now sit next to the knobs an operator turns rather than only in a load-results file — `docs/CONFIG.md` gains **~1.9 MB at a realistic 200-concurrent shape** (~2.5 % of daemon RSS) on `sip_ring_size` and the per-message cost on `sip_ring_max_messages`, which is the knob that sizes the ring. No behaviour change; the bounds themselves are unmoved.

---

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