#[non_exhaustive]pub struct CascadeBudget {
pub total: u64,
}Expand description
The runaway-detection budget for one scene’s change fan-out — how much work observers may generate from one batch before the drain declares the cascade non-terminating and panics.
§Why a budget at all
The drain runs until its queue is empty, which is what lets an observer read
the scene and write it back. An observer whose write never converges
therefore never empties it, and — unlike Signal::try_set, which recurses
and so blows the stack loudly on its own — this is an iterative loop whose
only other exit is an empty queue. Unchecked it is a frozen UI thread with
no diagnostic. The limit is enforced in every build profile for that
reason.
“Never converges” is the precise condition, and it is not the same as
“unguarded”. The geometry mutators already suppress a write that changes
nothing — Scene::set_local_pos returns without emitting when the
position is unchanged — so an observer that keeps writing the same
position settles on its own. A geometry cascade that does not settle is one
computing a different value every time: an accumulating offset, a rounding
drift, a spring with no rest state. The set_a11y_* mutators do not
self-suppress; they bump on every call, so there an equality check before
the call is a real guard.
§One trip condition, and why it is flat
Total observer-generated deliveries in a single drain, counted across both channels and every subject. Nothing else trips.
Two earlier shapes of this budget each fixed the previous one’s false positive and bought a worse problem, and the third is why this one is flat:
- A cap on rounds aborted a legitimate 300-link settling chain, because a round is what was queued when it began, so an N-link chain costs N rounds.
- A cap per subject moved the limit onto fan-in: a guarded relaxation over a hub passed at 4000 incident edges and aborted at 4200.
- Scaling the per-subject cap with the scene’s entry count fixed the fan-in false positive and destroyed the guard, because the bound it resolved grew with the model.
A flat total is the only one of the three that bounds a runaway to the same amount of work whatever the scene’s size. Measured in release, changing only this budget: an unguarded write-back aborts after 100 001 deliveries in a 10 000-entry scene and in a 50 000-entry one alike, where the budget the scaled design resolved for those scenes (640 000 and 3 200 000) let the same loop run 6× and 32× longer — 3.10 s and 131 s against 520 ms and 3.95 s. A spawner allocates ~100 000 items before tripping at either size, against 640 002 and 3 200 002 under the scaled budget.
(The flat write-back’s wall clock still grows with the scene, and that is the
app’s write, not the drain: Scene::set_local_pos re-buckets the moved
subtree, which walks every entry. The identical runaway on the logical-AT
channel, whose mutator does not, aborts in 4.8 ms at both sizes — that is
what this loop itself costs. The budget bounds how many times an observer’s
write runs, not what one costs.)
The default also clears every legitimate shape this tier runs by more than an order of magnitude: a 4200-edge guarded aggregate is about 4 200 deliveries, and a 2000-link chain that re-places eight port magnets and refreshes three AT properties per link is about 24 000.
§What it still does not decide
“Guarded cascade that is genuinely enormous” and “unguarded write-back” are
not distinguishable from the queue alone. This budget does not pretend
otherwise: it draws the line where the shapes this tier actually runs stop,
reports the subject with the highest delivery count so the diagnostic points
somewhere, and
SceneModel::set_cascade_budget is
the supported answer for a graph that genuinely lives past it. Mechanism in
the framework, policy in the consumer.
§What is exempt
The caller’s own batch — everything already queued when the drain began — is
not charged, however large. A bulk load, a ten-thousand-item teardown, or a
scene-wide a11y re-tag inside one SceneWriteGuard
is finite by construction, so charging it would make the budget a cap on
batch size instead — the mistake a plain delivery counter makes, whose fix is
to raise it until it catches nothing. Cascade depth and the number of
distinct subjects a cascade touches are not limited either.
use teksilo_scene::{CascadeBudget, SceneModel};
let model = SceneModel::new();
model.set_cascade_budget(CascadeBudget::new(1_000_000));
assert_eq!(model.cascade_budget().total, 1_000_000);#[non_exhaustive]: a budget is built through
CascadeBudget::new or Default, never by struct literal,
so a second dimension (a per-subject cap, a depth limit) can be added
without breaking a caller.
Fields (Non-exhaustive)§
This struct is marked as non-exhaustive
Struct { .. } syntax; cannot be matched against without a wildcard ..; and struct update syntax will not work.total: u64Observer-generated deliveries one drain may make before it panics.
Flat: not scaled by the scene’s entry count, by design — see the type’s own docs for the measurements that settled that. Default 100 000.
Zero is not a usable budget and is clamped to 1 by
CascadeBudget::new and by
SceneModel::set_cascade_budget.
Implementations§
Source§impl CascadeBudget
impl CascadeBudget
Sourcepub fn new(total: u64) -> Self
pub fn new(total: u64) -> Self
A budget of total observer-generated deliveries per drain, clamped up
to the smallest usable value.
The clamp is here rather than at the trip so that a nonsensical budget is rejected where it is written, instead of producing a panic whose numbers describe no cascade that happened.
Trait Implementations§
Source§impl Clone for CascadeBudget
impl Clone for CascadeBudget
Source§fn clone(&self) -> CascadeBudget
fn clone(&self) -> CascadeBudget
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read moreimpl Copy for CascadeBudget
Source§impl Debug for CascadeBudget
impl Debug for CascadeBudget
Source§impl Default for CascadeBudget
impl Default for CascadeBudget
impl Eq for CascadeBudget
Source§impl PartialEq for CascadeBudget
impl PartialEq for CascadeBudget
impl StructuralPartialEq for CascadeBudget
Auto Trait Implementations§
impl Freeze for CascadeBudget
impl RefUnwindSafe for CascadeBudget
impl Send for CascadeBudget
impl Sync for CascadeBudget
impl Unpin for CascadeBudget
impl UnsafeUnpin for CascadeBudget
impl UnwindSafe for CascadeBudget
Blanket Implementations§
§impl<T> AnyEq for T
impl<T> AnyEq for T
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can
then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.§fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be
further downcast into Rc<ConcreteType> where ConcreteType implements Trait.§fn as_any(&self) -> &(dyn Any + 'static)
fn as_any(&self) -> &(dyn Any + 'static)
&Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &Any’s vtable from &Trait’s.§fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &mut Any’s vtable from &mut Trait’s.§impl<T> DowncastSync for T
impl<T> DowncastSync for T
§impl<Q, K> Equivalent<K> for Q
impl<Q, K> Equivalent<K> for Q
§fn equivalent(&self, key: &K) -> bool
fn equivalent(&self, key: &K) -> bool
§impl<Q, K> Equivalent<K> for Q
impl<Q, K> Equivalent<K> for Q
§fn equivalent(&self, key: &K) -> bool
fn equivalent(&self, key: &K) -> bool
key and return true if they are equal.impl<T> ErasedDestructor for Twhere
T: 'static,
§impl<T> Instrument for T
impl<T> Instrument for T
§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more