use std::sync::mpsc;
use super::DiscoveryEvent;
use super::worker::{BrowseOutcome, DiscoveryWorker};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureKind {
Startup,
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveryFailure {
pub kind: FailureKind,
pub cause: String,
}
impl DiscoveryFailure {
pub fn headline(&self) -> &'static str {
match self.kind {
FailureKind::Startup => "discovery failed to start",
FailureKind::Stopped => "discovery stopped",
}
}
pub fn message(&self) -> String {
format!("{}: {}", self.headline(), self.cause)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionState {
Listening,
Complete,
Failed(DiscoveryFailure),
}
impl SessionState {
pub fn is_listening(&self) -> bool {
matches!(self, SessionState::Listening)
}
}
#[derive(Debug)]
pub enum SessionPoll {
Event(DiscoveryEvent),
Idle,
Ended(SessionState),
}
pub struct DiscoverySession {
receiver: mpsc::Receiver<DiscoveryEvent>,
producer: Option<DiscoveryWorker>,
#[cfg(test)]
#[allow(dead_code)]
keepalive: Option<mpsc::Sender<DiscoveryEvent>>,
state: SessionState,
}
impl DiscoverySession {
pub(super) fn from_worker(
receiver: mpsc::Receiver<DiscoveryEvent>,
worker: DiscoveryWorker,
) -> Self {
Self {
receiver,
producer: Some(worker),
#[cfg(test)]
keepalive: None,
state: SessionState::Listening,
}
}
#[cfg(test)]
pub(crate) fn detached(receiver: mpsc::Receiver<DiscoveryEvent>) -> Self {
Self {
receiver,
producer: None,
keepalive: None,
state: SessionState::Listening,
}
}
#[cfg(test)]
pub(crate) fn ended(state: SessionState) -> Self {
debug_assert!(
!state.is_listening(),
"`ended` takes an ending; use `inert` for a listening session"
);
let (_, receiver) = mpsc::channel();
Self {
receiver,
producer: None,
keepalive: None,
state,
}
}
#[cfg(test)]
pub(crate) fn inert() -> Self {
let (tx, rx) = mpsc::channel();
Self {
receiver: rx,
producer: None,
keepalive: Some(tx),
state: SessionState::Listening,
}
}
pub fn poll(&mut self) -> SessionPoll {
match self.receiver.try_recv() {
Ok(event) => SessionPoll::Event(event),
Err(mpsc::TryRecvError::Empty) => SessionPoll::Idle,
Err(mpsc::TryRecvError::Disconnected) => {
if !self.state.is_listening() {
return SessionPoll::Idle;
}
self.state = self.ending();
SessionPoll::Ended(self.state.clone())
}
}
}
pub fn state(&self) -> &SessionState {
&self.state
}
pub fn shutdown(&mut self) {
if let Some(producer) = &mut self.producer {
producer.shutdown();
}
}
fn ending(&self) -> SessionState {
match self.producer.as_ref().and_then(DiscoveryWorker::outcome) {
#[cfg(feature = "fake")]
Some(BrowseOutcome::Complete) => SessionState::Complete,
Some(BrowseOutcome::Startup(cause)) => SessionState::Failed(DiscoveryFailure {
kind: FailureKind::Startup,
cause,
}),
Some(BrowseOutcome::Cancelled) => SessionState::Failed(DiscoveryFailure {
kind: FailureKind::Stopped,
cause: "discovery was stopped".to_string(),
}),
Some(BrowseOutcome::Overloaded(cause)) => SessionState::Failed(DiscoveryFailure {
kind: FailureKind::Stopped,
cause,
}),
Some(BrowseOutcome::Stopped) | None => SessionState::Failed(DiscoveryFailure {
kind: FailureKind::Stopped,
cause: "the browse ended unexpectedly; refresh to retry".to_string(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::Entry;
#[cfg(feature = "fake")]
use crate::discovery::{DiscoveryBackend, DiscoveryConfig, DiscoveryOptions, start};
#[cfg(feature = "fake")]
fn fake_options(service_type: Option<&str>) -> DiscoveryOptions {
DiscoveryConfig {
backend: DiscoveryBackend::Fake,
domain: "local".to_string(),
service_type: service_type.map(str::to_string),
}
.validate()
.expect("valid test options")
}
fn drain_to_end(session: &mut DiscoverySession) -> (Vec<DiscoveryEvent>, SessionState) {
let mut events = Vec::new();
loop {
match session.poll() {
SessionPoll::Event(event) => events.push(event),
SessionPoll::Idle => std::thread::yield_now(),
SessionPoll::Ended(state) => return (events, state),
}
}
}
#[cfg(feature = "fake")]
fn upserts(events: &[DiscoveryEvent]) -> Vec<Entry> {
events
.iter()
.filter_map(|event| match event {
DiscoveryEvent::Upsert(entry) => Some(entry.clone()),
_ => None,
})
.collect()
}
#[test]
fn an_empty_channel_is_idle_and_a_dropped_one_ends_the_session() {
let (tx, rx) = mpsc::channel();
let mut session = DiscoverySession::detached(rx);
assert!(matches!(session.poll(), SessionPoll::Idle));
assert!(session.state().is_listening());
drop(tx);
match session.poll() {
SessionPoll::Ended(SessionState::Failed(failure)) => {
assert_eq!(failure.kind, FailureKind::Stopped);
}
other => panic!("expected a failed ending, got {other:?}"),
}
}
#[test]
fn the_ending_is_reported_once_and_the_state_persists() {
let (tx, rx) = mpsc::channel();
let mut session = DiscoverySession::detached(rx);
drop(tx);
assert!(matches!(session.poll(), SessionPoll::Ended(_)));
assert!(matches!(session.poll(), SessionPoll::Idle));
assert!(matches!(session.poll(), SessionPoll::Idle));
assert!(matches!(session.state(), SessionState::Failed(_)));
assert!(!session.state().is_listening());
}
#[test]
fn buffered_events_are_delivered_before_the_ending() {
let (tx, rx) = mpsc::channel();
let mut session = DiscoverySession::detached(rx);
tx.send(DiscoveryEvent::Status("browsing".to_string()))
.unwrap();
tx.send(DiscoveryEvent::Upsert(Entry::new(
"nas",
"_http._tcp",
"local",
)))
.unwrap();
drop(tx);
let (events, state) = drain_to_end(&mut session);
assert_eq!(events.len(), 2);
assert!(matches!(state, SessionState::Failed(_)));
}
#[cfg(feature = "fake")]
#[test]
fn the_fake_session_completes_normally_after_its_finite_stream() {
let mut session = start(&fake_options(Some("_ssh._tcp")));
let (events, state) = drain_to_end(&mut session);
assert_eq!(state, SessionState::Complete);
assert!(!state.is_listening());
let records = upserts(&events);
assert_eq!(records.len(), 2);
assert!(
records
.iter()
.all(|record| record.service_type == "_ssh._tcp"),
"the filter must admit only the requested type"
);
}
#[cfg(feature = "fake")]
#[test]
fn dropping_a_fake_session_cancels_its_delayed_stream() {
let mut session = start(&fake_options(None));
loop {
match session.poll() {
SessionPoll::Event(DiscoveryEvent::Upsert(_)) => break,
SessionPoll::Ended(state) => panic!("stream ended early: {state:?}"),
_ => std::thread::yield_now(),
}
}
drop(session);
}
#[cfg(feature = "fake")]
#[test]
fn shutdown_stops_the_producer_and_is_idempotent() {
let mut session = start(&fake_options(None));
session.shutdown();
session.shutdown();
loop {
match session.poll() {
SessionPoll::Idle => std::thread::yield_now(),
SessionPoll::Ended(state) => {
assert!(!state.is_listening());
break;
}
SessionPoll::Event(_) => {}
}
}
}
#[cfg(feature = "fake")]
#[test]
fn a_cancelled_producer_ends_as_stopped_not_complete() {
let mut session = start(&fake_options(None));
session.shutdown();
let (_events, state) = drain_to_end(&mut session);
match state {
SessionState::Failed(failure) => assert_eq!(failure.kind, FailureKind::Stopped),
other => panic!("expected a failed ending, got {other:?}"),
}
}
#[test]
fn a_failure_carries_its_own_cause_text() {
let failure = DiscoveryFailure {
kind: FailureKind::Startup,
cause: "mDNS discovery unavailable (no such device)".to_string(),
};
assert_eq!(failure.headline(), "discovery failed to start");
assert_eq!(
failure.message(),
"discovery failed to start: mDNS discovery unavailable (no such device)"
);
}
}