Skip to main content

aion_server/worker/
liminal_liveness.rs

1//! Server half of the liminal connection dead-man switch: the liveness probe.
2//!
3//! # The gap this closes
4//!
5//! The server's connection lease ([`HeartbeatTracker`]) is advanced by frames a
6//! worker SENDS, and an idle worker sends nothing. So on 2026-07-29 a healthy,
7//! connected, idle worker's lease expired 37 seconds after its last activity —
8//! "idle worker connection lease expired; worker deregistered" — the worker was
9//! never told, kept believing it was connected, and the next dispatch parked
10//! forever on a queue nobody was serving. The same absent mechanism cost a live
11//! run 28 minutes of mutual blindness when a link died mid-activity: the server
12//! pushed into a socket nobody was reading and the worker blocked on a socket
13//! nobody was writing.
14//!
15//! # The probe
16//!
17//! [`LivenessProbe`] pushes a [`LivenessPing`] to every liminal-connected worker
18//! on a fixed cadence and waits for the correlated [`LivenessPong`]. One
19//! exchange proves both legs:
20//!
21//! - The PONG proves the worker is alive to the server, and its arrival advances
22//!   the worker's connection lease — so an idle-but-alive connection is no
23//!   longer "idle" at the lease layer and the idle expiry cannot fire on it.
24//!   That is the structural death of the first failure above.
25//! - The PING's arrival proves the server is alive to the worker, whose own
26//!   dead-man switch (`aion-worker`'s `liminal_liveness`) declares the link dead
27//!   when pings stop. That is the death of the second.
28//!
29//! A ping that is not answered inside the cadence is logged LOUDLY with the
30//! worker's identity and queue, and withdraws the worker's DISPATCH
31//! ELIGIBILITY once the silence outlasts the window.
32//!
33//! # Why eligibility, and not the connection lease
34//!
35//! This module used to claim that an unanswered ping let "a genuinely dead
36//! worker's lease run down" so the expiry sweep would reap it. **That was
37//! false, and run `dfd2117c` proved it**: the server could not push to a worker
38//! for fifteen minutes and the worker never lost its lease, because the
39//! worker-side liveness pump beats from a background task and keeps refreshing
40//! it whatever the worker's serve loop is doing.
41//!
42//! Two different facts were collapsed into one lease:
43//!
44//! - **the worker process is alive** — proven by anything the worker sends,
45//!   pump included, on the worker-to-server direction;
46//! - **the server can reach the worker's dispatch path** — proven only by an
47//!   answered ping, on the server-to-worker direction.
48//!
49//! Only the second is a dispatch precondition, and
50//! [`LiminalWorkerDelivery::push_payload_with_deadline`] already says why the
51//! ping is the only thing that can prove it: it rides "the exact path a
52//! dispatch would take, not a parallel one that could be healthy while the real
53//! one is not." The pump is exactly such a parallel channel, so it must not
54//! feed the fact the ping exists to establish.
55//!
56//! So the probe advances a SEPARATE reachability clock
57//! ([`HeartbeatTracker::record_dispatch_reachability`]) and publishes an
58//! eligibility verdict the dispatch selector honours. The connection lease and
59//! its expiry sweep are untouched and still own process liveness — there is
60//! still deliberately no second reaper, and an unreachable worker is excluded
61//! from dispatch rather than torn down.
62//!
63//! # No knobs
64//!
65//! Both timings are DERIVED from the operator's existing
66//! `worker.heartbeat_window` — the one place they already declared what silence
67//! means:
68//!
69//! - the ping cadence is [`sweep_interval`] of that window (a quarter of it,
70//!   clamped to `[1s, window]`), the identical derivation the expiry sweeper
71//!   uses, so a healthy connection is refreshed four times per window and the
72//!   idle lease has no chance to expire;
73//! - the window the worker is told to expect is the heartbeat window ITSELF, so
74//!   both ends of the link declare death on exactly the same operator contract.
75//!
76//! Nothing here is separately configurable, and the worker holds no copy of the
77//! window: it is carried on every ping.
78//!
79//! # Both transports, one probation (#197)
80//!
81//! This module is named for the transport it was built against, but
82//! [`LivenessProbe`] is no longer liminal-only. A round enumerates liminal
83//! connections AND gRPC-delivered registrations
84//! ([`grpc_liveness`](super::grpc_liveness)), pings each over ITS OWN
85//! transport, and feeds every answer into the SAME
86//! [`HeartbeatTracker`] probation and the SAME
87//! [`publish_reachability_verdict`](LivenessProbe::publish_reachability_verdict)
88//! — one [`DISPATCH_PROBATION_PINGS`], one eligibility set, one set of
89//! announcement transitions, no per-transport constant and no exemption.
90//!
91//! Only the wire differs: a liminal worker is pinged with the serde
92//! [`LivenessPing`] on its connection, a gRPC worker with the protobuf
93//! [`ProtoLivenessPing`](aion_proto::ProtoLivenessPing) on its task stream. The
94//! fact each establishes is identical, which is why the verdict is.
95//!
96//! The accepted cost of riding the task stream is that stream death is not a
97//! separate signal: a closed stream simply makes the NEXT ping unanswerable, so
98//! withdrawal latency is bounded by the probe cadence rather than being
99//! instantaneous. That is deliberate — a special-case fast path for stream
100//! death would be a second, softer route into the eligibility verdict, and the
101//! registry's own disconnect teardown already owns deregistration.
102//!
103//! # A verdict reaches only the transports this probe CARRIES (#25)
104//!
105//! The probation is transport-blind; the verdict's REACH is not. A probe
106//! publishes an exclusion for a worker only if it holds a wire on which that
107//! worker could have been asked — a liminal notifier for a liminal worker, a
108//! gRPC answer registry for a gRPC one ([`LivenessProbe::covers`]). Without
109//! that scope the verdict was formed from the whole of
110//! [`HeartbeatTracker::unreachable_workers`], so a worker of an uncarried
111//! transport could never bank an answer, never serve its probation, and never
112//! leave the exclusion set: registered, healthy, and withheld from dispatch for
113//! the life of the process. That was reported live against a server whose probe
114//! covered liminal only, and it is what [`LivenessProbe::new`] would still do to
115//! a gRPC fleet if the reach were left unscoped.
116//!
117//! Coverage is STRUCTURAL — which wires the probe holds — and never the set of
118//! targets a round happened to enumerate. The difference is load-bearing: a
119//! liminal worker whose connection has closed appears in no round's target list,
120//! and it is exactly the worker that must stay excluded. Scoping by enumeration
121//! would re-admit it, which is a worse defect than the one being cured.
122
123use std::collections::BTreeMap;
124use std::sync::Arc;
125use std::time::{Duration, Instant};
126
127use aion_core::WorkerTransport;
128use serde::{Deserialize, Serialize};
129use tokio::sync::watch;
130use tracing::{info, warn};
131
132use super::grpc_liveness::{GrpcLivenessTarget, GrpcLivenessWaiters, ping_grpc_worker};
133use super::heartbeat::{
134    DISPATCH_PROBATION_PINGS, DispatchExclusion, ExcludedWorker, HeartbeatTracker, sweep_interval,
135};
136use super::liminal_transport::{LiminalConnectionNotifier, LiminalWorkerDelivery};
137use super::liveness::{PingFailure, ProbedTransport};
138use super::registry::{ConnectedWorkerRegistry, WorkerId};
139
140/// Wire liveness ping the server pushes on an established liminal connection.
141///
142/// Field-for-field mirror of `aion-worker`'s `liminal_liveness::LivenessPing`
143/// (same serde field names), the same cross-crate contract the
144/// dispatch/response and intervention pairs pin. `liveness_ping` is also the
145/// worker's demux discriminator: no other pushed frame carries it, and a ping
146/// carries none of the fields a dispatch or intervention requires.
147#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
148pub struct LivenessPing {
149    /// Monotonic ping sequence within this connection, echoed on the answer.
150    pub liveness_ping: u64,
151    /// How long the worker may hear NOTHING on this connection before it must
152    /// declare the link dead — this server's `worker.heartbeat_window`, carried
153    /// on the wire so the worker never holds a second copy of it.
154    pub silence_window_ms: u64,
155}
156
157/// Wire answer the worker replies with, echoing the ping's sequence.
158///
159/// Field-for-field mirror of `aion-worker`'s `liminal_liveness::LivenessPong`.
160#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
161pub struct LivenessPong {
162    /// The sequence of the ping being answered, echoed verbatim.
163    pub liveness_pong: u64,
164}
165
166/// One connection the probe pings on a round: the connection pid, the worker it
167/// registered, and the push leg to reach it.
168#[derive(Clone, Debug)]
169pub struct LivenessTarget {
170    /// Liminal connection process id the worker is addressed on.
171    pub pid: u64,
172    /// Registry identity of the worker that registered on this connection.
173    pub worker_id: WorkerId,
174    /// Push leg used to deliver the ping and await its answer.
175    pub delivery: LiminalWorkerDelivery,
176}
177
178/// The production driver of the liminal connection dead-man switch.
179///
180/// Shares the server's shutdown watch, so it drains with the transports exactly
181/// like [`HeartbeatSweeper`](super::HeartbeatSweeper) and the outbox dispatcher.
182pub struct LivenessProbe {
183    /// The liminal connection census, or `None` on a boot that hosts no liminal
184    /// listener. `None` is not "no liveness": the gRPC half below still runs.
185    notifier: Option<Arc<LiminalConnectionNotifier>>,
186    /// The gRPC answer-correlation registry, or `None` when this probe has no
187    /// channel to receive gRPC answers on.
188    ///
189    /// Structural rather than a flag: a probe with no correlation registry
190    /// cannot hear a gRPC answer, so it must not push gRPC pings either — every
191    /// one would time out and hold healthy workers off dispatch. The production
192    /// wiring
193    /// ([`ServerState::spawn_liminal_liveness_probe`](crate::ServerState::spawn_liminal_liveness_probe))
194    /// always supplies one; [`LivenessProbe::new`] is the liminal-only
195    /// construction for callers with no gRPC fleet.
196    grpc: Option<GrpcLivenessWaiters>,
197    tracker: HeartbeatTracker,
198    registry: ConnectedWorkerRegistry,
199    cadence: Duration,
200    silence_window: Duration,
201}
202
203impl std::fmt::Debug for LivenessProbe {
204    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        formatter
206            .debug_struct("LivenessProbe")
207            .field("cadence", &self.cadence)
208            .field("silence_window", &self.silence_window)
209            .finish_non_exhaustive()
210    }
211}
212
213impl LivenessProbe {
214    /// Build a probe over the notifier that owns the liminal connections, the
215    /// shared liveness tracker whose leases a pong refreshes, and the registry
216    /// the WARN lines resolve a worker's queue through.
217    ///
218    /// Both timings derive from `heartbeat_window` (see the module docs); there
219    /// is no separate configuration surface.
220    /// This construction probes the LIMINAL transport only: it carries no gRPC
221    /// answer-correlation registry, so it does not enumerate gRPC deliveries.
222    /// Use [`Self::across_transports`] for the production probe.
223    #[must_use]
224    pub fn new(
225        notifier: Arc<LiminalConnectionNotifier>,
226        tracker: HeartbeatTracker,
227        registry: ConnectedWorkerRegistry,
228        heartbeat_window: Duration,
229    ) -> Self {
230        Self::across_transports(Some(notifier), None, tracker, registry, heartbeat_window)
231    }
232
233    /// Build the probe that covers EVERY transport a worker may be delivered
234    /// over (#197).
235    ///
236    /// `notifier` is `None` on a boot with no liminal listener; `grpc` is the
237    /// correlation registry the gRPC stream handler delivers answers into, and
238    /// is `None` only for a probe that must not push gRPC pings it could never
239    /// hear the answers to.
240    ///
241    /// There is exactly ONE probe per server. Two would each publish a whole
242    /// eligibility set over the other's — the verdict is a replacement, not a
243    /// merge — so the last writer would silently erase the other transport's
244    /// findings every cadence.
245    #[must_use]
246    pub fn across_transports(
247        notifier: Option<Arc<LiminalConnectionNotifier>>,
248        grpc: Option<GrpcLivenessWaiters>,
249        tracker: HeartbeatTracker,
250        registry: ConnectedWorkerRegistry,
251        heartbeat_window: Duration,
252    ) -> Self {
253        Self {
254            notifier,
255            grpc,
256            tracker,
257            registry,
258            cadence: sweep_interval(heartbeat_window),
259            silence_window: heartbeat_window,
260        }
261    }
262
263    /// The interval between probe rounds.
264    #[must_use]
265    pub const fn cadence(&self) -> Duration {
266        self.cadence
267    }
268
269    /// The silence window this probe declares to every worker it pings.
270    #[must_use]
271    pub const fn silence_window(&self) -> Duration {
272        self.silence_window
273    }
274
275    /// Run the probe until `shutdown` flips to `true`.
276    ///
277    /// Rounds never overlap: each tick's pings are awaited to completion (each
278    /// bounded by the cadence) before the next round starts, and a missed tick
279    /// is skipped rather than queued.
280    ///
281    /// That bounds the concurrent WAITS to one per connection. It was once
282    /// claimed to bound the outstanding PUSHES to one as well, "so it can never
283    /// crowd out real dispatches against liminal's per-connection pending-push
284    /// cap." **That claim was false and the failure it denied is exactly what
285    /// happened** on run `dfd2117c`: a push slot is not released by the caller
286    /// giving up, only by a consumed reply, a deadline expiry, or a connection
287    /// close. Awaiting a round to completion ends the wait, not the slot. So
288    /// abandoning one unanswered no-deadline ping per round leaked one slot per
289    /// round — 32 of them, then total refusal of every push on that connection.
290    ///
291    /// The bound is now real because
292    /// [`LiminalWorkerDelivery::push_payload_with_deadline`] attaches the
293    /// cadence as the push's own reply deadline, so an unanswered ping's slot
294    /// expires and RELEASES its cap admission instead of accumulating.
295    pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
296        info!(
297            cadence_ms = self.cadence.as_millis(),
298            silence_window_ms = self.silence_window.as_millis(),
299            "worker liveness probe started (liminal + grpc)"
300        );
301        let mut sequence = 0_u64;
302        let mut ticks = tokio::time::interval(self.cadence);
303        ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
304        loop {
305            tokio::select! {
306                _ = ticks.tick() => {
307                    if *shutdown.borrow() {
308                        break;
309                    }
310                    sequence = sequence.saturating_add(1);
311                    self.probe_once(sequence).await;
312                }
313                changed = shutdown.changed() => {
314                    // A receive error means every sender dropped; treat that as
315                    // a shutdown request rather than spinning.
316                    if changed.is_err() || *shutdown.borrow() {
317                        break;
318                    }
319                }
320            }
321        }
322        info!("worker liveness probe stopped");
323    }
324
325    /// Ping every reachable worker once — liminal connections and gRPC task
326    /// streams alike, concurrently — apply each answer to the worker's dispatch
327    /// probation, and publish ONE verdict for the round.
328    ///
329    /// The two transports are pinged over their own wires, CONCURRENTLY with
330    /// each other, and are otherwise indistinguishable from here down: the same
331    /// probation, the same tracker, the same single
332    /// [`Self::publish_reachability_verdict`] call.
333    ///
334    /// EVERY round publishes, including one that found no target to ping. This
335    /// module used to skip publication for an empty round, reasoning that
336    /// "publishing an empty exclusion set would be indistinguishable from
337    /// clearing one". They are the same act, and skipping it is what left a
338    /// stale verdict standing: a verdict is published by REPLACEMENT, so a round
339    /// that publishes nothing leaves the previous round's exclusions in force
340    /// over a fleet this round measured afresh. The set is derived from the
341    /// tracker's standing evidence rather than from this round's targets, so it
342    /// is well defined whether or not anything was pinged (#25).
343    ///
344    /// Public because it is the whole unit of work: [`Self::run`] is a timer
345    /// around it and adds nothing but the schedule. Driving rounds directly is
346    /// how a test states a probation in ROUNDS rather than in wall-clock
347    /// sleeps, which is the difference between asserting the law and asserting
348    /// this machine's timing.
349    ///
350    /// `sequence` must advance between rounds: it is the echo a worker's answer
351    /// is matched against, and repeating one would let a previous round's late
352    /// answer satisfy this round's ping.
353    pub async fn probe_once(&self, sequence: u64) {
354        let liminal = self
355            .notifier
356            .as_ref()
357            .map_or_else(Vec::new, |notifier| notifier.liveness_targets());
358        let grpc = self.grpc_targets();
359        let silence_window_ms = u64::try_from(self.silence_window.as_millis()).unwrap_or(u64::MAX);
360        // Joined, not sequenced. Each half bounds its own pings by the cadence,
361        // so awaiting them in turn would let a round of unanswered gRPC pings
362        // push the NEXT liminal ping a full cadence late — stretching the gap
363        // between a healthy liminal connection's refreshes toward three
364        // quarters of the heartbeat window and eroding the four-refreshes-per-
365        // window margin this module's timings are derived to guarantee. The
366        // halves share no state until their answers are applied, so there is
367        // nothing to serialize.
368        tokio::join!(
369            self.probe_liminal(sequence, silence_window_ms, liminal),
370            self.probe_grpc(sequence, silence_window_ms, grpc),
371        );
372        self.publish_reachability_verdict(sequence);
373    }
374
375    /// This round's gRPC targets, or none when this probe carries no answer
376    /// channel (see [`Self::grpc`]). A registry read failure is LOUD and yields
377    /// no targets: the alternative is pinging a fleet the probe cannot describe.
378    fn grpc_targets(&self) -> Vec<GrpcLivenessTarget> {
379        let Some(_) = self.grpc.as_ref() else {
380            return Vec::new();
381        };
382        match self.registry.grpc_liveness_targets() {
383            Ok(targets) => targets,
384            Err(error) => {
385                warn!(
386                    %error,
387                    "could not enumerate gRPC-delivered workers for this liveness round; they \
388                     go unprobed and will therefore not clear their dispatch probation"
389                );
390                Vec::new()
391            }
392        }
393    }
394
395    /// Push the serde ping to every liminal connection concurrently.
396    async fn probe_liminal(
397        &self,
398        sequence: u64,
399        silence_window_ms: u64,
400        targets: Vec<LivenessTarget>,
401    ) {
402        if targets.is_empty() {
403            return;
404        }
405        let ping = LivenessPing {
406            liveness_ping: sequence,
407            silence_window_ms,
408        };
409        let payload = match serde_json::to_vec(&ping) {
410            Ok(payload) => payload,
411            Err(error) => {
412                // Structurally unreachable (two integers), but a probe that
413                // cannot encode its own ping must say so rather than silently
414                // stop being a dead-man switch.
415                warn!(%error, "liminal liveness probe could not encode its ping; skipping round");
416                return;
417            }
418        };
419        let deadline = self.cadence;
420        let answers = targets.into_iter().map(|target| {
421            let payload = payload.clone();
422            async move {
423                let outcome = tokio::task::spawn_blocking(move || {
424                    ping_one(&target.delivery, payload, deadline)
425                })
426                .await;
427                (target.pid, target.worker_id, outcome)
428            }
429        });
430        for (pid, worker_id, outcome) in futures::future::join_all(answers).await {
431            self.apply_answer(pid, worker_id, sequence, outcome);
432        }
433    }
434
435    /// Push the protobuf ping down every gRPC task stream concurrently.
436    async fn probe_grpc(
437        &self,
438        sequence: u64,
439        silence_window_ms: u64,
440        targets: Vec<GrpcLivenessTarget>,
441    ) {
442        let (Some(waiters), false) = (self.grpc.as_ref(), targets.is_empty()) else {
443            return;
444        };
445        let deadline = self.cadence;
446        let answers = targets.iter().map(|target| async move {
447            let outcome = ping_grpc_worker(
448                waiters,
449                target,
450                aion_proto::ProtoLivenessPing {
451                    liveness_ping: sequence,
452                    silence_window_ms,
453                },
454                deadline,
455            )
456            .await;
457            (target.worker_id, outcome)
458        });
459        for (worker_id, outcome) in futures::future::join_all(answers).await {
460            self.apply_grpc_answer(worker_id, sequence, outcome);
461        }
462    }
463
464    /// Publish this round's reachability verdict to the registry, so dispatch
465    /// selection skips workers the server cannot reach.
466    ///
467    /// Runs after every round, including rounds where every ping succeeded —
468    /// that is what RESTORES eligibility to a worker whose pings have started
469    /// answering again. Recovery must not need a separate trigger.
470    ///
471    /// This is the half that makes the switch able to fire at all. A worker's
472    /// connection lease is refreshed by anything it sends, including its own
473    /// background liveness pump, so a worker whose dispatch path is completely
474    /// dead can look perfectly alive indefinitely. Reachability is tracked
475    /// separately and only an answered ping advances it, so the pump can no
476    /// longer hold dispatch eligibility open against failing pings.
477    ///
478    /// # What this says out loud, and why it changed
479    ///
480    /// Until 2026-08-05 this announced exactly one transition — the withdrawal
481    /// — in one sentence that was FALSE in the commonest case. Every healthy
482    /// worker start logged `WITHDRAWING DISPATCH ELIGIBILITY … the server has
483    /// not been able to reach its dispatch path within the window`, because
484    /// registration opens an unserved probation and the first round after it
485    /// always finds the probation unserved. At that instant the server had
486    /// reached the worker — one answer was already banked — and the window had
487    /// nothing to do with it. The remedy sentence was wrong too: it promised
488    /// eligibility back after "one ping", when
489    /// [`DISPATCH_PROBATION_PINGS`](super::heartbeat::DISPATCH_PROBATION_PINGS)
490    /// consecutive answers are required and one was already in hand.
491    ///
492    /// The restoration a few seconds later was silent, so an operator saw the
493    /// alarm and never the all-clear. On Tom's server that read as a broken
494    /// worker and was reported to him as a caveat on a fix that was in fact
495    /// working. An alarm that fires on every ordinary connect carries no
496    /// information; a resolution nobody announces cannot cancel it.
497    ///
498    /// So the three transitions are now distinguished and all three are said:
499    /// the probation opening (ordinary, INFO), the loss of eligibility that was
500    /// actually held (an incident, WARN), and the recovery (INFO).
501    ///
502    /// # SATURATION IS NO LONGER MEASURED AS SILENCE
503    ///
504    /// This WARN used to carry a known false red, and it is struck: a gRPC
505    /// worker running at its full concurrency stopped reading its task stream,
506    /// because the receive loop awaited a concurrency permit before reading the
507    /// next frame. A ping already on the wire went unanswered until a permit
508    /// freed, so a worker saturated for longer than one probe cadence looked
509    /// silent while it was in fact working, and this line said its dispatch
510    /// path was unreachable.
511    ///
512    /// Both halves of that are now false, and by two independent changes:
513    ///
514    /// - The worker's admission never parks its receive loop. The permit is
515    ///   taken without awaiting, so a saturated worker keeps reading frames and
516    ///   answers this probe's ping while its work runs. SATURATION is no longer
517    ///   a way to produce an unanswered ping.
518    ///
519    ///   Not the same as saying an unanswered ping now means only one thing, and
520    ///   it does not: [`PingFailure::Unaskable`] still fires when a session's
521    ///   push channel is full, and a worker whose runtime is occupied by
522    ///   blocking handlers can still miss one. Those are different conditions
523    ///   with their own causes; what changed is that a worker running exactly as
524    ///   many activities as it advertised is no longer one of them.
525    /// - The server does not push a worker past the concurrency it advertised
526    ///   at registration. Selection counts each worker's in-flight dispatches
527    ///   against its own number, so the condition that produced the false red
528    ///   is not reached by the ordinary route at all.
529    ///
530    /// The old note also carried a bounding claim — "in-flight work is
531    /// untouched throughout" — which was true of THIS verdict and false of the
532    /// composed system. Eligibility withdrawal touched nothing, but the
533    /// heartbeat sweep read the same silence and DEREGISTERED the worker,
534    /// surfacing its live work as transport losses onto a queue the census then
535    /// reported as having no worker connected. That gap is closed at the sweep:
536    /// deregistration now requires an explicit connected fact to be absent, not
537    /// merely for noise to have stopped.
538    ///
539    /// What is unchanged is this verdict itself. Withdrawing eligibility from a
540    /// worker the server cannot prove it reaches is correct, and it remains the
541    /// resting state for one: ineligible, still registered, still holding its
542    /// in-flight work, restored after its probation the moment it answers.
543    fn publish_reachability_verdict(&self, sequence: u64) {
544        let now = Instant::now();
545        let measured = match self.tracker.unreachable_workers(now) {
546            Ok(workers) => workers,
547            Err(error) => {
548                warn!(
549                    %error,
550                    liveness_ping = sequence,
551                    "could not read worker reachability; leaving the previous dispatch \
552                     eligibility verdict in place rather than guessing"
553                );
554                return;
555            }
556        };
557        let Some(unreachable) = self.within_reach(&measured) else {
558            warn!(
559                liveness_ping = sequence,
560                "could not read which transport each unreachable worker is delivered over, so \
561                 this round cannot tell an exclusion it is entitled to publish from one it has \
562                 no wire to test; the previous verdict stands unchanged"
563            );
564            return;
565        };
566        // 🔴 The exclusion travels WITH the worker id into the published
567        // verdict. It used to be dropped here — `.map(|e| e.worker_id)` into a
568        // bare set — which left the registry unable to tell a worker serving
569        // its opening probation from one whose reachability was lost. Those are
570        // the two facts `DispatchExclusion`'s own documentation calls facts "an
571        // operator must be able to tell apart", and collapsing them is what let
572        // a fan-out row park on a non-clearing exclusion with nothing published
573        // about why. The value was already in hand; only this line threw it out.
574        let excluded_now: BTreeMap<WorkerId, DispatchExclusion> = unreachable
575            .iter()
576            .map(|excluded| (excluded.worker_id, excluded.exclusion))
577            .collect();
578        // The previous verdict is what makes a transition a transition. If it
579        // cannot be read the announcements are SKIPPED rather than guessed —
580        // assuming an empty previous set would re-announce every standing
581        // exclusion as though it had just happened. The verdict itself still
582        // publishes below: gating dispatch is the load-bearing half, and it
583        // must not be dropped because the narration failed.
584        match self.registry.dispatch_ineligible() {
585            Ok(previously_excluded) => {
586                self.announce_transitions(
587                    sequence,
588                    &previously_excluded,
589                    &unreachable,
590                    &excluded_now,
591                );
592            }
593            Err(error) => warn!(
594                %error,
595                liveness_ping = sequence,
596                "could not read the published liminal dispatch eligibility set; this round's \
597                 eligibility changes go UNANNOUNCED, though the verdict itself is still published"
598            ),
599        }
600        if let Err(error) = self.registry.set_dispatch_ineligible(excluded_now) {
601            warn!(
602                %error,
603                liveness_ping = sequence,
604                "could not publish worker dispatch eligibility; selection keeps the \
605                 previous verdict"
606            );
607        }
608    }
609
610    /// Narrow the tracker's evidence to the workers this probe is ENTITLED to
611    /// judge: those delivered over a transport it carries a wire for (#25).
612    ///
613    /// A worker of an uncarried transport is dropped — not because it is
614    /// reachable (nothing here knows that) but because this probe has no way to
615    /// find out, and an exclusion it could never lift is a permanent withdrawal
616    /// dressed as a measurement. A worker that has left the registry is dropped
617    /// for the same reason: it has no transport at all, selection can no longer
618    /// see it, and naming it would put a fabricated fact in an operator-facing
619    /// set.
620    ///
621    /// `None` when the registry cannot be read: the caller must then publish
622    /// NOTHING. Falling back to the unscoped set would reinstate the defect at
623    /// exactly the moment the server is least able to explain itself.
624    fn within_reach(&self, measured: &[ExcludedWorker]) -> Option<Vec<ExcludedWorker>> {
625        let transports = self
626            .registry
627            .transports_of(measured.iter().map(|excluded| excluded.worker_id))
628            .map_err(|error| warn!(%error, "could not read worker transports"))
629            .ok()?;
630        Some(
631            measured
632                .iter()
633                .filter(|excluded| {
634                    transports
635                        .get(&excluded.worker_id)
636                        .is_some_and(|transport| self.covers(*transport))
637                })
638                .copied()
639                .collect(),
640        )
641    }
642
643    /// Whether this probe holds a wire on which a worker of `transport` could be
644    /// asked its liveness question.
645    ///
646    /// Structural, and deliberately not a function of how many targets a round
647    /// enumerated: a liminal worker whose connection has closed is enumerated by
648    /// nothing and is precisely the worker a liminal-carrying probe must keep
649    /// excluded.
650    const fn covers(&self, transport: WorkerTransport) -> bool {
651        match transport {
652            WorkerTransport::Grpc => self.grpc.is_some(),
653            WorkerTransport::Liminal => self.notifier.is_some(),
654        }
655    }
656
657    /// Say what changed this round — and only what changed, so a persistently
658    /// excluded worker does not re-log every cadence.
659    fn announce_transitions(
660        &self,
661        sequence: u64,
662        previously_excluded: &BTreeMap<WorkerId, DispatchExclusion>,
663        unreachable: &[ExcludedWorker],
664        excluded_now: &BTreeMap<WorkerId, DispatchExclusion>,
665    ) {
666        for excluded in unreachable {
667            let Some(announcement) = transition(
668                previously_excluded.contains_key(&excluded.worker_id),
669                Some(excluded.exclusion),
670            ) else {
671                continue;
672            };
673            self.say(sequence, excluded.worker_id, announcement);
674        }
675        for worker_id in previously_excluded
676            .keys()
677            .filter(|worker_id| !excluded_now.contains_key(worker_id))
678        {
679            // A worker that DEPARTED is not a worker that recovered. The
680            // registry read is the discriminator: an entry no longer in it left
681            // the fleet, and announcing its recovery would be a fabrication.
682            let Some(task_queue) = self.task_queue_of(*worker_id) else {
683                continue;
684            };
685            let Some(announcement) = transition(true, None) else {
686                continue;
687            };
688            self.say_with_queue(sequence, *worker_id, &task_queue, announcement);
689        }
690    }
691
692    /// Emit one announcement, resolving the worker's queue for the line.
693    fn say(&self, sequence: u64, worker_id: WorkerId, announcement: Announcement) {
694        let task_queue = self
695            .task_queue_of(worker_id)
696            .unwrap_or_else(|| "<unregistered>".to_owned());
697        self.say_with_queue(sequence, worker_id, &task_queue, announcement);
698    }
699
700    /// Emit one announcement against an already-resolved queue.
701    fn say_with_queue(
702        &self,
703        sequence: u64,
704        worker_id: WorkerId,
705        task_queue: &str,
706        announcement: Announcement,
707    ) {
708        let transport = self.transport_of(worker_id);
709        match announcement {
710            Announcement::ProbationOpened { answers } => info!(
711                worker_id = worker_id.value(),
712                task_queue,
713                transport,
714                liveness_ping = sequence,
715                answers_banked = answers,
716                answers_required = DISPATCH_PROBATION_PINGS,
717                "worker is SERVING ITS DISPATCH PROBATION: it has answered {answers} of \
718                 {DISPATCH_PROBATION_PINGS} consecutive liveness pings since it connected, and is \
719                 not selected for dispatch until the run is complete. This is the ordinary cost of \
720                 connecting — every healthy worker start passes through it, on every transport — \
721                 not a fault, and not a statement that anything is unreachable"
722            ),
723            Announcement::EligibilityWithdrawn => warn!(
724                worker_id = worker_id.value(),
725                task_queue,
726                transport,
727                liveness_ping = sequence,
728                answers_required = DISPATCH_PROBATION_PINGS,
729                silence_window_ms = self.silence_window.as_millis(),
730                "WITHDRAWING DISPATCH ELIGIBILITY from worker: it had PROVED its dispatch path \
731                 reachable on this connection and the server can no longer prove it — either a \
732                 liveness ping failed or the last proof aged out of the window. It stays \
733                 registered and keeps its in-flight work, and becomes eligible again after \
734                 {DISPATCH_PROBATION_PINGS} consecutive answered pings"
735            ),
736            Announcement::EligibilityRestored => info!(
737                worker_id = worker_id.value(),
738                task_queue,
739                transport,
740                liveness_ping = sequence,
741                "DISPATCH ELIGIBILITY RESTORED to worker: it has answered a full run of \
742                 consecutive liveness pings, so the server can again prove it reaches this \
743                 worker's dispatch path. Dispatch selection includes it from now"
744            ),
745        }
746    }
747
748    /// The transport a worker is delivered over, for the announcement line.
749    ///
750    /// `"<unregistered>"` when the worker has left the registry or the registry
751    /// cannot be read — a departed worker's transport is genuinely unknowable
752    /// here, and naming one would be a fabrication.
753    fn transport_of(&self, worker_id: WorkerId) -> &'static str {
754        match self.registry.worker_by_id(worker_id) {
755            Ok(Some(handle)) => handle.delivery().transport().name(),
756            Ok(None) | Err(_) => "<unregistered>",
757        }
758    }
759
760    /// Apply one connection's ping outcome: a correct answer advances dispatch
761    /// reachability, anything else is logged LOUDLY, proves nothing, and RESETS
762    /// the probation — proof of reachability must be a consecutive run.
763    fn apply_answer(
764        &self,
765        pid: u64,
766        worker_id: WorkerId,
767        sequence: u64,
768        outcome: Result<Result<LivenessPong, PingFailure>, tokio::task::JoinError>,
769    ) {
770        let failure = match outcome {
771            Err(join_error) => {
772                PingFailure::Unanswered(format!("ping task failed to run: {join_error}"))
773            }
774            Ok(Err(failure)) => failure,
775            Ok(Ok(pong)) if pong.liveness_pong != sequence => PingFailure::Unanswered(format!(
776                "worker answered with mismatched sequence {}",
777                pong.liveness_pong
778            )),
779            Ok(Ok(_)) => {
780                self.record_reachable(ProbedTransport::Liminal { pid }, worker_id);
781                return;
782            }
783        };
784        self.record_unreachable(
785            ProbedTransport::Liminal { pid },
786            worker_id,
787            sequence,
788            failure,
789        );
790    }
791
792    /// The gRPC counterpart of [`Self::apply_answer`] (#197).
793    ///
794    /// Deliberately the same two outcomes fed into the same two recorders: the
795    /// only gRPC-specific step is that a mismatched echo cannot reach here at
796    /// all, because the correlation registry
797    /// ([`GrpcLivenessWaiters::answer`](super::grpc_liveness::GrpcLivenessWaiters::answer))
798    /// refuses to match one — so an echo that arrives is by construction the
799    /// sequence that was sent.
800    fn apply_grpc_answer(
801        &self,
802        worker_id: WorkerId,
803        sequence: u64,
804        outcome: Result<u64, PingFailure>,
805    ) {
806        match outcome {
807            Ok(echoed) if echoed == sequence => {
808                self.record_reachable(ProbedTransport::Grpc, worker_id);
809            }
810            Ok(echoed) => self.record_unreachable(
811                ProbedTransport::Grpc,
812                worker_id,
813                sequence,
814                PingFailure::Unanswered(format!(
815                    "worker answered with mismatched sequence {echoed}"
816                )),
817            ),
818            Err(failure) => {
819                self.record_unreachable(ProbedTransport::Grpc, worker_id, sequence, failure);
820            }
821        }
822    }
823
824    /// Bank one answered ping: the ONE thing that proves the server can reach
825    /// this worker's DISPATCH path.
826    ///
827    /// Only an answer advances reachability — an inbound frame proves the
828    /// opposite direction and cannot stand in for it. A `false` return from the
829    /// tracker means the worker was already deregistered (an answer racing a
830    /// reap); an answer must never resurrect it.
831    fn record_reachable(&self, transport: ProbedTransport, worker_id: WorkerId) {
832        if let Err(error) = self
833            .tracker
834            .record_dispatch_reachability(worker_id, Instant::now())
835        {
836            warn!(
837                %error,
838                connection_pid = transport.pid(),
839                transport = transport.name(),
840                worker_id = worker_id.value(),
841                "failed to record worker dispatch reachability from a liveness answer"
842            );
843        }
844    }
845
846    /// Record one FAILED ping and say which of the two failures it was.
847    ///
848    /// A probation is CONSECUTIVE, so any failure resets it to zero. Without
849    /// this the counter would be cumulative, and a link that answers one probe
850    /// in three would still accrue its way to eligibility and then flap in and
851    /// out of it forever — which is exactly the defect the probation exists to
852    /// stop. Both failure classes reset: whether we could not ask or the worker
853    /// did not answer, the run of answers is broken either way. `Ok(false)`
854    /// means the worker was already deregistered; nothing to reset.
855    fn record_unreachable(
856        &self,
857        transport: ProbedTransport,
858        worker_id: WorkerId,
859        sequence: u64,
860        failure: PingFailure,
861    ) {
862        if let Err(error) = self.tracker.record_dispatch_unreachable(worker_id) {
863            warn!(
864                %error,
865                connection_pid = transport.pid(),
866                transport = transport.name(),
867                worker_id = worker_id.value(),
868                "failed to reset worker dispatch probation after a failed liveness ping; \
869                 its eligibility may outlive the proof that earned it"
870            );
871        }
872        // The two failures are DIFFERENT FACTS and the operator must be able to
873        // tell them apart: one is about the worker, the other is about us.
874        let task_queue = self
875            .task_queue_of(worker_id)
876            .unwrap_or_else(|| "<unregistered>".to_owned());
877        let transport_name = transport.name();
878        match failure {
879            PingFailure::Unaskable(reason) => warn!(
880                connection_pid = transport.pid(),
881                transport = transport_name,
882                worker_id = worker_id.value(),
883                task_queue = %task_queue,
884                liveness_ping = sequence,
885                reason = %reason,
886                silence_window_ms = self.silence_window.as_millis(),
887                "THE SERVER COULD NOT ASK this {transport_name} worker for liveness — the push \
888                 itself was refused, so nothing was sent and the worker has no idea it was probed. \
889                 A dispatch would be refused by the same channel for the same reason. This says \
890                 nothing about whether the worker is healthy; it says this server cannot currently \
891                 reach it. Its dispatch eligibility is withdrawn NOW and it must answer a full run \
892                 of consecutive pings to earn it back"
893            ),
894            PingFailure::Unanswered(reason) => warn!(
895                connection_pid = transport.pid(),
896                transport = transport_name,
897                worker_id = worker_id.value(),
898                task_queue = %task_queue,
899                liveness_ping = sequence,
900                reason = %reason,
901                silence_window_ms = self.silence_window.as_millis(),
902                "{transport_name} worker did not answer its liveness ping; the server could not \
903                 prove it can reach this worker's dispatch path, so its dispatch eligibility is \
904                 withdrawn NOW and it must answer a full run of consecutive pings to earn it back \
905                 — an unanswered probe is direct evidence about the push leg, not mere silence. \
906                 NOTE: the worker's connection lease may still be fresh — its liveness pump beats \
907                 from a background task and keeps proving the process is alive — so do NOT expect \
908                 an expiry sweep to reap it"
909            ),
910        }
911    }
912
913    /// The task queue a worker is registered on, for the WARN line. `None` when
914    /// the worker is no longer in the registry (already reaped).
915    fn task_queue_of(&self, worker_id: WorkerId) -> Option<String> {
916        self.registry
917            .worker_by_id(worker_id)
918            .ok()
919            .flatten()
920            .map(|handle| handle.task_queue().to_owned())
921    }
922}
923
924/// Push one ping and block for its correlated answer, bounded by `deadline`.
925///
926/// Runs on a blocking thread: the liminal push/await pair is thread-based, not
927/// async. Every failure is rendered as a reason string, because at this layer
928/// the distinction that matters is "answered" vs "did not answer" — the typed
929/// error text rides into the WARN verbatim.
930/// A change in one worker's dispatch standing that the operator must be told
931/// about — never the standing state itself, so a persistently excluded worker
932/// does not re-log every cadence.
933#[derive(Clone, Copy, Debug, PartialEq, Eq)]
934enum Announcement {
935    /// A freshly connected worker began serving its probation. Ordinary.
936    ProbationOpened {
937        /// Consecutive answers banked when the probation was announced.
938        answers: u32,
939    },
940    /// A worker that HELD eligibility lost it. An incident.
941    EligibilityWithdrawn,
942    /// A worker that was excluded is dispatchable again.
943    EligibilityRestored,
944}
945
946/// The whole truth table for one worker's standing between two rounds.
947///
948/// Pure and total on purpose: the four inputs are exhaustively enumerated in
949/// the tests, which is the only way to be sure the alarming half and the
950/// reassuring half are both reachable. The old code had no such function — the
951/// decision was inlined and only ever produced one of the three lines, so the
952/// missing two were invisible.
953const fn transition(
954    was_excluded: bool,
955    now_excluded: Option<DispatchExclusion>,
956) -> Option<Announcement> {
957    match (was_excluded, now_excluded) {
958        // Newly excluded. WHICH exclusion decides whether this is news.
959        (false, Some(DispatchExclusion::OpeningProbation { answers })) => {
960            Some(Announcement::ProbationOpened { answers })
961        }
962        (false, Some(DispatchExclusion::ReachabilityLost)) => {
963            Some(Announcement::EligibilityWithdrawn)
964        }
965        // Left the exclusion set: the all-clear.
966        (true, None) => Some(Announcement::EligibilityRestored),
967        // No CHANGE in standing, by either route: a worker still excluded (its
968        // exclusion was announced when it began, and repeating it every cadence
969        // is how a log stops being read), or one that was eligible and stayed
970        // eligible. Both are silence, for different reasons.
971        (true, Some(_)) | (false, None) => None,
972    }
973}
974
975fn ping_one(
976    delivery: &LiminalWorkerDelivery,
977    payload: Vec<u8>,
978    deadline: Duration,
979) -> Result<LivenessPong, PingFailure> {
980    // The deadline is attached to the PUSH, not just to the wait. Without it the
981    // reply slot is reclaimed only by a consumed reply or a connection close, so
982    // abandoning an unanswered ping every cadence leaks one slot per round until
983    // the connection's push cap is exhausted and nothing — ping, dispatch or
984    // intervention — can be pushed to that worker again.
985    let awaiter = delivery
986        .push_payload_with_deadline(payload, deadline)
987        .map_err(|error| PingFailure::Unaskable(error.to_string()))?;
988    let reply = awaiter
989        .receive(deadline)
990        .map_err(|error| PingFailure::Unanswered(format!("no answer arrived: {error}")))?;
991    serde_json::from_slice(&reply)
992        .map_err(|error| PingFailure::Unanswered(format!("answer could not be decoded: {error}")))
993}
994
995#[cfg(test)]
996mod tests {
997    use crate::namespace::NamespaceGuard;
998    use std::sync::Arc;
999    use std::time::{Duration, Instant};
1000
1001    use super::super::heartbeat::{DISPATCH_PROBATION_PINGS, HeartbeatTracker};
1002    use super::super::liminal_transport::LiminalConnectionNotifier;
1003    use super::super::registry::{ConnectedWorkerRegistry, WorkerId};
1004    use super::{
1005        Announcement, DispatchExclusion, LivenessPing, LivenessPong, LivenessProbe, PingFailure,
1006        WorkerTransport, transition,
1007    };
1008
1009    /// The WHOLE truth table, enumerated. Four inputs, and every one of them is
1010    /// asserted here — which is the only way to be sure both the alarming and
1011    /// the reassuring outcomes are reachable.
1012    ///
1013    /// The version of this logic that shipped until 2026-08-05 could emit
1014    /// exactly one of these lines. The other two were not wrong; they did not
1015    /// exist, so an operator saw the withdrawal on every healthy worker start
1016    /// and never saw the recovery that followed seconds later.
1017    /// The runtime a test notifier's admission is bound to. These tests never
1018    /// register a worker through the notifier, so the runtime is only ever
1019    /// named, never driven.
1020    fn test_admission_runtime() -> std::io::Result<tokio::runtime::Runtime> {
1021        tokio::runtime::Builder::new_current_thread()
1022            .enable_all()
1023            .build()
1024    }
1025
1026    #[test]
1027    fn every_standing_change_has_exactly_one_announcement() {
1028        assert_eq!(
1029            transition(
1030                false,
1031                Some(DispatchExclusion::OpeningProbation { answers: 1 })
1032            ),
1033            Some(Announcement::ProbationOpened { answers: 1 }),
1034            "a fresh connection serving its probation is ORDINARY and must not be announced as a \
1035             worker the server cannot reach"
1036        );
1037        assert_eq!(
1038            transition(false, Some(DispatchExclusion::ReachabilityLost)),
1039            Some(Announcement::EligibilityWithdrawn),
1040            "losing eligibility that was actually held is the incident the WARN exists for"
1041        );
1042        assert_eq!(
1043            transition(true, None),
1044            Some(Announcement::EligibilityRestored),
1045            "the all-clear must be said out loud — an alarm nobody cancels is read as ongoing"
1046        );
1047        assert_eq!(
1048            transition(true, Some(DispatchExclusion::ReachabilityLost)),
1049            None,
1050            "a standing exclusion must not re-log every cadence"
1051        );
1052        assert_eq!(
1053            transition(
1054                true,
1055                Some(DispatchExclusion::OpeningProbation { answers: 0 })
1056            ),
1057            None,
1058            "including while a still-excluded worker is still serving its probation"
1059        );
1060        assert_eq!(
1061            transition(false, None),
1062            None,
1063            "an eligible worker that stayed eligible is not news"
1064        );
1065    }
1066
1067    /// THE WHOLE COVERAGE TABLE, enumerated (#25). Two transports by two
1068    /// wire-holdings, and every cell asserted.
1069    ///
1070    /// Coverage is what decides whether this probe may publish an exclusion for
1071    /// a worker at all, so a cell that silently answered `true` would hand a
1072    /// permanent withdrawal to a fleet the probe has no way to ask — the whole
1073    /// injury. Enumerating it here is the only way to be sure the deny half and
1074    /// the admit half are BOTH reachable: a `covers` that returned `true`
1075    /// unconditionally is exactly the pre-fix behaviour, and it satisfies any
1076    /// test that only checks the admitting direction.
1077    #[test]
1078    fn a_probe_covers_exactly_the_transports_it_holds_a_wire_for() -> std::io::Result<()> {
1079        let admission_runtime = test_admission_runtime()?;
1080        let window = Duration::from_secs(30);
1081        let registry = ConnectedWorkerRegistry::default();
1082        let tracker = HeartbeatTracker::new(window);
1083        let notifier = || {
1084            Arc::new(
1085                LiminalConnectionNotifier::new(registry.clone()).with_admission(
1086                    NamespaceGuard::shared_engine(),
1087                    false,
1088                    admission_runtime.handle().clone(),
1089                ),
1090            )
1091        };
1092        let build = |liminal: bool, grpc: bool| {
1093            LivenessProbe::across_transports(
1094                liminal.then(notifier),
1095                grpc.then(super::GrpcLivenessWaiters::new),
1096                tracker.clone(),
1097                registry.clone(),
1098                window,
1099            )
1100        };
1101
1102        let both = build(true, true);
1103        assert!(both.covers(WorkerTransport::Liminal));
1104        assert!(both.covers(WorkerTransport::Grpc));
1105
1106        let liminal_only = build(true, false);
1107        assert!(liminal_only.covers(WorkerTransport::Liminal));
1108        assert!(
1109            !liminal_only.covers(WorkerTransport::Grpc),
1110            "a probe holding no gRPC answer registry cannot hear a gRPC answer, so it must not \
1111             judge a gRPC worker: the exclusion it would publish could never be lifted"
1112        );
1113
1114        let grpc_only = build(false, true);
1115        assert!(
1116            !grpc_only.covers(WorkerTransport::Liminal),
1117            "and the same in the other direction — the reach follows the wire, not the fleet"
1118        );
1119        assert!(grpc_only.covers(WorkerTransport::Grpc));
1120
1121        // Not constructible in production (the composition root always supplies
1122        // at least the liminal notifier), but the fourth cell of a truth table
1123        // is not optional: a probe with no wire at all judges nobody.
1124        let neither = build(false, false);
1125        assert!(!neither.covers(WorkerTransport::Liminal));
1126        assert!(!neither.covers(WorkerTransport::Grpc));
1127        Ok(())
1128    }
1129
1130    /// The ping/pong pair round-trips with stable field names — the cross-crate
1131    /// wire contract with `aion-worker`'s mirror of these types. A drift here is
1132    /// a wire break, so the exact JSON is pinned.
1133    #[test]
1134    fn the_liveness_pair_round_trips_through_json() -> Result<(), serde_json::Error> {
1135        let ping = LivenessPing {
1136            liveness_ping: 7,
1137            silence_window_ms: 30_000,
1138        };
1139        let encoded = serde_json::to_string(&ping)?;
1140        assert_eq!(encoded, r#"{"liveness_ping":7,"silence_window_ms":30000}"#);
1141        assert_eq!(serde_json::from_str::<LivenessPing>(&encoded)?, ping);
1142
1143        let answer = LivenessPong { liveness_pong: 7 };
1144        let encoded = serde_json::to_string(&answer)?;
1145        assert_eq!(encoded, r#"{"liveness_pong":7}"#);
1146        assert_eq!(serde_json::from_str::<LivenessPong>(&encoded)?, answer);
1147        Ok(())
1148    }
1149
1150    /// A ping decodes as NEITHER of the other two frames that share the push
1151    /// channel, and neither of them decodes as a ping — the demux contract the
1152    /// worker's serve loop relies on.
1153    #[test]
1154    fn a_liveness_ping_is_disjoint_from_the_other_pushed_frames() -> Result<(), serde_json::Error> {
1155        let ping = serde_json::to_vec(&LivenessPing {
1156            liveness_ping: 1,
1157            silence_window_ms: 1_000,
1158        })?;
1159        assert!(
1160            serde_json::from_slice::<super::super::liminal_transport::DispatchRequest>(&ping)
1161                .is_err(),
1162            "a liveness ping must never decode as a dispatch"
1163        );
1164        assert!(
1165            serde_json::from_slice::<super::super::liminal_transport::InterventionRequest>(&ping)
1166                .is_err(),
1167            "a liveness ping must never decode as an intervention"
1168        );
1169        Ok(())
1170    }
1171
1172    /// Both timings derive from the operator's heartbeat window, with no
1173    /// separate knob: the cadence is the sweeper's quarter-window derivation and
1174    /// the declared silence window is the heartbeat window itself.
1175    #[test]
1176    fn probe_timings_derive_from_the_heartbeat_window() {
1177        let window = Duration::from_secs(30);
1178        assert_eq!(super::sweep_interval(window), Duration::from_millis(7_500));
1179        // The declared window IS the operator's window; the cadence divides it,
1180        // so a healthy connection is refreshed four times per window.
1181        assert!(super::sweep_interval(window) * 4 <= window);
1182    }
1183
1184    /// 🔴 THE WIRING PIN. The probation lives in the tracker, but only the probe
1185    /// can tell it a ping FAILED — and a probe that recorded successes and
1186    /// dropped failures would compile, log its WARN lines exactly as it does
1187    /// now, and leave the probation permanently unreset. The eligibility bug
1188    /// would be silently back with every test in `heartbeat` still green,
1189    /// because those tests drive the tracker directly and never go through this
1190    /// seam.
1191    ///
1192    /// Every failure class is asserted individually. The fourth arm — a
1193    /// `JoinError` from the ping task — converges on the same
1194    /// `PingFailure::Unanswered` path these two take, one line above the reset.
1195    #[test]
1196    fn every_failed_probe_class_withdraws_dispatch_eligibility() -> std::io::Result<()> {
1197        let admission_runtime = test_admission_runtime()?;
1198        let window = Duration::from_secs(30);
1199        let worker = WorkerId::from_value(1);
1200        let start = Instant::now();
1201
1202        // Each class gets its own probe and its own served probation, so a class
1203        // cannot pass by inheriting the withdrawal an earlier class performed.
1204        let failures = [
1205            (
1206                "the push was refused, so nothing was even asked",
1207                Ok(Err(PingFailure::Unaskable("push refused".to_owned()))),
1208            ),
1209            (
1210                "the ping was sent and no answer came back",
1211                Ok(Err(PingFailure::Unanswered("no answer arrived".to_owned()))),
1212            ),
1213            (
1214                "an answer came back carrying the wrong sequence",
1215                Ok(Ok(LivenessPong { liveness_pong: 99 })),
1216            ),
1217        ];
1218
1219        for (class, outcome) in failures {
1220            let registry = ConnectedWorkerRegistry::default();
1221            let tracker = HeartbeatTracker::new(window);
1222            let probe = LivenessProbe::new(
1223                Arc::new(
1224                    LiminalConnectionNotifier::new(registry.clone()).with_admission(
1225                        NamespaceGuard::shared_engine(),
1226                        false,
1227                        admission_runtime.handle().clone(),
1228                    ),
1229                ),
1230                tracker.clone(),
1231                registry,
1232                window,
1233            );
1234
1235            assert!(
1236                tracker.register_connection(worker, start).is_ok(),
1237                "tracker registration must succeed for {class}"
1238            );
1239            for _ in 0..DISPATCH_PROBATION_PINGS {
1240                assert!(
1241                    tracker
1242                        .record_dispatch_reachability(worker, start)
1243                        .is_ok_and(|tracked| tracked),
1244                    "the worker serves its probation before {class}"
1245                );
1246            }
1247            assert!(
1248                tracker
1249                    .is_dispatch_reachable(worker, start)
1250                    .is_ok_and(|reachable| reachable),
1251                "precondition: the worker is eligible before {class}"
1252            );
1253
1254            // Sequence 1 is the ping that was sent; the mismatched-answer case
1255            // deliberately answers 99.
1256            probe.apply_answer(7, worker, 1, outcome);
1257
1258            assert!(
1259                tracker
1260                    .is_dispatch_reachable(worker, start)
1261                    .is_ok_and(|reachable| !reachable),
1262                "the probe must withdraw dispatch eligibility when {class} — otherwise the \
1263                 probation never resets and a one-way link keeps its eligibility forever"
1264            );
1265        }
1266        Ok(())
1267    }
1268
1269    /// The control for the pin above: the SUCCESS path through the same seam
1270    /// must keep eligibility. Without it, a probe that withdrew eligibility on
1271    /// every outcome — including healthy answers — would satisfy every
1272    /// assertion above and strand every worker on the fleet.
1273    #[test]
1274    fn an_answered_probe_keeps_dispatch_eligibility_through_the_same_seam() -> std::io::Result<()> {
1275        let admission_runtime = test_admission_runtime()?;
1276        let window = Duration::from_secs(30);
1277        let worker = WorkerId::from_value(1);
1278        let start = Instant::now();
1279        let registry = ConnectedWorkerRegistry::default();
1280        let tracker = HeartbeatTracker::new(window);
1281        let probe = LivenessProbe::new(
1282            Arc::new(
1283                LiminalConnectionNotifier::new(registry.clone()).with_admission(
1284                    NamespaceGuard::shared_engine(),
1285                    false,
1286                    admission_runtime.handle().clone(),
1287                ),
1288            ),
1289            tracker.clone(),
1290            registry,
1291            window,
1292        );
1293
1294        assert!(tracker.register_connection(worker, start).is_ok());
1295        for sequence in 1..=u64::from(DISPATCH_PROBATION_PINGS) {
1296            probe.apply_answer(
1297                7,
1298                worker,
1299                sequence,
1300                Ok(Ok(LivenessPong {
1301                    liveness_pong: sequence,
1302                })),
1303            );
1304        }
1305
1306        assert!(
1307            tracker
1308                .is_dispatch_reachable(worker, start)
1309                .is_ok_and(|reachable| reachable),
1310            "answered probes must EARN eligibility through the probe seam, not merely fail to \
1311             withdraw it"
1312        );
1313        Ok(())
1314    }
1315}