259 lines
10 KiB
Markdown
259 lines
10 KiB
Markdown
# Concurrent runtime: multiple controllers, channels, supervision, reconnect
|
|
|
|
Date: 2026-08-20
|
|
Status: Approved (pending spec review)
|
|
|
|
## Goal
|
|
|
|
Make the HASS runtime production-quality:
|
|
|
|
- Support multiple controllers.
|
|
- A single websocket read loop; no per-controller connections.
|
|
- The read loop broadcasts messages to controllers over channels (`TChan`).
|
|
- Each controller runs on its own thread (`async`).
|
|
- Controllers know nothing of the websocket connection: service calls go
|
|
through an outbound channel; a writer thread does the actual sends.
|
|
- Crashed worker threads are restarted with backoff (`annotated-exception`
|
|
for context).
|
|
- The websocket reconnects on network failure.
|
|
|
|
Implemented as two slices, each independently buildable and committed:
|
|
|
|
1. **Concurrency architecture**: bus, reader/writer threads, controller
|
|
threads, channel interpreter, wiring. Any worker crash exits the
|
|
process (today's behavior).
|
|
2. **Robustness**: supervisor with backoff, `annotated-exception`, fatal
|
|
vs retryable failures, reconnect-as-restart.
|
|
|
|
## Current state
|
|
|
|
`HomeAssistant.Runtime.app` runs everything on one thread inside
|
|
`WS.runClient`: auth handshake, a `get_states` debug dump, one
|
|
`subscribe_events`, then a loop that steps a single `lightController`
|
|
via `dryRunHassEval`. The `get_states` dump is dead debug code and is
|
|
removed (state pre-seeding is a separate future concern).
|
|
|
|
## Architecture
|
|
|
|
```
|
|
HA websocket
|
|
▲ │
|
|
sends │ ▼ receives
|
|
┌─────────────┴──┐ ┌──────────────┐
|
|
│ writer thread │ │ reader thread │
|
|
└───────▲────────┘ └───────┬──────┘
|
|
│ │ decode → Value
|
|
│ TChan Service │ broadcast (single write)
|
|
│ ▼
|
|
┌───────┴───────────────────────────┐
|
|
│ busInbound (broadcast TChan) │
|
|
└──┬──────────────┬──────────────┬──┘
|
|
▼ dupTChan ▼ ▼
|
|
controller 1 controller 2 controller N (1 thread each)
|
|
```
|
|
|
|
### Bus
|
|
|
|
```haskell
|
|
data Bus = Bus
|
|
{ busInbound :: TChan Value -- newBroadcastTChanIO; reader writes only
|
|
, busOutbound :: TChan Service -- controllers → writer; fire-and-forget
|
|
, busConn :: TVar (Maybe WS.Connection)
|
|
, busGen :: CallIdGen -- existing IORef-based, thread-safe
|
|
}
|
|
```
|
|
|
|
- `busInbound` comes from `newBroadcastTChanIO`: a write-only broadcast
|
|
channel. A plain never-read `TChan` would pin its entire history; the
|
|
broadcast variant does not. Controllers receive via
|
|
`dupTChanIO busInbound`; the reader writes each message once.
|
|
- `busConn` is a plain current-value cell (no `MVar` blocking semantics).
|
|
`Maybe` + STM `retry` lets the writer block until a connection exists
|
|
and lets `defaultMain` spawn all workers up front: the initial connect
|
|
is just the reader's first attempt, so first-connect failures and
|
|
reconnect failures take the same backoff path.
|
|
- The controller list is static. Each entry is an existential:
|
|
|
|
```haskell
|
|
data Controller = forall b. Controller T.Text (HASS (Event Value) b)
|
|
```
|
|
|
|
No `Show` constraint: machine outputs are discarded; observability will
|
|
come from a logging effect later. The name tags restart logs.
|
|
|
|
### Send-safety without locks
|
|
|
|
No two threads ever send on the same live connection:
|
|
|
|
- The reader performs all setup sends (auth, subscribe) **before**
|
|
swapping the connection into `busConn`; afterwards it only receives.
|
|
- The writer only sends on connections read from `busConn`.
|
|
|
|
On reconnect the reader builds a new connection, handshakes, then
|
|
atomically swaps `busConn`. A writer mid-send on the dead connection
|
|
throws, its supervisor restarts it, and it picks up the new connection.
|
|
|
|
### Outbound backpressure
|
|
|
|
Fire-and-forget: services queue in `busOutbound` during outages and are
|
|
sent after reconnect. The queue is naturally bounded in practice: no
|
|
inbound events means controllers produce no calls.
|
|
|
|
## Module layout
|
|
|
|
| Module | Concern |
|
|
|---|---|
|
|
| `HomeAssistant.Runtime.Supervisor` | Generic restart-with-backoff combinator, `Backoff`, `Fatal`; no HA knowledge |
|
|
| `HomeAssistant.Runtime.Bus` | `Bus`, `newBus`, channel interpreter for `HASSEff` |
|
|
| `HomeAssistant.Runtime.Connection` | `readerAction`, `writerAction`; connect/auth/subscribe, receive-decode-broadcast loop, send loop |
|
|
| `HomeAssistant.Runtime` | Glue: `defaultMain`, `controllers`, `Controller`, `runController`, `step`, `dryRunHassEval` |
|
|
|
|
Exports removed from `HomeAssistant.Runtime`: `app`, `hassEval`,
|
|
`wsCallService`, `receiveJSON` (internal or superseded). Kept:
|
|
`defaultMain`, `step`, `dryRunHassEval`, `CallIdGen`, `mkCallIdGen`.
|
|
`app/Main.hs` unchanged.
|
|
|
|
## Connection lifecycle
|
|
|
|
**Reader action** (restartable unit; restart = reconnect):
|
|
|
|
```
|
|
connect → expect auth_required → send token → expect auth_ok
|
|
→ send subscribe_events state_changed (id from busGen)
|
|
→ swap busConn
|
|
→ forever: receive → decode → broadcast to busInbound
|
|
```
|
|
|
|
- `auth_invalid` (and undecodable handshake messages) is **fatal**: a bad
|
|
token cannot be fixed by retrying. The reader throws `Fatal`; the
|
|
supervisor rethrows it (with annotations) and the process exits.
|
|
- Undecodable messages in the receive loop are **not** fatal: log a
|
|
warning and skip. Reconnecting cannot fix a decode problem, so
|
|
crash-restarting would just be a hot loop.
|
|
- Controller Mealy state survives reconnects (explicit decision). State
|
|
may be stale until each watched entity's next `state_changed` event.
|
|
Re-seeding via `get_states` is a separate future concern.
|
|
|
|
**Writer action**:
|
|
|
|
```
|
|
forever: readTChan busOutbound
|
|
→ readTVar busConn (retry until Just)
|
|
→ encode with fresh id from busGen → send
|
|
```
|
|
|
|
Encoding fixes a latent bug: today's `wsCallService` drops
|
|
`serviceData`; the writer encodes it as `"service_data"` when present.
|
|
|
|
## Supervision
|
|
|
|
```haskell
|
|
supervised :: Text -> Backoff -> IO a -> IO Void -- never returns normally
|
|
```
|
|
|
|
- Catches synchronous exceptions; rethrows `SomeAsyncException`
|
|
(no restarting on cancellation).
|
|
- On `Fatal`: rethrow with annotations; process exits.
|
|
- Otherwise: log (component name, attempt, annotated exception), sleep
|
|
per backoff, restart the action.
|
|
- Backoff: exponential from base, capped; resets after a quiet period.
|
|
|
|
```haskell
|
|
data Backoff = Backoff
|
|
{ backoffBase :: NominalDiffTime -- first restart delay, e.g. 100ms
|
|
, backoffCap :: NominalDiffTime -- max delay, e.g. 30s
|
|
, backoffQuiet :: NominalDiffTime -- uptime that resets delay, e.g. 30s
|
|
}
|
|
```
|
|
|
|
Delay after the n-th consecutive crash: `min cap (base * 2^(n-1))`.
|
|
|
|
- `annotated-exception` adds context at catch sites (component, phase
|
|
such as "authenticating" / "receiving") so logs read like
|
|
`[reader] attempt 3: ConnectionClosed while receiving`.
|
|
|
|
### Wiring
|
|
|
|
- Slice 1: `defaultMain` = `newBus` → `mapConcurrently_` over reader,
|
|
writer, and controller actions. First worker crash cancels the rest
|
|
and exits the process.
|
|
- Slice 2: each action wrapped in `supervised`; main waits forever.
|
|
The worker `IO` actions are identical in both slices; only the
|
|
spawning changes.
|
|
|
|
## Controller runner
|
|
|
|
```haskell
|
|
runController :: Bus -> Controller -> IO Void
|
|
-- dupTChanIO busInbound
|
|
-- loop: readTChan → step (channelHassEval bus) → discard output
|
|
```
|
|
|
|
On a supervised restart the action re-dups: a fresh port sees only
|
|
messages written after the dup, consistent with the machine also
|
|
restarting from its initial state. Messages broadcast during a restart
|
|
window are lost (accepted, documented).
|
|
|
|
## Interpreter
|
|
|
|
```haskell
|
|
channelHassEval :: Bus -> HASSEff a -> IO a
|
|
channelHassEval bus (CallService svc) = atomically (writeTChan (busOutbound bus) svc)
|
|
channelHassEval _ (Pure a) = pure a
|
|
```
|
|
|
|
`dryRunHassEval` stays exported for experiments/tests.
|
|
|
|
## Logging
|
|
|
|
Tagged plain lines (`[reader] …`) via `putStrLn`. No framework.
|
|
|
|
## Dependencies
|
|
|
|
Added: `stm`, `async` (slice 1); `annotated-exception` (slice 2).
|
|
After editing the cabal file, regenerate `default.nix` via
|
|
`nix run nixpkgs#cabal2nix -- ./. > default.nix` (AGENTS.md).
|
|
|
|
## Testing
|
|
|
|
hspec for unit tests, hedgehog for property tests (AGENTS.md):
|
|
|
|
- hspec:
|
|
- Broadcast semantics: two `dupTChan` ports see all writes, in order.
|
|
- `channelHassEval` writes `Service` to the outbound chan.
|
|
- `runController` end-to-end on a real `Bus` with a test controller:
|
|
feed values into `busInbound`, assert services land in `busOutbound`.
|
|
- Writer encoding: golden JSON for `call_service` incl. `service_data`.
|
|
- Supervisor (tiny backoff values): crash-twice-then-succeed restarts;
|
|
`Fatal` rethrown, not restarted; async exceptions not restarted.
|
|
- hedgehog:
|
|
- Backoff property: delays double from base, clamp at cap, reset after
|
|
quiet period.
|
|
|
|
## Slices
|
|
|
|
1. **Concurrency architecture**: `Bus` (already reconnect-ready:
|
|
`TVar (Maybe Connection)`), `channelHassEval`, `readerAction`,
|
|
`writerAction`, `runController`, module split, cabal deps, wiring via
|
|
`mapConcurrently_`, tests for bus/runner/encoding. Decode
|
|
log-and-skip included.
|
|
2. **Robustness**: `Supervisor` module, `annotated-exception` dep,
|
|
backoff, `Fatal` auth failures, rewire `defaultMain` to supervised
|
|
workers, supervisor tests and backoff property.
|
|
|
|
In slice 1 an `auth_invalid` response exits the process like any other
|
|
crash; the fatal/retryable distinction only exists once the supervisor
|
|
does (slice 2).
|
|
|
|
Each slice: builds clean, tests green, committed separately.
|
|
|
|
## Non-goals
|
|
|
|
- Request/response correlation for service calls (approach B, deferred;
|
|
outbound message type can grow later).
|
|
- Dynamic controller registration (static list).
|
|
- Structured logging / logging effect (future).
|
|
- Host/port/token configuration via env (beyond current `HA_TOKEN`).
|
|
- State pre-seeding via `get_states` (user's future concern).
|
|
- Multiple websocket connections.
|