1use std::io::{Read, Write};
2use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, PoisonError};
5use std::thread::{self, JoinHandle};
6use std::time::Duration;
7
8use crate::ServerError;
9use crate::server::listener::{loopback_interrupt_target, shed_on_fd_exhaustion};
10
11use super::checks::{SharedReadinessState, health_check, readiness_check};
12use super::reissue::{
13 OperatorCredentialReissueOutcome, OperatorCredentialReissueRefusal,
14 OperatorCredentialReissueRequest, OperatorCredentialReissuer, SharedOperatorCredentialReissue,
15};
16use super::unloadable::{SharedUnloadableConversations, UnloadableConversationRecord};
17
18use super::metrics_route;
19
20const HEALTH_PATH: &str = "/health";
21const READY_PATH: &str = "/ready";
22const METRICS_PATH: &str = "/metrics";
23const UNLOADABLE_PATH: &str = "/unloadable-conversations";
24const REISSUE_PATH: &str = "/operator/credential-reissue";
26const APPLICATION_JSON: &str = "application/json";
27const READ_BUFFER_BYTES: usize = 2048;
28
29#[derive(Debug)]
39pub struct HealthServerHandle {
40 local_addr: SocketAddr,
41 interrupt_target: SocketAddr,
44 shutdown: Arc<AtomicBool>,
45 active_stream: Arc<Mutex<Option<TcpStream>>>,
53 unloadable: SharedUnloadableConversations,
59 reissue: SharedOperatorCredentialReissue,
64 worker: Option<JoinHandle<Result<(), ServerError>>>,
65 #[cfg(test)]
70 accept_attempts: Arc<AtomicU64>,
71 #[cfg(test)]
74 shed_count: Arc<AtomicU64>,
75 #[cfg(test)]
80 requests_entered: Arc<AtomicU64>,
81}
82
83impl HealthServerHandle {
84 #[must_use]
86 pub const fn local_addr(&self) -> SocketAddr {
87 self.local_addr
88 }
89
90 pub fn install_unloadable_record(&self, record: UnloadableConversationRecord) {
103 self.unloadable.install(record);
104 }
105
106 pub fn install_credential_reissuer(&self, reissuer: Arc<dyn OperatorCredentialReissuer>) {
116 self.reissue.install(reissuer);
117 }
118
119 pub fn shutdown(mut self) -> Result<(), ServerError> {
126 self.stop_worker()
127 }
128
129 fn stop_worker(&mut self) -> Result<(), ServerError> {
130 self.shutdown.store(true, Ordering::SeqCst);
131 let Some(worker) = self.worker.take() else {
132 return Ok(());
133 };
134 let in_flight = self
143 .active_stream
144 .lock()
145 .unwrap_or_else(PoisonError::into_inner)
146 .take();
147 if let Some(stream) = in_flight {
148 let _ = stream.shutdown(Shutdown::Both);
149 }
150 if let Ok(waker) = TcpStream::connect(self.interrupt_target) {
158 drop(waker);
159 }
160
161 worker.join().map_err(|_| ServerError::HealthEndpoint {
162 message: "health endpoint worker thread terminated unexpectedly".to_owned(),
163 })?
164 }
165
166 #[cfg(test)]
168 fn accept_attempts(&self) -> u64 {
169 self.accept_attempts.load(Ordering::SeqCst)
170 }
171
172 #[cfg(test)]
174 fn shed_count(&self) -> u64 {
175 self.shed_count.load(Ordering::SeqCst)
176 }
177
178 #[cfg(test)]
180 fn requests_entered(&self) -> u64 {
181 self.requests_entered.load(Ordering::SeqCst)
182 }
183}
184
185impl Drop for HealthServerHandle {
186 fn drop(&mut self) {
187 if let Err(error) = self.stop_worker() {
188 tracing::debug!(%error, "health endpoint shutdown during drop failed");
189 }
190 }
191}
192
193pub fn start_health_server(
205 bind_address: SocketAddr,
206 readiness: SharedReadinessState,
207) -> Result<HealthServerHandle, ServerError> {
208 let listener =
209 TcpListener::bind(bind_address).map_err(|error| ServerError::HealthEndpoint {
210 message: format!("failed to bind health endpoint at {bind_address}: {error}"),
211 })?;
212 let local_addr = listener
216 .local_addr()
217 .map_err(|error| ServerError::HealthEndpoint {
218 message: format!("failed to inspect health endpoint listener address: {error}"),
219 })?;
220 let interrupt_target = loopback_interrupt_target(local_addr);
221 let shutdown = Arc::new(AtomicBool::new(false));
222 let active_stream = Arc::new(Mutex::new(None));
223 let unloadable = SharedUnloadableConversations::default();
224 let reissue = SharedOperatorCredentialReissue::default();
225 let accept_attempts = Arc::new(AtomicU64::new(0));
226 let shed_count = Arc::new(AtomicU64::new(0));
227 let requests_entered = Arc::new(AtomicU64::new(0));
228 let worker_shutdown = Arc::clone(&shutdown);
229 let worker_stream = Arc::clone(&active_stream);
230 let worker_attempts = Arc::clone(&accept_attempts);
231 let worker_shed = Arc::clone(&shed_count);
232 let worker_entered = Arc::clone(&requests_entered);
233 let served = ServedState {
234 readiness,
235 unloadable: unloadable.clone(),
236 reissue: reissue.clone(),
237 };
238 let worker = thread::spawn(move || {
239 serve(
240 &listener,
241 &served,
242 &worker_shutdown,
243 &worker_stream,
244 &worker_attempts,
245 &worker_shed,
246 &worker_entered,
247 )
248 });
249
250 Ok(HealthServerHandle {
251 local_addr,
252 interrupt_target,
253 shutdown,
254 active_stream,
255 unloadable,
256 reissue,
257 worker: Some(worker),
258 #[cfg(test)]
259 accept_attempts,
260 #[cfg(test)]
261 shed_count,
262 #[cfg(test)]
263 requests_entered,
264 })
265}
266
267#[derive(Debug, Clone)]
270struct ServedState {
271 readiness: SharedReadinessState,
272 unloadable: SharedUnloadableConversations,
273 reissue: SharedOperatorCredentialReissue,
274}
275
276fn serve(
277 listener: &TcpListener,
278 served: &ServedState,
279 shutdown: &AtomicBool,
280 active_stream: &Mutex<Option<TcpStream>>,
281 accept_attempts: &AtomicU64,
282 shed_count: &AtomicU64,
283 requests_entered: &AtomicU64,
284) -> Result<(), ServerError> {
285 let mut reserve = listener.try_clone().ok();
288 while !shutdown.load(Ordering::SeqCst) {
289 accept_attempts.fetch_add(1, Ordering::SeqCst);
290 match listener.accept() {
291 Ok((stream, ..)) => {
292 let admitted = {
301 let mut slot = active_stream.lock().unwrap_or_else(PoisonError::into_inner);
302 if shutdown.load(Ordering::SeqCst) {
303 false
304 } else {
305 *slot = stream.try_clone().ok();
306 true
307 }
308 };
309 if !admitted {
310 drop(stream);
311 continue;
312 }
313 requests_entered.fetch_add(1, Ordering::SeqCst);
314 let result = handle_connection(stream, served);
320 *active_stream.lock().unwrap_or_else(PoisonError::into_inner) = None;
324 if let Err(error) = result {
325 tracing::debug!(%error, "health endpoint connection error");
326 }
327 }
328 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
329 Err(error) if is_transient_accept_error(&error) => {
330 shed_on_fd_exhaustion(listener, &mut reserve, shed_count, &error);
331 }
332 Err(error) => {
333 return Err(ServerError::HealthEndpoint {
334 message: format!("health endpoint accept failed: {error}"),
335 });
336 }
337 }
338 }
339
340 Ok(())
341}
342
343fn is_transient_accept_error(error: &std::io::Error) -> bool {
346 matches!(error.raw_os_error(), Some(code) if code == 24 || code == 23)
347}
348
349fn handle_connection(mut stream: TcpStream, served: &ServedState) -> Result<(), ServerError> {
350 stream
351 .set_nonblocking(false)
352 .map_err(|error| ServerError::HealthEndpoint {
353 message: format!("failed to configure health request stream: {error}"),
354 })?;
355 stream
356 .set_read_timeout(Some(Duration::from_secs(2)))
357 .map_err(|error| ServerError::HealthEndpoint {
358 message: format!("failed to set health request read timeout: {error}"),
359 })?;
360
361 let mut buffer = [0_u8; READ_BUFFER_BYTES];
362 let bytes_read = stream
363 .read(&mut buffer)
364 .map_err(|error| ServerError::HealthEndpoint {
365 message: format!("failed to read health request: {error}"),
366 })?;
367
368 if bytes_read == 0 {
369 return Ok(());
370 }
371
372 let response = response_for_request(&buffer[..bytes_read], served)?;
373 stream
374 .write_all(&response)
375 .map_err(|error| ServerError::HealthEndpoint {
376 message: format!("failed to write health response: {error}"),
377 })?;
378 stream.flush().map_err(|error| ServerError::HealthEndpoint {
379 message: format!("failed to flush health response: {error}"),
380 })
381}
382
383fn response_for_request(request: &[u8], served: &ServedState) -> Result<Vec<u8>, ServerError> {
384 let Ok(request) = std::str::from_utf8(request) else {
385 return Ok(empty_response(StatusCode::BadRequest));
386 };
387 let Some((method, path)) = parse_request_line(request) else {
388 return Ok(empty_response(StatusCode::BadRequest));
389 };
390
391 match (method, path) {
392 ("GET", HEALTH_PATH) => json_response(StatusCode::Ok, &health_check()),
393 ("GET", READY_PATH) => {
394 let status = readiness_check(&served.readiness.snapshot());
395 let status_code = if status.ready {
396 StatusCode::Ok
397 } else {
398 StatusCode::ServiceUnavailable
399 };
400 json_response(status_code, &status)
401 }
402 ("GET", METRICS_PATH) => Ok(response(
403 StatusCode::Ok,
404 Some(metrics_route::CONTENT_TYPE),
405 metrics_route::render_body().as_bytes(),
406 )),
407 ("GET", UNLOADABLE_PATH) => json_response(StatusCode::Ok, &served.unloadable.status()),
411 (_, HEALTH_PATH | READY_PATH | METRICS_PATH | UNLOADABLE_PATH) => {
412 Ok(empty_response(StatusCode::MethodNotAllowed))
413 }
414 _ if path.split('?').next() == Some(REISSUE_PATH) => {
419 credential_reissue_response(method, path, served)
420 }
421 _ => Ok(empty_response(StatusCode::NotFound)),
422 }
423}
424
425fn credential_reissue_response(
439 method: &str,
440 path: &str,
441 served: &ServedState,
442) -> Result<Vec<u8>, ServerError> {
443 if method != "POST" {
444 return Ok(empty_response(StatusCode::MethodNotAllowed));
445 }
446 let query = path.split_once('?').map_or("", |(_, query)| query);
447 let Some(request) = parse_reissue_query(query) else {
448 return Ok(empty_response(StatusCode::BadRequest));
449 };
450 match served.reissue.reissue(request) {
451 Ok(None) => Ok(empty_response(StatusCode::ServiceUnavailable)),
454 Ok(Some(OperatorCredentialReissueOutcome::Issued(issued))) => {
455 json_response(StatusCode::Ok, &issued)
456 }
457 Ok(Some(OperatorCredentialReissueOutcome::Refused(refusal))) => {
458 json_response(reissue_refusal_status(&refusal), &refusal)
459 }
460 Err(error) => {
461 tracing::error!(%error, "operator credential re-issue could not be decided");
464 json_response(
465 StatusCode::ServiceUnavailable,
466 &serde_json::json!({ "error": error.message }),
467 )
468 }
469 }
470}
471
472const fn reissue_refusal_status(refusal: &OperatorCredentialReissueRefusal) -> StatusCode {
479 match refusal {
480 OperatorCredentialReissueRefusal::ConversationUnknown { .. }
481 | OperatorCredentialReissueRefusal::ParticipantUnknown { .. } => StatusCode::NotFound,
482 OperatorCredentialReissueRefusal::Retired { .. }
483 | OperatorCredentialReissueRefusal::LiveBinding { .. }
484 | OperatorCredentialReissueRefusal::DetachReplayOpen { .. }
485 | OperatorCredentialReissueRefusal::LiveReceipt { .. }
486 | OperatorCredentialReissueRefusal::GenerationMismatch { .. } => StatusCode::Conflict,
487 }
488}
489
490fn parse_reissue_query(query: &str) -> Option<OperatorCredentialReissueRequest> {
497 let mut conversation_id = None;
498 let mut participant_id = None;
499 let mut expected_current_generation = None;
500 for pair in query.split('&') {
501 let (name, value) = pair.split_once('=')?;
502 let value = value.parse::<u64>().ok()?;
503 let slot = match name {
504 "conversation_id" => &mut conversation_id,
505 "participant_id" => &mut participant_id,
506 "expected_current_generation" => &mut expected_current_generation,
507 _ => return None,
508 };
509 if slot.replace(value).is_some() {
510 return None;
511 }
512 }
513 Some(OperatorCredentialReissueRequest {
514 conversation_id: conversation_id?,
515 participant_id: participant_id?,
516 expected_current_generation: expected_current_generation?,
517 })
518}
519
520fn parse_request_line(request: &str) -> Option<(&str, &str)> {
521 let request_line = request.lines().next()?;
522 let mut parts = request_line.split_whitespace();
523 let method = parts.next()?;
524 let path = parts.next()?;
525 parts.next()?;
526
527 Some((method, path))
528}
529
530fn json_response<T>(status: StatusCode, value: &T) -> Result<Vec<u8>, ServerError>
531where
532 T: serde::Serialize,
533{
534 let body = serde_json::to_vec(value).map_err(|error| ServerError::HealthEndpoint {
535 message: format!("failed to serialize health response: {error}"),
536 })?;
537 Ok(response(status, Some(APPLICATION_JSON), &body))
538}
539
540fn empty_response(status: StatusCode) -> Vec<u8> {
541 response(status, None, &[])
542}
543
544fn response(status: StatusCode, content_type: Option<&str>, body: &[u8]) -> Vec<u8> {
545 let mut response = Vec::new();
546 let status_line = format!("HTTP/1.1 {} {}\r\n", status.code(), status.reason());
547 response.extend_from_slice(status_line.as_bytes());
548 response.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
549 response.extend_from_slice(b"Connection: close\r\n");
550 if let Some(content_type) = content_type {
551 response.extend_from_slice(format!("Content-Type: {content_type}\r\n").as_bytes());
552 }
553 response.extend_from_slice(b"\r\n");
554 response.extend_from_slice(body);
555 response
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
559enum StatusCode {
560 Ok,
561 BadRequest,
562 NotFound,
563 MethodNotAllowed,
564 Conflict,
565 ServiceUnavailable,
566}
567
568impl StatusCode {
569 const fn code(self) -> u16 {
570 match self {
571 Self::Ok => 200,
572 Self::BadRequest => 400,
573 Self::NotFound => 404,
574 Self::MethodNotAllowed => 405,
575 Self::Conflict => 409,
576 Self::ServiceUnavailable => 503,
577 }
578 }
579
580 const fn reason(self) -> &'static str {
581 match self {
582 Self::Ok => "OK",
583 Self::BadRequest => "Bad Request",
584 Self::NotFound => "Not Found",
585 Self::MethodNotAllowed => "Method Not Allowed",
586 Self::Conflict => "Conflict",
587 Self::ServiceUnavailable => "Service Unavailable",
588 }
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use std::io::{Read, Write};
595 use std::net::{SocketAddr, TcpStream};
596 use std::sync::{Arc, Mutex, PoisonError};
597 use std::thread;
598 use std::time::{Duration, Instant};
599
600 use serde_json::Value;
601
602 use super::{
603 OperatorCredentialReissueRefusal, OperatorCredentialReissueRequest,
604 OperatorCredentialReissuer, ServedState, response_for_request, start_health_server,
605 };
606 use crate::health::checks::{
607 ClusterReadiness, ReadinessCondition, ReadinessState, SharedReadinessState,
608 };
609
610 fn loopback_ephemeral() -> Result<SocketAddr, Box<dyn std::error::Error>> {
611 Ok("127.0.0.1:0".parse()?)
612 }
613
614 fn served(readiness: SharedReadinessState) -> ServedState {
618 ServedState {
619 readiness,
620 unloadable: crate::health::unloadable::SharedUnloadableConversations::default(),
621 reissue: crate::health::reissue::SharedOperatorCredentialReissue::default(),
622 }
623 }
624
625 fn get(address: SocketAddr, path: &str) -> Result<String, Box<dyn std::error::Error>> {
626 let mut stream = TcpStream::connect(address)?;
627 stream.set_read_timeout(Some(Duration::from_secs(2)))?;
628 let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n");
629 stream.write_all(request.as_bytes())?;
630
631 let mut response = String::new();
632 stream.read_to_string(&mut response)?;
633 Ok(response)
634 }
635
636 fn assert_status(response: &str, status: u16) {
637 let expected = format!("HTTP/1.1 {status} ");
638 assert!(
639 response.starts_with(&expected),
640 "response status did not start with {expected}: {response}"
641 );
642 }
643
644 fn body(response: &str) -> Result<&str, Box<dyn std::error::Error>> {
645 let Some((_headers, body)) = response.split_once("\r\n\r\n") else {
646 return Err("response did not contain a header/body separator".into());
647 };
648 Ok(body)
649 }
650
651 fn json_body(response: &str) -> Result<Value, Box<dyn std::error::Error>> {
652 Ok(serde_json::from_str(body(response)?)?)
653 }
654
655 #[test]
656 fn health_endpoint_returns_json_200_regardless_of_readiness()
657 -> Result<(), Box<dyn std::error::Error>> {
658 let readiness = SharedReadinessState::new(ReadinessState::default());
659 let server = start_health_server(loopback_ephemeral()?, readiness)?;
660
661 let response = get(server.local_addr(), "/health")?;
662 server.shutdown()?;
663
664 assert_status(&response, 200);
665 assert!(response.contains("Content-Type: application/json\r\n"));
666 let body = json_body(&response)?;
667 assert_eq!(body["status"], "healthy");
668
669 Ok(())
670 }
671
672 #[test]
673 fn ready_endpoint_returns_503_before_main_listener_binds()
674 -> Result<(), Box<dyn std::error::Error>> {
675 let readiness = SharedReadinessState::new(ReadinessState::new(
676 true,
677 false,
678 ClusterReadiness::NotConfigured,
679 ));
680 let server = start_health_server(loopback_ephemeral()?, readiness)?;
681
682 let response = get(server.local_addr(), "/ready")?;
683 server.shutdown()?;
684
685 assert_status(&response, 503);
686 assert!(response.contains("Content-Type: application/json\r\n"));
687 let body = json_body(&response)?;
688 assert_eq!(body["ready"], false);
689 assert_eq!(body["unmet_conditions"][0], "listener_bound");
690
691 Ok(())
692 }
693
694 #[test]
695 fn ready_endpoint_returns_200_after_all_startup_gates() -> Result<(), Box<dyn std::error::Error>>
696 {
697 let readiness = SharedReadinessState::new(ReadinessState::ready_without_cluster());
698 let server = start_health_server(loopback_ephemeral()?, readiness)?;
699
700 let response = get(server.local_addr(), "/ready")?;
701 server.shutdown()?;
702
703 assert_status(&response, 200);
704 let body = json_body(&response)?;
705 assert_eq!(body["ready"], true);
706 let Some(unmet_conditions) = body["unmet_conditions"].as_array() else {
707 return Err("unmet_conditions should be an array".into());
708 };
709 assert!(unmet_conditions.is_empty());
710
711 Ok(())
712 }
713
714 #[test]
715 fn ready_endpoint_updates_from_shared_readiness_state() -> Result<(), Box<dyn std::error::Error>>
716 {
717 let readiness = SharedReadinessState::new(ReadinessState::default());
718 let server = start_health_server(loopback_ephemeral()?, readiness.clone())?;
719
720 let response = get(server.local_addr(), "/ready")?;
721 assert_status(&response, 503);
722
723 readiness.set_config_loaded(true);
724 readiness.set_listener_bound(true);
725 let response = get(server.local_addr(), "/ready")?;
726 server.shutdown()?;
727
728 assert_status(&response, 200);
729
730 Ok(())
731 }
732
733 #[test]
734 fn clustered_ready_transitions_503_to_200_when_membership_established()
735 -> Result<(), Box<dyn std::error::Error>> {
736 let readiness = SharedReadinessState::new(ReadinessState::new(
739 true,
740 true,
741 ClusterReadiness::Configured {
742 membership_established: false,
743 },
744 ));
745 let server = start_health_server(loopback_ephemeral()?, readiness.clone())?;
746
747 let response = get(server.local_addr(), "/ready")?;
748 assert_status(&response, 503);
749 let body = json_body(&response)?;
750 assert_eq!(body["ready"], false);
751 assert_eq!(
752 body["unmet_conditions"][0],
753 serde_json::to_value(ReadinessCondition::ClusterMembershipEstablished)?
754 );
755
756 readiness.set_cluster_membership_established(true);
759 let response = get(server.local_addr(), "/ready")?;
760 server.shutdown()?;
761
762 assert_status(&response, 200);
763 let body = json_body(&response)?;
764 assert_eq!(body["ready"], true);
765 let Some(unmet_conditions) = body["unmet_conditions"].as_array() else {
766 return Err("unmet_conditions should be an array".into());
767 };
768 assert!(unmet_conditions.is_empty());
769
770 Ok(())
771 }
772
773 #[test]
774 fn cluster_readiness_is_listed_when_configured_but_not_joined()
775 -> Result<(), Box<dyn std::error::Error>> {
776 let readiness = SharedReadinessState::new(ReadinessState::new(
777 true,
778 true,
779 ClusterReadiness::Configured {
780 membership_established: false,
781 },
782 ));
783 let response = response_for_request(b"GET /ready HTTP/1.1\r\n\r\n", &served(readiness))?;
784 let response = String::from_utf8(response)?;
785
786 assert_status(&response, 503);
787 let body = json_body(&response)?;
788 assert_eq!(
789 body["unmet_conditions"][0],
790 serde_json::to_value(ReadinessCondition::ClusterMembershipEstablished)?
791 );
792
793 Ok(())
794 }
795
796 #[test]
814 fn unloadable_conversations_route_answers_the_operator_a_json_shape()
815 -> Result<(), Box<dyn std::error::Error>> {
816 let readiness = SharedReadinessState::new(ReadinessState::default());
817 let server = start_health_server(loopback_ephemeral()?, readiness)?;
818
819 let response = get(server.local_addr(), "/unloadable-conversations")?;
820 let unknown = get(server.local_addr(), "/unloadable-conversations-typo")?;
821 server.shutdown()?;
822
823 assert_status(&unknown, 404);
826
827 assert_status(&response, 200);
828 assert!(
829 response.contains("Content-Type: application/json\r\n"),
830 "the unloadable-conversations route must answer JSON: {response}"
831 );
832 let body = json_body(&response)?;
833 assert_eq!(
834 body["count"], 0,
835 "a server with no participant record attached refuses nothing: {body}"
836 );
837 assert_eq!(
838 body["participant_installed"], false,
839 "no participant record is attached to this server, and the surface must say so \
840 rather than let a zero count read as a clean node: {body}"
841 );
842 let Some(conversations) = body["conversations"].as_array() else {
843 return Err("conversations should be an array".into());
844 };
845 assert!(
846 conversations.is_empty(),
847 "no conversation was refused: {conversations:?}"
848 );
849
850 Ok(())
851 }
852
853 #[test]
854 fn unsupported_paths_are_not_served() -> Result<(), Box<dyn std::error::Error>> {
855 let readiness = SharedReadinessState::default();
856 let response = response_for_request(b"GET /unknown HTTP/1.1\r\n\r\n", &served(readiness))?;
857 let response = String::from_utf8(response)?;
858
859 assert_status(&response, 404);
860
861 Ok(())
862 }
863
864 #[test]
865 fn unsupported_methods_on_health_paths_are_rejected() -> Result<(), Box<dyn std::error::Error>>
866 {
867 let readiness = SharedReadinessState::default();
868 let response = response_for_request(b"POST /health HTTP/1.1\r\n\r\n", &served(readiness))?;
869 let response = String::from_utf8(response)?;
870
871 assert_status(&response, 405);
872
873 Ok(())
874 }
875
876 #[derive(Debug)]
884 struct RecordingReissuer {
885 outcome: crate::health::reissue::OperatorCredentialReissueOutcome,
886 seen: Mutex<Vec<OperatorCredentialReissueRequest>>,
887 }
888
889 impl OperatorCredentialReissuer for RecordingReissuer {
890 fn reissue(
891 &self,
892 request: OperatorCredentialReissueRequest,
893 ) -> Result<
894 crate::health::reissue::OperatorCredentialReissueOutcome,
895 crate::health::reissue::OperatorCredentialReissueError,
896 > {
897 self.seen
898 .lock()
899 .unwrap_or_else(PoisonError::into_inner)
900 .push(request);
901 Ok(self.outcome.clone())
902 }
903 }
904
905 fn served_with_reissuer(
906 outcome: crate::health::reissue::OperatorCredentialReissueOutcome,
907 ) -> (ServedState, Arc<RecordingReissuer>) {
908 let reissuer = Arc::new(RecordingReissuer {
909 outcome,
910 seen: Mutex::new(Vec::new()),
911 });
912 let state = served(SharedReadinessState::default());
913 state
914 .reissue
915 .install(Arc::clone(&reissuer) as Arc<dyn OperatorCredentialReissuer>);
916 (state, reissuer)
917 }
918
919 const REISSUE_TARGET: &str =
920 "/operator/credential-reissue?conversation_id=7&participant_id=3&\
921 expected_current_generation=14";
922
923 #[test]
926 fn the_reissue_route_reports_an_uninstalled_participant()
927 -> Result<(), Box<dyn std::error::Error>> {
928 let request = format!("POST {REISSUE_TARGET} HTTP/1.1\r\n\r\n");
929 let response =
930 response_for_request(request.as_bytes(), &served(SharedReadinessState::default()))?;
931 let response = String::from_utf8(response)?;
932
933 assert_status(&response, 503);
934 Ok(())
935 }
936
937 #[test]
940 fn the_reissue_route_carries_the_three_inputs_and_returns_the_secret_once()
941 -> Result<(), Box<dyn std::error::Error>> {
942 let issued = crate::health::reissue::OperatorCredentialReissued {
943 conversation_id: 7,
944 participant_id: 3,
945 presented_generation: 14,
946 issued_generation: 15,
947 attach_secret: crate::health::reissue::encode_hex(&[0x5A; 32]),
948 };
949 let (state, reissuer) = served_with_reissuer(
950 crate::health::reissue::OperatorCredentialReissueOutcome::Issued(issued),
951 );
952 let request = format!("POST {REISSUE_TARGET} HTTP/1.1\r\n\r\n");
953
954 let response = String::from_utf8(response_for_request(request.as_bytes(), &state)?)?;
955
956 assert_status(&response, 200);
957 let body = json_body(&response)?;
958 assert_eq!(body["issued_generation"], 15);
959 assert_eq!(body["presented_generation"], 14);
960 assert_eq!(body["attach_secret"], "5a".repeat(32));
961 let seen = reissuer
962 .seen
963 .lock()
964 .unwrap_or_else(PoisonError::into_inner)
965 .clone();
966 assert_eq!(
967 seen.as_slice(),
968 [OperatorCredentialReissueRequest {
969 conversation_id: 7,
970 participant_id: 3,
971 expected_current_generation: 14,
972 }]
973 );
974 Ok(())
975 }
976
977 #[test]
980 fn the_reissue_route_serves_the_normative_generation_pair()
981 -> Result<(), Box<dyn std::error::Error>> {
982 let (state, _) = served_with_reissuer(
983 crate::health::reissue::OperatorCredentialReissueOutcome::Refused(
984 OperatorCredentialReissueRefusal::GenerationMismatch {
985 conversation_id: 7,
986 participant_id: 3,
987 presented_generation: 14,
988 current_generation: 15,
989 },
990 ),
991 );
992 let request = format!("POST {REISSUE_TARGET} HTTP/1.1\r\n\r\n");
993
994 let response = String::from_utf8(response_for_request(request.as_bytes(), &state)?)?;
995
996 assert_status(&response, 409);
997 let body = json_body(&response)?;
998 assert_eq!(body["refusal"], "generation_mismatch");
999 assert_eq!(body["presented_generation"], 14);
1000 assert_eq!(body["current_generation"], 15);
1001 Ok(())
1002 }
1003
1004 #[test]
1007 fn the_reissue_route_answers_a_lookup_miss_with_not_found()
1008 -> Result<(), Box<dyn std::error::Error>> {
1009 let (state, _) = served_with_reissuer(
1010 crate::health::reissue::OperatorCredentialReissueOutcome::Refused(
1011 OperatorCredentialReissueRefusal::ConversationUnknown { conversation_id: 7 },
1012 ),
1013 );
1014 let request = format!("POST {REISSUE_TARGET} HTTP/1.1\r\n\r\n");
1015
1016 let response = String::from_utf8(response_for_request(request.as_bytes(), &state)?)?;
1017
1018 assert_status(&response, 404);
1019 let body = json_body(&response)?;
1020 assert_eq!(body["refusal"], "conversation_unknown");
1021 assert_eq!(body["conversation_id"], 7);
1022 assert!(
1023 body.get("participant_id").is_none(),
1024 "an unknown-conversation refusal must disclose nothing beyond the presented \
1025 conversation id: {body}"
1026 );
1027 Ok(())
1028 }
1029
1030 #[test]
1034 fn a_malformed_reissue_call_is_refused_and_never_reaches_the_authority()
1035 -> Result<(), Box<dyn std::error::Error>> {
1036 let (state, reissuer) = served_with_reissuer(
1037 crate::health::reissue::OperatorCredentialReissueOutcome::Refused(
1038 OperatorCredentialReissueRefusal::ConversationUnknown { conversation_id: 7 },
1039 ),
1040 );
1041 let malformed = [
1042 "/operator/credential-reissue?conversation_id=7&participant_id=3",
1044 "/operator/credential-reissue",
1046 "/operator/credential-reissue?conversation_id=7&participant_id=3&\
1048 expected_current_generation=fourteen",
1049 "/operator/credential-reissue?conversation_id=7&participant_id=3&\
1051 expected_current_generation=14&force=1",
1052 "/operator/credential-reissue?conversation_id=7&conversation_id=8&\
1055 participant_id=3&expected_current_generation=14",
1056 ];
1057
1058 for target in malformed {
1059 let request = format!("POST {target} HTTP/1.1\r\n\r\n");
1060 let response = String::from_utf8(response_for_request(request.as_bytes(), &state)?)?;
1061 assert_status(&response, 400);
1062 }
1063
1064 assert!(
1065 reissuer
1066 .seen
1067 .lock()
1068 .unwrap_or_else(PoisonError::into_inner)
1069 .is_empty(),
1070 "a malformed call must never reach the serialized participant-state point"
1071 );
1072 Ok(())
1073 }
1074
1075 #[test]
1078 fn the_reissue_route_refuses_every_other_method() -> Result<(), Box<dyn std::error::Error>> {
1079 let (state, reissuer) = served_with_reissuer(
1080 crate::health::reissue::OperatorCredentialReissueOutcome::Refused(
1081 OperatorCredentialReissueRefusal::ConversationUnknown { conversation_id: 7 },
1082 ),
1083 );
1084
1085 for method in ["GET", "PUT", "DELETE"] {
1086 let request = format!("{method} {REISSUE_TARGET} HTTP/1.1\r\n\r\n");
1087 let response = String::from_utf8(response_for_request(request.as_bytes(), &state)?)?;
1088 assert_status(&response, 405);
1089 }
1090
1091 assert!(
1092 reissuer
1093 .seen
1094 .lock()
1095 .unwrap_or_else(PoisonError::into_inner)
1096 .is_empty()
1097 );
1098 Ok(())
1099 }
1100
1101 #[test]
1105 fn the_reissue_route_did_not_move_any_existing_routes_answer()
1106 -> Result<(), Box<dyn std::error::Error>> {
1107 let state = served(SharedReadinessState::default());
1108 for target in [
1109 "/health?x=1",
1110 "/ready?x=1",
1111 "/metrics?x=1",
1112 "/unloadable-conversations?x=1",
1113 ] {
1114 let request = format!("GET {target} HTTP/1.1\r\n\r\n");
1115 let response = String::from_utf8(response_for_request(request.as_bytes(), &state)?)?;
1116 assert_status(&response, 404);
1117 }
1118 let response =
1122 String::from_utf8(response_for_request(b"GET /health HTTP/1.1\r\n\r\n", &state)?)?;
1123 assert_status(&response, 200);
1124 Ok(())
1125 }
1126
1127 #[test]
1132 fn silent_health_listener_has_zero_application_wakes() -> Result<(), Box<dyn std::error::Error>>
1133 {
1134 let server = start_health_server(loopback_ephemeral()?, SharedReadinessState::default())?;
1135
1136 let deadline = Instant::now() + Duration::from_secs(2);
1137 while server.accept_attempts() < 1 && Instant::now() < deadline {
1138 thread::sleep(Duration::from_millis(5));
1139 }
1140 let armed = server.accept_attempts();
1141 assert_eq!(
1142 armed, 1,
1143 "the blocking accept is issued exactly once when parked"
1144 );
1145
1146 thread::sleep(Duration::from_millis(200));
1147 assert_eq!(
1148 server.accept_attempts(),
1149 armed,
1150 "a silent health listener must not wake or re-accept"
1151 );
1152 assert_eq!(
1153 server.shed_count(),
1154 0,
1155 "a silent health listener sheds nothing"
1156 );
1157
1158 let response = get(server.local_addr(), "/health")?;
1160 assert_status(&response, 200);
1161 let body = json_body(&response)?;
1162 assert_eq!(body["status"], "healthy");
1163
1164 server.shutdown()?;
1165 Ok(())
1166 }
1167
1168 #[test]
1172 fn health_accept_source_has_no_wouldblock_sleep_poll() {
1173 const SOURCE: &str = include_str!("endpoint.rs");
1174 let production = SOURCE.split("mod tests").next().unwrap_or(SOURCE);
1175 for forbidden in [
1176 "set_nonblocking(true)",
1177 "ErrorKind::WouldBlock",
1178 "thread::sleep",
1179 ] {
1180 assert!(
1181 !production.contains(forbidden),
1182 "retired health accept-path source `{forbidden}` reappeared"
1183 );
1184 }
1185 }
1186
1187 #[test]
1193 fn health_shutdown_interrupts_accept_wait() -> Result<(), Box<dyn std::error::Error>> {
1194 let server = start_health_server(loopback_ephemeral()?, SharedReadinessState::default())?;
1196 let start = Instant::now();
1197 server.shutdown()?;
1198 assert!(
1199 start.elapsed() < Duration::from_secs(2),
1200 "shutdown before arming must interrupt promptly, not sleep-poll"
1201 );
1202
1203 let server = start_health_server(loopback_ephemeral()?, SharedReadinessState::default())?;
1205 let deadline = Instant::now() + Duration::from_secs(2);
1206 while server.accept_attempts() < 1 && Instant::now() < deadline {
1207 thread::sleep(Duration::from_millis(5));
1208 }
1209 assert_eq!(
1210 server.accept_attempts(),
1211 1,
1212 "the worker parked before shutdown"
1213 );
1214 let parked_addr = server.local_addr();
1215 let start = Instant::now();
1216 server.shutdown()?;
1217 assert!(
1218 start.elapsed() < Duration::from_secs(2),
1219 "shutdown of a parked accept must interrupt promptly"
1220 );
1221 assert!(
1223 TcpStream::connect(parked_addr).is_err(),
1224 "the listener descriptor was released; further connects are refused"
1225 );
1226
1227 let server = start_health_server(loopback_ephemeral()?, SharedReadinessState::default())?;
1237 let deadline = Instant::now() + Duration::from_secs(2);
1238 while server.accept_attempts() < 1 && Instant::now() < deadline {
1239 thread::sleep(Duration::from_millis(5));
1240 }
1241 let _pending = TcpStream::connect(server.local_addr())?;
1242 let start = Instant::now();
1243 server.shutdown()?;
1244 assert!(
1245 start.elapsed() < Duration::from_millis(500),
1246 "shutdown concurrent with a pending accept must interrupt promptly (TOLD), \
1247 not defer by a request read deadline: elapsed {:?}",
1248 start.elapsed()
1249 );
1250
1251 let server = start_health_server(loopback_ephemeral()?, SharedReadinessState::default())?;
1253 let response = get(server.local_addr(), "/health")?;
1254 assert_status(&response, 200);
1255 let start = Instant::now();
1256 server.shutdown()?;
1257 assert!(
1258 start.elapsed() < Duration::from_secs(2),
1259 "shutdown after a served request must interrupt the next parked accept promptly"
1260 );
1261
1262 Ok(())
1263 }
1264
1265 #[test]
1281 fn shutdown_interrupts_in_flight_silent_request_read() -> Result<(), Box<dyn std::error::Error>>
1282 {
1283 let server = start_health_server(loopback_ephemeral()?, SharedReadinessState::default())?;
1284
1285 let _silent = TcpStream::connect(server.local_addr())?;
1290
1291 let deadline = Instant::now() + Duration::from_secs(2);
1294 while server.requests_entered() < 1 && Instant::now() < deadline {
1295 thread::sleep(Duration::from_millis(5));
1296 }
1297 assert_eq!(
1298 server.requests_entered(),
1299 1,
1300 "the worker entered the in-flight silent request read"
1301 );
1302
1303 let start = Instant::now();
1304 server.shutdown()?;
1305 assert!(
1306 start.elapsed() < Duration::from_millis(500),
1307 "shutdown must interrupt an in-flight silent request read promptly (TOLD), \
1308 not defer by the read's admitted 2s deadline: elapsed {:?}",
1309 start.elapsed()
1310 );
1311
1312 Ok(())
1313 }
1314
1315 #[test]
1322 fn health_idle_grows_unrelated_counters_while_accept_stays_flat()
1323 -> Result<(), Box<dyn std::error::Error>> {
1324 let idle = start_health_server(loopback_ephemeral()?, SharedReadinessState::default())?;
1325 let busy = start_health_server(loopback_ephemeral()?, SharedReadinessState::default())?;
1326
1327 let deadline = Instant::now() + Duration::from_secs(2);
1328 while (idle.accept_attempts() < 1 || busy.accept_attempts() < 1)
1329 && Instant::now() < deadline
1330 {
1331 thread::sleep(Duration::from_millis(5));
1332 }
1333 let idle_armed = idle.accept_attempts();
1334 assert_eq!(idle_armed, 1, "the idle listener parks exactly one accept");
1335 let busy_before = busy.accept_attempts();
1336
1337 for _ in 0..5 {
1340 let response = get(busy.local_addr(), "/health")?;
1341 assert_status(&response, 200);
1342 }
1343 let deadline = Instant::now() + Duration::from_secs(2);
1344 while busy.accept_attempts() <= busy_before && Instant::now() < deadline {
1345 thread::sleep(Duration::from_millis(5));
1346 }
1347
1348 assert!(
1349 busy.accept_attempts() > busy_before,
1350 "an unrelated served request grows the busy listener's accept counter"
1351 );
1352 assert_eq!(
1353 idle.accept_attempts(),
1354 idle_armed,
1355 "the silent listener's accept counter stays flat during the workload"
1356 );
1357 assert_eq!(idle.shed_count(), 0, "the silent listener sheds nothing");
1358
1359 idle.shutdown()?;
1360 busy.shutdown()?;
1361 Ok(())
1362 }
1363}