Skip to content

Subscriptions

A channel has to be subscribed before its actions arrive. In any real client several parts of the UI want the same channel, and they arrive and leave in an order nobody controls — so the runtime refcounts.

AhpRuntime.acquire returns a ChannelLease rather than subscribing directly:

final lease = runtime.acquire(Uri.parse('ahp-chat:/c-1'));
// ... later
lease.release();

The first acquire for a URI sends subscribe. Subsequent ones increment a count and send nothing. The last release sends unsubscribe. Two widgets watching one chat cost one subscription on the wire.

release is idempotent. A double release is a no-op, not a refcount underflow — teardown paths race each other in practice, and letting one corrupt the count would unsubscribe a channel somebody else is still watching.

runtime.acquire(
chat,
options: const SubscribeOptions(maxLatencyMs: 16, turns: 50),
);
Option
maxLatencyMs How long the host may coalesce envelopes before delivering
turns How many turns of history to include in the initial snapshot

maxLatencyMs is the highest-leverage knob for a streaming UI. Without it a chat channel delivers one envelope per token and every consumer pays for that. Coalescing server-side is strictly cheaper than any narrowing you can do after the fact, which is why it comes first — before selectors, before memoization.

Omit options and the runtime picks by channel kind:

SubscribeOptions.defaultsFor(ChannelKind.chat); // maxLatencyMs: 16
SubscribeOptions.defaultsFor(ChannelKind.terminal); // maxLatencyMs: 16
SubscribeOptions.defaultsFor(ChannelKind.root); // no coalescing

Chat and terminal are the high-frequency ones. A frame’s worth of coalescing costs no perceptible latency and collapses a token stream into something a UI can keep up with. The rest are low-rate enough that batching would only add delay.

Releasing the last lease does not unsubscribe immediately. The runtime holds the subscription for a linger period — five seconds by default — so navigating away and back does not pay for a fresh subscribe and a fresh snapshot.

AhpRuntime(
connect: connect,
clientId: 'my-app',
lingerDuration: const Duration(seconds: 5),
);

Set it to Duration.zero to tear down inline. That is what tests want: a non-zero linger schedules a timer, and a widget test that ends while one is pending fails on a pending-timer assertion.

The registry survives a reconnect. When the connection comes back the runtime re-subscribes everything currently leased, so a widget holding a lease does not have to notice the connection dropped at all.

What it cannot preserve is state continuity, and it does not pretend to — see Staying connected for what replay guarantees and what it does not.

AhpChannelMixin ties a lease to a State object’s lifetime, which is the right scope for one:

class _ChatViewState extends State<ChatView> with AhpChannelMixin {
@override
Widget build(BuildContext context) {
final chat = ahpWatch<ChatState>(widget.channel);
// ...
}
}

There is deliberately no context.ahpWatch(...) extension. Watching means acquiring a lease, and a context extension has no dispose hook to release it — every rebuild would leak one, and it would fire an outbound subscribe from inside build, which has to stay pure.