fix(client): wake finite managed schedules under backpressure

Expose the negotiated schedule end only while the schedule is unfinished,
and include it among Active targets' non-send deadlines. This keeps finite
expiry independent of socket writability and of Burst/Staggered pacing.
Staggered release gating continues to apply only to send opportunities.

The existing expiry path marks the schedule finished, removing its lifetime
deadline before pending probes continue draining. No new state, readiness
handling, cadence, or drain/close policy is introduced. Continuous schedules
have no lifetime deadline.

The prior public-API Linux namespace/veth/TBF investigation observed genuine
EAGAIN with Burst pacing, 500 ms duration, 1 ms interval, 1400-byte probes,
and 20 ms probe timeout. Polling stopped around 327 ms after pending probes
disappeared, remained stalled beyond 1.5 s, and resumed on queue release.
Staggered applicability follows from the shared deadline selection; it was
not separately reproduced under TBF.

Retain two deterministic owning-layer tests for schedule deadline lifetime
and managed selection under send_waiting and stagger gating, using ordinary
Instant values and a production socket. Keep the privileged networking
harness as investigation evidence rather than adding bespoke test machinery.

Validation: formatting; focused schedule/managed tests; managed lifecycle,
behavior, and blocking-owner integration tests; workspace all-feature check,
all-target tests, doctests, and strict Clippy; client no-default-features
check/tests/strict Clippy and Tokio-free normal dependency tree; Linux
all-feature client and integration suites, including ancillary transport.
This commit is contained in:
Nils Andreas Svee committed 2026-10-04 15:41:06 +07:00
1 parent 4b05c3027b
commit 798efaf360
2 files changed
+115

No files matched your search

@@ -48,6 +48,11 @@ impl ProbeSchedule {
self.next_send_at self.next_send_at
} }
/// Wake for finite expiry independently of send readiness until finished.
pub(crate) fn end_deadline(&self) -> Option<Instant> {
self.end_at.filter(|_| !self.is_finished())
}
pub(crate) fn interval(&self) -> Duration { pub(crate) fn interval(&self) -> Duration {
self.interval self.interval
} }
@@ -152,3 +157,42 @@ pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
.or_else(|| right.checked_duration_since(left)) .or_else(|| right.checked_duration_since(left))
.unwrap_or(Duration::ZERO) .unwrap_or(Duration::ZERO)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lifetime_deadline_exists_only_for_an_unfinished_finite_schedule() {
let start = Instant::now();
let end = start + Duration::from_millis(500);
let mut negotiated = NegotiatedParams {
params: irtt_proto::Params {
duration_ns: 500_000_000,
interval_ns: 1_000_000,
..irtt_proto::Params::default()
},
restrictions: Vec::new(),
};
let mut finite = ProbeSchedule::new(start, &negotiated).unwrap();
assert_eq!(finite.end_deadline(), Some(end));
assert!(finite.permit_probe_at(end - Duration::from_nanos(1)));
assert_eq!(finite.end_deadline(), Some(end));
assert!(!finite.permit_probe_at(end));
assert!(finite.is_finished());
assert_eq!(finite.end_deadline(), None);
let mut finite = ProbeSchedule::new(start, &negotiated).unwrap();
let commit = finite
.preflight_managed_commit(start, end - Duration::from_nanos(1))
.unwrap();
finite.commit(commit);
assert!(finite.is_finished());
assert_eq!(finite.end_deadline(), None);
negotiated.params.duration_ns = 0;
let mut continuous = ProbeSchedule::new(start, &negotiated).unwrap();
assert!(continuous.permit_probe_at(end));
assert_eq!(continuous.end_deadline(), None);
}
}
+71
View File
@@ -1936,6 +1936,12 @@ impl ManagedClientTask {
non_send_deadline = non_send_deadline non_send_deadline = non_send_deadline
.into_iter() .into_iter()
.chain(client.next_probe_timeout_deadline()) .chain(client.next_probe_timeout_deadline())
.chain(
target
.schedule
.as_ref()
.and_then(ProbeSchedule::end_deadline),
)
.min(); .min();
if target.desired && target.retirement.is_none() && !target.send_waiting { if target.desired && target.retirement.is_none() && !target.send_waiting {
send_deadline = send_deadline send_deadline = send_deadline
@@ -2399,6 +2405,71 @@ fn close_timeout_failure() -> ManagedTargetFailure {
mod tests { mod tests {
use super::*; use super::*;
// Real EAGAIN needs privileged queue shaping. Inspect only the owning
// deadline selection here, using a production socket without sending.
#[test]
fn finite_lifetime_deadline_survives_send_waiting_and_stagger_gating() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let start = Instant::now();
let end = start + Duration::from_millis(500);
let negotiated = crate::NegotiatedParams {
params: irtt_proto::Params {
duration_ns: 500_000_000,
interval_ns: 1_000_000,
..irtt_proto::Params::default()
},
restrictions: Vec::new(),
};
for pacing in [ManagedPacing::Burst, ManagedPacing::Staggered] {
let (mut task, _handle) = ManagedClient::task(
ManagedClientConfig {
pacing,
..ManagedClientConfig::default()
},
vec![ManagedTargetConfig::new("target", "127.0.0.1:9")],
)
.unwrap();
let client = AsyncClient::connect(ClientConfig {
server_addr: "127.0.0.1:9".into(),
..ClientConfig::default()
})
.await
.unwrap();
task.state = DriverState::Running;
task.install_target_state(0, TargetState::Active { client });
task.send_gate = Some(end + Duration::from_secs(1));
for send_waiting in [false, true] {
task.targets[0].send_waiting = send_waiting;
task.targets[0].schedule =
Some(ProbeSchedule::new(start, &negotiated).unwrap());
let expected = if pacing == ManagedPacing::Burst && !send_waiting {
start
} else {
end
};
assert_eq!(task.next_deadline(), Some(expected));
task.targets[0]
.schedule
.as_mut()
.unwrap()
.permit_probe_at(end);
assert_eq!(task.next_deadline(), None);
}
let mut continuous = negotiated.clone();
continuous.params.duration_ns = 0;
task.targets[0].schedule = Some(ProbeSchedule::new(start, &continuous).unwrap());
assert_eq!(task.next_deadline(), None);
}
});
}
#[test] #[test]
fn release_gate_only_delays_an_existing_send_deadline() { fn release_gate_only_delays_an_existing_send_deadline() {
let earlier = Instant::now(); let earlier = Instant::now();