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
79use std::collections::BTreeSet;
80use std::sync::Arc;
81use std::time::{Duration, Instant};
82
83use serde::{Deserialize, Serialize};
84use tokio::sync::watch;
85use tracing::{info, warn};
86
87use super::heartbeat::{
88    DISPATCH_PROBATION_PINGS, DispatchExclusion, ExcludedWorker, HeartbeatTracker, sweep_interval,
89};
90use super::liminal_transport::{LiminalConnectionNotifier, LiminalWorkerDelivery};
91use super::registry::{ConnectedWorkerRegistry, WorkerId};
92
93/// Wire liveness ping the server pushes on an established liminal connection.
94///
95/// Field-for-field mirror of `aion-worker`'s `liminal_liveness::LivenessPing`
96/// (same serde field names), the same cross-crate contract the
97/// dispatch/response and intervention pairs pin. `liveness_ping` is also the
98/// worker's demux discriminator: no other pushed frame carries it, and a ping
99/// carries none of the fields a dispatch or intervention requires.
100#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
101pub struct LivenessPing {
102    /// Monotonic ping sequence within this connection, echoed on the answer.
103    pub liveness_ping: u64,
104    /// How long the worker may hear NOTHING on this connection before it must
105    /// declare the link dead — this server's `worker.heartbeat_window`, carried
106    /// on the wire so the worker never holds a second copy of it.
107    pub silence_window_ms: u64,
108}
109
110/// Wire answer the worker replies with, echoing the ping's sequence.
111///
112/// Field-for-field mirror of `aion-worker`'s `liminal_liveness::LivenessPong`.
113#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
114pub struct LivenessPong {
115    /// The sequence of the ping being answered, echoed verbatim.
116    pub liveness_pong: u64,
117}
118
119/// One connection the probe pings on a round: the connection pid, the worker it
120/// registered, and the push leg to reach it.
121#[derive(Clone, Debug)]
122pub struct LivenessTarget {
123    /// Liminal connection process id the worker is addressed on.
124    pub pid: u64,
125    /// Registry identity of the worker that registered on this connection.
126    pub worker_id: WorkerId,
127    /// Push leg used to deliver the ping and await its answer.
128    pub delivery: LiminalWorkerDelivery,
129}
130
131/// The production driver of the liminal connection dead-man switch.
132///
133/// Shares the server's shutdown watch, so it drains with the transports exactly
134/// like [`HeartbeatSweeper`](super::HeartbeatSweeper) and the outbox dispatcher.
135pub struct LivenessProbe {
136    notifier: Arc<LiminalConnectionNotifier>,
137    tracker: HeartbeatTracker,
138    registry: ConnectedWorkerRegistry,
139    cadence: Duration,
140    silence_window: Duration,
141}
142
143impl std::fmt::Debug for LivenessProbe {
144    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        formatter
146            .debug_struct("LivenessProbe")
147            .field("cadence", &self.cadence)
148            .field("silence_window", &self.silence_window)
149            .finish_non_exhaustive()
150    }
151}
152
153impl LivenessProbe {
154    /// Build a probe over the notifier that owns the liminal connections, the
155    /// shared liveness tracker whose leases a pong refreshes, and the registry
156    /// the WARN lines resolve a worker's queue through.
157    ///
158    /// Both timings derive from `heartbeat_window` (see the module docs); there
159    /// is no separate configuration surface.
160    #[must_use]
161    pub fn new(
162        notifier: Arc<LiminalConnectionNotifier>,
163        tracker: HeartbeatTracker,
164        registry: ConnectedWorkerRegistry,
165        heartbeat_window: Duration,
166    ) -> Self {
167        Self {
168            notifier,
169            tracker,
170            registry,
171            cadence: sweep_interval(heartbeat_window),
172            silence_window: heartbeat_window,
173        }
174    }
175
176    /// The interval between probe rounds.
177    #[must_use]
178    pub const fn cadence(&self) -> Duration {
179        self.cadence
180    }
181
182    /// The silence window this probe declares to every worker it pings.
183    #[must_use]
184    pub const fn silence_window(&self) -> Duration {
185        self.silence_window
186    }
187
188    /// Run the probe until `shutdown` flips to `true`.
189    ///
190    /// Rounds never overlap: each tick's pings are awaited to completion (each
191    /// bounded by the cadence) before the next round starts, and a missed tick
192    /// is skipped rather than queued.
193    ///
194    /// That bounds the concurrent WAITS to one per connection. It was once
195    /// claimed to bound the outstanding PUSHES to one as well, "so it can never
196    /// crowd out real dispatches against liminal's per-connection pending-push
197    /// cap." **That claim was false and the failure it denied is exactly what
198    /// happened** on run `dfd2117c`: a push slot is not released by the caller
199    /// giving up, only by a consumed reply, a deadline expiry, or a connection
200    /// close. Awaiting a round to completion ends the wait, not the slot. So
201    /// abandoning one unanswered no-deadline ping per round leaked one slot per
202    /// round — 32 of them, then total refusal of every push on that connection.
203    ///
204    /// The bound is now real because
205    /// [`LiminalWorkerDelivery::push_payload_with_deadline`] attaches the
206    /// cadence as the push's own reply deadline, so an unanswered ping's slot
207    /// expires and RELEASES its cap admission instead of accumulating.
208    pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
209        info!(
210            cadence_ms = self.cadence.as_millis(),
211            silence_window_ms = self.silence_window.as_millis(),
212            "liminal worker liveness probe started"
213        );
214        let mut sequence = 0_u64;
215        let mut ticks = tokio::time::interval(self.cadence);
216        ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
217        loop {
218            tokio::select! {
219                _ = ticks.tick() => {
220                    if *shutdown.borrow() {
221                        break;
222                    }
223                    sequence = sequence.saturating_add(1);
224                    self.probe_once(sequence).await;
225                }
226                changed = shutdown.changed() => {
227                    // A receive error means every sender dropped; treat that as
228                    // a shutdown request rather than spinning.
229                    if changed.is_err() || *shutdown.borrow() {
230                        break;
231                    }
232                }
233            }
234        }
235        info!("liminal worker liveness probe stopped");
236    }
237
238    /// Ping every live liminal connection once, concurrently, and apply each
239    /// answer to the worker's connection lease.
240    async fn probe_once(&self, sequence: u64) {
241        let targets = self.notifier.liveness_targets();
242        if targets.is_empty() {
243            return;
244        }
245        let ping = LivenessPing {
246            liveness_ping: sequence,
247            silence_window_ms: u64::try_from(self.silence_window.as_millis()).unwrap_or(u64::MAX),
248        };
249        let payload = match serde_json::to_vec(&ping) {
250            Ok(payload) => payload,
251            Err(error) => {
252                // Structurally unreachable (two integers), but a probe that
253                // cannot encode its own ping must say so rather than silently
254                // stop being a dead-man switch.
255                warn!(%error, "liminal liveness probe could not encode its ping; skipping round");
256                return;
257            }
258        };
259        let deadline = self.cadence;
260        let answers = targets.into_iter().map(|target| {
261            let payload = payload.clone();
262            async move {
263                let outcome = tokio::task::spawn_blocking(move || {
264                    ping_one(&target.delivery, payload, deadline)
265                })
266                .await;
267                (target.pid, target.worker_id, outcome)
268            }
269        });
270        for (pid, worker_id, outcome) in futures::future::join_all(answers).await {
271            self.apply_answer(pid, worker_id, sequence, outcome);
272        }
273        self.publish_reachability_verdict(sequence);
274    }
275
276    /// Publish this round's reachability verdict to the registry, so dispatch
277    /// selection skips workers the server cannot reach.
278    ///
279    /// Runs after every round, including rounds where every ping succeeded —
280    /// that is what RESTORES eligibility to a worker whose pings have started
281    /// answering again. Recovery must not need a separate trigger.
282    ///
283    /// This is the half that makes the switch able to fire at all. A worker's
284    /// connection lease is refreshed by anything it sends, including its own
285    /// background liveness pump, so a worker whose dispatch path is completely
286    /// dead can look perfectly alive indefinitely. Reachability is tracked
287    /// separately and only an answered ping advances it, so the pump can no
288    /// longer hold dispatch eligibility open against failing pings.
289    ///
290    /// # What this says out loud, and why it changed
291    ///
292    /// Until 2026-08-05 this announced exactly one transition — the withdrawal
293    /// — in one sentence that was FALSE in the commonest case. Every healthy
294    /// worker start logged `WITHDRAWING DISPATCH ELIGIBILITY … the server has
295    /// not been able to reach its dispatch path within the window`, because
296    /// registration opens an unserved probation and the first round after it
297    /// always finds the probation unserved. At that instant the server had
298    /// reached the worker — one answer was already banked — and the window had
299    /// nothing to do with it. The remedy sentence was wrong too: it promised
300    /// eligibility back after "one ping", when
301    /// [`DISPATCH_PROBATION_PINGS`](super::heartbeat::DISPATCH_PROBATION_PINGS)
302    /// consecutive answers are required and one was already in hand.
303    ///
304    /// The restoration a few seconds later was silent, so an operator saw the
305    /// alarm and never the all-clear. On Tom's server that read as a broken
306    /// worker and was reported to him as a caveat on a fix that was in fact
307    /// working. An alarm that fires on every ordinary connect carries no
308    /// information; a resolution nobody announces cannot cancel it.
309    ///
310    /// So the three transitions are now distinguished and all three are said:
311    /// the probation opening (ordinary, INFO), the loss of eligibility that was
312    /// actually held (an incident, WARN), and the recovery (INFO).
313    fn publish_reachability_verdict(&self, sequence: u64) {
314        let now = Instant::now();
315        let unreachable = match self.tracker.unreachable_workers(now) {
316            Ok(workers) => workers,
317            Err(error) => {
318                warn!(
319                    %error,
320                    liveness_ping = sequence,
321                    "could not read liminal worker reachability; leaving the previous dispatch \
322                     eligibility verdict in place rather than guessing"
323                );
324                return;
325            }
326        };
327        let excluded_now: BTreeSet<WorkerId> = unreachable
328            .iter()
329            .map(|excluded| excluded.worker_id)
330            .collect();
331        // The previous verdict is what makes a transition a transition. If it
332        // cannot be read the announcements are SKIPPED rather than guessed —
333        // assuming an empty previous set would re-announce every standing
334        // exclusion as though it had just happened. The verdict itself still
335        // publishes below: gating dispatch is the load-bearing half, and it
336        // must not be dropped because the narration failed.
337        match self.registry.dispatch_ineligible() {
338            Ok(previously_excluded) => {
339                self.announce_transitions(
340                    sequence,
341                    &previously_excluded,
342                    &unreachable,
343                    &excluded_now,
344                );
345            }
346            Err(error) => warn!(
347                %error,
348                liveness_ping = sequence,
349                "could not read the published liminal dispatch eligibility set; this round's \
350                 eligibility changes go UNANNOUNCED, though the verdict itself is still published"
351            ),
352        }
353        if let Err(error) = self.registry.set_dispatch_ineligible(excluded_now) {
354            warn!(
355                %error,
356                liveness_ping = sequence,
357                "could not publish liminal worker dispatch eligibility; selection keeps the \
358                 previous verdict"
359            );
360        }
361    }
362
363    /// Say what changed this round — and only what changed, so a persistently
364    /// excluded worker does not re-log every cadence.
365    fn announce_transitions(
366        &self,
367        sequence: u64,
368        previously_excluded: &BTreeSet<WorkerId>,
369        unreachable: &[ExcludedWorker],
370        excluded_now: &BTreeSet<WorkerId>,
371    ) {
372        for excluded in unreachable {
373            let Some(announcement) = transition(
374                previously_excluded.contains(&excluded.worker_id),
375                Some(excluded.exclusion),
376            ) else {
377                continue;
378            };
379            self.say(sequence, excluded.worker_id, announcement);
380        }
381        for worker_id in previously_excluded.difference(excluded_now) {
382            // A worker that DEPARTED is not a worker that recovered. The
383            // registry read is the discriminator: an entry no longer in it left
384            // the fleet, and announcing its recovery would be a fabrication.
385            let Some(task_queue) = self.task_queue_of(*worker_id) else {
386                continue;
387            };
388            let Some(announcement) = transition(true, None) else {
389                continue;
390            };
391            self.say_with_queue(sequence, *worker_id, &task_queue, announcement);
392        }
393    }
394
395    /// Emit one announcement, resolving the worker's queue for the line.
396    fn say(&self, sequence: u64, worker_id: WorkerId, announcement: Announcement) {
397        let task_queue = self
398            .task_queue_of(worker_id)
399            .unwrap_or_else(|| "<unregistered>".to_owned());
400        self.say_with_queue(sequence, worker_id, &task_queue, announcement);
401    }
402
403    /// Emit one announcement against an already-resolved queue.
404    fn say_with_queue(
405        &self,
406        sequence: u64,
407        worker_id: WorkerId,
408        task_queue: &str,
409        announcement: Announcement,
410    ) {
411        match announcement {
412            Announcement::ProbationOpened { answers } => info!(
413                worker_id = worker_id.value(),
414                task_queue,
415                liveness_ping = sequence,
416                answers_banked = answers,
417                answers_required = DISPATCH_PROBATION_PINGS,
418                "liminal worker is SERVING ITS DISPATCH PROBATION: it has answered {answers} of \
419                 {DISPATCH_PROBATION_PINGS} consecutive liveness pings since it connected, and is \
420                 not selected for dispatch until the run is complete. This is the ordinary cost of \
421                 connecting — every healthy worker start passes through it — not a fault, and not \
422                 a statement that anything is unreachable"
423            ),
424            Announcement::EligibilityWithdrawn => warn!(
425                worker_id = worker_id.value(),
426                task_queue,
427                liveness_ping = sequence,
428                answers_required = DISPATCH_PROBATION_PINGS,
429                silence_window_ms = self.silence_window.as_millis(),
430                "WITHDRAWING DISPATCH ELIGIBILITY from liminal worker: it had PROVED its dispatch \
431                 path reachable on this connection and the server can no longer prove it — either \
432                 a liveness ping failed or the last proof aged out of the window. It stays \
433                 registered and keeps its in-flight work, and becomes eligible again after \
434                 {DISPATCH_PROBATION_PINGS} consecutive answered pings"
435            ),
436            Announcement::EligibilityRestored => info!(
437                worker_id = worker_id.value(),
438                task_queue,
439                liveness_ping = sequence,
440                "DISPATCH ELIGIBILITY RESTORED to liminal worker: it has answered a full run of \
441                 consecutive liveness pings, so the server can again prove it reaches this \
442                 worker's dispatch path. Dispatch selection includes it from now"
443            ),
444        }
445    }
446
447    /// Apply one connection's ping outcome: a correct answer advances dispatch
448    /// reachability, anything else is logged LOUDLY, proves nothing, and RESETS
449    /// the probation — proof of reachability must be a consecutive run.
450    fn apply_answer(
451        &self,
452        pid: u64,
453        worker_id: WorkerId,
454        sequence: u64,
455        outcome: Result<Result<LivenessPong, PingFailure>, tokio::task::JoinError>,
456    ) {
457        let failure = match outcome {
458            Err(join_error) => {
459                PingFailure::Unanswered(format!("ping task failed to run: {join_error}"))
460            }
461            Ok(Err(failure)) => failure,
462            Ok(Ok(pong)) if pong.liveness_pong != sequence => PingFailure::Unanswered(format!(
463                "worker answered with mismatched sequence {}",
464                pong.liveness_pong
465            )),
466            Ok(Ok(_)) => {
467                // The one thing that proves the server can reach this worker's
468                // DISPATCH path: a push we sent came back answered. Only this
469                // advances reachability — an inbound frame proves the opposite
470                // direction and cannot stand in for it. A `false` return means
471                // the worker was already deregistered (a pong racing a reap);
472                // an answer must never resurrect it.
473                if let Err(error) = self
474                    .tracker
475                    .record_dispatch_reachability(worker_id, Instant::now())
476                {
477                    warn!(
478                        %error,
479                        connection_pid = pid,
480                        worker_id = worker_id.value(),
481                        "failed to record liminal worker dispatch reachability from a liveness answer"
482                    );
483                }
484                return;
485            }
486        };
487        // A probation is CONSECUTIVE, so any failure resets it to zero. Without
488        // this the counter would be cumulative, and a link that answers one probe
489        // in three would still accrue its way to eligibility and then flap in and
490        // out of it forever — which is exactly the defect the probation exists to
491        // stop. Both failure classes below reset: whether we could not ask or the
492        // worker did not answer, the run of answers is broken either way.
493        // `Ok(false)` means the worker was already deregistered; nothing to reset.
494        if let Err(error) = self.tracker.record_dispatch_unreachable(worker_id) {
495            warn!(
496                %error,
497                connection_pid = pid,
498                worker_id = worker_id.value(),
499                "failed to reset liminal worker dispatch probation after a failed liveness ping; \
500                 its eligibility may outlive the proof that earned it"
501            );
502        }
503        // The two failures are DIFFERENT FACTS and the operator must be able to
504        // tell them apart: one is about the worker, the other is about us.
505        let task_queue = self
506            .task_queue_of(worker_id)
507            .unwrap_or_else(|| "<unregistered>".to_owned());
508        match failure {
509            PingFailure::Unaskable(reason) => warn!(
510                connection_pid = pid,
511                worker_id = worker_id.value(),
512                task_queue = %task_queue,
513                liveness_ping = sequence,
514                reason = %reason,
515                silence_window_ms = self.silence_window.as_millis(),
516                "THE SERVER COULD NOT ASK this liminal worker for liveness — the push itself was \
517                 refused, so nothing was sent and the worker has no idea it was probed. A dispatch \
518                 would be refused by the same connection for the same reason. This says nothing \
519                 about whether the worker is healthy; it says this server cannot currently reach \
520                 it. Its dispatch eligibility is withdrawn NOW and it must answer a full run of \
521                 consecutive pings to earn it back"
522            ),
523            PingFailure::Unanswered(reason) => warn!(
524                connection_pid = pid,
525                worker_id = worker_id.value(),
526                task_queue = %task_queue,
527                liveness_ping = sequence,
528                reason = %reason,
529                silence_window_ms = self.silence_window.as_millis(),
530                "liminal worker did not answer its liveness ping; the server could not prove it \
531                 can reach this worker's dispatch path, so its dispatch eligibility is withdrawn \
532                 NOW and it must answer a full run of consecutive pings to earn it back — an \
533                 unanswered probe is direct evidence about the push leg, not mere silence. NOTE: \
534                 the worker's connection lease may still be fresh — its liveness pump beats from a \
535                 background task and keeps proving the process is alive — so do NOT expect an \
536                 expiry sweep to reap it"
537            ),
538        }
539    }
540
541    /// The task queue a worker is registered on, for the WARN line. `None` when
542    /// the worker is no longer in the registry (already reaped).
543    fn task_queue_of(&self, worker_id: WorkerId) -> Option<String> {
544        self.registry
545            .worker_by_id(worker_id)
546            .ok()
547            .flatten()
548            .map(|handle| handle.task_queue().to_owned())
549    }
550}
551
552/// Push one ping and block for its correlated answer, bounded by `deadline`.
553///
554/// Runs on a blocking thread: the liminal push/await pair is thread-based, not
555/// async. Every failure is rendered as a reason string, because at this layer
556/// the distinction that matters is "answered" vs "did not answer" — the typed
557/// error text rides into the WARN verbatim.
558/// A change in one worker's dispatch standing that the operator must be told
559/// about — never the standing state itself, so a persistently excluded worker
560/// does not re-log every cadence.
561#[derive(Clone, Copy, Debug, PartialEq, Eq)]
562enum Announcement {
563    /// A freshly connected worker began serving its probation. Ordinary.
564    ProbationOpened {
565        /// Consecutive answers banked when the probation was announced.
566        answers: u32,
567    },
568    /// A worker that HELD eligibility lost it. An incident.
569    EligibilityWithdrawn,
570    /// A worker that was excluded is dispatchable again.
571    EligibilityRestored,
572}
573
574/// The whole truth table for one worker's standing between two rounds.
575///
576/// Pure and total on purpose: the four inputs are exhaustively enumerated in
577/// the tests, which is the only way to be sure the alarming half and the
578/// reassuring half are both reachable. The old code had no such function — the
579/// decision was inlined and only ever produced one of the three lines, so the
580/// missing two were invisible.
581const fn transition(
582    was_excluded: bool,
583    now_excluded: Option<DispatchExclusion>,
584) -> Option<Announcement> {
585    match (was_excluded, now_excluded) {
586        // Newly excluded. WHICH exclusion decides whether this is news.
587        (false, Some(DispatchExclusion::OpeningProbation { answers })) => {
588            Some(Announcement::ProbationOpened { answers })
589        }
590        (false, Some(DispatchExclusion::ReachabilityLost)) => {
591            Some(Announcement::EligibilityWithdrawn)
592        }
593        // Left the exclusion set: the all-clear.
594        (true, None) => Some(Announcement::EligibilityRestored),
595        // No CHANGE in standing, by either route: a worker still excluded (its
596        // exclusion was announced when it began, and repeating it every cadence
597        // is how a log stops being read), or one that was eligible and stayed
598        // eligible. Both are silence, for different reasons.
599        (true, Some(_)) | (false, None) => None,
600    }
601}
602
603/// Why one liveness round produced no proof of dispatch reachability.
604///
605/// The two cases are different facts about different parties and an operator
606/// must be able to act on the difference, so they are typed rather than folded
607/// into one string. Collapsing them is how the old WARN came to blame a worker
608/// for a connection the server had itself exhausted.
609#[derive(Debug)]
610enum PingFailure {
611    /// The server could not ASK. Push admission was refused, so no frame left
612    /// the server and the worker was never probed. A dispatch on this
613    /// connection would be refused identically. Says nothing about the worker.
614    Unaskable(String),
615    /// The server asked and got no usable answer. The push was admitted; the
616    /// reply did not arrive in the cadence, or did not decode.
617    Unanswered(String),
618}
619
620fn ping_one(
621    delivery: &LiminalWorkerDelivery,
622    payload: Vec<u8>,
623    deadline: Duration,
624) -> Result<LivenessPong, PingFailure> {
625    // The deadline is attached to the PUSH, not just to the wait. Without it the
626    // reply slot is reclaimed only by a consumed reply or a connection close, so
627    // abandoning an unanswered ping every cadence leaks one slot per round until
628    // the connection's push cap is exhausted and nothing — ping, dispatch or
629    // intervention — can be pushed to that worker again.
630    let awaiter = delivery
631        .push_payload_with_deadline(payload, deadline)
632        .map_err(|error| PingFailure::Unaskable(error.to_string()))?;
633    let reply = awaiter
634        .receive(deadline)
635        .map_err(|error| PingFailure::Unanswered(format!("no answer arrived: {error}")))?;
636    serde_json::from_slice(&reply)
637        .map_err(|error| PingFailure::Unanswered(format!("answer could not be decoded: {error}")))
638}
639
640#[cfg(test)]
641mod tests {
642    use std::sync::Arc;
643    use std::time::{Duration, Instant};
644
645    use super::super::heartbeat::{DISPATCH_PROBATION_PINGS, HeartbeatTracker};
646    use super::super::liminal_transport::LiminalConnectionNotifier;
647    use super::super::registry::{ConnectedWorkerRegistry, WorkerId};
648    use super::{
649        Announcement, DispatchExclusion, LivenessPing, LivenessPong, LivenessProbe, PingFailure,
650        transition,
651    };
652
653    /// The WHOLE truth table, enumerated. Four inputs, and every one of them is
654    /// asserted here — which is the only way to be sure both the alarming and
655    /// the reassuring outcomes are reachable.
656    ///
657    /// The version of this logic that shipped until 2026-08-05 could emit
658    /// exactly one of these lines. The other two were not wrong; they did not
659    /// exist, so an operator saw the withdrawal on every healthy worker start
660    /// and never saw the recovery that followed seconds later.
661    #[test]
662    fn every_standing_change_has_exactly_one_announcement() {
663        assert_eq!(
664            transition(
665                false,
666                Some(DispatchExclusion::OpeningProbation { answers: 1 })
667            ),
668            Some(Announcement::ProbationOpened { answers: 1 }),
669            "a fresh connection serving its probation is ORDINARY and must not be announced as a \
670             worker the server cannot reach"
671        );
672        assert_eq!(
673            transition(false, Some(DispatchExclusion::ReachabilityLost)),
674            Some(Announcement::EligibilityWithdrawn),
675            "losing eligibility that was actually held is the incident the WARN exists for"
676        );
677        assert_eq!(
678            transition(true, None),
679            Some(Announcement::EligibilityRestored),
680            "the all-clear must be said out loud — an alarm nobody cancels is read as ongoing"
681        );
682        assert_eq!(
683            transition(true, Some(DispatchExclusion::ReachabilityLost)),
684            None,
685            "a standing exclusion must not re-log every cadence"
686        );
687        assert_eq!(
688            transition(
689                true,
690                Some(DispatchExclusion::OpeningProbation { answers: 0 })
691            ),
692            None,
693            "including while a still-excluded worker is still serving its probation"
694        );
695        assert_eq!(
696            transition(false, None),
697            None,
698            "an eligible worker that stayed eligible is not news"
699        );
700    }
701
702    /// The ping/pong pair round-trips with stable field names — the cross-crate
703    /// wire contract with `aion-worker`'s mirror of these types. A drift here is
704    /// a wire break, so the exact JSON is pinned.
705    #[test]
706    fn the_liveness_pair_round_trips_through_json() -> Result<(), serde_json::Error> {
707        let ping = LivenessPing {
708            liveness_ping: 7,
709            silence_window_ms: 30_000,
710        };
711        let encoded = serde_json::to_string(&ping)?;
712        assert_eq!(encoded, r#"{"liveness_ping":7,"silence_window_ms":30000}"#);
713        assert_eq!(serde_json::from_str::<LivenessPing>(&encoded)?, ping);
714
715        let answer = LivenessPong { liveness_pong: 7 };
716        let encoded = serde_json::to_string(&answer)?;
717        assert_eq!(encoded, r#"{"liveness_pong":7}"#);
718        assert_eq!(serde_json::from_str::<LivenessPong>(&encoded)?, answer);
719        Ok(())
720    }
721
722    /// A ping decodes as NEITHER of the other two frames that share the push
723    /// channel, and neither of them decodes as a ping — the demux contract the
724    /// worker's serve loop relies on.
725    #[test]
726    fn a_liveness_ping_is_disjoint_from_the_other_pushed_frames() -> Result<(), serde_json::Error> {
727        let ping = serde_json::to_vec(&LivenessPing {
728            liveness_ping: 1,
729            silence_window_ms: 1_000,
730        })?;
731        assert!(
732            serde_json::from_slice::<super::super::liminal_transport::DispatchRequest>(&ping)
733                .is_err(),
734            "a liveness ping must never decode as a dispatch"
735        );
736        assert!(
737            serde_json::from_slice::<super::super::liminal_transport::InterventionRequest>(&ping)
738                .is_err(),
739            "a liveness ping must never decode as an intervention"
740        );
741        Ok(())
742    }
743
744    /// Both timings derive from the operator's heartbeat window, with no
745    /// separate knob: the cadence is the sweeper's quarter-window derivation and
746    /// the declared silence window is the heartbeat window itself.
747    #[test]
748    fn probe_timings_derive_from_the_heartbeat_window() {
749        let window = Duration::from_secs(30);
750        assert_eq!(super::sweep_interval(window), Duration::from_millis(7_500));
751        // The declared window IS the operator's window; the cadence divides it,
752        // so a healthy connection is refreshed four times per window.
753        assert!(super::sweep_interval(window) * 4 <= window);
754    }
755
756    /// 🔴 THE WIRING PIN. The probation lives in the tracker, but only the probe
757    /// can tell it a ping FAILED — and a probe that recorded successes and
758    /// dropped failures would compile, log its WARN lines exactly as it does
759    /// now, and leave the probation permanently unreset. The eligibility bug
760    /// would be silently back with every test in `heartbeat` still green,
761    /// because those tests drive the tracker directly and never go through this
762    /// seam.
763    ///
764    /// Every failure class is asserted individually. The fourth arm — a
765    /// `JoinError` from the ping task — converges on the same
766    /// `PingFailure::Unanswered` path these two take, one line above the reset.
767    #[test]
768    fn every_failed_probe_class_withdraws_dispatch_eligibility() {
769        let window = Duration::from_secs(30);
770        let worker = WorkerId::from_value(1);
771        let start = Instant::now();
772
773        // Each class gets its own probe and its own served probation, so a class
774        // cannot pass by inheriting the withdrawal an earlier class performed.
775        let failures = [
776            (
777                "the push was refused, so nothing was even asked",
778                Ok(Err(PingFailure::Unaskable("push refused".to_owned()))),
779            ),
780            (
781                "the ping was sent and no answer came back",
782                Ok(Err(PingFailure::Unanswered("no answer arrived".to_owned()))),
783            ),
784            (
785                "an answer came back carrying the wrong sequence",
786                Ok(Ok(LivenessPong { liveness_pong: 99 })),
787            ),
788        ];
789
790        for (class, outcome) in failures {
791            let registry = ConnectedWorkerRegistry::default();
792            let tracker = HeartbeatTracker::new(window);
793            let probe = LivenessProbe::new(
794                Arc::new(LiminalConnectionNotifier::new(registry.clone())),
795                tracker.clone(),
796                registry,
797                window,
798            );
799
800            assert!(
801                tracker.register_connection(worker, start).is_ok(),
802                "tracker registration must succeed for {class}"
803            );
804            for _ in 0..DISPATCH_PROBATION_PINGS {
805                assert!(
806                    tracker
807                        .record_dispatch_reachability(worker, start)
808                        .is_ok_and(|tracked| tracked),
809                    "the worker serves its probation before {class}"
810                );
811            }
812            assert!(
813                tracker
814                    .is_dispatch_reachable(worker, start)
815                    .is_ok_and(|reachable| reachable),
816                "precondition: the worker is eligible before {class}"
817            );
818
819            // Sequence 1 is the ping that was sent; the mismatched-answer case
820            // deliberately answers 99.
821            probe.apply_answer(7, worker, 1, outcome);
822
823            assert!(
824                tracker
825                    .is_dispatch_reachable(worker, start)
826                    .is_ok_and(|reachable| !reachable),
827                "the probe must withdraw dispatch eligibility when {class} — otherwise the \
828                 probation never resets and a one-way link keeps its eligibility forever"
829            );
830        }
831    }
832
833    /// The control for the pin above: the SUCCESS path through the same seam
834    /// must keep eligibility. Without it, a probe that withdrew eligibility on
835    /// every outcome — including healthy answers — would satisfy every
836    /// assertion above and strand every worker on the fleet.
837    #[test]
838    fn an_answered_probe_keeps_dispatch_eligibility_through_the_same_seam() {
839        let window = Duration::from_secs(30);
840        let worker = WorkerId::from_value(1);
841        let start = Instant::now();
842        let registry = ConnectedWorkerRegistry::default();
843        let tracker = HeartbeatTracker::new(window);
844        let probe = LivenessProbe::new(
845            Arc::new(LiminalConnectionNotifier::new(registry.clone())),
846            tracker.clone(),
847            registry,
848            window,
849        );
850
851        assert!(tracker.register_connection(worker, start).is_ok());
852        for sequence in 1..=u64::from(DISPATCH_PROBATION_PINGS) {
853            probe.apply_answer(
854                7,
855                worker,
856                sequence,
857                Ok(Ok(LivenessPong {
858                    liveness_pong: sequence,
859                })),
860            );
861        }
862
863        assert!(
864            tracker
865                .is_dispatch_reachable(worker, start)
866                .is_ok_and(|reachable| reachable),
867            "answered probes must EARN eligibility through the probe seam, not merely fail to \
868             withdraw it"
869        );
870    }
871}