Skip to main content

inferlab_runtime/server/
readiness.rs

1use super::{
2    ProcessHandle, ProcessObserver, ProcessStatus, ReadinessAttemptEvidence, ReadinessEvidence,
3    ReadinessFailure, ReadinessFailureKind, ReadinessObserver, SystemProcessRuntime,
4    TargetRegistryMatchEvidence,
5};
6use crate::interrupt;
7use crate::operation_bound::{AttemptBound, OperationBound, OperationTerminalCause, Remaining};
8use crate::plan::{ProcessEndpointPlan, ReadinessPlan, TargetRegistryExpectedTarget};
9use std::thread;
10use std::time::Duration;
11
12const POLL_INTERVAL: Duration = Duration::from_millis(100);
13const MAX_PROBE_INTERVAL: Duration = Duration::from_secs(5);
14pub(super) const READINESS_ATTEMPT_CAP: Duration = Duration::from_millis(250);
15
16#[derive(Debug, thiserror::Error)]
17pub(super) enum ReadinessProbeError {
18    #[error("readiness operation deadline expired")]
19    Deadline,
20    #[error("bounded readiness attempt was unexpectedly unbounded")]
21    UnexpectedUnbounded,
22    #[error("{label} request failed: {source}")]
23    Request {
24        label: String,
25        #[source]
26        source: reqwest::Error,
27    },
28    #[error("{label} returned HTTP {status}")]
29    HttpStatus { label: String, status: u16 },
30    #[error("{label} returned invalid JSON: {source}")]
31    InvalidJson {
32        label: String,
33        #[source]
34        source: serde_json::Error,
35    },
36    #[error("target registry observation mismatch: {details}")]
37    RegistryMismatch { details: String },
38}
39
40pub(super) fn ensure_alive(status: ProcessStatus) -> Result<(), ReadinessFailure> {
41    if !status.queried {
42        return Err(readiness_failure(
43            ReadinessFailureKind::Exited,
44            status
45                .error
46                .unwrap_or_else(|| "failed to query server process group".to_owned()),
47        ));
48    }
49    if !status.alive {
50        return Err(readiness_failure(
51            ReadinessFailureKind::Exited,
52            status
53                .error
54                .unwrap_or_else(|| "server process group exited before readiness".to_owned()),
55        ));
56    }
57    Ok(())
58}
59
60fn readiness_failure(kind: ReadinessFailureKind, message: String) -> ReadinessFailure {
61    ReadinessFailure {
62        kind,
63        message,
64        timing: None,
65        diagnostic_attempts: Vec::new(),
66    }
67}
68
69pub(super) fn timed_readiness_failure(
70    mut failure: ReadinessFailure,
71    bound: &OperationBound,
72    terminal_cause: OperationTerminalCause,
73    diagnostic_attempts: Vec<ReadinessAttemptEvidence>,
74) -> ReadinessFailure {
75    failure.timing = Some(bound.timing("before_readiness_wait", terminal_cause));
76    failure.diagnostic_attempts = diagnostic_attempts;
77    failure
78}
79
80pub(super) fn wait_http_ready<R: ProcessObserver>(
81    runtime: &R,
82    handle: &ProcessHandle,
83    endpoint: &ProcessEndpointPlan,
84    path: &str,
85    timeout_seconds: Option<u64>,
86    on_probe_failure: &mut dyn FnMut(&str),
87) -> Result<ReadinessEvidence, ReadinessFailure> {
88    // A capture-armed server carries no readiness deadline
89    // ([[RFC-0004:C-WORKLOAD-PROFILING]]); the loop still terminates on
90    // readiness, process-group exit, or interruption.
91    let bound = timeout_seconds
92        .map(|seconds| OperationBound::finite(Duration::from_secs(seconds)))
93        .unwrap_or_else(OperationBound::unbounded);
94    let url = format!("http://{}:{}{}", endpoint.host, endpoint.port, path);
95    let mut attempts = 0_u32;
96    let mut diagnostic_attempts = Vec::new();
97    // The probe cadence backs off from POLL_INTERVAL to a cap: sub-second
98    // detection for ordinary startups without tens of thousands of no-op
99    // probes across a capture-armed unbounded wait. The sleep is clamped to
100    // the remaining deadline so a configured timeout fires within one
101    // interval.
102    let mut probe_interval = POLL_INTERVAL;
103    loop {
104        ensure_readiness_active(&bound, timeout_seconds, "no readiness probe completed").map_err(
105            |failure| {
106                timed_readiness_failure(
107                    failure,
108                    &bound,
109                    OperationTerminalCause::TimedOut,
110                    diagnostic_attempts.clone(),
111                )
112            },
113        )?;
114        if interrupt::received() {
115            return Err(timed_readiness_failure(
116                readiness_failure(
117                    ReadinessFailureKind::Interrupted,
118                    "server startup was interrupted".to_owned(),
119                ),
120                &bound,
121                OperationTerminalCause::Interrupted,
122                diagnostic_attempts,
123            ));
124        }
125        let status = runtime.status_with_bound(handle, &bound);
126        ensure_readiness_active(
127            &bound,
128            timeout_seconds,
129            "the server process status attempt did not complete in time",
130        )
131        .map_err(|failure| {
132            timed_readiness_failure(
133                failure,
134                &bound,
135                OperationTerminalCause::TimedOut,
136                diagnostic_attempts.clone(),
137            )
138        })?;
139        if interrupt::received() {
140            return Err(timed_readiness_failure(
141                readiness_failure(
142                    ReadinessFailureKind::Interrupted,
143                    "server startup was interrupted".to_owned(),
144                ),
145                &bound,
146                OperationTerminalCause::Interrupted,
147                diagnostic_attempts,
148            ));
149        }
150        ensure_alive(status).map_err(|failure| {
151            timed_readiness_failure(
152                failure,
153                &bound,
154                OperationTerminalCause::Failed,
155                diagnostic_attempts.clone(),
156            )
157        })?;
158        attempts = attempts.saturating_add(1);
159        let attempt = probe_http_attempt(&endpoint.host, endpoint.port, path, &bound);
160        let effective_bound_ms = attempt.effective_bound_ms;
161        let last_error = match attempt.outcome {
162            Ok(()) => {
163                diagnostic_attempts = vec![ReadinessAttemptEvidence {
164                    operation: "http_readiness".to_owned(),
165                    effective_bound_ms,
166                    succeeded: true,
167                    error: None,
168                }];
169                let ready_unix_ms = unix_time_millis().map_err(|failure| {
170                    timed_readiness_failure(
171                        failure,
172                        &bound,
173                        OperationTerminalCause::Failed,
174                        diagnostic_attempts.clone(),
175                    )
176                })?;
177                ensure_readiness_active(
178                    &bound,
179                    timeout_seconds,
180                    "the readiness response completed after the deadline",
181                )
182                .map_err(|failure| {
183                    timed_readiness_failure(
184                        failure,
185                        &bound,
186                        OperationTerminalCause::TimedOut,
187                        diagnostic_attempts.clone(),
188                    )
189                })?;
190                return Ok(ReadinessEvidence::Http {
191                    url,
192                    attempts,
193                    ready_unix_ms,
194                    timing: bound
195                        .timing("before_readiness_wait", OperationTerminalCause::Succeeded),
196                    diagnostic_attempts,
197                });
198            }
199            Err(error) => {
200                let error = error.to_string();
201                diagnostic_attempts = vec![ReadinessAttemptEvidence {
202                    operation: "http_readiness".to_owned(),
203                    effective_bound_ms,
204                    succeeded: false,
205                    error: Some(error.clone()),
206                }];
207                error
208            }
209        };
210        on_probe_failure(&last_error);
211        if bound.is_expired() {
212            let timeout_seconds = timeout_seconds.unwrap_or_default();
213            return Err(timed_readiness_failure(
214                readiness_failure(
215                    ReadinessFailureKind::Timeout,
216                    format!(
217                        "server did not become ready within {timeout_seconds} seconds; last probe error: {last_error}"
218                    ),
219                ),
220                &bound,
221                OperationTerminalCause::TimedOut,
222                diagnostic_attempts,
223            ));
224        }
225        sleep_within_readiness(&bound, probe_interval);
226        probe_interval = (probe_interval * 2).min(MAX_PROBE_INTERVAL);
227    }
228}
229
230pub(super) struct HttpTargetRegistryProbe<'a> {
231    pub(super) readiness_path: &'a str,
232    pub(super) registry_path: &'a str,
233    pub(super) targets_field: &'a str,
234    pub(super) target_url_field: &'a str,
235    pub(super) target_role_field: &'a str,
236    pub(super) target_healthy_field: &'a str,
237    pub(super) target_bootstrap_port_field: &'a str,
238    pub(super) expected_targets: &'a [TargetRegistryExpectedTarget],
239}
240
241fn sleep_within_readiness(bound: &OperationBound, cadence: Duration) {
242    match bound.remaining() {
243        Remaining::Finite(remaining) => thread::sleep(cadence.min(remaining)),
244        Remaining::Expired => {}
245        Remaining::Unbounded => thread::sleep(cadence),
246    }
247}
248
249fn ensure_readiness_active(
250    bound: &OperationBound,
251    timeout_seconds: Option<u64>,
252    last_error: &str,
253) -> Result<(), ReadinessFailure> {
254    if !bound.is_expired() {
255        return Ok(());
256    }
257    Err(readiness_failure(
258        ReadinessFailureKind::Timeout,
259        format!(
260            "server did not become ready within {} seconds; last probe error: {last_error}",
261            timeout_seconds.unwrap_or_default()
262        ),
263    ))
264}
265
266fn attempt_remaining(attempt: &AttemptBound) -> Result<Duration, ReadinessProbeError> {
267    match attempt.remaining() {
268        Remaining::Finite(remaining) => Ok(remaining),
269        Remaining::Expired => Err(ReadinessProbeError::Deadline),
270        Remaining::Unbounded => Err(ReadinessProbeError::UnexpectedUnbounded),
271    }
272}
273
274pub(super) fn wait_http_target_registry_ready(
275    status: impl Fn(&OperationBound) -> ProcessStatus,
276    endpoint: &ProcessEndpointPlan,
277    probe: HttpTargetRegistryProbe<'_>,
278    timeout_seconds: Option<u64>,
279    on_probe_failure: &mut dyn FnMut(&str),
280) -> Result<ReadinessEvidence, ReadinessFailure> {
281    let bound = timeout_seconds
282        .map(|seconds| OperationBound::finite(Duration::from_secs(seconds)))
283        .unwrap_or_else(OperationBound::unbounded);
284    let readiness_url = format!(
285        "http://{}:{}{}",
286        endpoint.host, endpoint.port, probe.readiness_path
287    );
288    let registry_url = format!(
289        "http://{}:{}{}",
290        endpoint.host, endpoint.port, probe.registry_path
291    );
292    let mut attempts = 0_u32;
293    let mut diagnostic_attempts = Vec::new();
294    let mut probe_interval = POLL_INTERVAL;
295    loop {
296        ensure_readiness_active(&bound, timeout_seconds, "no readiness probe completed").map_err(
297            |failure| {
298                timed_readiness_failure(
299                    failure,
300                    &bound,
301                    OperationTerminalCause::TimedOut,
302                    diagnostic_attempts.clone(),
303                )
304            },
305        )?;
306        if interrupt::received() {
307            return Err(timed_readiness_failure(
308                readiness_failure(
309                    ReadinessFailureKind::Interrupted,
310                    "server startup was interrupted".to_owned(),
311                ),
312                &bound,
313                OperationTerminalCause::Interrupted,
314                diagnostic_attempts,
315            ));
316        }
317        let process_status = status(&bound);
318        ensure_readiness_active(
319            &bound,
320            timeout_seconds,
321            "the server process status attempt did not complete in time",
322        )
323        .map_err(|failure| {
324            timed_readiness_failure(
325                failure,
326                &bound,
327                OperationTerminalCause::TimedOut,
328                diagnostic_attempts.clone(),
329            )
330        })?;
331        if interrupt::received() {
332            return Err(timed_readiness_failure(
333                readiness_failure(
334                    ReadinessFailureKind::Interrupted,
335                    "server startup was interrupted".to_owned(),
336                ),
337                &bound,
338                OperationTerminalCause::Interrupted,
339                diagnostic_attempts,
340            ));
341        }
342        ensure_alive(process_status).map_err(|failure| {
343            timed_readiness_failure(
344                failure,
345                &bound,
346                OperationTerminalCause::Failed,
347                diagnostic_attempts.clone(),
348            )
349        })?;
350        attempts = attempts.saturating_add(1);
351        let public_attempt =
352            probe_http_attempt(&endpoint.host, endpoint.port, probe.readiness_path, &bound);
353        let public_effective_bound_ms = public_attempt.effective_bound_ms;
354        let last_error = match public_attempt.outcome {
355            Ok(()) => {
356                let registry_attempt =
357                    probe_target_registry_attempt(&endpoint.host, endpoint.port, &probe, &bound);
358                let registry_effective_bound_ms = registry_attempt.effective_bound_ms;
359                match registry_attempt.outcome {
360                    Ok(matched_targets) => {
361                        diagnostic_attempts = vec![
362                            ReadinessAttemptEvidence {
363                                operation: "public_http_readiness".to_owned(),
364                                effective_bound_ms: public_effective_bound_ms,
365                                succeeded: true,
366                                error: None,
367                            },
368                            ReadinessAttemptEvidence {
369                                operation: "target_registry".to_owned(),
370                                effective_bound_ms: registry_effective_bound_ms,
371                                succeeded: true,
372                                error: None,
373                            },
374                        ];
375                        let ready_unix_ms = unix_time_millis().map_err(|failure| {
376                            timed_readiness_failure(
377                                failure,
378                                &bound,
379                                OperationTerminalCause::Failed,
380                                diagnostic_attempts.clone(),
381                            )
382                        })?;
383                        ensure_readiness_active(
384                            &bound,
385                            timeout_seconds,
386                            "the target registry response completed after the deadline",
387                        )
388                        .map_err(|failure| {
389                            timed_readiness_failure(
390                                failure,
391                                &bound,
392                                OperationTerminalCause::TimedOut,
393                                diagnostic_attempts.clone(),
394                            )
395                        })?;
396                        return Ok(ReadinessEvidence::HttpTargetRegistry {
397                            readiness_url,
398                            registry_url,
399                            attempts,
400                            ready_unix_ms,
401                            matched_targets,
402                            timing: bound
403                                .timing("before_readiness_wait", OperationTerminalCause::Succeeded),
404                            diagnostic_attempts,
405                        });
406                    }
407                    Err(error) => {
408                        let error = error.to_string();
409                        diagnostic_attempts = vec![
410                            ReadinessAttemptEvidence {
411                                operation: "public_http_readiness".to_owned(),
412                                effective_bound_ms: public_effective_bound_ms,
413                                succeeded: true,
414                                error: None,
415                            },
416                            ReadinessAttemptEvidence {
417                                operation: "target_registry".to_owned(),
418                                effective_bound_ms: registry_effective_bound_ms,
419                                succeeded: false,
420                                error: Some(error.clone()),
421                            },
422                        ];
423                        error
424                    }
425                }
426            }
427            Err(error) => {
428                let error = error.to_string();
429                diagnostic_attempts = vec![ReadinessAttemptEvidence {
430                    operation: "public_http_readiness".to_owned(),
431                    effective_bound_ms: public_effective_bound_ms,
432                    succeeded: false,
433                    error: Some(error.clone()),
434                }];
435                format!("public readiness probe failed: {error}")
436            }
437        };
438        on_probe_failure(&last_error);
439        if bound.is_expired() {
440            let timeout_seconds = timeout_seconds.unwrap_or_default();
441            return Err(timed_readiness_failure(
442                readiness_failure(
443                    ReadinessFailureKind::Timeout,
444                    format!(
445                        "server did not become ready within {timeout_seconds} seconds; last probe error: {last_error}"
446                    ),
447                ),
448                &bound,
449                OperationTerminalCause::TimedOut,
450                diagnostic_attempts,
451            ));
452        }
453        sleep_within_readiness(&bound, probe_interval);
454        probe_interval = (probe_interval * 2).min(MAX_PROBE_INTERVAL);
455    }
456}
457
458fn probe_target_registry_attempt(
459    host: &str,
460    port: u16,
461    probe: &HttpTargetRegistryProbe<'_>,
462    bound: &OperationBound,
463) -> ProbeAttempt<Vec<TargetRegistryMatchEvidence>> {
464    let response =
465        probe_http_json_attempt(host, port, probe.registry_path, "target registry", bound);
466    let effective_bound_ms = response.effective_bound_ms;
467    let outcome = response
468        .outcome
469        .and_then(|response| match_target_registry(&response, probe, bound));
470    ProbeAttempt {
471        effective_bound_ms,
472        outcome,
473    }
474}
475
476pub(super) fn match_target_registry(
477    response: &serde_json::Value,
478    probe: &HttpTargetRegistryProbe<'_>,
479    bound: &OperationBound,
480) -> Result<Vec<TargetRegistryMatchEvidence>, ReadinessProbeError> {
481    readiness_remaining(bound)?;
482    let targets = response
483        .get(probe.targets_field)
484        .and_then(serde_json::Value::as_array)
485        .ok_or_else(|| ReadinessProbeError::RegistryMismatch {
486            details: format!(
487                "target registry response has no array field {:?}",
488                probe.targets_field
489            ),
490        })?;
491    let mut evidence = Vec::with_capacity(probe.expected_targets.len());
492    for expected in probe.expected_targets {
493        readiness_remaining(bound)?;
494        let matches: Vec<&serde_json::Map<String, serde_json::Value>> = targets
495            .iter()
496            .filter_map(serde_json::Value::as_object)
497            .filter(|target| {
498                target
499                    .get(probe.target_url_field)
500                    .and_then(serde_json::Value::as_str)
501                    == Some(expected.url.as_str())
502                    && target
503                        .get(probe.target_role_field)
504                        .and_then(serde_json::Value::as_str)
505                        == Some(expected.role.as_str())
506            })
507            .collect();
508        let target = match matches.as_slice() {
509            [] => {
510                return Err(ReadinessProbeError::RegistryMismatch {
511                    details: format!(
512                        "target registry has no {:?} target at {:?}",
513                        expected.role, expected.url
514                    ),
515                });
516            }
517            [target] => *target,
518            _ => {
519                return Err(ReadinessProbeError::RegistryMismatch {
520                    details: format!(
521                        "target registry has multiple {:?} targets at {:?}",
522                        expected.role, expected.url
523                    ),
524                });
525            }
526        };
527        let healthy = target
528            .get(probe.target_healthy_field)
529            .and_then(serde_json::Value::as_bool)
530            .ok_or_else(|| ReadinessProbeError::RegistryMismatch {
531                details: format!(
532                    "target registry entry for {:?} at {:?} has no boolean {:?} field",
533                    expected.role, expected.url, probe.target_healthy_field
534                ),
535            })?;
536        if !healthy {
537            return Err(ReadinessProbeError::RegistryMismatch {
538                details: format!(
539                    "target registry entry for {:?} at {:?} is not healthy",
540                    expected.role, expected.url
541                ),
542            });
543        }
544        let bootstrap_port = match target.get(probe.target_bootstrap_port_field) {
545            None | Some(serde_json::Value::Null) => None,
546            Some(value) => {
547                let port = value.as_u64().and_then(|port| u16::try_from(port).ok());
548                Some(port.ok_or_else(|| ReadinessProbeError::RegistryMismatch {
549                    details: format!(
550                        "target registry entry for {:?} at {:?} has invalid {:?}",
551                        expected.role, expected.url, probe.target_bootstrap_port_field
552                    ),
553                })?)
554            }
555        };
556        if let Some(expected_port) = expected.bootstrap_port
557            && bootstrap_port != Some(expected_port)
558        {
559            return Err(ReadinessProbeError::RegistryMismatch {
560                details: format!(
561                    "target registry entry for {:?} at {:?} has bootstrap port {bootstrap_port:?}, expected {expected_port}",
562                    expected.role, expected.url
563                ),
564            });
565        }
566        evidence.push(TargetRegistryMatchEvidence {
567            url: expected.url.clone(),
568            role: expected.role.clone(),
569            healthy,
570            bootstrap_port,
571        });
572    }
573    readiness_remaining(bound)?;
574    Ok(evidence)
575}
576
577struct ProbeAttempt<T> {
578    effective_bound_ms: u64,
579    outcome: Result<T, ReadinessProbeError>,
580}
581
582#[cfg(test)]
583pub(super) fn probe_http(
584    host: &str,
585    port: u16,
586    path: &str,
587    bound: &OperationBound,
588) -> Result<(), ReadinessProbeError> {
589    probe_http_attempt(host, port, path, bound).outcome
590}
591
592fn probe_http_attempt(
593    host: &str,
594    port: u16,
595    path: &str,
596    bound: &OperationBound,
597) -> ProbeAttempt<()> {
598    let attempt = bound.attempt(Some(READINESS_ATTEMPT_CAP));
599    let effective_bound_ms = attempt.configured_ms().unwrap_or_default();
600    let outcome = (|| {
601        let url = format!("http://{host}:{port}{path}");
602        let timeout = attempt_remaining(&attempt)?;
603        let client = reqwest::blocking::Client::builder()
604            .timeout(timeout)
605            .connect_timeout(timeout.min(Duration::from_secs(2)))
606            .redirect(reqwest::redirect::Policy::none())
607            .no_proxy()
608            .build()
609            .map_err(|source| readiness_request_error("readiness", source))?;
610        let response = client
611            .get(&url)
612            .send()
613            .map_err(|source| readiness_request_error("readiness", source))?;
614        let status = response.status().as_u16();
615        if (200..300).contains(&status) {
616            Ok(())
617        } else {
618            Err(ReadinessProbeError::HttpStatus {
619                label: "readiness".to_owned(),
620                status,
621            })
622        }
623    })();
624    ProbeAttempt {
625        effective_bound_ms,
626        outcome,
627    }
628}
629
630#[cfg(test)]
631pub(super) fn probe_http_json(
632    host: &str,
633    port: u16,
634    path: &str,
635    label: &str,
636    bound: &OperationBound,
637) -> Result<serde_json::Value, ReadinessProbeError> {
638    probe_http_json_attempt(host, port, path, label, bound).outcome
639}
640
641fn probe_http_json_attempt(
642    host: &str,
643    port: u16,
644    path: &str,
645    label: &str,
646    bound: &OperationBound,
647) -> ProbeAttempt<serde_json::Value> {
648    let attempt = bound.attempt(Some(READINESS_ATTEMPT_CAP));
649    let effective_bound_ms = attempt.configured_ms().unwrap_or_default();
650    let outcome = (|| {
651        let url = format!("http://{host}:{port}{path}");
652        let timeout = attempt_remaining(&attempt)?;
653        let client = reqwest::blocking::Client::builder()
654            .timeout(timeout)
655            .connect_timeout(timeout.min(Duration::from_secs(2)))
656            .redirect(reqwest::redirect::Policy::none())
657            .no_proxy()
658            .build()
659            .map_err(|source| readiness_request_error(label, source))?;
660        let response = client
661            .get(&url)
662            .send()
663            .map_err(|source| readiness_request_error(label, source))?;
664        let status = response.status().as_u16();
665        if !(200..300).contains(&status) {
666            return Err(ReadinessProbeError::HttpStatus {
667                label: label.to_owned(),
668                status,
669            });
670        }
671        let body = response
672            .bytes()
673            .map_err(|source| readiness_request_error(label, source))?;
674        let value =
675            serde_json::from_slice(&body).map_err(|source| ReadinessProbeError::InvalidJson {
676                label: label.to_owned(),
677                source,
678            })?;
679        readiness_remaining(bound)?;
680        Ok(value)
681    })();
682    ProbeAttempt {
683        effective_bound_ms,
684        outcome,
685    }
686}
687
688fn readiness_request_error(label: &str, source: reqwest::Error) -> ReadinessProbeError {
689    if source.is_timeout() {
690        ReadinessProbeError::Deadline
691    } else {
692        ReadinessProbeError::Request {
693            label: label.to_owned(),
694            source,
695        }
696    }
697}
698
699fn readiness_remaining(bound: &OperationBound) -> Result<(), ReadinessProbeError> {
700    match bound.remaining() {
701        Remaining::Expired => Err(ReadinessProbeError::Deadline),
702        Remaining::Finite(_) | Remaining::Unbounded => Ok(()),
703    }
704}
705
706pub(super) fn unix_time_millis() -> Result<u64, ReadinessFailure> {
707    std::time::SystemTime::now()
708        .duration_since(std::time::UNIX_EPOCH)
709        .map(crate::operation_bound::duration_millis)
710        .map_err(|error| {
711            readiness_failure(
712                ReadinessFailureKind::Exited,
713                format!("system clock is before Unix epoch: {error}"),
714            )
715        })
716}
717
718impl ReadinessObserver for SystemProcessRuntime {
719    fn wait_ready(
720        &self,
721        handle: &ProcessHandle,
722        endpoint: &ProcessEndpointPlan,
723        readiness: &ReadinessPlan,
724        on_probe_failure: &mut dyn FnMut(&str),
725    ) -> Result<ReadinessEvidence, ReadinessFailure> {
726        match readiness {
727            ReadinessPlan::ProcessAlive => {
728                let bound = OperationBound::unbounded();
729                ensure_alive(self.status(handle)).map_err(|failure| {
730                    timed_readiness_failure(
731                        failure,
732                        &bound,
733                        OperationTerminalCause::Failed,
734                        Vec::new(),
735                    )
736                })?;
737                Ok(ReadinessEvidence::ProcessAlive {
738                    ready_unix_ms: unix_time_millis().map_err(|failure| {
739                        timed_readiness_failure(
740                            failure,
741                            &bound,
742                            OperationTerminalCause::Failed,
743                            Vec::new(),
744                        )
745                    })?,
746                    timing: bound.timing(
747                        "before_process_alive_check",
748                        OperationTerminalCause::Succeeded,
749                    ),
750                })
751            }
752            ReadinessPlan::Http {
753                path,
754                timeout_seconds,
755                ..
756            } => wait_http_ready(
757                self,
758                handle,
759                endpoint,
760                path,
761                *timeout_seconds,
762                on_probe_failure,
763            ),
764            ReadinessPlan::HttpTargetRegistry {
765                readiness_path,
766                registry_path,
767                targets_field,
768                target_url_field,
769                target_role_field,
770                target_healthy_field,
771                target_bootstrap_port_field,
772                expected_targets,
773                timeout_seconds,
774                ..
775            } => wait_http_target_registry_ready(
776                |bound| self.status_with_bound(handle, bound),
777                endpoint,
778                HttpTargetRegistryProbe {
779                    readiness_path,
780                    registry_path,
781                    targets_field,
782                    target_url_field,
783                    target_role_field,
784                    target_healthy_field,
785                    target_bootstrap_port_field,
786                    expected_targets,
787                },
788                *timeout_seconds,
789                on_probe_failure,
790            ),
791        }
792    }
793}