feat(managed): add unified Tokio managed task #49

Merged
Lochnair merged 5 commits from feat/tokio-managed-task into main 2026-08-04 17:19:18 +00:00
9 changed files with 1720 additions and 76 deletions
Showing only changes of commit fd3c29e63a - Show all commits

No files matched your search

+35
View File
@@ -682,6 +682,41 @@ impl AsyncClient {
self.machine.is_peer_closed()
}
pub(crate) fn remote_addr(&self) -> SocketAddr {
self.remote
}
pub(crate) fn packets_sent(&self) -> u64 {
self.machine.packets_sent()
}
pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> {
self.machine.next_probe_timeout_deadline()
}
pub(crate) fn latest_probe_timeout_deadline(&self) -> Option<Instant> {
self.machine.latest_probe_timeout_deadline()
}
pub(crate) fn discard_prepared_probe(&mut self) {
self.prepared_probe = None;
}
pub(crate) fn skip_missed_probe_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
let Some(schedule) = self.schedule.as_mut() else {
return Ok(());
};
let Some(deadline) = schedule.next_send_deadline() else {
return Ok(());
};
if deadline > now {
return Ok(());
}
let commit = schedule.preflight_managed_commit(deadline, now)?;
schedule.commit(commit);
Ok(())
}
fn prepare_async_open(
&mut self,
machine: PreparedOpenAcceptance,
+4 -3
View File
@@ -59,7 +59,7 @@ mod client;
mod config;
mod error;
mod event;
mod managed;
pub mod managed;
mod metadata;
mod probe;
mod receive;
@@ -80,14 +80,15 @@ pub use event::{
ClientEvent, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample, RttSample,
ServerTiming, SignedDuration, WarningKind,
};
pub use managed::{
pub use managed::legacy::{
CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientGroup,
ManagedClientGroupConfig, ManagedClientGroupSession, ManagedClientSession,
ManagedGroupCompletionPolicy, ManagedGroupEndReason, ManagedGroupEvent, ManagedGroupOutcome,
ManagedGroupPacing, ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetFailure,
ManagedTargetFailureKind, ManagedTargetOutcome, SessionEndReason, SessionOutcome,
SubscriberConfig, SubscriberOverflow, TargetEvent, TargetEventSubscription, TargetId,
SubscriberConfig, SubscriberOverflow, TargetEvent, TargetEventSubscription,
MANAGED_GROUP_OUTCOME_HISTORY_LIMIT,
};
pub use managed::TargetId;
pub use session::{NegotiatedParams, NegotiationRestriction};
pub use timing::ClientTimestamp;
+2 -55
View File
@@ -1,7 +1,6 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
fmt,
hash::{Hash, Hasher},
net::{SocketAddr, UdpSocket},
sync::{
atomic::{AtomicU64, Ordering},
@@ -37,6 +36,7 @@ use crate::client::ProbeSendTimestamps;
use super::{
cancellation::CancellationToken,
hub::{EventHub, EventSubscription, SubscriberConfig},
TargetId,
};
const GROUP_RECV_TIMEOUT: Duration = Duration::from_millis(20);
@@ -49,59 +49,6 @@ const RECV_BUFFER_SIZE: usize = 65_536;
/// Maximum number of recent target outcomes retained in [`ManagedGroupOutcome`].
pub const MANAGED_GROUP_OUTCOME_HISTORY_LIMIT: usize = 256;
/// Caller-owned target identity for managed multi-target probing.
#[derive(Clone, Eq)]
pub struct TargetId(Arc<str>);
impl TargetId {
/// Borrow the identifier as a string slice.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for TargetId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("TargetId").field(&self.0).finish()
}
}
impl fmt::Display for TargetId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl PartialEq for TargetId {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl Hash for TargetId {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.hash(state);
}
}
impl From<&str> for TargetId {
fn from(value: &str) -> Self {
Self(Arc::from(value))
}
}
impl From<String> for TargetId {
fn from(value: String) -> Self {
Self(Arc::from(value))
}
}
impl AsRef<str> for TargetId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
/// Per-target configuration for [`ManagedClientGroup`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagedTargetConfig {
@@ -2283,7 +2230,7 @@ fn divide_duration(duration: Duration, divisor: usize) -> Duration {
mod tests {
use super::*;
use crate::{
config::NegotiationPolicy, managed::SubscriberOverflow, EventSubscriptionError, WarningKind,
config::NegotiationPolicy, EventSubscriptionError, SubscriberOverflow, WarningKind,
};
use irtt_proto::{
compute_hmac_in_place, echo_packet_len,
+30 -10
View File
@@ -2,14 +2,34 @@ mod cancellation;
mod group;
mod hub;
mod runner;
mod target_id;
pub use cancellation::CancellationToken;
pub use group::{
ManagedClientGroup, ManagedClientGroupConfig, ManagedClientGroupSession,
ManagedGroupCompletionPolicy, ManagedGroupEndReason, ManagedGroupEvent, ManagedGroupOutcome,
ManagedGroupPacing, ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetFailure,
ManagedTargetFailureKind, ManagedTargetOutcome, TargetEvent, TargetEventSubscription, TargetId,
MANAGED_GROUP_OUTCOME_HISTORY_LIMIT,
};
pub use hub::{EventHub, EventSubscription, SubscriberConfig, SubscriberOverflow};
pub use runner::{ManagedClient, ManagedClientSession, SessionEndReason, SessionOutcome};
pub use target_id::TargetId;
/// Temporary exports for the obsolete thread-based managed implementations.
#[doc(hidden)]
pub mod legacy {
pub use super::cancellation::CancellationToken;
pub use super::group::{
ManagedClientGroup, ManagedClientGroupConfig, ManagedClientGroupSession,
ManagedGroupCompletionPolicy, ManagedGroupEndReason, ManagedGroupEvent,
ManagedGroupOutcome, ManagedGroupPacing, ManagedTargetConfig, ManagedTargetEndReason,
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetOutcome, TargetEvent,
TargetEventSubscription, MANAGED_GROUP_OUTCOME_HISTORY_LIMIT,
};
pub use super::hub::{EventHub, EventSubscription, SubscriberConfig, SubscriberOverflow};
pub use super::runner::{
ManagedClient, ManagedClientSession, SessionEndReason, SessionOutcome,
};
pub use super::TargetId;
}
#[cfg(feature = "tokio")]
mod model;
#[cfg(feature = "tokio")]
mod task;
#[cfg(feature = "tokio")]
pub use model::*;
#[cfg(feature = "tokio")]
pub use task::*;
+329
View File
@@ -0,0 +1,329 @@
use std::{net::SocketAddr, sync::Arc, time::Duration};
use thiserror::Error;
use crate::{ClientAuthConfig, ClientConfig, ClientError, ClientEvent};
use super::TargetId;
/// Default capacity of the lossy managed event channel.
pub const DEFAULT_MANAGED_EVENT_CAPACITY: usize = 256;
/// Default number of recent target outcomes retained in status and output.
pub const DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT: usize = 256;
/// Default maximum number of simultaneously live target generations.
pub const DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
/// Default time retained for late replies after the final committed timeout.
pub const DEFAULT_MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
/// One globally allocated incarnation of a target within a managed task.
///
/// Generations increase globally across the task, not independently per ID.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct TargetInstance {
pub id: TargetId,
pub generation: u64,
}
/// Endpoint and optional authentication override for one managed target.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedTargetConfig {
pub id: TargetId,
pub server_addr: String,
pub auth: Option<ClientAuthConfig>,
}
impl ManagedTargetConfig {
pub fn new(id: impl Into<TargetId>, server_addr: impl Into<String>) -> Self {
Self {
id: id.into(),
server_addr: server_addr.into(),
auth: None,
}
}
}
/// Coordination mode for sends across active targets.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ManagedPacing {
#[default]
Staggered,
Burst,
}
/// Policy controlling top-level natural completion.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ManagedCompletionPolicy {
#[default]
FinishWhenQuiescent,
ExplicitStop,
}
/// Shared configuration for one managed task.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedClientConfig {
pub client: ClientConfig,
pub pacing: ManagedPacing,
pub completion: ManagedCompletionPolicy,
pub event_capacity: usize,
pub outcome_history_limit: usize,
pub max_live_target_generations: usize,
pub final_drain: Duration,
}
impl Default for ManagedClientConfig {
fn default() -> Self {
Self {
client: ClientConfig::default(),
pacing: ManagedPacing::default(),
completion: ManagedCompletionPolicy::default(),
event_capacity: DEFAULT_MANAGED_EVENT_CAPACITY,
outcome_history_limit: DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT,
max_live_target_generations: DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS,
final_drain: DEFAULT_MANAGED_FINAL_DRAIN,
}
}
}
/// Durable top-level lifecycle.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagedLifecycle {
NotStarted,
Running,
Stopping,
Completed,
Failed,
Abandoned,
}
/// Durable lifecycle for one target generation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagedTargetLifecycle {
Pending,
Connecting,
Opening,
Active,
Draining,
Closing,
Terminal,
}
/// Status for one target generation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedTargetStatus {
pub target: TargetInstance,
pub lifecycle: ManagedTargetLifecycle,
pub server_addr: Arc<str>,
pub remote: Option<SocketAddr>,
}
/// Authoritative immutable managed status snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedStatus {
pub lifecycle: ManagedLifecycle,
pub stop_requested: bool,
pub desired_target_count: usize,
pub connecting_target_count: usize,
pub opening_target_count: usize,
pub active_target_count: usize,
pub draining_target_count: usize,
pub closing_target_count: usize,
pub terminal_target_count: usize,
pub total_target_outcomes: u64,
pub successful_target_outcomes: u64,
pub failed_target_outcomes: u64,
pub peer_closed_target_outcomes: u64,
pub discarded_target_outcomes: u64,
pub targets: Arc<[ManagedTargetStatus]>,
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
pub final_outcome: Option<Arc<ManagedOutcome>>,
}
/// Lossy presentation event emitted by the managed task.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ManagedEvent {
Started,
TargetStateChanged {
target: TargetInstance,
lifecycle: ManagedTargetLifecycle,
},
Client {
target: TargetInstance,
event: ClientEvent,
},
TargetFinished {
outcome: Arc<ManagedTargetOutcome>,
},
Stopping,
Completed {
outcome: Arc<ManagedOutcome>,
},
Failed {
outcome: Arc<ManagedOutcome>,
},
Abandoned,
}
/// Receiving half of the lossy managed event stream.
pub type ManagedEventSubscription = tokio::sync::broadcast::Receiver<ManagedEvent>;
/// Final authoritative outcome returned by the task.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedOutcome {
pub end_reason: ManagedEndReason,
pub total_target_outcomes: u64,
pub successful_target_outcomes: u64,
pub failed_target_outcomes: u64,
pub peer_closed_target_outcomes: u64,
pub discarded_target_outcomes: u64,
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
}
/// Why the managed task ended.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ManagedEndReason {
TargetsComplete,
StopRequested,
DriverFailed(ManagedDriverFailure),
}
/// Durable outcome for one target generation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedTargetOutcome {
pub target: TargetInstance,
pub server_addr: Arc<str>,
pub remote: Option<SocketAddr>,
pub end_reason: ManagedTargetEndReason,
pub packets_sent: u64,
pub replies_received: u64,
pub duplicates: u64,
pub late: u64,
pub warning_events: u64,
pub cleanup_failure: Option<ManagedTargetFailure>,
}
/// Why one target generation ended.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ManagedTargetEndReason {
TestComplete,
NoTestComplete,
PeerClosed,
Removed,
Replaced,
Stopped,
Failed(ManagedTargetFailure),
}
/// Phase in which a target-local failure occurred.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagedTargetFailurePhase {
Connecting,
Opening,
Sending,
Receiving,
Timing,
Closing,
}
/// Stable category for a target-local failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagedTargetFailureKind {
Resolve,
Socket,
SocketOption,
Protocol,
Timeout,
Configuration,
ResourceExhausted,
InvalidState,
Other,
}
/// Durable target-local failure details.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedTargetFailure {
pub phase: ManagedTargetFailurePhase,
pub kind: ManagedTargetFailureKind,
pub message: Arc<str>,
}
/// Configuration failure detected before any runtime work begins.
#[derive(Debug, Error)]
pub enum ManagedConfigError {
#[error("FinishWhenQuiescent requires at least one initial target")]
EmptyInitialTargets,
#[error("duplicate managed target id {id}")]
DuplicateTargetId { id: TargetId },
#[error("configured {configured} targets exceeds live-generation limit {limit}")]
TooManyTargets { configured: usize, limit: usize },
#[error("managed event capacity must be greater than zero")]
ZeroEventCapacity,
#[error("managed live-generation limit must be greater than zero")]
ZeroLiveGenerationLimit,
#[error("invalid managed target {id}: {source}")]
InvalidTarget {
id: TargetId,
#[source]
source: ClientError,
},
#[error("managed target generation space is exhausted")]
GenerationExhausted,
}
/// Failure to create a new event subscription.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum ManagedSubscribeError {
#[error("managed event stream is closed")]
Closed,
}
/// Whole-driver failure.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum ManagedDriverFailure {
#[error("ManagedClientTask requires a current Tokio runtime")]
NoTokioRuntime,
#[error("managed driver exhausted resources for {operation}")]
ResourceExhausted { operation: &'static str },
#[error("managed driver invariant failed: {message}")]
Internal { message: Arc<str> },
}
pub(crate) fn classify_client_error(
phase: ManagedTargetFailurePhase,
error: &ClientError,
) -> ManagedTargetFailure {
let kind = match error {
ClientError::Resolve { .. } => ManagedTargetFailureKind::Resolve,
ClientError::Socket(_) => ManagedTargetFailureKind::Socket,
ClientError::SocketOption { .. } | ClientError::ReadTimeoutRestore { .. } => {
ManagedTargetFailureKind::SocketOption
}
ClientError::Protocol(_)
| ClientError::ProtocolVersionMismatch { .. }
| ClientError::ZeroToken
| ClientError::UnexpectedNoTestReply
| ClientError::NonZeroNoTestToken { .. }
| ClientError::ServerRejected
| ClientError::NegotiationRejected { .. } => ManagedTargetFailureKind::Protocol,
ClientError::OpenTimeout => ManagedTargetFailureKind::Timeout,
ClientError::InvalidConfig { .. }
| ClientError::OpenTimeoutTooSmall { .. }
| ClientError::NoOpenTimeouts => ManagedTargetFailureKind::Configuration,
ClientError::AllocationFailed { .. }
| ClientError::CounterOverflow { .. }
| ClientError::DurationOverflow
| ClientError::PendingLimitExceeded { .. } => ManagedTargetFailureKind::ResourceExhausted,
ClientError::NotOpen
| ClientError::AlreadyOpen
| ClientError::AlreadyCompleted
| ClientError::AlreadyClosed
| ClientError::StalePreparedProbe { .. }
| ClientError::PendingSequenceCollision { .. } => ManagedTargetFailureKind::InvalidState,
_ => ManagedTargetFailureKind::Other,
};
ManagedTargetFailure {
phase,
kind,
message: Arc::from(error.to_string()),
}
}
@@ -0,0 +1,47 @@
use std::{fmt, sync::Arc};
/// Caller-owned stable target identity.
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct TargetId(Arc<str>);
impl TargetId {
/// Construct a target identifier.
pub fn new(value: impl Into<Arc<str>>) -> Self {
Self(value.into())
}
/// Borrow the identifier as a string slice.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for TargetId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("TargetId").field(&self.0).finish()
}
}
impl fmt::Display for TargetId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for TargetId {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for TargetId {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl AsRef<str> for TargetId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
File diff suppressed because it is too large. Load diff
+15
View File
@@ -71,6 +71,16 @@ impl PendingMap {
self.map.len()
}
#[cfg(feature = "tokio")]
pub fn next_timeout_deadline(&self) -> Option<Instant> {
self.map.values().map(|probe| probe.timeout_at).min()
}
#[cfg(feature = "tokio")]
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
self.map.values().map(|probe| probe.timeout_at).max()
}
#[cfg(test)]
pub fn contains(&self, wire_seq: u32) -> bool {
self.map.contains_key(&wire_seq)
@@ -139,6 +149,11 @@ impl TimedOutMap {
self.map.len()
}
#[cfg(feature = "tokio")]
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
self.map.values().map(|probe| probe.timeout_at).max()
}
#[cfg(test)]
pub fn contains(&self, wire_seq: u32) -> bool {
self.map.contains_key(&wire_seq)
+38 -8
View File
@@ -169,6 +169,18 @@ impl SessionMachine {
config: ClientConfig,
remote: std::net::SocketAddr,
) -> Result<Self, ClientError> {
Self::validate_config(&config)?;
let requested = params_from_config(&config)?;
Ok(Self {
config,
remote,
requested,
state: MachineState::Connected,
})
}
pub(crate) fn validate_config(config: &ClientConfig) -> Result<(), ClientError> {
if config.max_pending_probes == 0 {
return Err(ClientError::InvalidConfig {
reason: "max_pending_probes must be greater than zero".to_owned(),
@@ -179,14 +191,7 @@ impl SessionMachine {
reason: "probe_timeout must be greater than zero".to_owned(),
});
}
let requested = params_from_config(&config)?;
Ok(Self {
config,
remote,
requested,
state: MachineState::Connected,
})
params_from_config(config).map(|_| ())
}
pub(crate) fn config(&self) -> &ClientConfig {
@@ -490,6 +495,31 @@ impl SessionMachine {
}
}
#[cfg(feature = "tokio")]
pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> {
match &self.state {
MachineState::Open(session) => session.pending.next_timeout_deadline(),
MachineState::Connected
| MachineState::NoTestCompleted
| MachineState::Closed { .. } => None,
}
}
#[cfg(feature = "tokio")]
pub(crate) fn latest_probe_timeout_deadline(&self) -> Option<Instant> {
match &self.state {
MachineState::Open(session) => session
.pending
.latest_timeout_deadline()
.into_iter()
.chain(session.timed_out.latest_timeout_deadline())
.max(),
MachineState::Connected
| MachineState::NoTestCompleted
| MachineState::Closed { .. } => None,
}
}
pub(crate) fn is_open(&self) -> bool {
matches!(self.state, MachineState::Open(_))
}