35. A sharedbox transport answers writes in its boxes¶
Date: 2026-10-28
Status¶
Accepted
Context¶
A session reaches a fastcs service through a
transport, and until now both transports were
network protocols: Channel Access and
PVAccess. A session and a service on the same
computer gain nothing from a network server between them, so a third
transport serves the controller through sharedbox
instead, as boxes in shared memory.
sharedbox gives a process typed fields to write and a way to wait for a
field to change, but no way to send a request and wait for its answer. A
write still has to behave as it does over PVAccess: set() returns once the
service has put the value, and raises with the service's message when the
service refuses it. So the transport had to decide how a write travels and
how its answer comes back.
It also had to decide how a controller's attributes are laid out in boxes.
sharedbox refuses to open a box unless the process opening it declares the
same fields as the process that made it. A controller whose attributes are
read from the hardware in initialise can't have its fields written down
ahead of time, so the session has to learn them from the service.
Decision¶
- A write travels in request fields of the controller's box, and the answer
in fields the service writes. For a writable attribute
exposure, the session writes the value and a request number, and the service answers with the number it has served and an error, empty on success:
# the session
box.update(exposure__setpoint=12.5, exposure__request=7)
# the service, once the put has returned or raised
box.update(exposure__error="", exposure__done=7)
The session writes only the setpoint and request fields, and the service
only the error and done fields, so the two processes never write the same
field. The service writes the error and the done number in one update, so
the session never reads a new number beside an old error. set() waits
until exposure__done reaches its own request number, which keeps two
writes, or a write after one that timed out, paired with their own answers.
A refused write raises BoxPutError with the attribute's name and the
service's message.
- Each controller gets one box, and one index box describes them all. The index has a fixed name under the service's prefix. It holds the controller tree as JSON: for each controller, its box name, its attributes with their datatypes, its commands and its sub-controllers. Both sides build a controller's box class from that description with one function, so the session's fields always match the service's:
index = ServiceIndex.attach("MYAPP:DET:fastcs:index")
description = json.loads(index.description)
root = box_class(description).attach(description["box"])
- Each run of the service names its boxes anew. A box is named
<prefix>:fastcs:<run>:root, with a sub-controller's path afterroot, and<run>is six random hex digits picked when the service starts. On Windows a box lives until every process has closed it, so a restarted service could not make a box under the old name while a session still held it. The index keeps its name and lists the new ones, so a device reconnects by reading it again.
The options weighed for the write were a pair of streams and a request and
reply primitive in sharedbox itself. A pair of streams sends each request
as an item and each answer as another:
requests.send({"attribute": "exposure", "value": 12.5, "id": 7})
answer = await replies.receive() # the answer to 7, or to a write before it?
A request number in each item would pair the two, but the session would still
read every answer a timed-out write left on the stream before reaching its
own. A box holds only the latest answer, so the session compares one number
and reads one error. A primitive in sharedbox would have been the cleanest, but asking for
it meant a design and a release of another project before this transport
could ship.
The options weighed for the layout were one box per attribute and box classes declared in the service's package. One box per attribute turns a controller of 150 attributes into 150 boxes, each opened and watched on its own. Classes declared ahead of time fail on the case that decided it: a controller whose attributes come from the hardware has no class to declare until it connects.
Consequences¶
- A device names no field.
BoxDeviceConnectorfills its signals, commands and sub-devices from the index, as the connectorophyd-asyncships forfastcsover PVAccess does from that service's list of records, and checks the ones the class declares. - The transport lives behind the
sharedboxextra, inredsun.services.sharedboxandredsun.device.sharedbox. A session that doesn't use it never importssharedboxorfastcs. - The service and the session have to run on the same computer.
- A
Tableattribute is refused, since a table has no row limit to size a field with. - A service starting under a prefix removes the boxes a previous run left there, so a run that crashed leaves nothing behind on Linux.