Repository navigation
Connections and supervisors - #432
tomkane-dls wants to merge 16 commits into
Conversation
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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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-mixinbranch. It uses an Eiger detector with an Odin data system as the running example.Summary
fastcs.yamlis fully schema-checked, including connection names and their settings.CONNECTEDstatus. The Runner only owns the startup and shutdown sequence.set_disconnected(). Every call into a connection passes through the Supervisor, which turns link failures into aDisconnectedErroritself. Authors write no error handling.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.CONNECTEDattribute per controller, plus a per-attribute flag, with no transport changes.The example
What the author of an instance writes
eigerandodinare the constructor argument names ofEigerController, so the schema knows exactly which names are required.portis the connection's own setting.reconnect_attemptsandreconnect_periodare framework settings, set per instance.depends_onhere. Odin's need for Eiger is declared once, onOdinConnection(change 3).type:, because the type hint supplies it.What the author of a driver writes
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. TheConnectionsregistry andconnections.get(name, type)are removed.Why:
__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.yamlvalidated against a shape nobody had declared.MotorControllerthat callsconnections.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 instanceChange:
reconnect_attemptsandreconnect_periodare framework keys on each connection infastcs.yaml, read by the Supervisor. They are no longer constructor arguments onConnection.Why: Retry behaviour is a fact about a deployment, not about a device class. Moving it out also leaves the
Connectionconstructor 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_onis no longer set infastcs.yaml.Why:
build()exist by the seal, so they are picked up automatically (Appendix B).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
CONNECTEDstatus all move off theConnectionand 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 raisesDisconnectedErrorChange: Authors no longer call a hook when IO fails. Every call through a handle passes the Supervisor's boundary, which:
DisconnectedErrorif the link is already down,DisconnectedError, marks the link down and wakes the reconnect loop,Why:
try/exceptcan no longer hide a dead link.If a device has its own way of saying "I'm offline", such as a special reply, the author may raise
DisconnectedErrordirectly. That's optional.6. Scans are owned per connection, and pause while it's down
Change: Each attribute and
@scanmethod 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:
7. Transports see health through
CONNECTEDChange: Every controller that holds a connection gets an implicit read-only
CONNECTEDattribute, 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 reportCONNECTEDas always on.Why:
8. Read-once reads re-run after every reconnect;
setup()does notChange: After a successful reconnect, the Supervisor re-runs the read-once reads of the controllers holding its connection.
setup()runs once only.Why:
connect(), which means "make the link usable", not insetup().9. An explicit seal
Change: After the build phase, the tree is sealed.
CONNECTEDattributes 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
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.