Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ never from the exception (`Sdk/ADR-0056`).
- No counter, gauge, activity or event changes, and no public API change — no new type, no new member, no
changed signature, and `AriConnectionState` itself is untouched.

**Migration guide:** [`docs/guides/ari-connection-state-and-accept-loop-migration.md`](docs/guides/ari-connection-state-and-accept-loop-migration.md)
— required by ADR-0028 for a minor that carries a breaking change.

### Changed — BREAKING: `AriOutboundListener` keeps accepting after an accept fails (#291)

`AcceptLoopAsync` wrapped its whole `while` in a `try` whose last clause was `catch (SocketException) { }`,
Expand Down Expand Up @@ -315,6 +318,9 @@ cap with each consecutive failure and starts over after a successful accept —
- No public API change here either: the backoff bounds, the accept seam and the `TimeProvider` constructor the
wait runs on are all `internal`.

**Migration guide:** [`docs/guides/ari-connection-state-and-accept-loop-migration.md`](docs/guides/ari-connection-state-and-accept-loop-migration.md)
— required by ADR-0028 for a minor that carries a breaking change.

## [2.5.3] - 2026-09-13

### Fixed — BREAKING: `VoiceAiPipeline` counted a synthesizer's own cancellation as a completed synthesis
Expand Down
1 change: 1 addition & 0 deletions docs/claim-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Missed by the first sweep — tracked, public, and read as current by every cont
| `high-load-tuning.md:13-18,20,251` | events/sec per agent tier; 200K/sec queue storm; VarSet 50%+ of volume | — | GAP — workload estimates about the reader's PBX; see *Unresolved* |
| `high-load-tuning.md:138-140` | pauseWriter 1 MB / resumeWriter 512 KB / segment 4 KB "hardcoded" | ENFORCING | GAP |
| `high-load-tuning.md:197` | EventPumpCapacity 20,000 | ENFORCING | OK — matches source; `README-technical.md:503` is the wrong one |
| `ari-connection-state-and-accept-loop-migration.md` | accept backoff 100 ms doubling to a 5 s cap; at most 12 Error lines a minute at the cap | ENFORCING | GAP — the bounds are `internal` constants in `AriOutboundListener`; the per-minute figure is 60/5 s, arithmetic over them. Both are restated from the `[Unreleased]` #291 entry, not newly derived |
| `session-store-backends.md:5,56,70,76` | three backends, three overloads, three indexes, pageSize 500 | ENFORCING | GAP |
| `session-store-backends.md:9-11,22,27,35` at `e250182e` | read latency <0.1 ms / <1 ms / 5-10 ms; the "sub-millisecond" InMemory and Redis bullets; "5-10 ms read latency is acceptable" | COHERENCE | **DELETED** — nothing measures InMemory, the record binds no read figure, and the only read the committed measurements time, `GetAsync` over loopback, put Postgres at p50 48 µs, not 5-10 ms; Postgres `GetAsync` measures under a millisecond too, so "sub-millisecond reads" did not tell Redis apart. This row cited `:26` until then; the bullets are `:22` and `:27`. The words that replaced these figures are the next two rows |
| `session-store-backends.md:9-11,22,35` | what a store call costs, in words: InMemory in-process, with no I/O and no serialization; Redis one `GET` for `GetAsync` and two for `GetByLinkedIdAsync`; Postgres one `SELECT` per read, one WAL flush per single save under the default `synchronous_commit=on`, and one shared by a `SaveBatchAsync` | ENFORCING | GAP — this repository's own code: true against `InMemorySessionStore.cs`, `RedisSessionStore.cs` and `PostgresSessionStore.cs` as of 2026-09-12 (`GetByLinkedIdAsync` sends one `GET` when the linked index misses), and nothing counts the commands |
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ Practical how-to guides for working with Verbara Sdk.

| Guide | Description |
|-------|-------------|
| [ari-connection-state-and-accept-loop-migration.md](ari-connection-state-and-accept-loop-migration.md) | Moving to the ARI connect attempt that leaves a terminal state and the outbound listener that survives an accept failure -- what `AriClient.State` reports now, which workarounds can be deleted, and the Error line a persistent accept failure produces. |
| [asterisk-version-compatibility.md](asterisk-version-compatibility.md) | AMI event coverage matrix across Asterisk 18-23, listing typed classes and fallback behavior per version. |
| [asterisk-version-matrix.md](asterisk-version-matrix.md) | Supported Asterisk versions (22 LTS primary, 23 Standard secondary), Docker test infrastructure, and known-divergent behavior. |
| [audiosocket-wire-format-migration.md](audiosocket-wire-format-migration.md) | Moving to the corrected AudioSocket frame header -- the three-byte shape Asterisk actually sends, the enum values that changed, and why a rebuild is what picks them up. |
| [externalmedia-channel-id-migration.md](externalmedia-channel-id-migration.md) | Moving to the `CreateExternalMediaAsync` signature that takes a `channelId` -- why the break was bought, which callers have to be edited rather than rebuilt, and how to make an AudioSocket stream findable by the channel id the create returned. |
| [high-load-tuning.md](high-load-tuning.md) | Configuration guidance for high-load scenarios (1K-100K+ agents), including EventPump sizing and buffer capacity recommendations. |
| [log-analysis-prompt.md](log-analysis-prompt.md) | Ready-to-use LLM prompt for analyzing Verbara.Sdk structured log files with extract, classify, and diagnose phases. |
Expand Down
102 changes: 102 additions & 0 deletions docs/guides/ari-connection-state-and-accept-loop-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Migrating to the ARI terminal connect state and the surviving accept loop

Required by ADR-0028: a minor that carries a breaking change ships a migration guide.

Two behaviours changed in `Verbara.Sdk.Ari`, both recorded in `Sdk/ADR-0056`. Neither changes an API
signature, so nothing stops compiling. What changes is what the SDK reports, and in one case it moves
an observable that a previous release explicitly told you to watch.

## What you have to do

**Update the package.** There is nothing to edit for most consumers.

Two situations need a look, and both are about code you may have written *because* of the old
behaviour:

1. You read `AriClient.State` after a connect attempt that failed, or you built a timeout around it
because the state never settled. See below — it settles now.
2. You restart `AriOutboundListener` when it stops accepting, or you watch for that condition. The
listener no longer stops. See below.

## `AriClient.State` after a failed first connect

**Before:** a first `ConnectAsync` that threw left `State` at `Connecting` for the life of the
instance. The state machine wrote `Connecting`, dialled the events socket, and wrote `Connected` on
the next statement — a throw from the dial skipped that statement, and nothing else could ever write
again, because the events loop starts after the dial and the reconnect loop is only reached from it.

**Now:** the attempt leaves a terminal state, and which one is decided by **who ended the attempt**,
read from your own cancellation token rather than from the exception:

| How the attempt ended | `State` before | `State` now |
|---|---|---|
| Refused upgrade (`401`, `503`), nothing listening, name does not resolve | `Connecting` | `Faulted` |
| You cancelled the token you passed | `Connecting` | `Disconnected` |

**The exception you catch is unchanged** — same type, same message, same stack. The state is written
in a `finally` that catches nothing and runs before the exception becomes observable to the awaiting
caller, so a `catch` block of yours already reads the terminal value.

### What this means for code you may have written

- **A poll or timeout waiting for `State` to leave `Connecting`** can be deleted. It never left
before; it leaves immediately now.
- **A check that treats `Connecting` as "an attempt is still in progress"** is now correct, where
before it was permanently wrong after a failed first connect.
- **This amends what 2.5.3 told you.** That release's entry for the credential-refusal fix said an
initial `ConnectAsync` answered `401` *"still throws `WebSocketException` to the caller and leaves
`State` at `Connecting`"*, and pointed you at `State` or the health check because observers receive
no `OnError` when the loop stops. The throw is still exactly that. The state it named is not.
- **`AriHealthCheck` reports the same status.** `Connecting`, `Faulted` and `Disconnected` all fall to
its `Unhealthy` arm, so a failed first connect was Unhealthy before and is Unhealthy after. Only the
message text moves, from `"ARI state: Connecting"` to `"ARI state: Faulted"` or
`"ARI state: Disconnected"`. **If you assert on that string, it changes.**
- **`IsConnected` is unchanged.** It was false under `Connecting` and is false under both terminal
values.

## `AriOutboundListener` after an accept failure

**Before:** the accept loop wrapped its whole `while` in a `try` whose last clause was
`catch (SocketException) { }`, outside the loop. One transient accept failure while the listener was
meant to be running — `EMFILE`, `ENOBUFS`, a connection aborted in the backlog — ended the loop
silently and for good. `IsRunning` still reported `true`, the socket was still in `LISTEN`, and the
kernel kept completing handshakes nobody would ever read. An outbound connector hung instead of being
refused, and `StartAsync` could not restart the listener because it was still marked running.

**Now:** such a failure is logged at Error, waited out, and the loop keeps accepting. The wait doubles
from 100 ms to a 5 s cap with each consecutive failure and resets after a successful accept.

### What this means for code you may have written

- **A watchdog that restarts the listener** when it notices connections are no longer arriving can be
removed. It could not work anyway: `StartAsync` refused while `IsRunning` was true.
- **You will see Error lines you did not see before**, under a condition that used to be silent. At
most twelve a minute once the wait reaches its cap:
`[AriOutbound] Accept failed — the listener stays bound and accepts again after a backoff`.
A persistent failure is now noisy on purpose; it was invisible before.
- **A connection that arrives during a wait** stays in the listen backlog until the wait ends, rather
than being accepted and dropped.
- **The stop path is unchanged.** `StopAsync` still ends the loop at once; it clears the running flag
before stopping the listener, so an accept aborted by that stop is told apart from a failure by
`IsRunning` and never by the token.

## How to check which behaviour you are on

Force a connect that cannot succeed — point `AriClientOptions` at a port with nothing listening — and
read `State` in your `catch`:

```csharp
try
{
await client.ConnectAsync(cancellationToken);
}
catch (Exception)
{
// 2.5.3 and earlier: Connecting
// this release: Faulted
Console.WriteLine(client.State);
}
```

For the listener, there is nothing to probe safely from a consumer — the condition needs a real
accept failure. The Error line above is the signal that the new behaviour is in play.
Loading