Skip to content

[Bug] The supervision restart budget is per-parent rather than per-child, so five siblings that each fail once exhaust one budget and two are stopped #917

Description

@pathosDev

Problem

The supervision restart budget is kept per parent, in one array shared by all of that parent's children, rather than per child. Five unrelated children that each crash exactly once therefore consume one budget between them: with maxRetries: 2, three survive and two are stopped — even though no individual child ever failed more than once.

This matters most where fan-out is highest and failures are most likely to be independent: a router with 50 routees, or a shard hosting N entities, loses the whole subtree after maxRetries + 1 unrelated failures in the window.

The behaviour is acknowledged in a comment at the decision site, but Actor.supervisorStrategy's JSDoc reads as per-actor, and OneForOneStrategy — "applies the directive only to the failing child" — reinforces that reading.

Evidence

One array on the cell, not one per child:

src/internal/ActorCell.ts:100
  private _failureTimes: number[] = [];

The decision site, with the comment that concedes it:

src/internal/ActorCell.ts:1136-1140
    // `registerRestart` stays per-parent, so siblings share one allowance.
    const strategy: SupervisorStrategy =
      child.blueprint.supervisorStrategy
      ?? this.actor?.supervisorStrategy()
      ?? defaultStrategy;

Reproduced: one parent with OneForOneStrategy(() => Restart, { maxRetries: 2, withinTimeRangeMs: 60_000 }) and five children, each told to crash exactly once.

kids restarted after one crash each: 3/5 -> [ "k0", "k1", "k2" ]

k3 and k4 were stopped with the threshold message, having failed once each.

A second, compounding defect lives in the same code. registerRestart prunes _failureTimes only when withinTimeRangeMs > 0, and a hand-built OneForOneStrategy defaults withinTimeRangeMs to 0 (src/Supervision.ts:37). So new OneForOneStrategy(d, { maxRetries: 5 }) — the natural way to write it — produces a budget that never resets and an array that grows for the process lifetime.

Proposal

Move _failureTimes from the parent cell to per-child state (the child's own cell, or a Map on the parent keyed by child). AllForOneStrategy legitimately wants a shared budget; OneForOneStrategy does not, and the two should not share a counter.

Separately, decide what withinTimeRangeMs: 0 means and make it consistent: either it is "no window, budget never resets" — in which case the array must still be bounded — or it should default to the same 60 s defaultStrategy uses.

Acceptance sketch

  • Five siblings that each fail once under maxRetries: 2 all restart.
  • A single child that fails maxRetries + 1 times within the window is still stopped.
  • AllForOneStrategy keeps a shared budget, deliberately and documented.
  • withinTimeRangeMs: 0 does not grow _failureTimes without bound.
  • Actor.supervisorStrategy's JSDoc states which scope the budget has.

Verification status

Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution — the 3/5 result above is from a running system; the shared-array mechanism and the withinTimeRangeMs: 0 pruning gap were read at the cited lines.

Part of the production-readiness review batch — tracked in #913.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: highTop priority — high impact, plan nextproduction-goalBlocks or defines the path to production readiness

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions