Staying connected
AhpConnection connects once. Anything long-lived wants AhpRuntime, which
owns the connection, reconnects with backoff, replays what was missed, and
re-subscribes everything still leased.
Starting one
Section titled “Starting one”final runtime = AhpRuntime( connect: () async => AhpConnection( await WebSocketAhpTransport.connect(Uri.parse('ws://localhost:51234')), ), clientId: 'my-app',)..start();connect is a factory, called again on every attempt — not a connection handed
over once. That matters more than it looks: hosts hand out URLs carrying a
connection token, and a reconnect an hour later may need a different one. A
factory can go and fetch it.
Connection tokens
Section titled “Connection tokens”Drop --without-connection-token and the host mints a secret that must
accompany every request. Authentication happens during the WebSocket handshake,
before the protocol starts, so the token rides the URL or the headers rather
than any AHP message:
connect: () async => AhpConnection( await WebSocketAhpTransport.connect( Uri.parse('ws://localhost:51234?token=$token'), ),),Because the factory runs on every attempt, a token that expires between attempts can be refreshed there.
Lifecycle
Section titled “Lifecycle”stateDiagram-v2 [*] --> connecting connecting --> handshaking handshaking --> catchingUp: replay or snapshots catchingUp --> ready ready --> disconnected: socket drops disconnected --> reconnecting: backoff reconnecting --> handshaking handshaking --> fatal: unsupported version handshaking --> awaitingAuth: auth required
Two phases behave differently on purpose. fatal is never retried — every
version this client speaks was already offered, so there is nothing to degrade
to. awaitingAuth parks without backing off, because the wait is on a person.
runtime.states.listen((state) { switch (state.lifecycle) { case AhpLifecycle.disconnected: case AhpLifecycle.connecting: case AhpLifecycle.handshaking: // initialize or reconnect in flight case AhpLifecycle.catchingUp: // replay or snapshots being applied case AhpLifecycle.ready: case AhpLifecycle.awaitingAuth: case AhpLifecycle.reconnecting: }});catchingUp is the one worth surfacing. The connection is live but the mirror
is still stale — a UI that treats it as ready shows the user state from before
the drop and then jumps.
Reconnect and replay
Section titled “Reconnect and replay”On reconnect the runtime calls reconnect rather than initialize, and the
host answers one of two ways:
| Result | Means |
|---|---|
ReconnectResultReplay |
Here are the envelopes you missed |
ReconnectResultSnapshot |
The gap exceeded my buffer; here is fresh state |
Replay is continuous — the client catches up exactly. A snapshot is not: the client resets confirmed state and drops pending actions, because there is no longer any basis for rebasing them.
Either way, subscriptions held by a lease are re-established. A widget holding one does not have to notice any of this happened.
Sequence gaps
Section titled “Sequence gaps”serverSeq is a connection-global counter. A hole in it means envelopes were
lost in transit:
runtime.mirror.gaps.listen((gap) { // SeqGap(expected: 42, received: 47)});The runtime’s response is set by gapPolicy:
AhpRuntime( connect: connect, clientId: 'my-app', gapPolicy: GapPolicy.observe,);observe reports the gap and carries on. The alternative is to force a
reconnect, trading a visible stall for a guarantee of continuity — worth it
when the state drives something consequential, not worth it for a status
indicator.
Authentication
Section titled “Authentication”A host may refuse the handshake pending authentication. The runtime parks in
awaitingAuth rather than failing, and waits:
runtime.states.listen((state) async { if (state.lifecycle == AhpLifecycle.awaitingAuth) { await showSignInFlow(); runtime.authenticationCompleted(); }});Nothing retries until authenticationCompleted() is called — retrying an
unauthenticated handshake on a timer would just spend the user’s rate limit.
Keepalive
Section titled “Keepalive”Idle WebSockets get culled by intermediaries. The runtime pings on an interval:
AhpRuntime( connect: connect, clientId: 'my-app', keepaliveInterval: const Duration(seconds: 20),);Pass null to disable it — reasonable when something else already keeps the
socket warm, and wrong otherwise.
Shutting down
Section titled “Shutting down”await runtime.stop(); // disconnect, keep the mirror readableawait runtime.dispose(); // release everythingstop leaves state readable, which is what a backgrounded app wants. dispose
is terminal.