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 /// # A KNOWN FALSE RED IN THE WITHDRAWAL, STATED PLAINLY
503 ///
504 /// A gRPC worker running at its full concurrency stops reading its task
505 /// stream: the worker runtime's receive loop waits for a concurrency permit
506 /// before it reads the next frame, so a ping already on the wire is not
507 /// answered until a permit frees. A worker saturated for longer than one
508 /// probe cadence therefore LOOKS silent while it is in fact working, and
509 /// this WARN will say its dispatch path is unreachable when the truth is
510 /// that it is busy. The line is honest about what was measured — no answer
511 /// arrived — and wrong about what that means.
512 ///
513 /// It is bounded and self-healing, and no operator action is required:
514 /// eligibility is withdrawn, so no further dispatch is sent to that worker;
515 /// the worker finishes its in-flight activities and its permits free; it
516 /// reads and answers the next ping; it serves its two-ping probation and
517 /// returns to the eligible set with the INFO all-clear. In-flight work is
518 /// untouched throughout — this verdict governs who may be SELECTED, never
519 /// what happens to work already dispatched.
520 ///
521 /// The fix is on the admission path (the receive loop must read a frame
522 /// before it waits for a permit), which is a change to how work is
523 /// ACCEPTED, not to how liveness is measured, and it is board item #206.
524 /// This paragraph is struck by the change that lands it.
525 ///
526 /// It is written here rather than left to the reviewer's notes because a
527 /// verdict must not lie about what it measured: an operator reading a
528 /// withdrawal for a saturated worker deserves to find, at the place the
529 /// reason is minted, that this case is known, is not their fault, and needs
530 /// nothing from them.
531 fn publish_reachability_verdict(&self, sequence: u64) {
532 let now = Instant::now();
533 let measured = match self.tracker.unreachable_workers(now) {
534 Ok(workers) => workers,
535 Err(error) => {
536 warn!(
537 %error,
538 liveness_ping = sequence,
539 "could not read worker reachability; leaving the previous dispatch \
540 eligibility verdict in place rather than guessing"
541 );
542 return;
543 }
544 };
545 let Some(unreachable) = self.within_reach(&measured) else {
546 warn!(
547 liveness_ping = sequence,
548 "could not read which transport each unreachable worker is delivered over, so \
549 this round cannot tell an exclusion it is entitled to publish from one it has \
550 no wire to test; the previous verdict stands unchanged"
551 );
552 return;
553 };
554 // 🔴 The exclusion travels WITH the worker id into the published
555 // verdict. It used to be dropped here — `.map(|e| e.worker_id)` into a
556 // bare set — which left the registry unable to tell a worker serving
557 // its opening probation from one whose reachability was lost. Those are
558 // the two facts `DispatchExclusion`'s own documentation calls facts "an
559 // operator must be able to tell apart", and collapsing them is what let
560 // a fan-out row park on a non-clearing exclusion with nothing published
561 // about why. The value was already in hand; only this line threw it out.
562 let excluded_now: BTreeMap<WorkerId, DispatchExclusion> = unreachable
563 .iter()
564 .map(|excluded| (excluded.worker_id, excluded.exclusion))
565 .collect();
566 // The previous verdict is what makes a transition a transition. If it
567 // cannot be read the announcements are SKIPPED rather than guessed —
568 // assuming an empty previous set would re-announce every standing
569 // exclusion as though it had just happened. The verdict itself still
570 // publishes below: gating dispatch is the load-bearing half, and it
571 // must not be dropped because the narration failed.
572 match self.registry.dispatch_ineligible() {
573 Ok(previously_excluded) => {
574 self.announce_transitions(
575 sequence,
576 &previously_excluded,
577 &unreachable,
578 &excluded_now,
579 );
580 }
581 Err(error) => warn!(
582 %error,
583 liveness_ping = sequence,
584 "could not read the published liminal dispatch eligibility set; this round's \
585 eligibility changes go UNANNOUNCED, though the verdict itself is still published"
586 ),
587 }
588 if let Err(error) = self.registry.set_dispatch_ineligible(excluded_now) {
589 warn!(
590 %error,
591 liveness_ping = sequence,
592 "could not publish worker dispatch eligibility; selection keeps the \
593 previous verdict"
594 );
595 }
596 }
597
598 /// Narrow the tracker's evidence to the workers this probe is ENTITLED to
599 /// judge: those delivered over a transport it carries a wire for (#25).
600 ///
601 /// A worker of an uncarried transport is dropped — not because it is
602 /// reachable (nothing here knows that) but because this probe has no way to
603 /// find out, and an exclusion it could never lift is a permanent withdrawal
604 /// dressed as a measurement. A worker that has left the registry is dropped
605 /// for the same reason: it has no transport at all, selection can no longer
606 /// see it, and naming it would put a fabricated fact in an operator-facing
607 /// set.
608 ///
609 /// `None` when the registry cannot be read: the caller must then publish
610 /// NOTHING. Falling back to the unscoped set would reinstate the defect at
611 /// exactly the moment the server is least able to explain itself.
612 fn within_reach(&self, measured: &[ExcludedWorker]) -> Option<Vec<ExcludedWorker>> {
613 let transports = self
614 .registry
615 .transports_of(measured.iter().map(|excluded| excluded.worker_id))
616 .map_err(|error| warn!(%error, "could not read worker transports"))
617 .ok()?;
618 Some(
619 measured
620 .iter()
621 .filter(|excluded| {
622 transports
623 .get(&excluded.worker_id)
624 .is_some_and(|transport| self.covers(*transport))
625 })
626 .copied()
627 .collect(),
628 )
629 }
630
631 /// Whether this probe holds a wire on which a worker of `transport` could be
632 /// asked its liveness question.
633 ///
634 /// Structural, and deliberately not a function of how many targets a round
635 /// enumerated: a liminal worker whose connection has closed is enumerated by
636 /// nothing and is precisely the worker a liminal-carrying probe must keep
637 /// excluded.
638 const fn covers(&self, transport: WorkerTransport) -> bool {
639 match transport {
640 WorkerTransport::Grpc => self.grpc.is_some(),
641 WorkerTransport::Liminal => self.notifier.is_some(),
642 }
643 }
644
645 /// Say what changed this round — and only what changed, so a persistently
646 /// excluded worker does not re-log every cadence.
647 fn announce_transitions(
648 &self,
649 sequence: u64,
650 previously_excluded: &BTreeMap<WorkerId, DispatchExclusion>,
651 unreachable: &[ExcludedWorker],
652 excluded_now: &BTreeMap<WorkerId, DispatchExclusion>,
653 ) {
654 for excluded in unreachable {
655 let Some(announcement) = transition(
656 previously_excluded.contains_key(&excluded.worker_id),
657 Some(excluded.exclusion),
658 ) else {
659 continue;
660 };
661 self.say(sequence, excluded.worker_id, announcement);
662 }
663 for worker_id in previously_excluded
664 .keys()
665 .filter(|worker_id| !excluded_now.contains_key(worker_id))
666 {
667 // A worker that DEPARTED is not a worker that recovered. The
668 // registry read is the discriminator: an entry no longer in it left
669 // the fleet, and announcing its recovery would be a fabrication.
670 let Some(task_queue) = self.task_queue_of(*worker_id) else {
671 continue;
672 };
673 let Some(announcement) = transition(true, None) else {
674 continue;
675 };
676 self.say_with_queue(sequence, *worker_id, &task_queue, announcement);
677 }
678 }
679
680 /// Emit one announcement, resolving the worker's queue for the line.
681 fn say(&self, sequence: u64, worker_id: WorkerId, announcement: Announcement) {
682 let task_queue = self
683 .task_queue_of(worker_id)
684 .unwrap_or_else(|| "<unregistered>".to_owned());
685 self.say_with_queue(sequence, worker_id, &task_queue, announcement);
686 }
687
688 /// Emit one announcement against an already-resolved queue.
689 fn say_with_queue(
690 &self,
691 sequence: u64,
692 worker_id: WorkerId,
693 task_queue: &str,
694 announcement: Announcement,
695 ) {
696 let transport = self.transport_of(worker_id);
697 match announcement {
698 Announcement::ProbationOpened { answers } => info!(
699 worker_id = worker_id.value(),
700 task_queue,
701 transport,
702 liveness_ping = sequence,
703 answers_banked = answers,
704 answers_required = DISPATCH_PROBATION_PINGS,
705 "worker is SERVING ITS DISPATCH PROBATION: it has answered {answers} of \
706 {DISPATCH_PROBATION_PINGS} consecutive liveness pings since it connected, and is \
707 not selected for dispatch until the run is complete. This is the ordinary cost of \
708 connecting — every healthy worker start passes through it, on every transport — \
709 not a fault, and not a statement that anything is unreachable"
710 ),
711 Announcement::EligibilityWithdrawn => warn!(
712 worker_id = worker_id.value(),
713 task_queue,
714 transport,
715 liveness_ping = sequence,
716 answers_required = DISPATCH_PROBATION_PINGS,
717 silence_window_ms = self.silence_window.as_millis(),
718 "WITHDRAWING DISPATCH ELIGIBILITY from worker: it had PROVED its dispatch path \
719 reachable on this connection and the server can no longer prove it — either a \
720 liveness ping failed or the last proof aged out of the window. It stays \
721 registered and keeps its in-flight work, and becomes eligible again after \
722 {DISPATCH_PROBATION_PINGS} consecutive answered pings"
723 ),
724 Announcement::EligibilityRestored => info!(
725 worker_id = worker_id.value(),
726 task_queue,
727 transport,
728 liveness_ping = sequence,
729 "DISPATCH ELIGIBILITY RESTORED to worker: it has answered a full run of \
730 consecutive liveness pings, so the server can again prove it reaches this \
731 worker's dispatch path. Dispatch selection includes it from now"
732 ),
733 }
734 }
735
736 /// The transport a worker is delivered over, for the announcement line.
737 ///
738 /// `"<unregistered>"` when the worker has left the registry or the registry
739 /// cannot be read — a departed worker's transport is genuinely unknowable
740 /// here, and naming one would be a fabrication.
741 fn transport_of(&self, worker_id: WorkerId) -> &'static str {
742 match self.registry.worker_by_id(worker_id) {
743 Ok(Some(handle)) => handle.delivery().transport().name(),
744 Ok(None) | Err(_) => "<unregistered>",
745 }
746 }
747
748 /// Apply one connection's ping outcome: a correct answer advances dispatch
749 /// reachability, anything else is logged LOUDLY, proves nothing, and RESETS
750 /// the probation — proof of reachability must be a consecutive run.
751 fn apply_answer(
752 &self,
753 pid: u64,
754 worker_id: WorkerId,
755 sequence: u64,
756 outcome: Result<Result<LivenessPong, PingFailure>, tokio::task::JoinError>,
757 ) {
758 let failure = match outcome {
759 Err(join_error) => {
760 PingFailure::Unanswered(format!("ping task failed to run: {join_error}"))
761 }
762 Ok(Err(failure)) => failure,
763 Ok(Ok(pong)) if pong.liveness_pong != sequence => PingFailure::Unanswered(format!(
764 "worker answered with mismatched sequence {}",
765 pong.liveness_pong
766 )),
767 Ok(Ok(_)) => {
768 self.record_reachable(ProbedTransport::Liminal { pid }, worker_id);
769 return;
770 }
771 };
772 self.record_unreachable(
773 ProbedTransport::Liminal { pid },
774 worker_id,
775 sequence,
776 failure,
777 );
778 }
779
780 /// The gRPC counterpart of [`Self::apply_answer`] (#197).
781 ///
782 /// Deliberately the same two outcomes fed into the same two recorders: the
783 /// only gRPC-specific step is that a mismatched echo cannot reach here at
784 /// all, because the correlation registry
785 /// ([`GrpcLivenessWaiters::answer`](super::grpc_liveness::GrpcLivenessWaiters::answer))
786 /// refuses to match one — so an echo that arrives is by construction the
787 /// sequence that was sent.
788 fn apply_grpc_answer(
789 &self,
790 worker_id: WorkerId,
791 sequence: u64,
792 outcome: Result<u64, PingFailure>,
793 ) {
794 match outcome {
795 Ok(echoed) if echoed == sequence => {
796 self.record_reachable(ProbedTransport::Grpc, worker_id);
797 }
798 Ok(echoed) => self.record_unreachable(
799 ProbedTransport::Grpc,
800 worker_id,
801 sequence,
802 PingFailure::Unanswered(format!(
803 "worker answered with mismatched sequence {echoed}"
804 )),
805 ),
806 Err(failure) => {
807 self.record_unreachable(ProbedTransport::Grpc, worker_id, sequence, failure);
808 }
809 }
810 }
811
812 /// Bank one answered ping: the ONE thing that proves the server can reach
813 /// this worker's DISPATCH path.
814 ///
815 /// Only an answer advances reachability — an inbound frame proves the
816 /// opposite direction and cannot stand in for it. A `false` return from the
817 /// tracker means the worker was already deregistered (an answer racing a
818 /// reap); an answer must never resurrect it.
819 fn record_reachable(&self, transport: ProbedTransport, worker_id: WorkerId) {
820 if let Err(error) = self
821 .tracker
822 .record_dispatch_reachability(worker_id, Instant::now())
823 {
824 warn!(
825 %error,
826 connection_pid = transport.pid(),
827 transport = transport.name(),
828 worker_id = worker_id.value(),
829 "failed to record worker dispatch reachability from a liveness answer"
830 );
831 }
832 }
833
834 /// Record one FAILED ping and say which of the two failures it was.
835 ///
836 /// A probation is CONSECUTIVE, so any failure resets it to zero. Without
837 /// this the counter would be cumulative, and a link that answers one probe
838 /// in three would still accrue its way to eligibility and then flap in and
839 /// out of it forever — which is exactly the defect the probation exists to
840 /// stop. Both failure classes reset: whether we could not ask or the worker
841 /// did not answer, the run of answers is broken either way. `Ok(false)`
842 /// means the worker was already deregistered; nothing to reset.
843 fn record_unreachable(
844 &self,
845 transport: ProbedTransport,
846 worker_id: WorkerId,
847 sequence: u64,
848 failure: PingFailure,
849 ) {
850 if let Err(error) = self.tracker.record_dispatch_unreachable(worker_id) {
851 warn!(
852 %error,
853 connection_pid = transport.pid(),
854 transport = transport.name(),
855 worker_id = worker_id.value(),
856 "failed to reset worker dispatch probation after a failed liveness ping; \
857 its eligibility may outlive the proof that earned it"
858 );
859 }
860 // The two failures are DIFFERENT FACTS and the operator must be able to
861 // tell them apart: one is about the worker, the other is about us.
862 let task_queue = self
863 .task_queue_of(worker_id)
864 .unwrap_or_else(|| "<unregistered>".to_owned());
865 let transport_name = transport.name();
866 match failure {
867 PingFailure::Unaskable(reason) => warn!(
868 connection_pid = transport.pid(),
869 transport = transport_name,
870 worker_id = worker_id.value(),
871 task_queue = %task_queue,
872 liveness_ping = sequence,
873 reason = %reason,
874 silence_window_ms = self.silence_window.as_millis(),
875 "THE SERVER COULD NOT ASK this {transport_name} worker for liveness — the push \
876 itself was refused, so nothing was sent and the worker has no idea it was probed. \
877 A dispatch would be refused by the same channel for the same reason. This says \
878 nothing about whether the worker is healthy; it says this server cannot currently \
879 reach it. Its dispatch eligibility is withdrawn NOW and it must answer a full run \
880 of consecutive pings to earn it back"
881 ),
882 PingFailure::Unanswered(reason) => warn!(
883 connection_pid = transport.pid(),
884 transport = transport_name,
885 worker_id = worker_id.value(),
886 task_queue = %task_queue,
887 liveness_ping = sequence,
888 reason = %reason,
889 silence_window_ms = self.silence_window.as_millis(),
890 "{transport_name} worker did not answer its liveness ping; the server could not \
891 prove it can reach this worker's dispatch path, so its dispatch eligibility is \
892 withdrawn NOW and it must answer a full run of consecutive pings to earn it back \
893 — an unanswered probe is direct evidence about the push leg, not mere silence. \
894 NOTE: the worker's connection lease may still be fresh — its liveness pump beats \
895 from a background task and keeps proving the process is alive — so do NOT expect \
896 an expiry sweep to reap it"
897 ),
898 }
899 }
900
901 /// The task queue a worker is registered on, for the WARN line. `None` when
902 /// the worker is no longer in the registry (already reaped).
903 fn task_queue_of(&self, worker_id: WorkerId) -> Option<String> {
904 self.registry
905 .worker_by_id(worker_id)
906 .ok()
907 .flatten()
908 .map(|handle| handle.task_queue().to_owned())
909 }
910}
911
912/// Push one ping and block for its correlated answer, bounded by `deadline`.
913///
914/// Runs on a blocking thread: the liminal push/await pair is thread-based, not
915/// async. Every failure is rendered as a reason string, because at this layer
916/// the distinction that matters is "answered" vs "did not answer" — the typed
917/// error text rides into the WARN verbatim.
918/// A change in one worker's dispatch standing that the operator must be told
919/// about — never the standing state itself, so a persistently excluded worker
920/// does not re-log every cadence.
921#[derive(Clone, Copy, Debug, PartialEq, Eq)]
922enum Announcement {
923 /// A freshly connected worker began serving its probation. Ordinary.
924 ProbationOpened {
925 /// Consecutive answers banked when the probation was announced.
926 answers: u32,
927 },
928 /// A worker that HELD eligibility lost it. An incident.
929 EligibilityWithdrawn,
930 /// A worker that was excluded is dispatchable again.
931 EligibilityRestored,
932}
933
934/// The whole truth table for one worker's standing between two rounds.
935///
936/// Pure and total on purpose: the four inputs are exhaustively enumerated in
937/// the tests, which is the only way to be sure the alarming half and the
938/// reassuring half are both reachable. The old code had no such function — the
939/// decision was inlined and only ever produced one of the three lines, so the
940/// missing two were invisible.
941const fn transition(
942 was_excluded: bool,
943 now_excluded: Option<DispatchExclusion>,
944) -> Option<Announcement> {
945 match (was_excluded, now_excluded) {
946 // Newly excluded. WHICH exclusion decides whether this is news.
947 (false, Some(DispatchExclusion::OpeningProbation { answers })) => {
948 Some(Announcement::ProbationOpened { answers })
949 }
950 (false, Some(DispatchExclusion::ReachabilityLost)) => {
951 Some(Announcement::EligibilityWithdrawn)
952 }
953 // Left the exclusion set: the all-clear.
954 (true, None) => Some(Announcement::EligibilityRestored),
955 // No CHANGE in standing, by either route: a worker still excluded (its
956 // exclusion was announced when it began, and repeating it every cadence
957 // is how a log stops being read), or one that was eligible and stayed
958 // eligible. Both are silence, for different reasons.
959 (true, Some(_)) | (false, None) => None,
960 }
961}
962
963fn ping_one(
964 delivery: &LiminalWorkerDelivery,
965 payload: Vec<u8>,
966 deadline: Duration,
967) -> Result<LivenessPong, PingFailure> {
968 // The deadline is attached to the PUSH, not just to the wait. Without it the
969 // reply slot is reclaimed only by a consumed reply or a connection close, so
970 // abandoning an unanswered ping every cadence leaks one slot per round until
971 // the connection's push cap is exhausted and nothing — ping, dispatch or
972 // intervention — can be pushed to that worker again.
973 let awaiter = delivery
974 .push_payload_with_deadline(payload, deadline)
975 .map_err(|error| PingFailure::Unaskable(error.to_string()))?;
976 let reply = awaiter
977 .receive(deadline)
978 .map_err(|error| PingFailure::Unanswered(format!("no answer arrived: {error}")))?;
979 serde_json::from_slice(&reply)
980 .map_err(|error| PingFailure::Unanswered(format!("answer could not be decoded: {error}")))
981}
982
983#[cfg(test)]
984mod tests {
985 use crate::namespace::NamespaceGuard;
986 use std::sync::Arc;
987 use std::time::{Duration, Instant};
988
989 use super::super::heartbeat::{DISPATCH_PROBATION_PINGS, HeartbeatTracker};
990 use super::super::liminal_transport::LiminalConnectionNotifier;
991 use super::super::registry::{ConnectedWorkerRegistry, WorkerId};
992 use super::{
993 Announcement, DispatchExclusion, LivenessPing, LivenessPong, LivenessProbe, PingFailure,
994 WorkerTransport, transition,
995 };
996
997 /// The WHOLE truth table, enumerated. Four inputs, and every one of them is
998 /// asserted here — which is the only way to be sure both the alarming and
999 /// the reassuring outcomes are reachable.
1000 ///
1001 /// The version of this logic that shipped until 2026-08-05 could emit
1002 /// exactly one of these lines. The other two were not wrong; they did not
1003 /// exist, so an operator saw the withdrawal on every healthy worker start
1004 /// and never saw the recovery that followed seconds later.
1005 /// The runtime a test notifier's admission is bound to. These tests never
1006 /// register a worker through the notifier, so the runtime is only ever
1007 /// named, never driven.
1008 fn test_admission_runtime() -> std::io::Result<tokio::runtime::Runtime> {
1009 tokio::runtime::Builder::new_current_thread()
1010 .enable_all()
1011 .build()
1012 }
1013
1014 #[test]
1015 fn every_standing_change_has_exactly_one_announcement() {
1016 assert_eq!(
1017 transition(
1018 false,
1019 Some(DispatchExclusion::OpeningProbation { answers: 1 })
1020 ),
1021 Some(Announcement::ProbationOpened { answers: 1 }),
1022 "a fresh connection serving its probation is ORDINARY and must not be announced as a \
1023 worker the server cannot reach"
1024 );
1025 assert_eq!(
1026 transition(false, Some(DispatchExclusion::ReachabilityLost)),
1027 Some(Announcement::EligibilityWithdrawn),
1028 "losing eligibility that was actually held is the incident the WARN exists for"
1029 );
1030 assert_eq!(
1031 transition(true, None),
1032 Some(Announcement::EligibilityRestored),
1033 "the all-clear must be said out loud — an alarm nobody cancels is read as ongoing"
1034 );
1035 assert_eq!(
1036 transition(true, Some(DispatchExclusion::ReachabilityLost)),
1037 None,
1038 "a standing exclusion must not re-log every cadence"
1039 );
1040 assert_eq!(
1041 transition(
1042 true,
1043 Some(DispatchExclusion::OpeningProbation { answers: 0 })
1044 ),
1045 None,
1046 "including while a still-excluded worker is still serving its probation"
1047 );
1048 assert_eq!(
1049 transition(false, None),
1050 None,
1051 "an eligible worker that stayed eligible is not news"
1052 );
1053 }
1054
1055 /// THE WHOLE COVERAGE TABLE, enumerated (#25). Two transports by two
1056 /// wire-holdings, and every cell asserted.
1057 ///
1058 /// Coverage is what decides whether this probe may publish an exclusion for
1059 /// a worker at all, so a cell that silently answered `true` would hand a
1060 /// permanent withdrawal to a fleet the probe has no way to ask — the whole
1061 /// injury. Enumerating it here is the only way to be sure the deny half and
1062 /// the admit half are BOTH reachable: a `covers` that returned `true`
1063 /// unconditionally is exactly the pre-fix behaviour, and it satisfies any
1064 /// test that only checks the admitting direction.
1065 #[test]
1066 fn a_probe_covers_exactly_the_transports_it_holds_a_wire_for() -> std::io::Result<()> {
1067 let admission_runtime = test_admission_runtime()?;
1068 let window = Duration::from_secs(30);
1069 let registry = ConnectedWorkerRegistry::default();
1070 let tracker = HeartbeatTracker::new(window);
1071 let notifier = || {
1072 Arc::new(
1073 LiminalConnectionNotifier::new(registry.clone()).with_admission(
1074 NamespaceGuard::shared_engine(),
1075 false,
1076 admission_runtime.handle().clone(),
1077 ),
1078 )
1079 };
1080 let build = |liminal: bool, grpc: bool| {
1081 LivenessProbe::across_transports(
1082 liminal.then(notifier),
1083 grpc.then(super::GrpcLivenessWaiters::new),
1084 tracker.clone(),
1085 registry.clone(),
1086 window,
1087 )
1088 };
1089
1090 let both = build(true, true);
1091 assert!(both.covers(WorkerTransport::Liminal));
1092 assert!(both.covers(WorkerTransport::Grpc));
1093
1094 let liminal_only = build(true, false);
1095 assert!(liminal_only.covers(WorkerTransport::Liminal));
1096 assert!(
1097 !liminal_only.covers(WorkerTransport::Grpc),
1098 "a probe holding no gRPC answer registry cannot hear a gRPC answer, so it must not \
1099 judge a gRPC worker: the exclusion it would publish could never be lifted"
1100 );
1101
1102 let grpc_only = build(false, true);
1103 assert!(
1104 !grpc_only.covers(WorkerTransport::Liminal),
1105 "and the same in the other direction — the reach follows the wire, not the fleet"
1106 );
1107 assert!(grpc_only.covers(WorkerTransport::Grpc));
1108
1109 // Not constructible in production (the composition root always supplies
1110 // at least the liminal notifier), but the fourth cell of a truth table
1111 // is not optional: a probe with no wire at all judges nobody.
1112 let neither = build(false, false);
1113 assert!(!neither.covers(WorkerTransport::Liminal));
1114 assert!(!neither.covers(WorkerTransport::Grpc));
1115 Ok(())
1116 }
1117
1118 /// The ping/pong pair round-trips with stable field names — the cross-crate
1119 /// wire contract with `aion-worker`'s mirror of these types. A drift here is
1120 /// a wire break, so the exact JSON is pinned.
1121 #[test]
1122 fn the_liveness_pair_round_trips_through_json() -> Result<(), serde_json::Error> {
1123 let ping = LivenessPing {
1124 liveness_ping: 7,
1125 silence_window_ms: 30_000,
1126 };
1127 let encoded = serde_json::to_string(&ping)?;
1128 assert_eq!(encoded, r#"{"liveness_ping":7,"silence_window_ms":30000}"#);
1129 assert_eq!(serde_json::from_str::<LivenessPing>(&encoded)?, ping);
1130
1131 let answer = LivenessPong { liveness_pong: 7 };
1132 let encoded = serde_json::to_string(&answer)?;
1133 assert_eq!(encoded, r#"{"liveness_pong":7}"#);
1134 assert_eq!(serde_json::from_str::<LivenessPong>(&encoded)?, answer);
1135 Ok(())
1136 }
1137
1138 /// A ping decodes as NEITHER of the other two frames that share the push
1139 /// channel, and neither of them decodes as a ping — the demux contract the
1140 /// worker's serve loop relies on.
1141 #[test]
1142 fn a_liveness_ping_is_disjoint_from_the_other_pushed_frames() -> Result<(), serde_json::Error> {
1143 let ping = serde_json::to_vec(&LivenessPing {
1144 liveness_ping: 1,
1145 silence_window_ms: 1_000,
1146 })?;
1147 assert!(
1148 serde_json::from_slice::<super::super::liminal_transport::DispatchRequest>(&ping)
1149 .is_err(),
1150 "a liveness ping must never decode as a dispatch"
1151 );
1152 assert!(
1153 serde_json::from_slice::<super::super::liminal_transport::InterventionRequest>(&ping)
1154 .is_err(),
1155 "a liveness ping must never decode as an intervention"
1156 );
1157 Ok(())
1158 }
1159
1160 /// Both timings derive from the operator's heartbeat window, with no
1161 /// separate knob: the cadence is the sweeper's quarter-window derivation and
1162 /// the declared silence window is the heartbeat window itself.
1163 #[test]
1164 fn probe_timings_derive_from_the_heartbeat_window() {
1165 let window = Duration::from_secs(30);
1166 assert_eq!(super::sweep_interval(window), Duration::from_millis(7_500));
1167 // The declared window IS the operator's window; the cadence divides it,
1168 // so a healthy connection is refreshed four times per window.
1169 assert!(super::sweep_interval(window) * 4 <= window);
1170 }
1171
1172 /// 🔴 THE WIRING PIN. The probation lives in the tracker, but only the probe
1173 /// can tell it a ping FAILED — and a probe that recorded successes and
1174 /// dropped failures would compile, log its WARN lines exactly as it does
1175 /// now, and leave the probation permanently unreset. The eligibility bug
1176 /// would be silently back with every test in `heartbeat` still green,
1177 /// because those tests drive the tracker directly and never go through this
1178 /// seam.
1179 ///
1180 /// Every failure class is asserted individually. The fourth arm — a
1181 /// `JoinError` from the ping task — converges on the same
1182 /// `PingFailure::Unanswered` path these two take, one line above the reset.
1183 #[test]
1184 fn every_failed_probe_class_withdraws_dispatch_eligibility() -> std::io::Result<()> {
1185 let admission_runtime = test_admission_runtime()?;
1186 let window = Duration::from_secs(30);
1187 let worker = WorkerId::from_value(1);
1188 let start = Instant::now();
1189
1190 // Each class gets its own probe and its own served probation, so a class
1191 // cannot pass by inheriting the withdrawal an earlier class performed.
1192 let failures = [
1193 (
1194 "the push was refused, so nothing was even asked",
1195 Ok(Err(PingFailure::Unaskable("push refused".to_owned()))),
1196 ),
1197 (
1198 "the ping was sent and no answer came back",
1199 Ok(Err(PingFailure::Unanswered("no answer arrived".to_owned()))),
1200 ),
1201 (
1202 "an answer came back carrying the wrong sequence",
1203 Ok(Ok(LivenessPong { liveness_pong: 99 })),
1204 ),
1205 ];
1206
1207 for (class, outcome) in failures {
1208 let registry = ConnectedWorkerRegistry::default();
1209 let tracker = HeartbeatTracker::new(window);
1210 let probe = LivenessProbe::new(
1211 Arc::new(
1212 LiminalConnectionNotifier::new(registry.clone()).with_admission(
1213 NamespaceGuard::shared_engine(),
1214 false,
1215 admission_runtime.handle().clone(),
1216 ),
1217 ),
1218 tracker.clone(),
1219 registry,
1220 window,
1221 );
1222
1223 assert!(
1224 tracker.register_connection(worker, start).is_ok(),
1225 "tracker registration must succeed for {class}"
1226 );
1227 for _ in 0..DISPATCH_PROBATION_PINGS {
1228 assert!(
1229 tracker
1230 .record_dispatch_reachability(worker, start)
1231 .is_ok_and(|tracked| tracked),
1232 "the worker serves its probation before {class}"
1233 );
1234 }
1235 assert!(
1236 tracker
1237 .is_dispatch_reachable(worker, start)
1238 .is_ok_and(|reachable| reachable),
1239 "precondition: the worker is eligible before {class}"
1240 );
1241
1242 // Sequence 1 is the ping that was sent; the mismatched-answer case
1243 // deliberately answers 99.
1244 probe.apply_answer(7, worker, 1, outcome);
1245
1246 assert!(
1247 tracker
1248 .is_dispatch_reachable(worker, start)
1249 .is_ok_and(|reachable| !reachable),
1250 "the probe must withdraw dispatch eligibility when {class} — otherwise the \
1251 probation never resets and a one-way link keeps its eligibility forever"
1252 );
1253 }
1254 Ok(())
1255 }
1256
1257 /// The control for the pin above: the SUCCESS path through the same seam
1258 /// must keep eligibility. Without it, a probe that withdrew eligibility on
1259 /// every outcome — including healthy answers — would satisfy every
1260 /// assertion above and strand every worker on the fleet.
1261 #[test]
1262 fn an_answered_probe_keeps_dispatch_eligibility_through_the_same_seam() -> std::io::Result<()> {
1263 let admission_runtime = test_admission_runtime()?;
1264 let window = Duration::from_secs(30);
1265 let worker = WorkerId::from_value(1);
1266 let start = Instant::now();
1267 let registry = ConnectedWorkerRegistry::default();
1268 let tracker = HeartbeatTracker::new(window);
1269 let probe = LivenessProbe::new(
1270 Arc::new(
1271 LiminalConnectionNotifier::new(registry.clone()).with_admission(
1272 NamespaceGuard::shared_engine(),
1273 false,
1274 admission_runtime.handle().clone(),
1275 ),
1276 ),
1277 tracker.clone(),
1278 registry,
1279 window,
1280 );
1281
1282 assert!(tracker.register_connection(worker, start).is_ok());
1283 for sequence in 1..=u64::from(DISPATCH_PROBATION_PINGS) {
1284 probe.apply_answer(
1285 7,
1286 worker,
1287 sequence,
1288 Ok(Ok(LivenessPong {
1289 liveness_pong: sequence,
1290 })),
1291 );
1292 }
1293
1294 assert!(
1295 tracker
1296 .is_dispatch_reachable(worker, start)
1297 .is_ok_and(|reachable| reachable),
1298 "answered probes must EARN eligibility through the probe seam, not merely fail to \
1299 withdraw it"
1300 );
1301 Ok(())
1302 }
1303}