Skip to content

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.

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.

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.

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.

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.

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.

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.

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.

await runtime.stop(); // disconnect, keep the mirror readable
await runtime.dispose(); // release everything

stop leaves state readable, which is what a backgrounded app wants. dispose is terminal.