Retransmit stream controls in transport
ci / test (push) Waiting to run
ci / fuzz-smoke (push) Waiting to run
ci / windows-client (push) Waiting to run
ci / package-release (linux-x86_64, ubuntu-latest) (push) Waiting to run
ci / package-release (macos-aarch64, macos-14) (push) Waiting to run
ci / package-release (macos-x86_64, macos-13) (push) Waiting to run
ci / package-release (windows-x86_64, windows-latest) (push) Waiting to run
ci / publish-gitea-release (push) Blocked by required conditions
ci / remote-bench (push) Waiting to run
ci / test (push) Waiting to run
ci / fuzz-smoke (push) Waiting to run
ci / windows-client (push) Waiting to run
ci / package-release (linux-x86_64, ubuntu-latest) (push) Waiting to run
ci / package-release (macos-aarch64, macos-14) (push) Waiting to run
ci / package-release (macos-x86_64, macos-13) (push) Waiting to run
ci / package-release (windows-x86_64, windows-latest) (push) Waiting to run
ci / publish-gitea-release (push) Blocked by required conditions
ci / remote-bench (push) Waiting to run
This commit is contained in:
+283
-3
@@ -9,7 +9,8 @@
|
|||||||
//! 1. authenticate and maintain a Dosh session using [`crate::protocol`],
|
//! 1. authenticate and maintain a Dosh session using [`crate::protocol`],
|
||||||
//! 2. encrypt each [`OutgoingStreamPacket::body`] as the given packet kind,
|
//! 2. encrypt each [`OutgoingStreamPacket::body`] as the given packet kind,
|
||||||
//! 3. pass received stream packet bodies back into [`StreamMux`],
|
//! 3. pass received stream packet bodies back into [`StreamMux`],
|
||||||
//! 4. call [`StreamMux::tick`] periodically to retransmit lost stream data.
|
//! 4. call [`StreamMux::tick`] periodically to retransmit lost stream data and
|
||||||
|
//! idempotent stream control packets.
|
||||||
//!
|
//!
|
||||||
//! That split is intentional: Dosh roaming/reconnect stays at the authenticated
|
//! That split is intentional: Dosh roaming/reconnect stays at the authenticated
|
||||||
//! session layer, while this module provides the reusable fast persistent byte
|
//! session layer, while this module provides the reusable fast persistent byte
|
||||||
@@ -33,6 +34,7 @@ pub const ADAPTIVE_RETRANSMIT_PAD: Duration = Duration::from_millis(10);
|
|||||||
pub const ADAPTIVE_RETRANSMIT_MIN: Duration = Duration::from_millis(10);
|
pub const ADAPTIVE_RETRANSMIT_MIN: Duration = Duration::from_millis(10);
|
||||||
pub const DEFAULT_KEEPALIVE_AFTER: Duration = Duration::from_secs(2);
|
pub const DEFAULT_KEEPALIVE_AFTER: Duration = Duration::from_secs(2);
|
||||||
pub const DEFAULT_RETIRED_STREAM_TOMBSTONES: usize = 16 * 1024;
|
pub const DEFAULT_RETIRED_STREAM_TOMBSTONES: usize = 16 * 1024;
|
||||||
|
pub const STREAM_CONTROL_RETRANSMIT_MAX_ATTEMPTS: u32 = 8;
|
||||||
pub const SERVICE_TARGET_PREFIX: &str = "@dosh-";
|
pub const SERVICE_TARGET_PREFIX: &str = "@dosh-";
|
||||||
pub const MAX_STREAM_DATA_BYTES: usize = 60 * 1024;
|
pub const MAX_STREAM_DATA_BYTES: usize = 60 * 1024;
|
||||||
|
|
||||||
@@ -171,6 +173,20 @@ struct PendingStreamOpen {
|
|||||||
attempts: u32,
|
attempts: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PendingStreamControl {
|
||||||
|
last_sent: Instant,
|
||||||
|
attempts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PendingWindowAdjust {
|
||||||
|
received_offset: u64,
|
||||||
|
bytes: usize,
|
||||||
|
last_sent: Instant,
|
||||||
|
attempts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StreamMux {
|
pub struct StreamMux {
|
||||||
config: TransportConfig,
|
config: TransportConfig,
|
||||||
@@ -182,6 +198,9 @@ pub struct StreamMux {
|
|||||||
next_send_offset: HashMap<u64, u64>,
|
next_send_offset: HashMap<u64, u64>,
|
||||||
next_recv_offset: HashMap<u64, u64>,
|
next_recv_offset: HashMap<u64, u64>,
|
||||||
recv_pending: HashMap<u64, BTreeMap<u64, Vec<u8>>>,
|
recv_pending: HashMap<u64, BTreeMap<u64, Vec<u8>>>,
|
||||||
|
pending_eofs: HashMap<u64, PendingStreamControl>,
|
||||||
|
pending_closes: HashMap<u64, PendingStreamControl>,
|
||||||
|
pending_window_adjusts: HashMap<u64, PendingWindowAdjust>,
|
||||||
retired_streams: HashSet<u64>,
|
retired_streams: HashSet<u64>,
|
||||||
retired_stream_order: VecDeque<u64>,
|
retired_stream_order: VecDeque<u64>,
|
||||||
srtt: Option<Duration>,
|
srtt: Option<Duration>,
|
||||||
@@ -240,6 +259,9 @@ impl StreamMux {
|
|||||||
next_send_offset: HashMap::new(),
|
next_send_offset: HashMap::new(),
|
||||||
next_recv_offset: HashMap::new(),
|
next_recv_offset: HashMap::new(),
|
||||||
recv_pending: HashMap::new(),
|
recv_pending: HashMap::new(),
|
||||||
|
pending_eofs: HashMap::new(),
|
||||||
|
pending_closes: HashMap::new(),
|
||||||
|
pending_window_adjusts: HashMap::new(),
|
||||||
retired_streams: HashSet::new(),
|
retired_streams: HashSet::new(),
|
||||||
retired_stream_order: VecDeque::new(),
|
retired_stream_order: VecDeque::new(),
|
||||||
srtt: None,
|
srtt: None,
|
||||||
@@ -411,6 +433,17 @@ impl StreamMux {
|
|||||||
bytes: consumed.min(u32::MAX as usize) as u32,
|
bytes: consumed.min(u32::MAX as usize) as u32,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
if consumed > 0 {
|
||||||
|
self.pending_window_adjusts.insert(
|
||||||
|
stream_id,
|
||||||
|
PendingWindowAdjust {
|
||||||
|
received_offset,
|
||||||
|
bytes: consumed,
|
||||||
|
last_sent: Instant::now(),
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(Some(IncomingStreamData {
|
Ok(Some(IncomingStreamData {
|
||||||
stream_id,
|
stream_id,
|
||||||
chunks,
|
chunks,
|
||||||
@@ -437,13 +470,27 @@ impl StreamMux {
|
|||||||
bail!("stream {stream_id} is not open");
|
bail!("stream {stream_id} is not open");
|
||||||
}
|
}
|
||||||
self.retire_stream(stream_id);
|
self.retire_stream(stream_id);
|
||||||
|
self.pending_closes.insert(
|
||||||
|
stream_id,
|
||||||
|
PendingStreamControl {
|
||||||
|
last_sent: Instant::now(),
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
encode_packet(PacketKind::StreamClose, &StreamClose { stream_id })
|
encode_packet(PacketKind::StreamClose, &StreamClose { stream_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn eof_stream(&self, stream_id: u64) -> Result<OutgoingStreamPacket> {
|
pub fn eof_stream(&mut self, stream_id: u64) -> Result<OutgoingStreamPacket> {
|
||||||
if !self.has_stream_state(stream_id) {
|
if !self.has_stream_state(stream_id) {
|
||||||
bail!("stream {stream_id} is not open");
|
bail!("stream {stream_id} is not open");
|
||||||
}
|
}
|
||||||
|
self.pending_eofs.insert(
|
||||||
|
stream_id,
|
||||||
|
PendingStreamControl {
|
||||||
|
last_sent: Instant::now(),
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
encode_packet(PacketKind::StreamEof, &StreamEof { stream_id })
|
encode_packet(PacketKind::StreamEof, &StreamEof { stream_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +499,9 @@ impl StreamMux {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_close(&mut self, close: StreamClose) -> bool {
|
pub fn handle_close(&mut self, close: StreamClose) -> bool {
|
||||||
if !self.has_stream_state(close.stream_id) {
|
let had_state = self.has_stream_state(close.stream_id);
|
||||||
|
self.pending_closes.remove(&close.stream_id);
|
||||||
|
if !had_state {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.retire_stream(close.stream_id);
|
self.retire_stream(close.stream_id);
|
||||||
@@ -579,6 +628,79 @@ impl StreamMux {
|
|||||||
},
|
},
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut retransmit_eofs = Vec::new();
|
||||||
|
for (stream_id, pending) in self.pending_eofs.iter_mut() {
|
||||||
|
if !self.opened_streams.contains(stream_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pending.attempts >= STREAM_CONTROL_RETRANSMIT_MAX_ATTEMPTS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if now.duration_since(pending.last_sent) < retransmit_after {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.last_sent = now;
|
||||||
|
pending.attempts = pending.attempts.saturating_add(1);
|
||||||
|
retransmit_eofs.push(*stream_id);
|
||||||
|
}
|
||||||
|
let opened_streams = self.opened_streams.clone();
|
||||||
|
self.pending_eofs.retain(|stream_id, pending| {
|
||||||
|
opened_streams.contains(stream_id)
|
||||||
|
&& pending.attempts < STREAM_CONTROL_RETRANSMIT_MAX_ATTEMPTS
|
||||||
|
});
|
||||||
|
for stream_id in retransmit_eofs {
|
||||||
|
out.push(encode_packet(
|
||||||
|
PacketKind::StreamEof,
|
||||||
|
&StreamEof { stream_id },
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut retransmit_closes = Vec::new();
|
||||||
|
for (stream_id, pending) in self.pending_closes.iter_mut() {
|
||||||
|
if pending.attempts >= STREAM_CONTROL_RETRANSMIT_MAX_ATTEMPTS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if now.duration_since(pending.last_sent) < retransmit_after {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.last_sent = now;
|
||||||
|
pending.attempts = pending.attempts.saturating_add(1);
|
||||||
|
retransmit_closes.push(*stream_id);
|
||||||
|
}
|
||||||
|
self.pending_closes
|
||||||
|
.retain(|_, pending| pending.attempts < STREAM_CONTROL_RETRANSMIT_MAX_ATTEMPTS);
|
||||||
|
for stream_id in retransmit_closes {
|
||||||
|
out.push(encode_packet(
|
||||||
|
PacketKind::StreamClose,
|
||||||
|
&StreamClose { stream_id },
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut retransmit_adjusts = Vec::new();
|
||||||
|
for (stream_id, pending) in self.pending_window_adjusts.iter_mut() {
|
||||||
|
if pending.attempts >= STREAM_CONTROL_RETRANSMIT_MAX_ATTEMPTS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if now.duration_since(pending.last_sent) < retransmit_after {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.last_sent = now;
|
||||||
|
pending.attempts = pending.attempts.saturating_add(1);
|
||||||
|
retransmit_adjusts.push((*stream_id, pending.received_offset, pending.bytes));
|
||||||
|
}
|
||||||
|
self.pending_window_adjusts
|
||||||
|
.retain(|_, pending| pending.attempts < STREAM_CONTROL_RETRANSMIT_MAX_ATTEMPTS);
|
||||||
|
for (stream_id, received_offset, bytes) in retransmit_adjusts {
|
||||||
|
out.push(encode_packet(
|
||||||
|
PacketKind::StreamWindowAdjust,
|
||||||
|
&StreamWindowAdjust {
|
||||||
|
stream_id,
|
||||||
|
received_offset,
|
||||||
|
bytes: bytes.min(u32::MAX as usize) as u32,
|
||||||
|
},
|
||||||
|
)?);
|
||||||
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,6 +860,8 @@ impl StreamMux {
|
|||||||
|| self.next_send_offset.contains_key(&stream_id)
|
|| self.next_send_offset.contains_key(&stream_id)
|
||||||
|| self.next_recv_offset.contains_key(&stream_id)
|
|| self.next_recv_offset.contains_key(&stream_id)
|
||||||
|| self.recv_pending.contains_key(&stream_id)
|
|| self.recv_pending.contains_key(&stream_id)
|
||||||
|
|| self.pending_eofs.contains_key(&stream_id)
|
||||||
|
|| self.pending_window_adjusts.contains_key(&stream_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn retire_stream(&mut self, stream_id: u64) {
|
fn retire_stream(&mut self, stream_id: u64) {
|
||||||
@@ -764,6 +888,8 @@ impl StreamMux {
|
|||||||
self.next_send_offset.remove(&stream_id);
|
self.next_send_offset.remove(&stream_id);
|
||||||
self.next_recv_offset.remove(&stream_id);
|
self.next_recv_offset.remove(&stream_id);
|
||||||
self.recv_pending.remove(&stream_id);
|
self.recv_pending.remove(&stream_id);
|
||||||
|
self.pending_eofs.remove(&stream_id);
|
||||||
|
self.pending_window_adjusts.remove(&stream_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1691,6 +1817,160 @@ mod tests {
|
|||||||
assert_eq!(mux.send_data(1, b"!".to_vec()).unwrap().len(), 1);
|
assert_eq!(mux.send_data(1, b"!".to_vec()).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tick_retransmits_pending_stream_eof() {
|
||||||
|
let mut mux = StreamMux::new(TransportConfig {
|
||||||
|
retransmit_after: Duration::from_millis(200),
|
||||||
|
..TransportConfig::default()
|
||||||
|
});
|
||||||
|
mux.accept_open(StreamOpen {
|
||||||
|
stream_id: 1,
|
||||||
|
target_host: "@dosh-test".to_string(),
|
||||||
|
target_port: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let eof = mux.eof_stream(1).unwrap();
|
||||||
|
assert_eq!(eof.kind, PacketKind::StreamEof);
|
||||||
|
mux.pending_eofs.get_mut(&1).unwrap().last_sent =
|
||||||
|
Instant::now() - Duration::from_millis(250);
|
||||||
|
|
||||||
|
let retransmits = mux.tick().unwrap();
|
||||||
|
assert_eq!(retransmits.len(), 1);
|
||||||
|
assert_eq!(retransmits[0].kind, PacketKind::StreamEof);
|
||||||
|
let eof: StreamEof = decode(&retransmits[0]);
|
||||||
|
assert_eq!(eof.stream_id, 1);
|
||||||
|
assert_eq!(mux.pending_eofs[&1].attempts, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tick_retransmits_pending_stream_close() {
|
||||||
|
let mut mux = StreamMux::new(TransportConfig {
|
||||||
|
retransmit_after: Duration::from_millis(200),
|
||||||
|
..TransportConfig::default()
|
||||||
|
});
|
||||||
|
mux.accept_open(StreamOpen {
|
||||||
|
stream_id: 1,
|
||||||
|
target_host: "@dosh-test".to_string(),
|
||||||
|
target_port: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let close = mux.close_stream(1).unwrap();
|
||||||
|
assert_eq!(close.kind, PacketKind::StreamClose);
|
||||||
|
mux.pending_closes.get_mut(&1).unwrap().last_sent =
|
||||||
|
Instant::now() - Duration::from_millis(250);
|
||||||
|
|
||||||
|
let retransmits = mux.tick().unwrap();
|
||||||
|
assert_eq!(retransmits.len(), 1);
|
||||||
|
assert_eq!(retransmits[0].kind, PacketKind::StreamClose);
|
||||||
|
let close: StreamClose = decode(&retransmits[0]);
|
||||||
|
assert_eq!(close.stream_id, 1);
|
||||||
|
assert_eq!(mux.pending_closes[&1].attempts, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tick_retransmits_pending_window_adjust() {
|
||||||
|
let mut mux = StreamMux::new(TransportConfig {
|
||||||
|
retransmit_after: Duration::from_millis(200),
|
||||||
|
..TransportConfig::default()
|
||||||
|
});
|
||||||
|
mux.accept_open(StreamOpen {
|
||||||
|
stream_id: 1,
|
||||||
|
target_host: "@dosh-test".to_string(),
|
||||||
|
target_port: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let incoming = mux
|
||||||
|
.handle_data(StreamData {
|
||||||
|
stream_id: 1,
|
||||||
|
offset: 0,
|
||||||
|
bytes: b"hello".to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(incoming.consumed, 5);
|
||||||
|
assert_eq!(incoming.received_offset, 5);
|
||||||
|
assert_eq!(incoming.window_adjust.kind, PacketKind::StreamWindowAdjust);
|
||||||
|
mux.pending_window_adjusts.get_mut(&1).unwrap().last_sent =
|
||||||
|
Instant::now() - Duration::from_millis(250);
|
||||||
|
|
||||||
|
let retransmits = mux.tick().unwrap();
|
||||||
|
assert_eq!(retransmits.len(), 1);
|
||||||
|
assert_eq!(retransmits[0].kind, PacketKind::StreamWindowAdjust);
|
||||||
|
let adjust: StreamWindowAdjust = decode(&retransmits[0]);
|
||||||
|
assert_eq!(adjust.stream_id, 1);
|
||||||
|
assert_eq!(adjust.received_offset, 5);
|
||||||
|
assert_eq!(adjust.bytes, 5);
|
||||||
|
assert_eq!(mux.pending_window_adjusts[&1].attempts, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_control_retransmits_expire_after_cap() {
|
||||||
|
let mut mux = StreamMux::new(TransportConfig {
|
||||||
|
retransmit_after: Duration::from_millis(200),
|
||||||
|
..TransportConfig::default()
|
||||||
|
});
|
||||||
|
mux.accept_open(StreamOpen {
|
||||||
|
stream_id: 1,
|
||||||
|
target_host: "@dosh-test".to_string(),
|
||||||
|
target_port: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
mux.close_stream(1).unwrap();
|
||||||
|
let pending = mux.pending_closes.get_mut(&1).unwrap();
|
||||||
|
pending.last_sent = Instant::now() - Duration::from_millis(250);
|
||||||
|
pending.attempts = STREAM_CONTROL_RETRANSMIT_MAX_ATTEMPTS;
|
||||||
|
|
||||||
|
assert!(mux.tick().unwrap().is_empty());
|
||||||
|
assert!(!mux.pending_closes.contains_key(&1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_stream_removes_reliable_controls() {
|
||||||
|
let mut mux = StreamMux::new(TransportConfig::default());
|
||||||
|
mux.accept_open(StreamOpen {
|
||||||
|
stream_id: 1,
|
||||||
|
target_host: "@dosh-test".to_string(),
|
||||||
|
target_port: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
mux.eof_stream(1).unwrap();
|
||||||
|
mux.handle_data(StreamData {
|
||||||
|
stream_id: 1,
|
||||||
|
offset: 0,
|
||||||
|
bytes: b"hello".to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
mux.retire_stream(1);
|
||||||
|
|
||||||
|
assert!(!mux.pending_eofs.contains_key(&1));
|
||||||
|
assert!(!mux.pending_window_adjusts.contains_key(&1));
|
||||||
|
assert!(!mux.is_open(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_close_after_local_close_stops_close_retransmits() {
|
||||||
|
let mut mux = StreamMux::new(TransportConfig {
|
||||||
|
retransmit_after: Duration::from_millis(200),
|
||||||
|
..TransportConfig::default()
|
||||||
|
});
|
||||||
|
mux.accept_open(StreamOpen {
|
||||||
|
stream_id: 1,
|
||||||
|
target_host: "@dosh-test".to_string(),
|
||||||
|
target_port: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
mux.close_stream(1).unwrap();
|
||||||
|
|
||||||
|
assert!(!mux.handle_close(StreamClose { stream_id: 1 }));
|
||||||
|
assert!(!mux.pending_closes.contains_key(&1));
|
||||||
|
assert!(mux.tick().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_packet_decodes_and_dispatches_stream_packets() {
|
fn handle_packet_decodes_and_dispatches_stream_packets() {
|
||||||
let mut mux = StreamMux::new(TransportConfig::default());
|
let mut mux = StreamMux::new(TransportConfig::default());
|
||||||
|
|||||||
Reference in New Issue
Block a user