Merge pull request #53 from Lochnair/fix/managed-liveness-control
fix(managed): harden liveness and control bounds
This commit was merged in pull request #53.
This commit is contained in:
5 files changed
+657
-29
No files matched your search
@@ -34,6 +34,13 @@ use crate::{
|
|||||||
timing::ClientTimestamp,
|
timing::ClientTimestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Maximum amount of immediately available opening work performed by one poll.
|
||||||
|
///
|
||||||
|
/// Opening may consume ignored UDP datagrams, roll over an expired attempt, or
|
||||||
|
/// encounter readiness false positives without ever awaiting. Bound that work
|
||||||
|
/// so one managed target cannot retain the current-thread runtime.
|
||||||
|
const OPEN_POLL_WORK_BUDGET: usize = 64;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::client::ProbeSendTimestamps;
|
use crate::client::ProbeSendTimestamps;
|
||||||
|
|
||||||
@@ -315,7 +322,7 @@ impl AsyncClient {
|
|||||||
unreachable!("connected async clients retain an open request");
|
unreachable!("connected async clients retain an open request");
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
for _ in 0..OPEN_POLL_WORK_BUDGET {
|
||||||
if state.cleanup.is_some() {
|
if state.cleanup.is_some() {
|
||||||
return self.poll_open_cleanup(state, cx);
|
return self.poll_open_cleanup(state, cx);
|
||||||
}
|
}
|
||||||
@@ -430,6 +437,11 @@ impl AsyncClient {
|
|||||||
};
|
};
|
||||||
return Poll::Ready(Ok(self.commit_async_open(prepared)));
|
return Poll::Ready(Ok(self.commit_async_open(prepared)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We deliberately truncated known immediate opening work. Arrange for
|
||||||
|
// another poll even if no new socket-readiness edge occurs.
|
||||||
|
cx.waker().wake_by_ref();
|
||||||
|
Poll::Pending
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn poll_send_probe(
|
pub(crate) fn poll_send_probe(
|
||||||
@@ -855,7 +867,6 @@ impl AsyncClient {
|
|||||||
state: &mut AsyncOpenState,
|
state: &mut AsyncOpenState,
|
||||||
cx: &mut Context<'_>,
|
cx: &mut Context<'_>,
|
||||||
) -> Poll<Result<OpenOutcome, ClientError>> {
|
) -> Poll<Result<OpenOutcome, ClientError>> {
|
||||||
loop {
|
|
||||||
let Some(packet) = state
|
let Some(packet) = state
|
||||||
.cleanup
|
.cleanup
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -883,21 +894,21 @@ impl AsyncClient {
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
let send_result = self.socket.try_send(packet);
|
let send_result = self.socket.try_send(packet);
|
||||||
match send_result {
|
match send_result {
|
||||||
Ok(_) => {
|
Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
|
||||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
||||||
Err(_) => {
|
Err(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
|
||||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
|
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
|
||||||
Poll::Pending => return Poll::Pending,
|
Poll::Pending => Poll::Pending,
|
||||||
Poll::Ready(Ok(true)) => {}
|
Poll::Ready(Ok(true)) => {
|
||||||
Poll::Ready(Ok(false) | Err(_)) => {
|
// Retain cleanup state and resume the send in a fresh poll. A
|
||||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
// writable readiness false positive must not spin this future.
|
||||||
|
cx.waker().wake_by_ref();
|
||||||
|
Poll::Pending
|
||||||
}
|
}
|
||||||
|
Poll::Ready(Ok(false) | Err(_)) => {
|
||||||
|
Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,6 +334,56 @@ fn ignored_open_traffic_uses_one_request_and_deadline_per_attempt() {
|
|||||||
assert_eq!(server.finish().len(), 2);
|
assert_eq!(server.finish().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opening_poll_budget_yields_then_public_open_completes() {
|
||||||
|
let (queued, queue_ready) = mpsc::channel();
|
||||||
|
let server = start_server(move |socket, tx| {
|
||||||
|
let (open_packet, peer) = recv_packet(&socket, &tx);
|
||||||
|
let request = decode_open_request(&open_packet, None).unwrap();
|
||||||
|
for _ in 0..=OPEN_POLL_WORK_BUDGET {
|
||||||
|
socket.send_to(&[0_u8], peer).unwrap();
|
||||||
|
}
|
||||||
|
send_open_reply(
|
||||||
|
&socket,
|
||||||
|
peer,
|
||||||
|
request.params,
|
||||||
|
None,
|
||||||
|
flags::FLAG_OPEN | flags::FLAG_REPLY,
|
||||||
|
TOKEN,
|
||||||
|
);
|
||||||
|
queued.send(()).unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime().block_on(async {
|
||||||
|
let mut client = AsyncClient::connect(config(server.addr, None, 0))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
client.socket.writable().await.unwrap();
|
||||||
|
client.test_hooks.pause_open_before_readable.set(true);
|
||||||
|
let mut opening = Box::pin(client.open());
|
||||||
|
|
||||||
|
// Submit the one opening request, then let the server queue all noise
|
||||||
|
// before performing the explicit bounded poll below.
|
||||||
|
assert!(matches!(
|
||||||
|
poll_once(Pin::as_mut(&mut opening)),
|
||||||
|
Poll::Pending
|
||||||
|
));
|
||||||
|
queue_ready.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||||
|
|
||||||
|
// The valid reply is behind more ignored datagrams than one opening
|
||||||
|
// poll may consume, so this cannot complete without the budget yield.
|
||||||
|
assert!(matches!(
|
||||||
|
poll_once(Pin::as_mut(&mut opening)),
|
||||||
|
Poll::Pending
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
opening.await.unwrap(),
|
||||||
|
OpenOutcome::Started { .. }
|
||||||
|
));
|
||||||
|
});
|
||||||
|
assert_eq!(server.finish().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignored_open_traffic_cannot_extend_the_absolute_attempt_deadline() {
|
fn ignored_open_traffic_cannot_extend_the_absolute_attempt_deadline() {
|
||||||
let server = start_server(move |socket, tx| {
|
let server = start_server(move |socket, tx| {
|
||||||
|
|||||||
@@ -268,8 +268,12 @@ pub enum ManagedConfigError {
|
|||||||
TooManyTargets { configured: usize, limit: usize },
|
TooManyTargets { configured: usize, limit: usize },
|
||||||
#[error("managed event capacity must be greater than zero")]
|
#[error("managed event capacity must be greater than zero")]
|
||||||
ZeroEventCapacity,
|
ZeroEventCapacity,
|
||||||
|
#[error("managed event capacity {configured} exceeds Tokio's maximum {maximum}")]
|
||||||
|
EventCapacityTooLarge { configured: usize, maximum: usize },
|
||||||
#[error("managed command capacity must be greater than zero")]
|
#[error("managed command capacity must be greater than zero")]
|
||||||
ZeroCommandCapacity,
|
ZeroCommandCapacity,
|
||||||
|
#[error("managed command capacity {configured} exceeds Tokio's maximum {maximum}")]
|
||||||
|
CommandCapacityTooLarge { configured: usize, maximum: usize },
|
||||||
#[error("managed live-generation limit must be greater than zero")]
|
#[error("managed live-generation limit must be greater than zero")]
|
||||||
ZeroLiveGenerationLimit,
|
ZeroLiveGenerationLimit,
|
||||||
#[error("managed final drain {duration:?} cannot be scheduled from the current instant")]
|
#[error("managed final drain {duration:?} cannot be scheduled from the current instant")]
|
||||||
@@ -289,6 +293,8 @@ pub enum ManagedConfigError {
|
|||||||
pub enum ManagedCommandError {
|
pub enum ManagedCommandError {
|
||||||
#[error("managed target updates are no longer accepted")]
|
#[error("managed target updates are no longer accepted")]
|
||||||
Stopping,
|
Stopping,
|
||||||
|
#[error("managed target update contains {configured} targets but the limit is {limit}")]
|
||||||
|
TooManyTargets { configured: usize, limit: usize },
|
||||||
#[error("managed command queue is full")]
|
#[error("managed command queue is full")]
|
||||||
QueueFull,
|
QueueFull,
|
||||||
#[error("managed command receiver is closed")]
|
#[error("managed command receiver is closed")]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::{
|
|||||||
mem,
|
mem,
|
||||||
pin::Pin,
|
pin::Pin,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
},
|
},
|
||||||
task::{Context, Poll},
|
task::{Context, Poll},
|
||||||
@@ -38,6 +38,9 @@ use super::{
|
|||||||
const TARGET_WORK_BUDGET: usize = 128;
|
const TARGET_WORK_BUDGET: usize = 128;
|
||||||
const COMMAND_WORK_BUDGET: usize = 32;
|
const COMMAND_WORK_BUDGET: usize = 32;
|
||||||
const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
|
const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
|
||||||
|
// Tokio 1.53 broadcast asserts this exact bound before rounding capacity to a
|
||||||
|
// power of two; Tokio does not expose it as a public constant.
|
||||||
|
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
|
||||||
|
|
||||||
/// Entry point for constructing a unified Tokio managed task.
|
/// Entry point for constructing a unified Tokio managed task.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -56,25 +59,64 @@ impl ManagedClient {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct StopSignal {
|
struct StopSignal {
|
||||||
requested: AtomicBool,
|
requested: AtomicBool,
|
||||||
updates_accepted: AtomicBool,
|
update_admission: AtomicU8,
|
||||||
wake: watch::Sender<()>,
|
wake: watch::Sender<()>,
|
||||||
acknowledged: AtomicBool,
|
acknowledged: AtomicBool,
|
||||||
acknowledgement: watch::Sender<()>,
|
acknowledgement: watch::Sender<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum UpdateAdmission {
|
||||||
|
Open,
|
||||||
|
Stopping,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateAdmission {
|
||||||
|
fn from_raw(value: u8) -> Self {
|
||||||
|
match value {
|
||||||
|
value if value == Self::Open as u8 => Self::Open,
|
||||||
|
value if value == Self::Stopping as u8 => Self::Stopping,
|
||||||
|
value if value == Self::Closed as u8 => Self::Closed,
|
||||||
|
_ => unreachable!("managed update admission stores a known state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl StopSignal {
|
impl StopSignal {
|
||||||
fn request(&self) {
|
fn request(&self) {
|
||||||
self.updates_accepted.store(false, Ordering::Release);
|
self.begin_stopping();
|
||||||
if !self.requested.swap(true, Ordering::AcqRel) {
|
if !self.requested.swap(true, Ordering::AcqRel) {
|
||||||
self.wake.send_replace(());
|
self.wake.send_replace(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn updates_accepted(&self) -> bool {
|
fn update_admission(&self) -> UpdateAdmission {
|
||||||
self.updates_accepted.load(Ordering::Acquire)
|
UpdateAdmission::from_raw(self.update_admission.load(Ordering::Acquire))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn begin_stopping(&self) {
|
||||||
|
let mut current = self.update_admission.load(Ordering::Acquire);
|
||||||
|
loop {
|
||||||
|
match UpdateAdmission::from_raw(current) {
|
||||||
|
UpdateAdmission::Open => match self.update_admission.compare_exchange_weak(
|
||||||
|
UpdateAdmission::Open as u8,
|
||||||
|
UpdateAdmission::Stopping as u8,
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
) {
|
||||||
|
Ok(_) => return,
|
||||||
|
Err(observed) => current = observed,
|
||||||
|
},
|
||||||
|
UpdateAdmission::Stopping | UpdateAdmission::Closed => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn close_updates(&self) {
|
fn close_updates(&self) {
|
||||||
self.updates_accepted.store(false, Ordering::Release);
|
self.update_admission
|
||||||
|
.store(UpdateAdmission::Closed as u8, Ordering::Release);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_requested(&self) -> bool {
|
fn is_requested(&self) -> bool {
|
||||||
@@ -93,8 +135,76 @@ impl StopSignal {
|
|||||||
pub struct ManagedClientHandle {
|
pub struct ManagedClientHandle {
|
||||||
stop: Arc<StopSignal>,
|
stop: Arc<StopSignal>,
|
||||||
commands: mpsc::Sender<ManagedCommand>,
|
commands: mpsc::Sender<ManagedCommand>,
|
||||||
|
max_live_target_generations: usize,
|
||||||
status: watch::Receiver<Arc<ManagedStatus>>,
|
status: watch::Receiver<Arc<ManagedStatus>>,
|
||||||
events: broadcast::WeakSender<ManagedEvent>,
|
events: broadcast::WeakSender<ManagedEvent>,
|
||||||
|
#[cfg(test)]
|
||||||
|
update_submission_hook: Option<Arc<UpdateSubmissionTestHook>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct UpdateSubmissionTestHook {
|
||||||
|
state: std::sync::Mutex<UpdateSubmissionTestState>,
|
||||||
|
arrived: std::sync::Condvar,
|
||||||
|
released: std::sync::Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct UpdateSubmissionTestState {
|
||||||
|
armed: bool,
|
||||||
|
arrived: bool,
|
||||||
|
released: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl UpdateSubmissionTestHook {
|
||||||
|
fn arm(&self) {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.armed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pause_after_admission(&self) {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if !state.armed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.arrived = true;
|
||||||
|
self.arrived.notify_all();
|
||||||
|
while !state.released {
|
||||||
|
state = self
|
||||||
|
.released
|
||||||
|
.wait(state)
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_until_arrived(&self) {
|
||||||
|
let state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let (state, timeout) = self
|
||||||
|
.arrived
|
||||||
|
.wait_timeout_while(state, Duration::from_secs(2), |state| !state.arrived)
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
assert!(state.arrived && !timeout.timed_out());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(&self) {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
state.released = true;
|
||||||
|
self.released.notify_all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for ManagedClientHandle {
|
impl fmt::Debug for ManagedClientHandle {
|
||||||
@@ -130,16 +240,41 @@ impl ManagedClientHandle {
|
|||||||
&self,
|
&self,
|
||||||
targets: Vec<ManagedTargetConfig>,
|
targets: Vec<ManagedTargetConfig>,
|
||||||
) -> Result<ManagedCommandReceipt, ManagedCommandError> {
|
) -> Result<ManagedCommandReceipt, ManagedCommandError> {
|
||||||
if !self.stop.updates_accepted() {
|
match self.stop.update_admission() {
|
||||||
return Err(ManagedCommandError::Stopping);
|
UpdateAdmission::Open => {}
|
||||||
|
UpdateAdmission::Stopping => return Err(ManagedCommandError::Stopping),
|
||||||
|
UpdateAdmission::Closed => return Err(ManagedCommandError::DriverClosed),
|
||||||
}
|
}
|
||||||
|
if targets.len() > self.max_live_target_generations {
|
||||||
|
return Err(ManagedCommandError::TooManyTargets {
|
||||||
|
configured: targets.len(),
|
||||||
|
limit: self.max_live_target_generations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(hook) = &self.update_submission_hook {
|
||||||
|
hook.pause_after_admission();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observing `Open` only permits attempting submission. A successful
|
||||||
|
// `try_send` transfers command ownership to the driver. A concurrent
|
||||||
|
// stop may move admission to `Stopping` before this send, allowing the
|
||||||
|
// command to enqueue; the driver-side check immediately before
|
||||||
|
// `apply_targets` determines whether it applies or resolves as
|
||||||
|
// `Stopping`. Terminal sealing closes the receiver before draining
|
||||||
|
// accepted commands.
|
||||||
let (acknowledgement, receiver) = oneshot::channel();
|
let (acknowledgement, receiver) = oneshot::channel();
|
||||||
match self.commands.try_send(ManagedCommand::UpdateTargets {
|
match self.commands.try_send(ManagedCommand::UpdateTargets {
|
||||||
targets,
|
targets,
|
||||||
acknowledgement,
|
acknowledgement,
|
||||||
}) {
|
}) {
|
||||||
Ok(()) => Ok(ManagedCommandReceipt { receiver }),
|
Ok(()) => Ok(ManagedCommandReceipt { receiver }),
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => Err(ManagedCommandError::QueueFull),
|
Err(mpsc::error::TrySendError::Full(_)) => match self.stop.update_admission() {
|
||||||
|
UpdateAdmission::Open => Err(ManagedCommandError::QueueFull),
|
||||||
|
UpdateAdmission::Stopping => Err(ManagedCommandError::Stopping),
|
||||||
|
UpdateAdmission::Closed => Err(ManagedCommandError::DriverClosed),
|
||||||
|
},
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => Err(ManagedCommandError::DriverClosed),
|
Err(mpsc::error::TrySendError::Closed(_)) => Err(ManagedCommandError::DriverClosed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -657,11 +792,15 @@ impl ManagedClientTask {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.stop_observed = true;
|
self.stop_observed = true;
|
||||||
self.resources().stop.close_updates();
|
self.resources().stop.begin_stopping();
|
||||||
|
let lifecycle_transition =
|
||||||
|
self.state != DriverState::Stopping || self.lifecycle != ManagedLifecycle::Stopping;
|
||||||
self.state = DriverState::Stopping;
|
self.state = DriverState::Stopping;
|
||||||
self.lifecycle = ManagedLifecycle::Stopping;
|
self.lifecycle = ManagedLifecycle::Stopping;
|
||||||
self.replace_status();
|
self.replace_status();
|
||||||
|
if lifecycle_transition {
|
||||||
self.publish_event(ManagedEvent::Stopping);
|
self.publish_event(ManagedEvent::Stopping);
|
||||||
|
}
|
||||||
self.resources().stop.acknowledge();
|
self.resources().stop.acknowledge();
|
||||||
self.scan_remaining = self.targets.len();
|
self.scan_remaining = self.targets.len();
|
||||||
self.burst_remaining = 0;
|
self.burst_remaining = 0;
|
||||||
@@ -683,7 +822,7 @@ impl ManagedClientTask {
|
|||||||
// `apply_targets` cannot suspend, so a stop observed here wins over
|
// `apply_targets` cannot suspend, so a stop observed here wins over
|
||||||
// this command; once it passes, this command may complete atomically.
|
// this command; once it passes, this command may complete atomically.
|
||||||
let result = if self.state != DriverState::Running
|
let result = if self.state != DriverState::Running
|
||||||
|| self.resources().stop.is_requested()
|
|| self.resources().stop.update_admission() != UpdateAdmission::Open
|
||||||
{
|
{
|
||||||
Err(ManagedCommandApplyError::Stopping)
|
Err(ManagedCommandApplyError::Stopping)
|
||||||
} else {
|
} else {
|
||||||
@@ -767,7 +906,7 @@ impl ManagedClientTask {
|
|||||||
let stopping = self.targets.iter().all(|runtime| !runtime.desired)
|
let stopping = self.targets.iter().all(|runtime| !runtime.desired)
|
||||||
&& self.config.completion == ManagedCompletionPolicy::FinishWhenQuiescent;
|
&& self.config.completion == ManagedCompletionPolicy::FinishWhenQuiescent;
|
||||||
if stopping {
|
if stopping {
|
||||||
self.resources().stop.close_updates();
|
self.resources().stop.begin_stopping();
|
||||||
self.state = DriverState::Stopping;
|
self.state = DriverState::Stopping;
|
||||||
self.lifecycle = ManagedLifecycle::Stopping;
|
self.lifecycle = ManagedLifecycle::Stopping;
|
||||||
}
|
}
|
||||||
@@ -1677,7 +1816,22 @@ impl ManagedClientTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seal(&mut self, end_reason: ManagedEndReason, failed: bool) -> Poll<ManagedOutcome> {
|
fn seal(&mut self, mut end_reason: ManagedEndReason, failed: bool) -> Poll<ManagedOutcome> {
|
||||||
|
// Sealing is the final stop-observation point. A stop that reaches the
|
||||||
|
// latch before this terminal linearization becomes durable even when
|
||||||
|
// quiescence had already entered `Stopping`.
|
||||||
|
if self.resources().stop.is_requested() {
|
||||||
|
self.observe_stop();
|
||||||
|
}
|
||||||
|
if self.stop_observed && matches!(end_reason, ManagedEndReason::TargetsComplete) {
|
||||||
|
end_reason = ManagedEndReason::StopRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing admission before closing and draining the receiver makes every
|
||||||
|
// accepted command task-owned. Tokio still permits buffered commands
|
||||||
|
// to be drained after `Receiver::close`, but rejects later sends.
|
||||||
|
self.resources().stop.close_updates();
|
||||||
|
self.commands.close();
|
||||||
while let Ok(ManagedCommand::UpdateTargets {
|
while let Ok(ManagedCommand::UpdateTargets {
|
||||||
acknowledgement, ..
|
acknowledgement, ..
|
||||||
}) = self.commands.try_recv()
|
}) = self.commands.try_recv()
|
||||||
@@ -1759,11 +1913,14 @@ impl Future for ManagedClientTask {
|
|||||||
return this.fail_driver(failure);
|
return this.fail_driver(failure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if this.state == DriverState::Running && this.resources().stop.is_requested() {
|
if this.resources().stop.is_requested() {
|
||||||
this.observe_stop();
|
this.observe_stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut immediate = this.process_commands(cx);
|
let mut immediate = this.process_commands(cx);
|
||||||
|
if this.resources().stop.is_requested() {
|
||||||
|
this.observe_stop();
|
||||||
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
immediate |= this.poll_target_pass(cx, now);
|
immediate |= this.poll_target_pass(cx, now);
|
||||||
this.prune_undesired_terminal();
|
this.prune_undesired_terminal();
|
||||||
@@ -1774,6 +1931,10 @@ impl Future for ManagedClientTask {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if this.resources().stop.is_requested() {
|
||||||
|
this.observe_stop();
|
||||||
|
}
|
||||||
|
|
||||||
if this.all_targets_terminal() {
|
if this.all_targets_terminal() {
|
||||||
match this.state {
|
match this.state {
|
||||||
DriverState::Stopping => {
|
DriverState::Stopping => {
|
||||||
@@ -1791,7 +1952,7 @@ impl Future for ManagedClientTask {
|
|||||||
{
|
{
|
||||||
this.state = DriverState::Stopping;
|
this.state = DriverState::Stopping;
|
||||||
this.lifecycle = ManagedLifecycle::Stopping;
|
this.lifecycle = ManagedLifecycle::Stopping;
|
||||||
this.resources().stop.close_updates();
|
this.resources().stop.begin_stopping();
|
||||||
this.replace_status();
|
this.replace_status();
|
||||||
this.publish_event(ManagedEvent::Stopping);
|
this.publish_event(ManagedEvent::Stopping);
|
||||||
return this.seal(ManagedEndReason::TargetsComplete, false);
|
return this.seal(ManagedEndReason::TargetsComplete, false);
|
||||||
@@ -1816,6 +1977,8 @@ impl Drop for ManagedClientTask {
|
|||||||
}
|
}
|
||||||
self.lifecycle = ManagedLifecycle::Abandoned;
|
self.lifecycle = ManagedLifecycle::Abandoned;
|
||||||
self.final_outcome = None;
|
self.final_outcome = None;
|
||||||
|
self.resources().stop.close_updates();
|
||||||
|
self.commands.close();
|
||||||
self.replace_status();
|
self.replace_status();
|
||||||
self.publish_event(ManagedEvent::Abandoned);
|
self.publish_event(ManagedEvent::Abandoned);
|
||||||
self.resources().stop.acknowledge();
|
self.resources().stop.acknowledge();
|
||||||
@@ -1833,9 +1996,21 @@ fn build_task(
|
|||||||
if config.event_capacity == 0 {
|
if config.event_capacity == 0 {
|
||||||
return Err(ManagedConfigError::ZeroEventCapacity);
|
return Err(ManagedConfigError::ZeroEventCapacity);
|
||||||
}
|
}
|
||||||
|
if config.event_capacity > MAX_BROADCAST_CHANNEL_CAPACITY {
|
||||||
|
return Err(ManagedConfigError::EventCapacityTooLarge {
|
||||||
|
configured: config.event_capacity,
|
||||||
|
maximum: MAX_BROADCAST_CHANNEL_CAPACITY,
|
||||||
|
});
|
||||||
|
}
|
||||||
if config.command_capacity == 0 {
|
if config.command_capacity == 0 {
|
||||||
return Err(ManagedConfigError::ZeroCommandCapacity);
|
return Err(ManagedConfigError::ZeroCommandCapacity);
|
||||||
}
|
}
|
||||||
|
if config.command_capacity > tokio::sync::Semaphore::MAX_PERMITS {
|
||||||
|
return Err(ManagedConfigError::CommandCapacityTooLarge {
|
||||||
|
configured: config.command_capacity,
|
||||||
|
maximum: tokio::sync::Semaphore::MAX_PERMITS,
|
||||||
|
});
|
||||||
|
}
|
||||||
if config.max_live_target_generations == 0 {
|
if config.max_live_target_generations == 0 {
|
||||||
return Err(ManagedConfigError::ZeroLiveGenerationLimit);
|
return Err(ManagedConfigError::ZeroLiveGenerationLimit);
|
||||||
}
|
}
|
||||||
@@ -1899,7 +2074,7 @@ fn build_task(
|
|||||||
let (acknowledgement_sender, _) = watch::channel(());
|
let (acknowledgement_sender, _) = watch::channel(());
|
||||||
let stop = Arc::new(StopSignal {
|
let stop = Arc::new(StopSignal {
|
||||||
requested: AtomicBool::new(false),
|
requested: AtomicBool::new(false),
|
||||||
updates_accepted: AtomicBool::new(true),
|
update_admission: AtomicU8::new(UpdateAdmission::Open as u8),
|
||||||
wake: wake_sender,
|
wake: wake_sender,
|
||||||
acknowledged: AtomicBool::new(false),
|
acknowledged: AtomicBool::new(false),
|
||||||
acknowledgement: acknowledgement_sender,
|
acknowledgement: acknowledgement_sender,
|
||||||
@@ -1937,6 +2112,7 @@ fn build_task(
|
|||||||
});
|
});
|
||||||
let (status_sender, status_receiver) = watch::channel(initial);
|
let (status_sender, status_receiver) = watch::channel(initial);
|
||||||
let (command_sender, command_receiver) = mpsc::channel(config.command_capacity);
|
let (command_sender, command_receiver) = mpsc::channel(config.command_capacity);
|
||||||
|
let max_live_target_generations = config.max_live_target_generations;
|
||||||
let resources = TaskResources {
|
let resources = TaskResources {
|
||||||
status: status_sender,
|
status: status_sender,
|
||||||
events: Some(event_sender),
|
events: Some(event_sender),
|
||||||
@@ -1973,8 +2149,11 @@ fn build_task(
|
|||||||
let handle = ManagedClientHandle {
|
let handle = ManagedClientHandle {
|
||||||
stop,
|
stop,
|
||||||
commands: command_sender,
|
commands: command_sender,
|
||||||
|
max_live_target_generations,
|
||||||
status: status_receiver,
|
status: status_receiver,
|
||||||
events: weak_events,
|
events: weak_events,
|
||||||
|
#[cfg(test)]
|
||||||
|
update_submission_hook: None,
|
||||||
};
|
};
|
||||||
Ok((task, handle))
|
Ok((task, handle))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ enum ServerBehavior {
|
|||||||
PeerClose,
|
PeerClose,
|
||||||
DelayedEcho(Duration),
|
DelayedEcho(Duration),
|
||||||
DeferredBurst(usize),
|
DeferredBurst(usize),
|
||||||
|
NoisyOpen(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -47,6 +48,7 @@ struct TestServer {
|
|||||||
records: Arc<Mutex<Vec<PacketRecord>>>,
|
records: Arc<Mutex<Vec<PacketRecord>>>,
|
||||||
probe_seen: Arc<(Mutex<bool>, Condvar)>,
|
probe_seen: Arc<(Mutex<bool>, Condvar)>,
|
||||||
reply_sent: Arc<(Mutex<bool>, Condvar)>,
|
reply_sent: Arc<(Mutex<bool>, Condvar)>,
|
||||||
|
open_reply_sent: Arc<(Mutex<bool>, Condvar)>,
|
||||||
thread: JoinHandle<()>,
|
thread: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +112,8 @@ fn start_server_with_gates(
|
|||||||
let thread_probe_seen = Arc::clone(&probe_seen);
|
let thread_probe_seen = Arc::clone(&probe_seen);
|
||||||
let reply_sent = Arc::new((Mutex::new(false), Condvar::new()));
|
let reply_sent = Arc::new((Mutex::new(false), Condvar::new()));
|
||||||
let thread_reply_sent = Arc::clone(&reply_sent);
|
let thread_reply_sent = Arc::clone(&reply_sent);
|
||||||
|
let open_reply_sent = Arc::new((Mutex::new(false), Condvar::new()));
|
||||||
|
let thread_open_reply_sent = Arc::clone(&open_reply_sent);
|
||||||
let thread = thread::spawn(move || {
|
let thread = thread::spawn(move || {
|
||||||
let mut buffer = [0_u8; 8192];
|
let mut buffer = [0_u8; 8192];
|
||||||
let (open_len, peer) = socket.recv_from(&mut buffer).unwrap();
|
let (open_len, peer) = socket.recv_from(&mut buffer).unwrap();
|
||||||
@@ -137,8 +141,17 @@ fn start_server_with_gates(
|
|||||||
key.as_deref(),
|
key.as_deref(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let noisy_open = matches!(behavior, ServerBehavior::NoisyOpen(_));
|
||||||
|
if let ServerBehavior::NoisyOpen(count) = behavior {
|
||||||
|
for _ in 0..count {
|
||||||
|
socket.send_to(&[0_u8], peer).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
socket.send_to(&open_reply, peer).unwrap();
|
socket.send_to(&open_reply, peer).unwrap();
|
||||||
if no_test {
|
let (sent, ready) = &*thread_open_reply_sent;
|
||||||
|
*sent.lock().unwrap() = true;
|
||||||
|
ready.notify_all();
|
||||||
|
if no_test || noisy_open {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +240,7 @@ fn start_server_with_gates(
|
|||||||
records,
|
records,
|
||||||
probe_seen,
|
probe_seen,
|
||||||
reply_sent,
|
reply_sent,
|
||||||
|
open_reply_sent,
|
||||||
thread,
|
thread,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -805,6 +819,374 @@ fn dynamic_empty_update_orders_stopping_before_acknowledgement() {
|
|||||||
assert!(Arc::ptr_eq(&acknowledgement.status, &events[stopping].1));
|
assert!(Arc::ptr_eq(&acknowledgement.status, &events[stopping].1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noisy_opening_yields_before_stop_observation() {
|
||||||
|
let open_gate = Arc::new(PacketGate::default());
|
||||||
|
let server = start_server_with_gates(
|
||||||
|
ServerBehavior::NoisyOpen(256),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(Arc::clone(&open_gate)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
managed.client.duration = None;
|
||||||
|
let (task, handle) = ManagedClient::task(managed, vec![target("noisy", server.addr)]).unwrap();
|
||||||
|
let runtime = runtime();
|
||||||
|
let mut task = Box::pin(task);
|
||||||
|
|
||||||
|
drive_task_until(&runtime, &mut task, |task| {
|
||||||
|
matches!(task.targets[0].state, TargetState::Opening { .. })
|
||||||
|
});
|
||||||
|
drive_task_until(&runtime, &mut task, |task| {
|
||||||
|
matches!(
|
||||||
|
task.targets[0].state,
|
||||||
|
TargetState::Opening { ref open, .. } if open.has_in_flight_work()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let seen = open_gate.seen.lock().unwrap();
|
||||||
|
let (seen, timeout) = open_gate
|
||||||
|
.seen_ready
|
||||||
|
.wait_timeout_while(seen, Duration::from_secs(2), |seen| !*seen)
|
||||||
|
.unwrap();
|
||||||
|
assert!(*seen && !timeout.timed_out());
|
||||||
|
drop(seen);
|
||||||
|
open_gate.release();
|
||||||
|
wait_flag(&server.open_reply_sent);
|
||||||
|
|
||||||
|
// One managed task poll must leave a valid open reply behind the noisy
|
||||||
|
// queue, rather than letting this target consume all immediate work.
|
||||||
|
let poll = runtime.block_on(poll_fn(|cx| Poll::Ready(task.as_mut().poll(cx))));
|
||||||
|
assert!(poll.is_pending());
|
||||||
|
assert!(matches!(task.targets[0].state, TargetState::Opening { .. }));
|
||||||
|
|
||||||
|
let receipt = handle.stop();
|
||||||
|
let poll = runtime.block_on(poll_fn(|cx| Poll::Ready(task.as_mut().poll(cx))));
|
||||||
|
assert!(poll.is_pending());
|
||||||
|
assert!(handle.status().stop_requested);
|
||||||
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Stopping);
|
||||||
|
let outcome = runtime.block_on(task);
|
||||||
|
runtime.block_on(receipt);
|
||||||
|
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||||
|
server.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_during_quiescent_stopping_is_durable_without_duplicate_event() {
|
||||||
|
let server = start_server(ServerBehavior::Echo, None);
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.client.duration = None;
|
||||||
|
managed.final_drain = Duration::from_millis(80);
|
||||||
|
let (mut task, handle) =
|
||||||
|
ManagedClient::task(managed, vec![target("old", server.addr)]).unwrap();
|
||||||
|
let observations = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
task.event_observations = Some(Arc::clone(&observations));
|
||||||
|
let runtime = runtime();
|
||||||
|
let mut task = Box::pin(task);
|
||||||
|
drive_task_until(&runtime, &mut task, |task| task.active_count() == 1);
|
||||||
|
|
||||||
|
let removal = handle.update_targets(vec![]).unwrap();
|
||||||
|
drive_task_until(&runtime, &mut task, |task| {
|
||||||
|
task.state == DriverState::Stopping
|
||||||
|
&& !task.stop_observed
|
||||||
|
&& task.targets.iter().any(|target| !target.desired)
|
||||||
|
});
|
||||||
|
let acknowledgement = runtime.block_on(removal).unwrap();
|
||||||
|
assert_eq!(acknowledgement.status.lifecycle, ManagedLifecycle::Stopping);
|
||||||
|
|
||||||
|
let stop = handle.stop();
|
||||||
|
let outcome = runtime.block_on(task);
|
||||||
|
runtime.block_on(stop);
|
||||||
|
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||||
|
assert!(handle.status().stop_requested);
|
||||||
|
assert!(outcome
|
||||||
|
.recent_target_outcomes
|
||||||
|
.iter()
|
||||||
|
.any(|outcome| { matches!(outcome.end_reason, ManagedTargetEndReason::Removed) }));
|
||||||
|
assert_eq!(
|
||||||
|
observations
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|(event, _)| matches!(event, ManagedEvent::Stopping))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
server.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_before_enqueue_rejects_without_allocating_a_generation() {
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
let (task, handle) = ManagedClient::task(managed, vec![]).unwrap();
|
||||||
|
let initial = handle.status();
|
||||||
|
let stop = handle.stop();
|
||||||
|
assert!(matches!(
|
||||||
|
handle.update_targets(vec![ManagedTargetConfig::new("later", "127.0.0.1:9")]),
|
||||||
|
Err(ManagedCommandError::Stopping)
|
||||||
|
));
|
||||||
|
assert_eq!(task.next_generation, 1);
|
||||||
|
assert_eq!(task.applied_command_sequence, 0);
|
||||||
|
assert_eq!(handle.status(), initial);
|
||||||
|
let outcome = runtime().block_on(task);
|
||||||
|
runtime().block_on(stop);
|
||||||
|
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paused_submission_cannot_outlive_terminal_seal() {
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
let (task, mut handle) = ManagedClient::task(managed, vec![]).unwrap();
|
||||||
|
let hook = Arc::new(UpdateSubmissionTestHook::default());
|
||||||
|
hook.arm();
|
||||||
|
handle.update_submission_hook = Some(Arc::clone(&hook));
|
||||||
|
let submitter = handle.clone();
|
||||||
|
let sender = thread::spawn(move || {
|
||||||
|
submitter.update_targets(vec![ManagedTargetConfig::new("late", "127.0.0.1:9")])
|
||||||
|
});
|
||||||
|
hook.wait_until_arrived();
|
||||||
|
|
||||||
|
let stop = handle.stop();
|
||||||
|
let runtime = runtime();
|
||||||
|
let mut task = Box::pin(task);
|
||||||
|
let Poll::Ready(outcome) = runtime.block_on(poll_fn(|cx| Poll::Ready(task.as_mut().poll(cx))))
|
||||||
|
else {
|
||||||
|
panic!("empty stopped task did not seal")
|
||||||
|
};
|
||||||
|
runtime.block_on(stop);
|
||||||
|
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||||
|
|
||||||
|
hook.release();
|
||||||
|
assert!(matches!(
|
||||||
|
sender.join().unwrap(),
|
||||||
|
Err(ManagedCommandError::DriverClosed)
|
||||||
|
));
|
||||||
|
drop(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paused_submission_can_enqueue_during_stopping() {
|
||||||
|
let reply_gate = Arc::new(PacketGate::default());
|
||||||
|
let server = start_server_with_gates(
|
||||||
|
ServerBehavior::Echo,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(Arc::clone(&reply_gate)),
|
||||||
|
);
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
managed.client.duration = None;
|
||||||
|
managed.client.interval = Duration::from_secs(1);
|
||||||
|
managed.client.probe_timeout = Duration::from_secs(1);
|
||||||
|
let (task, mut handle) =
|
||||||
|
ManagedClient::task(managed, vec![target("old", server.addr)]).unwrap();
|
||||||
|
let hook = Arc::new(UpdateSubmissionTestHook::default());
|
||||||
|
hook.arm();
|
||||||
|
handle.update_submission_hook = Some(Arc::clone(&hook));
|
||||||
|
let runtime = runtime();
|
||||||
|
let mut task = Box::pin(task);
|
||||||
|
drive_task_until(&runtime, &mut task, |task| {
|
||||||
|
task.targets[0].counters.packets_sent == 1
|
||||||
|
});
|
||||||
|
wait_flag(&server.probe_seen);
|
||||||
|
|
||||||
|
let next_generation = task.next_generation;
|
||||||
|
let applied_command_sequence = task.applied_command_sequence;
|
||||||
|
let desired_membership = task
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| (target.instance.clone(), target.desired))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let submitter = handle.clone();
|
||||||
|
let sender = thread::spawn(move || {
|
||||||
|
submitter.update_targets(vec![ManagedTargetConfig::new("new", "127.0.0.1:9")])
|
||||||
|
});
|
||||||
|
hook.wait_until_arrived();
|
||||||
|
|
||||||
|
let stop = handle.stop();
|
||||||
|
drive_task_until(&runtime, &mut task, |task| {
|
||||||
|
task.state == DriverState::Stopping
|
||||||
|
&& task.stop_observed
|
||||||
|
&& task.resources().stop.update_admission() == UpdateAdmission::Stopping
|
||||||
|
&& matches!(task.targets[0].state, TargetState::Draining { .. })
|
||||||
|
});
|
||||||
|
let stopping_status = handle.status();
|
||||||
|
assert_eq!(stopping_status.lifecycle, ManagedLifecycle::Stopping);
|
||||||
|
|
||||||
|
hook.release();
|
||||||
|
let receipt = sender
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.expect("send must succeed while the receiver remains open during stopping");
|
||||||
|
|
||||||
|
runtime.block_on(poll_fn(|cx| {
|
||||||
|
assert!(task.as_mut().poll(cx).is_pending());
|
||||||
|
Poll::Ready(())
|
||||||
|
}));
|
||||||
|
let result = runtime.block_on(receipt);
|
||||||
|
assert!(!matches!(
|
||||||
|
&result,
|
||||||
|
Err(ManagedCommandApplyError::AcknowledgementDisconnected)
|
||||||
|
));
|
||||||
|
assert!(matches!(result, Err(ManagedCommandApplyError::Stopping)));
|
||||||
|
assert_eq!(task.applied_command_sequence, applied_command_sequence);
|
||||||
|
assert_eq!(task.next_generation, next_generation);
|
||||||
|
assert_eq!(
|
||||||
|
task.targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| (target.instance.clone(), target.desired))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
desired_membership
|
||||||
|
);
|
||||||
|
assert_eq!(handle.status(), stopping_status);
|
||||||
|
|
||||||
|
reply_gate.release();
|
||||||
|
wait_flag(&server.reply_sent);
|
||||||
|
let outcome = runtime.block_on(task);
|
||||||
|
runtime.block_on(stop);
|
||||||
|
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||||
|
server.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepted_update_before_stop_resolves_stopping() {
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
let (task, handle) = ManagedClient::task(managed, vec![]).unwrap();
|
||||||
|
let receipt = handle
|
||||||
|
.update_targets(vec![ManagedTargetConfig::new("queued", "127.0.0.1:9")])
|
||||||
|
.unwrap();
|
||||||
|
let stop = handle.stop();
|
||||||
|
let outcome = runtime().block_on(task);
|
||||||
|
runtime().block_on(stop);
|
||||||
|
assert_eq!(outcome.applied_command_sequence, 0);
|
||||||
|
assert!(matches!(
|
||||||
|
runtime().block_on(receipt),
|
||||||
|
Err(ManagedCommandApplyError::Stopping)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepted_update_before_driver_failure_resolves_driver_failed() {
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
let (task, handle) = ManagedClient::task(managed, vec![]).unwrap();
|
||||||
|
let receipt = handle
|
||||||
|
.update_targets(vec![ManagedTargetConfig::new("queued", "127.0.0.1:9")])
|
||||||
|
.unwrap();
|
||||||
|
let mut task = Box::pin(task);
|
||||||
|
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
||||||
|
panic!("first poll outside Tokio did not fail")
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
outcome.end_reason,
|
||||||
|
ManagedEndReason::DriverFailed(ManagedDriverFailure::NoTokioRuntime)
|
||||||
|
);
|
||||||
|
let mut receipt = Box::pin(receipt);
|
||||||
|
assert!(matches!(
|
||||||
|
poll_once(Pin::as_mut(&mut receipt)),
|
||||||
|
Poll::Ready(Err(ManagedCommandApplyError::DriverFailed(
|
||||||
|
ManagedDriverFailure::NoTokioRuntime
|
||||||
|
)))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
handle.update_targets(vec![]),
|
||||||
|
Err(ManagedCommandError::DriverClosed)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extreme_event_capacity_is_rejected_without_panic() {
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
managed.event_capacity = usize::MAX;
|
||||||
|
assert!(matches!(
|
||||||
|
ManagedClient::task(managed, vec![]),
|
||||||
|
Err(ManagedConfigError::EventCapacityTooLarge {
|
||||||
|
configured: usize::MAX,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extreme_command_capacity_is_rejected_without_panic() {
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
managed.command_capacity = usize::MAX;
|
||||||
|
assert!(matches!(
|
||||||
|
ManagedClient::task(managed, vec![]),
|
||||||
|
Err(ManagedConfigError::CommandCapacityTooLarge {
|
||||||
|
configured: usize::MAX,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_desired_set_is_rejected_before_enqueue() {
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
managed.max_live_target_generations = 1;
|
||||||
|
let (task, handle) = ManagedClient::task(managed, vec![]).unwrap();
|
||||||
|
let initial = handle.status();
|
||||||
|
let capacity = handle.commands.capacity();
|
||||||
|
assert!(matches!(
|
||||||
|
handle.update_targets(vec![
|
||||||
|
ManagedTargetConfig::new("one", "127.0.0.1:9"),
|
||||||
|
ManagedTargetConfig::new("two", "127.0.0.1:10"),
|
||||||
|
]),
|
||||||
|
Err(ManagedCommandError::TooManyTargets {
|
||||||
|
configured: 2,
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert_eq!(handle.commands.capacity(), capacity);
|
||||||
|
assert_eq!(task.next_generation, 1);
|
||||||
|
assert_eq!(task.applied_command_sequence, 0);
|
||||||
|
assert_eq!(handle.status(), initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legal_desired_set_can_still_fail_at_overlap_application() {
|
||||||
|
let server = start_server(ServerBehavior::Echo, None);
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
managed.client.duration = None;
|
||||||
|
managed.max_live_target_generations = 1;
|
||||||
|
let (task, handle) = ManagedClient::task(managed, vec![target("same", server.addr)]).unwrap();
|
||||||
|
let runtime = runtime();
|
||||||
|
let mut task = Box::pin(task);
|
||||||
|
drive_task_until(&runtime, &mut task, |task| task.active_count() == 1);
|
||||||
|
|
||||||
|
let receipt = handle
|
||||||
|
.update_targets(vec![ManagedTargetConfig::new("same", "127.0.0.1:10")])
|
||||||
|
.unwrap();
|
||||||
|
let mut receipt = Box::pin(receipt);
|
||||||
|
let result = runtime.block_on(poll_fn(|cx| {
|
||||||
|
assert!(task.as_mut().poll(cx).is_pending());
|
||||||
|
match receipt.as_mut().poll(cx) {
|
||||||
|
Poll::Ready(result) => Poll::Ready(result),
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded {
|
||||||
|
required: 2,
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
));
|
||||||
|
let stop = handle.stop();
|
||||||
|
runtime.block_on(task);
|
||||||
|
runtime.block_on(stop);
|
||||||
|
server.finish();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dynamic_explicit_remove_and_readd_uses_new_generation() {
|
fn dynamic_explicit_remove_and_readd_uses_new_generation() {
|
||||||
let first = start_server(ServerBehavior::Echo, None);
|
let first = start_server(ServerBehavior::Echo, None);
|
||||||
|
|||||||
Reference in new issue
Block a user