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}