Skip to main content

irtt_client/managed/task/
control.rs

1//! Task construction, public control/observation handles, and stop/update admission.
2
3use std::{
4    collections::{HashSet, VecDeque},
5    fmt,
6    future::Future,
7    pin::Pin,
8    sync::{
9        atomic::{AtomicBool, AtomicU8, Ordering},
10        Arc,
11    },
12    task::{Context, Poll},
13    time::Instant,
14};
15
16use tokio::sync::{broadcast, mpsc, oneshot, watch};
17
18use crate::{
19    managed::{
20        ManagedClientConfig, ManagedCommandAcknowledgement, ManagedCommandApplyError,
21        ManagedCommandError, ManagedCompletionPolicy, ManagedConfigError, ManagedEvent,
22        ManagedEventSubscription, ManagedLifecycle, ManagedStatus, ManagedStatusSubscription,
23        ManagedSubscribeError, ManagedTargetConfig, ManagedTargetLifecycle, ManagedTargetStatus,
24        TargetInstance,
25    },
26    session::machine::SessionMachine,
27    socket_options::validate_ttl,
28    ClientConfig, ClientError,
29};
30
31use super::{
32    arm_wake,
33    target::{TargetCounters, TargetMembership, TargetPhase, TargetRuntime},
34    timeouts::TIMEOUT_WORK_BUDGET,
35    ManagedClientTask, OutcomeHistory, TaskPhase, TaskResources,
36};
37
38// Tokio 1.53 broadcast asserts this exact bound before rounding capacity to a
39// power of two; Tokio does not expose it as a public constant.
40const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
41
42/// Entry point for constructing a unified Tokio managed task.
43///
44/// # Example
45///
46/// Construct the task and its separate control handle, subscribe before
47/// starting the task, then run it under the caller's Tokio runtime. A reachable
48/// server is required at runtime, so this example only type-checks.
49///
50/// ```no_run
51/// use irtt_client::{ClientConfig, ClientEvent};
52/// use irtt_client::managed::{
53///     ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig,
54/// };
55///
56/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
57/// let config = ManagedClientConfig {
58///     client: ClientConfig {
59///         request: irtt_client::SessionRequest {
60///             duration: Some(std::time::Duration::from_secs(10)),
61///             ..irtt_client::SessionRequest::default()
62///         },
63///         ..ClientConfig::default()
64///     },
65///     ..ManagedClientConfig::default()
66/// };
67/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")];
68/// let (task, handle) = ManagedClient::task(config, targets)?;
69/// let mut events = handle.subscribe()?;
70/// let driver = tokio::spawn(task);
71///
72/// while let Ok(event) = events.recv().await {
73///     if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event {
74///         println!("received a probe reply");
75///     }
76/// }
77/// let outcome = driver.await?;
78/// println!("run ended: {:?}", outcome.end_reason);
79/// # Ok(())
80/// # }
81/// ```
82#[derive(Debug, Default)]
83pub struct ManagedClient;
84
85impl ManagedClient {
86    /// Validate configuration and construct a runtime-independent task/handle pair.
87    pub fn task(
88        config: ManagedClientConfig,
89        targets: Vec<ManagedTargetConfig>,
90    ) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
91        build_task(config, targets)
92    }
93}
94
95#[derive(Debug)]
96pub(super) struct StopSignal {
97    requested: AtomicBool,
98    update_admission: AtomicU8,
99    wake: watch::Sender<()>,
100}
101
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103#[repr(u8)]
104pub(super) enum UpdateAdmission {
105    Open,
106    Stopping,
107    Closed,
108}
109
110impl UpdateAdmission {
111    fn from_raw(value: u8) -> Self {
112        match value {
113            value if value == Self::Open as u8 => Self::Open,
114            value if value == Self::Stopping as u8 => Self::Stopping,
115            value if value == Self::Closed as u8 => Self::Closed,
116            _ => unreachable!("managed update admission stores a known state"),
117        }
118    }
119}
120
121impl StopSignal {
122    fn request(&self) {
123        self.begin_stopping();
124        if !self.requested.swap(true, Ordering::AcqRel) {
125            self.wake.send_replace(());
126        }
127    }
128
129    pub(super) fn update_admission(&self) -> UpdateAdmission {
130        UpdateAdmission::from_raw(self.update_admission.load(Ordering::Acquire))
131    }
132
133    pub(super) fn begin_stopping(&self) {
134        let mut current = self.update_admission.load(Ordering::Acquire);
135        loop {
136            match UpdateAdmission::from_raw(current) {
137                UpdateAdmission::Open => match self.update_admission.compare_exchange_weak(
138                    UpdateAdmission::Open as u8,
139                    UpdateAdmission::Stopping as u8,
140                    Ordering::AcqRel,
141                    Ordering::Acquire,
142                ) {
143                    Ok(_) => return,
144                    Err(observed) => current = observed,
145                },
146                UpdateAdmission::Stopping | UpdateAdmission::Closed => return,
147            }
148        }
149    }
150
151    pub(super) fn close_updates(&self) {
152        self.update_admission
153            .store(UpdateAdmission::Closed as u8, Ordering::Release);
154    }
155
156    pub(super) fn is_requested(&self) -> bool {
157        self.requested.load(Ordering::Acquire)
158    }
159}
160
161/// Cloneable control and observation capability for a managed task.
162#[derive(Clone)]
163pub struct ManagedClientHandle {
164    stop: Arc<StopSignal>,
165    commands: mpsc::Sender<ManagedCommand>,
166    max_live_target_generations: usize,
167    status: watch::Receiver<Arc<ManagedStatus>>,
168    events: broadcast::WeakSender<ManagedEvent>,
169}
170
171impl fmt::Debug for ManagedClientHandle {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.debug_struct("ManagedClientHandle")
174            .field("status", &self.status())
175            .finish_non_exhaustive()
176    }
177}
178
179impl ManagedClientHandle {
180    /// Return the latest durable immutable status snapshot.
181    pub fn status(&self) -> Arc<ManagedStatus> {
182        Arc::clone(&self.status.borrow())
183    }
184
185    /// Subscribe to authoritative durable latest-state observation.
186    ///
187    /// The current snapshot is marked as seen when subscribing.
188    /// The current snapshot is immediately available through `borrow()`. Updates
189    /// may coalesce; this is not a lossless event log. After the task terminates,
190    /// the sender closes but the final snapshot remains readable. Use
191    /// [`Self::subscribe`] for the lossy presentation-event stream.
192    pub fn subscribe_status(&self) -> ManagedStatusSubscription {
193        let mut status = self.status.clone();
194        drop(status.borrow_and_update());
195        status
196    }
197
198    /// Subscribe to future lossy presentation events.
199    pub fn subscribe(&self) -> Result<ManagedEventSubscription, ManagedSubscribeError> {
200        self.events
201            .upgrade()
202            .map(|sender| sender.subscribe())
203            .ok_or(ManagedSubscribeError::Closed)
204    }
205
206    /// Request idempotent graceful stop and return a durable-status receipt.
207    pub fn stop(&self) -> ManagedStopReceipt {
208        self.stop.request();
209        ManagedStopReceipt::new(self.subscribe_status())
210    }
211
212    /// Submit the complete desired target set, replacing any previous one.
213    ///
214    /// `targets` is not a delta: any live target whose id is missing from
215    /// `targets` is stopped and removed. A target that is still present with an
216    /// unchanged configuration continues undisturbed — it is not restarted and
217    /// does not get a new generation. A target present with a *changed*
218    /// configuration, or whose current generation already reached a terminal
219    /// state (e.g. failed, completed, or was closed by the peer), is treated as
220    /// a fresh start: it is retired and a new generation is created for it, even
221    /// if the configuration is byte-for-byte identical to the one that just
222    /// finished.
223    ///
224    /// This call enqueues the update without waiting for queue capacity; await
225    /// the returned [`ManagedCommandReceipt`] to observe whether — and how — it
226    /// was applied.
227    pub fn update_targets(
228        &self,
229        targets: Vec<ManagedTargetConfig>,
230    ) -> Result<ManagedCommandReceipt, ManagedCommandError> {
231        match self.stop.update_admission() {
232            UpdateAdmission::Open => {}
233            UpdateAdmission::Stopping => return Err(ManagedCommandError::Stopping),
234            UpdateAdmission::Closed => return Err(ManagedCommandError::DriverClosed),
235        }
236        if targets.len() > self.max_live_target_generations {
237            return Err(ManagedCommandError::TooManyTargets {
238                configured: targets.len(),
239                limit: self.max_live_target_generations,
240            });
241        }
242
243        // Observing `Open` only permits attempting submission. A successful
244        // `try_send` transfers command ownership to the driver. A concurrent
245        // stop may move admission to `Stopping` before this send, allowing the
246        // command to enqueue; the driver-side check immediately before
247        // `apply_targets` determines whether it applies or resolves as
248        // `Stopping`. Terminal sealing closes the receiver before draining
249        // accepted commands.
250        let (acknowledgement, receiver) = oneshot::channel();
251        match self.commands.try_send(ManagedCommand::UpdateTargets {
252            targets,
253            acknowledgement,
254        }) {
255            Ok(()) => Ok(ManagedCommandReceipt { receiver }),
256            Err(mpsc::error::TrySendError::Full(_)) => match self.stop.update_admission() {
257                UpdateAdmission::Open => Err(ManagedCommandError::QueueFull),
258                UpdateAdmission::Stopping => Err(ManagedCommandError::Stopping),
259                UpdateAdmission::Closed => Err(ManagedCommandError::DriverClosed),
260            },
261            Err(mpsc::error::TrySendError::Closed(_)) => Err(ManagedCommandError::DriverClosed),
262        }
263    }
264}
265
266/// Receipt for a target-set transaction accepted by the driver queue.
267#[must_use = "await the receipt to observe target-update application"]
268pub struct ManagedCommandReceipt {
269    receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
270}
271
272impl ManagedCommandReceipt {
273    /// Block until the target-set transaction is applied or rejected.
274    ///
275    /// This is for synchronous callers such as [`crate::managed::BlockingManagedClient`]. It
276    /// must not be called from an asynchronous runtime; use the [`Future`]
277    /// implementation there instead.
278    pub fn blocking_wait(self) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
279        self.receiver
280            .blocking_recv()
281            .unwrap_or(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
282    }
283}
284
285impl Future for ManagedCommandReceipt {
286    type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
287    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
288        match Pin::new(&mut self.receiver).poll(cx) {
289            Poll::Ready(Ok(result)) => Poll::Ready(result),
290            Poll::Ready(Err(_)) => {
291                Poll::Ready(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
292            }
293            Poll::Pending => Poll::Pending,
294        }
295    }
296}
297
298pub(super) enum ManagedCommand {
299    UpdateTargets {
300        targets: Vec<ManagedTargetConfig>,
301        acknowledgement:
302            oneshot::Sender<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
303    },
304}
305
306/// Receipt resolving once stop is durably observed or the task is terminal.
307///
308/// Resolves from authoritative status as soon as `stop_requested` is true or
309/// the lifecycle is Completed, Failed, or Abandoned. It need not wait for
310/// graceful cleanup after stop observation.
311#[must_use = "await the receipt to observe durable stop acknowledgement"]
312pub struct ManagedStopReceipt {
313    future: Pin<Box<dyn Future<Output = ()> + Send>>,
314}
315
316impl ManagedStopReceipt {
317    fn new(mut status: ManagedStatusSubscription) -> Self {
318        let future = Box::pin(async move {
319            while !{
320                let snapshot = status.borrow();
321                snapshot.stop_requested
322                    || matches!(
323                        snapshot.lifecycle,
324                        ManagedLifecycle::Completed
325                            | ManagedLifecycle::Failed
326                            | ManagedLifecycle::Abandoned
327                    )
328            } {
329                // A closed sender retains the terminal snapshot. Never wait
330                // indefinitely if the channel closes.
331                if status.changed().await.is_err() {
332                    break;
333                }
334            }
335        });
336        Self { future }
337    }
338}
339
340impl Future for ManagedStopReceipt {
341    type Output = ();
342
343    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
344        self.future.as_mut().poll(cx)
345    }
346}
347
348fn build_task(
349    config: ManagedClientConfig,
350    targets: Vec<ManagedTargetConfig>,
351) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
352    if config.event_capacity == 0 {
353        return Err(ManagedConfigError::ZeroEventCapacity);
354    }
355    if config.event_capacity > MAX_BROADCAST_CHANNEL_CAPACITY {
356        return Err(ManagedConfigError::EventCapacityTooLarge {
357            configured: config.event_capacity,
358            maximum: MAX_BROADCAST_CHANNEL_CAPACITY,
359        });
360    }
361    if config.command_capacity == 0 {
362        return Err(ManagedConfigError::ZeroCommandCapacity);
363    }
364    if config.command_capacity > tokio::sync::Semaphore::MAX_PERMITS {
365        return Err(ManagedConfigError::CommandCapacityTooLarge {
366            configured: config.command_capacity,
367            maximum: tokio::sync::Semaphore::MAX_PERMITS,
368        });
369    }
370    if config.max_live_target_generations == 0 {
371        return Err(ManagedConfigError::ZeroLiveGenerationLimit);
372    }
373    if Instant::now().checked_add(config.final_drain).is_none() {
374        return Err(ManagedConfigError::UnschedulableFinalDrain {
375            duration: config.final_drain,
376        });
377    }
378    if targets.len() > config.max_live_target_generations {
379        return Err(ManagedConfigError::TooManyTargets {
380            configured: targets.len(),
381            limit: config.max_live_target_generations,
382        });
383    }
384    if targets.is_empty() && config.completion == ManagedCompletionPolicy::FinishWhenQuiescent {
385        return Err(ManagedConfigError::EmptyInitialTargets);
386    }
387
388    let mut ids = HashSet::with_capacity(targets.len());
389    let mut runtimes = Vec::with_capacity(targets.len());
390    let mut next_generation = 1_u64;
391    for target in targets {
392        if !ids.insert(target.id.clone()) {
393            return Err(ManagedConfigError::DuplicateTargetId { id: target.id });
394        }
395        let generation = next_generation;
396        next_generation = next_generation
397            .checked_add(1)
398            .ok_or(ManagedConfigError::GenerationExhausted)?;
399        let mut client_config = config.client.clone();
400        client_config.address_family = target
401            .address_family
402            .unwrap_or(config.client.address_family);
403        client_config.auth = target.auth.resolve(&config.client.auth);
404        validate_target_config(&client_config).map_err(|source| {
405            ManagedConfigError::InvalidTarget {
406                id: target.id.clone(),
407                source,
408            }
409        })?;
410        runtimes.push(TargetRuntime {
411            instance: TargetInstance {
412                id: target.id.clone(),
413                generation,
414            },
415            config: target.clone(),
416            membership: TargetMembership::Desired,
417            server_addr: Arc::from(target.server_addr),
418            remote: None,
419            counters: TargetCounters::default(),
420            phase: Some(TargetPhase::Pending { client_config }),
421        });
422    }
423
424    let (event_sender, _) = broadcast::channel(config.event_capacity);
425    let weak_events = event_sender.downgrade();
426    let (wake_sender, wake_receiver) = watch::channel(());
427    let stop = Arc::new(StopSignal {
428        requested: AtomicBool::new(false),
429        update_admission: AtomicU8::new(UpdateAdmission::Open as u8),
430        wake: wake_sender,
431    });
432    let history = OutcomeHistory::new(config.outcome_history_limit);
433    let initial_targets = runtimes
434        .iter()
435        .map(|target| ManagedTargetStatus {
436            target: target.instance.clone(),
437            desired: target.membership.is_desired(),
438            lifecycle: ManagedTargetLifecycle::Pending,
439            outcome: None,
440            server_addr: Arc::clone(&target.server_addr),
441            remote: None,
442        })
443        .collect::<Vec<_>>();
444    let phase = TaskPhase::NotStarted;
445    let initial = Arc::new(ManagedStatus {
446        lifecycle: phase.lifecycle(),
447        stop_requested: false,
448        applied_command_sequence: 0,
449        desired_target_count: runtimes.len(),
450        connecting_target_count: 0,
451        opening_target_count: 0,
452        active_target_count: 0,
453        draining_target_count: 0,
454        closing_target_count: 0,
455        terminal_target_count: 0,
456        total_target_outcomes: 0,
457        successful_target_outcomes: 0,
458        failed_target_outcomes: 0,
459        peer_closed_target_outcomes: 0,
460        discarded_target_outcomes: 0,
461        targets: Arc::from(initial_targets.into_boxed_slice()),
462        recent_target_outcomes: history.recent(),
463        final_outcome: phase.final_outcome(),
464    });
465    let (status_sender, status_receiver) = watch::channel(initial);
466    let (command_sender, command_receiver) = mpsc::channel(config.command_capacity);
467    let max_live_target_generations = config.max_live_target_generations;
468    let resources = TaskResources {
469        status: status_sender,
470        events: Some(event_sender),
471        stop: Arc::clone(&stop),
472    };
473    let task = ManagedClientTask {
474        #[cfg(test)]
475        timeout_inspections: std::cell::RefCell::new(Vec::new()),
476        phase,
477        config,
478        targets: runtimes,
479        commands: command_receiver,
480        history,
481        resources: Some(resources),
482        wake: Some(arm_wake(wake_receiver)),
483        timer: None,
484        cursor: 0,
485        timeout_cursor: 0,
486        timeout_backlog: VecDeque::with_capacity(TIMEOUT_WORK_BUDGET),
487        scan_remaining: 0,
488        send_cursor: 0,
489        burst_remaining: 0,
490        stagger_remaining: 0,
491        send_gate: None,
492        last_stagger_send: None,
493        stop_observed: false,
494        next_generation,
495        applied_command_sequence: 0,
496    };
497    let handle = ManagedClientHandle {
498        stop,
499        commands: command_sender,
500        max_live_target_generations,
501        status: status_receiver,
502        events: weak_events,
503    };
504    Ok((task, handle))
505}
506
507pub(super) fn validate_target_config(config: &ClientConfig) -> Result<(), ClientError> {
508    config.open.validate()?;
509    SessionMachine::validate_config(config)?;
510    if let Some(ttl) = config.socket.ttl {
511        validate_ttl(ttl)?;
512    }
513    Ok(())
514}