Skip to content

Connections and supervisors - #432

Open
tomkane-dls wants to merge 16 commits into
refactorfrom
connections-and-supervisors
Open

tomkane-dls wants to merge 16 commits into
refactorfrom
connections-and-supervisors

Conversation

@tomkane-dls

@tomkane-dls tomkane-dls commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Changes

  • Controllers receive connections, they don't look them up. The top-level controller declares them as constructor type hints, and launch builds them and passes them in. Connections registry and connections.get() is gone.
  • Schema: fastcs.yaml now checks connection names and settings against the schema. type: is gone from connections:.
  • Retry settings: reconnect_attempts and reconnect_period are now per-connection keys in yaml, read by the Supervisor.
  • Dependencies: depends_on is set on the Connection class by type and resolved into a graph when the tree is sealed, which also checks for cycles. It's no longer set in yaml.
  • One Supervisor per connection: it owns health, the reconnect loop, the retry budget, the scan loops and CONNECTED. The Runner now only handles startup and shutdown.
  • No set_disconnected(): every call goes through a handle to the Supervisor. It fails fast with DisconnectedError while the link is down, turns link errors into DisconnectedError, and passes device errors back unchanged.
  • ConnectionPolicy (e.g. DRAPolicy) [connections/policy.py] replaces the dra recovery tried previously. It decides which failures are disconnects, which are timeouts (N in a row counts as a disconnect) and which are device errors.
  • scheduling.py: scans are grouped per Supervisor and pause while the link is down. The Runner owns the scans for soft controllers.
  • Implicit CONNECTED attribute on every controller: a poll on a dead link marks it invalid. Soft controllers always report connected for now, will decide if they should get a different state later.
  • Reconnects: read-once reads re-run after each reconnect. setup() runs only once.
  • Seal: there's an explicit seal step after build.
  • DisconnectedError has moved to fastcs.exceptions and subclasses ConnectionError. Setters pass it through without logging it.
  • Readback callbacks now fire when only the severity changes.

Explanation

FastCS connections: proposed design

This document describes the proposed changes to how FastCS declares, opens, supervises and recovers connections, relative to the connections-dra-mixin branch. It uses an Eiger detector with an Odin data system as the running example.

Summary

  • Controllers receive connections; they never look them up. The top-level controller declares its connections with type hints and passes each one down to the children that need it.
  • fastcs.yaml is fully schema-checked, including connection names and their settings.
  • A Supervisor per connection owns everything ongoing about that link: health, reconnecting, its scan loops and its CONNECTED status. The Runner only owns the startup and shutdown sequence.
  • No set_disconnected(). Every call into a connection passes through the Supervisor, which turns link failures into a DisconnectedError itself. Authors write no error handling.
  • Connections are small: a constructor, connect(), close() and IO methods, plus an optional list of the connection types they depend on. There is no health state and there are no hooks.
  • Dependencies are declared by type, on the Connection class: every Odin waits for the Eiger in its entry.
  • Transports see connection health through an implicit CONNECTED attribute per controller, plus a per-attribute flag, with no transport changes.

The example

What the author of an instance writes

controllers:
  - id: EIGER
    type: mydriver.EigerController
    connections:
      eiger:
        port: 8000
      odin:
        port: 8888
        reconnect_attempts: 5
        reconnect_period: 2.0
  • eiger and odin are the constructor argument names of EigerController, so the schema knows exactly which names are required.
  • port is the connection's own setting.
  • reconnect_attempts and reconnect_period are framework settings, set per instance.
  • There is no depends_on here. Odin's need for Eiger is declared once, on OdinConnection (change 3).
  • There is no type:, because the type hint supplies it.

What the author of a driver writes

class EigerConnection(Connection):
__init__(port)                 REQUIRED   its arguments become the yaml settings
connect()                      REQUIRED   open the link and make it usable
close()                        REQUIRED   close the link; safe if already closed
<io methods>                   REQUIRED   talk to the device; on failure just raise

class OdinConnection(Connection): same shape as EigerConnection, plus:

depends_on = [EigerConnection] optional   wait for every EigerConnection in the same entry

class EigerDetController(Controller): (OdinController has the same shape)

__init__(connection)           REQUIRED   store the connection it was given
<attributes + getters>         REQUIRED   getters call self.connection.<io method>
<setters, commands>            optional   also call self.connection.<io method>
build()                        optional   add structure that needs the hardware
setup()                        optional   once, after the whole tree is sealed

class EigerController(Controller): # top level, the class named in fastcs.yaml

__init__(eiger: EigerConnection,        REQUIRED   type hints declare the connections
         odin:  OdinConnection)
    EIGER_DET = EigerDetController(eiger)          pass each connection down
    ODIN      = OdinController(odin)

The framework handles everything else: health, reconnecting, scheduling, CONNECTED, and the schema.

Overall structure

Controllers never hold the raw connection. They hold a handle: a stand-in that looks like the declared connection type, but routes every call through its Supervisor. Authors never create or see a handle type. It is simply what arrives in the constructor.

Design changes and why

1. Connections are declared by type hints and passed down

Change: The top-level controller's constructor declares its connections by name and type. Launch reads those hints, generates the schema for the connections: block, builds each connection, and passes it in. Children receive connections as ordinary constructor arguments. The Connections registry and connections.get(name, type) are removed.

Why:

  • The schema couldn't see connections. The contract between a controller and its connections lived inside __init__ bodies, as string lookups possibly several levels down. The schema could check each connection's settings, but not which names were needed or what type each had to be. fastcs.yaml validated against a shape nobody had declared.
  • Lookup by name breaks reuse. A MotorController that calls connections.get("motor") cannot be used twice in one tree, for example for pitch and yaw, because the name belongs to the instance, not the class. When the parent passes connections in, the same class works for any number of instances, and naming stays at the top level, where the schema can check it.

2. Framework settings live in fastcs.yaml, per instance

Change: reconnect_attempts and reconnect_period are framework keys on each connection in fastcs.yaml, read by the Supervisor. They are no longer constructor arguments on Connection.

Why: Retry behaviour is a fact about a deployment, not about a device class. Moving it out also leaves the Connection constructor holding only device settings.

3. Dependencies are declared by type, on the Connection class

Change: A Connection class lists the connection types it needs, e.g. OdinConnection.depends_on = [EigerConnection]. The framework reads this as: this connection waits for every connection of those types under the same top-level controller. At startup, connections open in dependency order, so all Eigers before any Odins. At the seal, when every connection exists, the type rules are resolved into a fixed graph between instances and checked for cycles. Reconnect loops use that fixed graph. depends_on is no longer set in fastcs.yaml.

Why:

  • It's a driver fact, not a deployment fact. Odin can't work without Eiger, whatever the deployment. The rule belongs in code, written once by whoever knows it, not repeated in every instance's YAML.
  • It covers dynamic connections. Connections created during build() exist by the seal, so they are picked up automatically (Appendix B).
  • Type is enough. Scoped to one top-level controller, there is normally only one connection of each type, so "every Eiger" means "the Eiger". Where there are several, the only cost is waiting for all of them. See Appendix A for the override that could handle that case, if it ever matters.

4. A Supervisor per connection; the Runner owns only the lifecycle

Change: Health state, the reconnect loop, the retry budget, the scan loops and the CONNECTED status all move off the Connection and the Runner, into one Supervisor per connection.

Why: The Runner was doing three jobs: the one-off startup and shutdown sequence, ongoing scheduling, and ongoing supervision. Health lived on the connection, but scheduling was organised around the controller tree. Nothing tied "these reads" to "that connection". This caused a real bug (see Appendix C). Giving each connection one owner for everything ongoing removes that mismatch.

5. No set_disconnected(): the boundary raises DisconnectedError

Change: Authors no longer call a hook when IO fails. Every call through a handle passes the Supervisor's boundary, which:

  • fails fast with DisconnectedError if the link is already down,
  • converts link errors (socket reset, EOF, several timeouts in a row) into DisconnectedError, marks the link down and wakes the reconnect loop,
  • passes device errors ("the device said no") back to the caller unchanged.

Why:

  • Nothing to forget. Before, the author sent two signals: the hook, which informed the framework, and an exception, which informed nobody. If the hook was missing, nothing reconnected.
  • Swallowed errors don't matter. The Supervisor sees the error where it starts, so a getter with a broad try/except can no longer hide a dead link.
  • Every caller is covered: getters, setters, commands and spawned tasks alike. This is what makes failure on puts come for free.
  • Reads and writes are gated the same way. Fail-fast at the boundary is the single gate.

If a device has its own way of saying "I'm offline", such as a special reply, the author may raise DisconnectedError directly. That's optional.

6. Scans are owned per connection, and pause while it's down

Change: Each attribute and @scan method belongs to the Supervisor of its controller's connection, grouped by period. The Supervisor pauses its loops while the link is down and resumes them on reconnect. Soft controllers' periodic work runs in a loop owned by the Runner.

Why:

  • The branch gated the whole tree on the root's connection. Each connection is now paused on its own.
  • Isolation. A struggling Odin link can't delay Eiger polls that share the same period.

7. Transports see health through CONNECTED

Change: Every controller that holds a connection gets an implicit read-only CONNECTED attribute, added before the seal and updated by the Supervisor. When a poll hits a dead link, that attribute's severity is set to invalid. Soft controllers report CONNECTED as always on.

Why:

  • Before, the runner knew the link was down, but clients saw stale values with no alarm.
  • Both signals are ordinary attributes, so every transport publishes them with no changes.

8. Read-once reads re-run after every reconnect; setup() does not

Change: After a successful reconnect, the Supervisor re-runs the read-once reads of the controllers holding its connection. setup() runs once only.

Why:

  • A rebooted device can come back with different values, and those reads would otherwise stay stale forever.
  • Configuration a device needs every time it comes back belongs in connect(), which means "make the link usable", not in setup().

9. An explicit seal

Change: After the build phase, the tree is sealed. CONNECTED attributes are added, the API is frozen, and no controllers, attributes or connections can be added afterwards.

Why: The API snapshot was already an implicit seal. Anything added later silently never reached a transport. The seal makes that rule explicit and enforced.

How it runs

The Supervisor logs each transition once: down, retrying, back up, giving up. Scans stay silent on DisconnectedError, because the Supervisor has already reported it.

The reconnect loop

reconnect loop  (one per connection, run by its supervisor)

forever:
sleep until the link is marked down

loop:
    wait until every connection it depends on is up  # attempts are not used while waiting
    if one of them gave up:
        stall and stop                               # only a restart can help now

    close(), then connect()

    if it worked:
        reset attempts
        mark link up        # CONNECTED on, re-run read-once reads, resume scans
        back to sleeping

    attempts += 1
    if attempts == reconnect_attempts:
        give up and stop    # CONNECTED stays off; dependents stall
    sleep reconnect_period

In the example: if both links drop, Odin waits for Eiger to recover before trying, without using any of its 5 attempts. If Eiger gives up, Odin stalls rather than retrying something that can't work.

claude and others added 15 commits September 4, 2026 14:51
Connection state moves off `Controller` onto a first-class `Connection`
object. Controllers hold a connection, several may hold the same one, and
the connection owns its own health, reconnect task and retry budget. The
`ControllerRunner` owns the order of the startup sequence.

- New `Connection` base class and `Connections` registry, with `IPConnection`
  and `SerialConnection` moved under the new contract.
- `Controller.connect`/`reconnect`/`disconnect`/`_connected` removed;
  `initialise`/`post_initialise` become `build`/`setup`.
- Runner rewritten: connections opened first, build phase to a fixpoint,
  setup phase, then one reconnect task per connection with `depends_on`
  awaiting, per-connection retry budgets and an introspection check.
- Scans gate on the controller's connection rather than a flag.

Part of #422

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMdCRdimKdK6hugXzZHam5
The `docs` env is the one tox environment this sandbox cannot run, so these
only showed up in CI.

- Remap every `emphasize-lines` and `:lines:` spec in the tutorials onto the
  edited snippets, by diff, so each points at the source line it did before.
  One was out of range outright; the rest had silently drifted.
- Rewrite the tutorial prose that described the removed `connect` hook, and
  say what a ramp sharing its parent's connection buys.
- Restore `TemperatureProtocol._connection` in static09-15: the earlier
  rename was meant for controllers, and a protocol class is not one.
- Ramp sub controllers now hold the same connection object as their parent,
  so their scans gate and recover with it.
- Name the new TypeVars `Introspection_T`/`Connection_T` after the repo
  convention and ignore them in `conf.py`, as `DType_T` already is.
- Spell `connect` as a literal in the connection docstrings; as a default
  role it resolves ambiguously across every `Connection` subclass.

Part of #422

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMdCRdimKdK6hugXzZHam5
Acts on the CodeRabbit review. Four were real bugs in code, two in the docs.

- `IPConnection.send_query` treated EOF as an empty reply. `readline` returns
  b"" when the peer closes, so a dead link produced "" forever: the caller
  failed to parse it, the connection was never marked down, and its reconnect
  task stayed idle. It now raises, and `DisconnectedError` is a
  `ConnectionError` so the existing transport-failure path handles it.
- A failure part-way through startup left every connection already opened
  dangling - no task existed yet for a later `stop` to be called to cancel, so
  nothing would ever close them. `build` and `start` now unwind, and
  `FastCS.serve` brings the application down rather than serving a partly
  built one.
- `depends_on` naming a connection the runner does not supervise left the
  dependent unattemptable and blew up with a bare `KeyError` inside the
  reconnect task. Rejected at startup, next to the cycle check.
- The `TypeError` for an uncomparable introspection result escaped into the
  reconnect task, which is exactly what it was written to prevent: the task
  died silently and every scan gated on that connection waited forever. It
  goes through `_fail` now, like a mismatch does.
- `_call_build` raised a bare `KeyError` for an unopened connection, hiding
  the diagnostic written for that case.
- Docs: the connection example dropped the configured port, and the
  controller example still read a `_client` the rewrite had removed.

Part of #422

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMdCRdimKdK6hugXzZHam5
Addresses the codecov patch failure. The rework rewrote these modules, so most
of their lines counted as new and untested - `SerialConnection` had no tests at
all before.

- New `tests/test_connections.py`: the `Connections` registry in full (claim,
  bad name, wrong type, unclaimed, naming by identity, declaration order), and
  `IPConnection`/`SerialConnection` open, round trip, and mark themselves down
  when their transport goes away.
- `FastCS.serve` raises a fatal runner condition rather than exiting, which is
  what lets an embedded FastCS survive one. Pinned with a test.

`fastcs.connections` and `controllers/runner.py` are now at 100%; project
coverage 91% -> 93%.

Part of #422

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMdCRdimKdK6hugXzZHam5
`pre-commit run --all-files` only sees files git knows about, so the new test
module was invisible to it until it was staged - it passed locally and failed
in CI on one over-long line.

Part of #422

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMdCRdimKdK6hugXzZHam5
…onnect()

Two CodeRabbit findings reported outside the diff range, and so not covered
by the earlier pass over the inline ones.

The dynamically-created `TemperatureRampController` held the shared link only
inside its `TemperatureProtocol`, never as `self.connection`. The runner reads
`controller.connection` to gate scans, so the ramps read as always-connected
and would have kept polling a dead link while the parent waited for it to come
back. It now takes the connection and assigns it, as the static snippets do.

`update-attributes-from-device.md` still told the reader a failed scan waits
for `reconnect()`, which is neither a controller hook nor something a driver
calls any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP7FfLHyvQYkzKs3nm8ARv
Implements the spec @shihab-dls attached to #424, which resolves the two
decisions the PR deferred: how the registry reaches a controller, and how a
connection's `type:` resolves to a class.

Connections are declared per top-level controller entry rather than in a
global block, because the key is the *role* the driver asks for while the
entry identifies the instance - two motors both claim "motor" and each
resolves to a different object. One `Connections` registry is built per
entry and forwarded down its subtree.

- `connections` is a reserved name at entry level and in a Controller's
  `__init__`: excluded from the argument count and the inlined option
  fields, so a controller may take it alongside an options object. Reserved
  whether or not any Connection classes are registered.
- `type:` resolves through a discriminated union over the classes handed to
  `launch(connection_classes=...)`, following the Controller pattern rather
  than the Transport one - no global `Connection.subclasses`, no new public
  surface. Each connection's fields come from its `__init__` signature, with
  a forwarded `**kwargs` standing in for `Connection`'s own arguments.
- `depends_on` takes a name or a list, and `Connection.depends_on` is now a
  list. All of them must be up before the dependent is attempted, and any
  one of them giving up stalls it. Names are resolved after the whole block
  is built; an unknown name and a cycle are both config errors.
- The runner opens connections in dependency order rather than declaration
  order, and so closes dependents before what they ride on.

The demo moves its `ip_settings` out of the controller options and into a
`connections:` block, so both entries claim the same role name.

Part of #422

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BymsiALDVCGv1nJWK8DyAV
`refactor` moved on with #425 and #426 while this PR was open, and #426's
`ControllerFiller` touches the same lifecycle hooks this branch renames.

Resolved by keeping the filler as the declarative mechanism and layering this
branch's lifecycle on top:

- `BaseController.post_initialise` -> `setup` keeps this branch's async,
  empty hook; the `check_filled()` it used to call moves up to the runner,
  which calls it once the build walk has settled and nothing else is going
  to fill a declaration in.
- `initialise` -> `build` throughout the filler's own docs, tests and
  docstrings, so the hook the filler talks about is the one the framework
  calls.
- `EigerController.build` keeps this branch's loop over the connection's
  introspection result, and takes #426's filler-aware provisioning inside
  it: a parameter the class body declared is filled rather than added a
  second time.
- `_validate_type_hints` is gone from `base_controller`, replaced by the
  filler, so the runner's call to it becomes `check_filled()`.

Docs and snippets take the declarative spelling from `refactor` with this
branch's `connection:` narrowing added.

Verified: `pre-commit` and `type-checking` green in full; `pytest src tests
--ignore=tests/benchmarking` 552 passed, with only the same 10 pre-existing
p4p/socket-family failures this sandbox cannot run. Docs built offline with
the version-switcher fetch stubbed - no warnings beyond the intersphinx
misses that come of having no network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BymsiALDVCGv1nJWK8DyAV
Implements the spec attached to the review of #424.

Introspection goes: `Connection` is no longer generic, `connect()` returns
None, `build()` takes no parameter, and the reconnect attempt no longer
compares what the device said about itself. A controller that must ask the
device what it has still does, in `build`, against an open connection.

Because every connection is declared up front, the runner's list is exactly
what the launcher built: no tree walking to collect connections, no
deduplication by identity, no second sweep after the build phase, and
`ControllerRunner` requires its connections argument.

Adds `HTTPConnection` (httpx - already a FastCS dependency) so `fastcs-eiger`
and `fastcs-odin` stop hand-rolling a client each, and `SimConnection` as the
base for a simulated device: a sibling of the real transports, chosen by
`type:` in fastcs.yaml. Renames `max_attempts` to `reconnect_attempts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011umsn2vzL4tKtA6sWGLPn4
…ecting and report loudly if FileNotFoundError is raised
… use of a DRA recovery policy, as an example
…om setters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eduling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tomkane-dls
tomkane-dls requested a review from coretl September 28, 2026 15:07
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 7d4e98f5-7e3a-4b13-80d9-6d2d60cd991e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.27128% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.33%. Comparing base (e73453b) to head (2b98c74).
⚠️ Report is 8 commits behind head on refactor.

Files with missing lines Patch % Lines
src/fastcs/scheduling.py 93.57% 7 Missing ⚠️
src/fastcs/connections/supervisor.py 98.34% 3 Missing ⚠️
src/fastcs/controllers/runner.py 98.78% 2 Missing ⚠️
src/fastcs/attributes/attr_w.py 66.66% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           refactor     #432      +/-   ##
============================================
+ Coverage     91.25%   94.33%   +3.08%     
============================================
  Files            72       76       +4     
  Lines          2892     4063    +1171     
============================================
+ Hits           2639     3833    +1194     
+ Misses          253      230      -23     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tomkane-dls
tomkane-dls requested a review from shihab-dls October 7, 2026 11:34

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants