v0.49.10

thevoiceguy/siphon-aiv0.49.10Aug 22, 2026by github-actions[bot]

Upgrading?

AI Summary

Fixes the inbound call path to return `503 Service Unavailable` instead of `500 Server Internal Error` when the RTP port pool is exhausted. Adds a new metric to track capacity rejections separately and completes load test documentation.

Key Highlights

  • RTP port pool exhaustion now returns `503` with `Retry-After` instead of `500`.
  • New metric label `siphon_ai_invites_total{result="rejected_capacity"}` added.
  • Documentation for outbound origination, mixed-direction load, and soak tests is now complete.

Full Release Notes

One fix to the **inbound** call path: a full RTP port pool is a capacity
condition, and it now answers like one. Relevant to any node that can run
out of ports — most sharply to a node that also originates, since both
directions draw from the same pool.

**No config change, no protocol change** (still `1`), CDR still v8. Adds
one metric label. Wants a restart to take effect.

### Fixed

- **An exhausted RTP port pool now answers `503 Service Unavailable` +
  `Retry-After`, not `500 Server Internal Error`** (#554). A full pool is a
  capacity condition — the daemon is working exactly as configured and is
  simply out of ports — but `500` tells the peer we have a defect. RFC 3261
  §21.5.4 reserves `503` for temporary overload, a proxy that receives one
  SHOULD try the next server in its target set, and carrier SBCs treat the
  two very differently. It was also inconsistent with this daemon's own
  overload signalling: `[sip.admission]` and the drain path already answer
  `503` + `Retry-After`, so alerting built on "503 means siphon-ai is full"
  got an internal-error page instead.
  * `forge-core` has always distinguished `ForgeError::ResourceLimit`;
    `media-glue` was flattening every forge error into one stringly
    `SetupError::Session`, so the acceptor could not tell capacity from a
    fault. The type now survives the conversion.
  * The delayed-offer path hardcoded `500` and the flat `rejected` label
    instead of consulting the shared status table, so a delayed-offer
    caller got a different answer to the same condition. It routes through
    the table now.
  * New metric label **`siphon_ai_invites_total{result="rejected_capacity"}`**,
    separately alertable from `rejected`. On a node that also originates the
    pool is shared with outbound, so this counter is the only inbound-side
    signal that a surge in the other direction is refusing calls — see
    `test-harness/load/RESULTS-0.49.9-mixed-and-soak.md` §2.1. That pool
    has no reservation between the two directions, which is tracked
    separately as #556; this release changes only the answer given, not
    who gets a port.

### Documentation

- **The load test plan is complete — outbound origination, a live-carrier
  tier, both directions at once, and a churning soak** (#553). Everything
  through §11 loaded *inbound* only, which is how a per-originated-call
  dialog leak (#548) reached production unnoticed; the plan now has a §12
  outbound phase, §13 mixed-direction phase, and the tier-3 live run its
  three tiers always promised. Three results documents land with it:
  - `RESULTS-0.49.9-outbound.md` — origination under load. `fds = 13 + 3N`
    and two RTP ports per originated leg; a 60-minute churning soak
    (2,995 placed / 2,995 completed / 0 failed / 0 WARN) whose RSS growth
    is **arena, not a leak** — the same drained process absorbed 750 more
    calls at 1.9 KB each where a leak needed ~16 MB.
  - `RESULTS-0.49.9-tier3.md` — 60 sequential calls over a live Twilio
    trunk (TLS + SRTP), out through the carrier and back in to the same
    node. MOS p50 **4.435**, jitter p50 **1.94 ms**, loss **0.001 %**,
    setup p95 **460 ms**. It also proves the tier-2 netem model
    *pessimistic*: live jitter 1.94 ms against netem's 3.35, live loss
    0.001 % against 0.495 %.
  - `RESULTS-0.49.9-mixed-and-soak.md` — the two directions compose
    linearly (`fds = 13 + 3N`, `udp_sockets = 2N + 1`, N the *total*), and
    the shared RTP pool is first-come-first-served with **no reservation
    between them** (#556). Running the exhaustion test in the reverse
    order is what found #554, fixed above.

---

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