Skip to content

fix(test): register the supervisor restore BEFORE mutating (#643)#644

Merged
hyperpolymath merged 1 commit into
mainfrom
fix/reflexive-restore-ordering
Jul 21, 2026
Merged

fix(test): register the supervisor restore BEFORE mutating (#643)#644
hyperpolymath merged 1 commit into
mainfrom
fix/reflexive-restore-ordering

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

Partial fix for #643 — kills the cascade, not the whole red suite. Scope is stated
plainly at the bottom.

The bug

take_supervised/1 (test/reflexive_test.exs:28) removed a child from the live
application supervisor
and only then registered the on_exit that restores it:

Supervisor.terminate_child(Hypatia.Supervisor, module)
Supervisor.delete_child(Hypatia.Supervisor, module)
start_supervised!(module)          # <- if this raises...
on_exit(fn -> ...restore... end)   # <- ...this never registers

If anything between the delete and the on_exit fails, the child is gone from
Hypatia.Supervisor permanently, for the rest of the suite. Every later test needing
that module then fails too.

That explains both puzzling observations: one root failure cascading into ~20, and the
failing set changing with the seed.

Proof of mechanism

Injected a raise after the delete and asked the supervisor what children it actually has —
load-independent, unlike whole-suite counts:

[[PROOF]] OLD  -> child present after raise: false     <- lost forever
[[PROOF]] NEW  -> restore registered, runs on_exit

Changes

  • Register the restore first. on_exit callbacks still run when setup raises, so the
    restore becomes unconditional.
  • Extract restore_supervised/1 and make it idempotent — safe whether the spec is
    absent, present-but-stopped, or already running.
  • Inspect the return value of Supervisor.start_child/2 instead of discarding it, and
    raise on an unexpected result. A silent restore failure is precisely what made the
    original cascade invisible.

Verification

Check Result
mix test test/reflexive_test.exs 16 tests, 0 failures
reflexive_test + safety_test together 42 tests, 0 failures
reflexive_test + intactness assertion (all 4 modules still children of Hypatia.Supervisor, live pids) 17 tests, 0 failures
Injected-raise proof (above) old loses child, new restores

Scope — what this does NOT fix

mix test is still red for other reasons, and I am not claiming otherwise.

A whole-suite A/B across seeds came out inconclusive on this machine: reflexive_test
(and others) synchronise with :timer.sleep/1, so failure counts swing with machine load,
and this box was busy building containers. Counts moved in both directions across seeds for
reasons unrelated to this change, so I am not presenting them as evidence either way — the
injected-raise proof above is the load-independent evidence.

Remaining work, tracked in #643 (which this updates rather than closes):

  • other order-dependent failures (dashboard, root_hygiene, workflow_audit, metrics,
    recipe_health … vary by seed)
  • :timer.sleep/1-based synchronisation, which should become deterministic waits

🤖 Generated with Claude Code

`take_supervised/1` removed a child from the live application supervisor and
only THEN registered the `on_exit` that puts it back:

    Supervisor.terminate_child(Hypatia.Supervisor, module)
    Supervisor.delete_child(Hypatia.Supervisor, module)
    start_supervised!(module)          # <- if this raises...
    on_exit(fn -> ...restore... end)   # <- ...this never registers

If anything between the delete and the on_exit fails, the child is deleted
from Hypatia.Supervisor and NEVER restored — for the whole rest of the suite.
Every later test needing that module then fails too. That is why one root
failure cascaded into ~20, and why the failing set changed with the seed.

Fix: register the restore first (on_exit still runs when setup raises), and
make it idempotent — clear any stale spec, re-add, and inspect the return
rather than discarding it, so a restore failure is loud instead of silently
poisoning everything downstream.

PROOF of the mechanism (load-independent, by injecting a raise after the
delete and asking the supervisor what children it has):

    [[PROOF]] OLD  -> child present after raise: false     <- lost forever
    [[PROOF]] NEW  -> restore registered, runs on_exit

Verified:
  mix test test/reflexive_test.exs                    -> 16 tests, 0 failures
  mix test reflexive_test + safety_test               -> 42 tests, 0 failures
  reflexive_test followed by an intactness assertion
    (all four modules still children of Hypatia.Supervisor
     with live pids)                                  -> 17 tests, 0 failures

SCOPE — deliberately honest: this fixes the cascade, NOT the whole suite.
`mix test` remains red for other reasons. Whole-suite A/B across seeds was
inconclusive here because reflexive_test (and others) use `:timer.sleep/1`
for synchronisation, so counts swing with machine load — this box was busy.
The remaining order- and timing-dependent failures are separate work and are
tracked in #643, which this commit updates rather than closes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hyperpolymath
hyperpolymath marked this pull request as ready for review July 21, 2026 12:04
@hyperpolymath
hyperpolymath merged commit e5d6f26 into main Jul 21, 2026
62 of 75 checks passed
@hyperpolymath
hyperpolymath deleted the fix/reflexive-restore-ordering branch July 21, 2026 12:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant