-
Notifications
You must be signed in to change notification settings - Fork 1
Subscriptions
A subscription lets one machine read another machine's value resources directly inside the Runtime, cycle by cycle, without going through the controller.
Typical uses: a winder that follows an extruder's line speed, a cutter that needs a laser's measured diameter, or a production line whose stations coordinate.
Code: qitech_framework/src/machine/subscribe.rs (SubscribeContext, RemoteProperty), qitech_framework/src/resource/mod.rs (lifetime tokens) and qitech_framework/src/runtime/request.rs (subscribe/unsubscribe handling).
| Role | Meaning |
|---|---|
| Provider | The machine whose resources are read. It doesn't take part: nothing is called on it, and it doesn't know it has subscribers. |
| Subscriber | The machine that reads. It decides in Machine::subscribe which of the provider's resources it wants, and whether it accepts this provider at all. |
| Controller | Starts and ends subscriptions with requests. Machines can't subscribe on their own. |
A subscription is identified by the pair (provider, subscriber). A subscriber can subscribe to several providers, a provider can have many subscribers, and each pair can exist only once.
-
Config properties, state properties and measurements of the provider, each as a
RemoteProperty<T>. -
Read-only. A subscriber can't write the provider's resources. Changing another machine's config is the controller's job, through
SetConfigProperty. - No commands or events. They have no stored value to read (see Resources).
-
Values are one cycle old. A
RemotePropertyreads the provider's cache, which is the snapshot copied at the end of every cycle. So all subscribers see the same consistent state of the provider, whatever order the machines run in.
sequenceDiagram
participant C as Controller
participant R as Runtime
participant S as Subscriber machine
C->>R: SubscribeMachine { provider, subscriber }
R->>R: check both exist, not already subscribed
R->>R: create LifetimeTokenOwner
R->>S: subscribe(ctx) (ctx holds a token)
S->>R: ctx.measurement("speed") → RemoteProperty
S-->>R: Ok(())
R->>R: store owner in subscriber.subscriptions[provider]
R-->>C: response Ok + RuntimeEvent::SubscriptionAdded (next report)
loop every cycle
S->>S: act(): remote.get() (token checked)
end
C->>R: UnsubscribeMachine { provider, subscriber }
R->>S: unsubscribe(provider)
R->>R: drop LifetimeTokenOwner → all tokens expire
R-->>C: response Ok + RuntimeEvent::SubscriptionRemoved
The controller sends SubscribeMachine { provider, subscriber }. The Runtime handles it at the start of a cycle, before the machines run, so the subscriber can use its new handles in the same cycle.
-
The provider must exist (
ProviderNotFound), the subscriber must exist (SubscriberNotFound), and the pair must not already be subscribed (AlreadySubscribed). -
The Runtime creates a
LifetimeTokenOwnerfor this subscription (see below). -
It calls the subscriber's
Machine::subscribe(ctx). TheSubscribeContextoffers:ctx.provider() // MachineInstanceIdentification of the provider ctx.config::<T>("path")? // → RemoteProperty<T> ctx.state::<T>("path")? ctx.measurement::<T>("path")?
Tis the stored type, e.g.VelocityorOption<Length>, not the unit. A lookup fails withResourceAccessError:-
MachineNotFound, -
ResourceNotFound { kind, path }, or -
TypeMismatch, whenTisn't the type the provider registered.
-
-
If
subscribereturnsOk, the owner is stored on the subscriber, andRuntimeEvent::SubscriptionAddedgoes into the next report. If it returnsErr, the owner is dropped and the error becomes the request's response.
Any machine can be asked to subscribe to any other machine, so subscribe is also where the subscriber decides whether this provider makes sense:
fn subscribe(&mut self, ctx: &mut SubscribeContext) -> SubscribeResult {
// only follow extruders
if ctx.provider().machine != <Extruder as MachineDescriptor>::IDENTIFICATION {
return Err(MachineSubscribeError::UnsupportedMachine);
}
// look everything up first; store only when all lookups succeeded
let speed = ctx.measurement::<Velocity>("speed")?;
let target = ctx.config::<Velocity>("speed.target")?;
self.upstream = Some(Upstream { speed, target });
Ok(())
}The default implementation of subscribe rejects every subscription with UnsupportedMachine, so a machine has to opt in. TooManySubscriptions is available for machines that only accept a limited number of providers.
In act, the subscriber reads its handles like its own resources:
fn act(&mut self, dt: Duration) -> ActResult {
if let Some(up) = &self.upstream {
let line_speed = up.speed.get_as::<meter_per_second>();
// …
}
Ok(())
}RemoteProperty<T> offers get_ref(), get() (for T: Copy) and get_as::<unit>() for quantities. Every read first checks the handle's lifetime token.
The controller sends UnsubscribeMachine { provider, subscriber }. The subscriber should throw away its handles in Machine::unsubscribe(provider), and the Runtime drops the subscription's owner, which expires every handle from it. RuntimeEvent::SubscriptionRemoved goes into the next report.
fn unsubscribe(&mut self, provider: MachineInstanceIdentification) {
self.upstream = None;
}A RemoteProperty is a raw pointer into the provider's cached storage. Rust's borrow checker can't bound how long it may be used:
- The subscriber is stored as
Box<dyn Machine + 'static>and keeps its handles in its own struct across many cycles, so it can't hold a borrow with a shorter lifetime. - The subscription ends at a point decided at runtime, by a controller request.
Lifetime tokens move that check from compile time to runtime.
pub struct LifetimeTokenOwner { inner: Rc<()> } // one per subscription
pub struct LifetimeToken { inner: Weak<()> } // one clone per RemoteProperty
impl LifetimeTokenOwner {
pub fn new_token(&self) -> LifetimeToken { LifetimeToken { inner: Rc::downgrade(&self.inner) } }
}
impl LifetimeToken {
pub(crate) fn validate(&self) {
assert!(!self.expired(), "LifetimeToken outlived LifetimeTokenOwner");
}
fn expired(&self) -> bool { self.inner.upgrade().is_none() }
}-
The owner holds the only strong reference (
Rc). It lives insubscriber.subscriptions[provider]:struct MachineInstance { // … subscriptions: HashMap<MachineInstanceIdentification /* provider */, LifetimeTokenOwner>, }
-
Every token holds a weak reference (
Weak).SubscribeContextholds one, and everyRemotePropertyit creates gets a clone. -
Dropping the owner expires every token at once. Every later
upgrade()returnsNone, however many handles exist and wherever they are stored. -
The
()payload holds no data. Only the reference counts matter.
subscriptions[provider]
Subscriber ─────────► LifetimeTokenOwner (Rc)
│ ▲ weak
├─ RemoteProperty<A> ── token ─┤
└─ RemoteProperty<B> ── token ─┘
│
└─ pointer ─► provider's cached value in ResourceRegistry
Every access goes through the check:
impl<T> RemoteProperty<T> {
pub fn get_ref(&self) -> &T {
self.token.validate(); // panics if the subscription has ended
unsafe { self.p_value.as_ref() }
}
}Properties:
-
Cheap. A check is one non-atomic
upgrade(). Cloning a token bumps a weak count. -
Single-threaded.
RcandWeakare notSend, which matches the single-threaded Runtime. - One-way. An expired token can never become valid again. A new subscription gets a new owner.
Resource memory is never freed during a Runtime's lifetime, because the bump allocators only grow. So today an expired handle would read valid memory, but data the subscriber is no longer entitled to. The token enforces the subscription contract.
It becomes a memory-safety guarantee once resource memory is reclaimed, for example when a removed machine's slots are released (clear_machine, currently commented out). Any change that frees or reuses resource memory must first expire every token that points into it.
For machine authors:
- Get handles only in
subscribe, and drop them inunsubscribe. -
Store handles only once every lookup has succeeded. If
subscribereturnsErr, the owner is dropped straight away, and any handle you already stored has expired. -
Check the provider type in
subscribe, and reject providers you don't understand withUnsupportedMachine. - Expect one-cycle latency on everything you read.
For framework developers:
- An expired-token panic in
actbrings down the whole Runtime thread, not just one machine. Treat it as an assertion that catches bugs, not as error handling. - Anything that ends a subscription must notify the subscriber (
Machine::unsubscribe) and drop the owner, in that order.