supervisor
stdlib/extra/supervisor.hk: restart-policy bookkeeping for actor supervision.
The supervision wiring is per-actor worked code and no generic module: a handler cannot be passed as a value and spawn takes a static actor name, which leaves a reusable supervisor unable to spawn an arbitrary child. See the timer and restart patterns in HANKI.md §15. What is reusable is the pure decision and window bookkeeping: after a child has died N times, should its supervisor restart it (and after how long a backoff) or escalate?
RestartPolicy supplies consecutive exponential backoff. RestartIntensity and RestartWindow supply a sliding restart budget. The actor owns one window per logical child or strategy group. After an allowed restart begins, its handler schedules one actor.send_after! expiry after within_ms; handling that expiry calls RestartWindow.expired(epoch). No actor sleeps, and the module itself remains free of actor and clock effects so the bookkeeping is easy to test.
RestartDecision
type RestartDecision
Restart(i32)
GiveUp
end
What a supervisor should do after a child's death: restart it after a backoff of the given milliseconds, or stop trying. Non-positive timing configuration means an immediate restart.
impl Eq<RestartDecision>
Hand-written (not @derive) so it sits in the baked stdlib prefix: a stdlib body compares RestartDecision, and a stale-blob chunked lower must resolve Eq<RestartDecision> from the prefix and never an impl synthesised after the user items. Mirrors @derive(Eq); the build_stdlib_bytecode assert pins it there.
eq?
def eq?(self, other: Self) -> bool
Two decisions are equal when they are the same variant and, for Restart, carry the same delay.
Restart(50i32).eq?(Restart(50i32)) => true
Restart(50i32).eq?(Restart(80i32)) => false
(GiveUp == GiveUp) => true
RestartAllowance
type RestartAllowance
RestartAllowed(RestartWindow)
IntensityExceeded
end
The result of charging one restart to a sliding intensity window. RestartAllowed contains the updated window which the supervisor stores; IntensityExceeded leaves the old window unchanged and tells the actor to perform its configured escalation.
RestartWindow
struct RestartWindow
restarts: i32
epoch: i64
end
The live restart charges for one logical child or one strategy group. Each charge ages out independently. This creates a sliding window with no fixed bucket. Start with RestartWindow.empty() and store the value returned by RestartIntensity.next. Carry epoch in each delayed restart and expiry message. A reset makes old timers inert.
impl RestartWindow
empty
def empty() -> RestartWindow
A first-use empty window.
RestartWindow.empty().restarts => 0i32
RestartWindow.empty().epoch => 0i64
expired
def expired(self, epoch: i64) -> RestartWindow
Age one previously allowed restart out of the sliding window. epoch is the token carried by that restart's delayed expiry. A token from an older reset is ignored; the last live expiry advances the epoch before a future restart can reuse the empty window.
w = RestartWindow(restarts=2i32, epoch=7i64)
w.expired(7i64).restarts => 1i32
w.expired(8i64).restarts => 2i32
reset
def reset(self) -> RestartWindow
Discard every live charge and advance the epoch. Use this only when discarding or reassigning a logical child slot or strategy group. An ordinary respawn preserves the window; its outstanding expiry messages still belong to that slot.
reset = RestartWindow(restarts=2i32, epoch=7i64).reset()
reset.restarts => 0i32
reset.epoch => 8i64
RestartIntensity
struct RestartIntensity
max_restarts: i32
within_ms: i32
end
A sliding restart-intensity limit: permit at most max_restarts restart decisions that have not yet reached their individual within_ms expiry. max_restarts <= 0 permits none. within_ms is an i32 so it can be passed directly to actor.send_after!; as there, a negative interval clamps to zero.
impl RestartIntensity
next
def next(self, window: RestartWindow) -> RestartAllowance
Charge a restart to window. An allowed result contains the incremented window; the caller stores it, performs or schedules the restart, and when that restart begins schedules one expiry after within_ms. Once the active count reaches max_restarts, the result is IntensityExceeded until an expiry is handled.
limit = RestartIntensity(max_restarts=1i32, within_ms=1000i32)
first = match limit.next(RestartWindow.empty())
RestartAllowed(w) -> w.restarts
IntensityExceeded -> -1i32
end
first => 1i32
disabled = match RestartIntensity(max_restarts=0i32, within_ms=1i32).next(RestartWindow.empty())
RestartAllowed(_) -> false
IntensityExceeded -> true
end
disabled => true
RestartPolicy
struct RestartPolicy
max_restarts: i32
base_ms: i32
max_ms: i32
end
A consecutive-restart policy with exponential backoff. max_restarts is how many restarts to allow before giving up; the backoff delay starts at base_ms and doubles each restart, capped at max_ms.
impl RestartPolicy
next
def next(self, restarts: i32) -> RestartDecision
Decide what to do for a child that has already been restarted restarts times. Restart(delay_ms) once more is allowed, carrying the backoff to wait first; GiveUp once restarts reaches max_restarts.
p = RestartPolicy(max_restarts=3i32, base_ms=10i32, max_ms=1000i32)
p.next(0i32) => Restart(10i32)
p.next(1i32) => Restart(20i32)
p.next(2i32) => Restart(40i32)
p.next(3i32) => GiveUp
backoff
def backoff(self, restarts: i32) -> i32
The backoff delay before the restarts-th restart: positive base_ms doubled restarts times, saturating at positive max_ms before an i32 multiplication could overflow. A non-positive base or ceiling gives zero. The work is bounded by the width of i32, however large restarts is. Pure; exposed on its own so a supervisor can log or adjust the schedule.
p = RestartPolicy(max_restarts=5i32, base_ms=10i32, max_ms=50i32)
p.backoff(0i32) => 10i32
p.backoff(2i32) => 40i32
p.backoff(3i32) => 50i32