mirror of https://github.com/ospab/ostp.git
Compare commits
9 Commits
cdfd2babc0
...
2ede607027
| Author | SHA1 | Date |
|---|---|---|
|
|
2ede607027 | |
|
|
0c69617725 | |
|
|
88e0634f09 | |
|
|
7473278cc2 | |
|
|
77e42b77f7 | |
|
|
e7a4f2b4a4 | |
|
|
6bc646c8a5 | |
|
|
d9fe749cd4 | |
|
|
de5cee103b |
|
|
@ -2,5 +2,5 @@
|
||||||
"target_version": "0.4.2",
|
"target_version": "0.4.2",
|
||||||
"branch": "beta",
|
"branch": "beta",
|
||||||
"alpha_iteration": 0,
|
"alpha_iteration": 0,
|
||||||
"beta_iteration": 4
|
"beta_iteration": 5
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ use crate::app::{BridgeCommand, ConnectionStatus, UiEvent};
|
||||||
use crate::config::ClientConfig;
|
use crate::config::ClientConfig;
|
||||||
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
||||||
|
|
||||||
|
/// Per-address ceiling on the UoT/TCP connect attempt. Long enough that a
|
||||||
|
/// genuinely slow mobile path still completes its handshake, short enough that
|
||||||
|
/// a blackholed address (typically IPv6 advertised without a working route)
|
||||||
|
/// costs seconds instead of the kernel's full SYN-retry budget before the next
|
||||||
|
/// candidate address is tried.
|
||||||
|
const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
|
||||||
|
|
||||||
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
pub fn set_socket_protector<F>(f: F)
|
pub fn set_socket_protector<F>(f: F)
|
||||||
|
|
@ -288,8 +295,8 @@ impl Bridge {
|
||||||
) {
|
) {
|
||||||
match udp_msg {
|
match udp_msg {
|
||||||
Some((session_index, inbound)) => {
|
Some((session_index, inbound)) => {
|
||||||
|
// Raw byte counter — every datagram that reached the socket counts.
|
||||||
self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed);
|
self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed);
|
||||||
self.last_valid_recv = Instant::now();
|
|
||||||
if let Some(sessions) = sessions_opt.as_mut() {
|
if let Some(sessions) = sessions_opt.as_mut() {
|
||||||
if session_index < sessions.len() {
|
if session_index < sessions.len() {
|
||||||
let session = &mut sessions[session_index];
|
let session = &mut sessions[session_index];
|
||||||
|
|
@ -302,6 +309,22 @@ impl Bridge {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only NOW, after the datagram actually authenticated and
|
||||||
|
// decrypted, does it count as a sign of life. This used to
|
||||||
|
// be set above, before any validation — so a datagram that
|
||||||
|
// failed to decrypt still reset the stall detector on its
|
||||||
|
// way to the `return` above. Anything arriving at this port
|
||||||
|
// (frames from a session the server already evicted, stale
|
||||||
|
// retransmits, or plain garbage from an off-path source that
|
||||||
|
// knows the ip:port) kept the client convinced the tunnel
|
||||||
|
// was healthy: the 25s background reconnect in
|
||||||
|
// handle_keepalive never fired and the tunnel sat dead at
|
||||||
|
// 0 b/s until the user reconnected by hand. It also made
|
||||||
|
// `is_healthy` (see emit_metrics) lie in the UI, and handed
|
||||||
|
// any off-path sender a trivial way to pin a client in a
|
||||||
|
// dead session indefinitely.
|
||||||
|
self.last_valid_recv = Instant::now();
|
||||||
|
|
||||||
let mut actions_queue = std::collections::VecDeque::new();
|
let mut actions_queue = std::collections::VecDeque::new();
|
||||||
actions_queue.push_back(initial_action);
|
actions_queue.push_back(initial_action);
|
||||||
|
|
||||||
|
|
@ -1084,7 +1107,27 @@ impl Bridge {
|
||||||
) -> Result<crate::transport::Transport> {
|
) -> Result<crate::transport::Transport> {
|
||||||
let mode = self.transport_mode.to_lowercase();
|
let mode = self.transport_mode.to_lowercase();
|
||||||
if mode == "uot" || mode == "tcp" {
|
if mode == "uot" || mode == "tcp" {
|
||||||
let stream = tokio::net::TcpStream::connect((target_ip, port)).await?;
|
// Bound the TCP connect. Without this it inherits the kernel's SYN
|
||||||
|
// retry budget, which is tens of seconds (and can reach ~2 minutes).
|
||||||
|
// That is exactly what made UoT appear to hang on mobile: callers
|
||||||
|
// resolve every address for the server and try IPv6 first (see the
|
||||||
|
// sort in perform_handshake_with_id), and a mobile network that
|
||||||
|
// advertises IPv6 without a working route blackholes the SYN rather
|
||||||
|
// than rejecting it — so the client sat through the full retry
|
||||||
|
// budget before it ever reached the IPv4 address that would have
|
||||||
|
// connected immediately. UDP never showed this because connect() on
|
||||||
|
// a UDP socket only sets the default peer and returns at once.
|
||||||
|
let stream = tokio::time::timeout(
|
||||||
|
UOT_CONNECT_TIMEOUT,
|
||||||
|
tokio::net::TcpStream::connect((target_ip, port)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"TCP connect to {target_ip}:{port} timed out after {:?}",
|
||||||
|
UOT_CONNECT_TIMEOUT
|
||||||
|
)
|
||||||
|
})??;
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
let (mut read_half, mut write_half) = stream.into_split();
|
let (mut read_half, mut write_half) = stream.into_split();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,17 @@ pub struct ProtocolMachine {
|
||||||
_mtu: usize,
|
_mtu: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gap recovery (see `ProtocolMachine::recover_stalled_gap`) ────────────────
|
||||||
|
// How long the receive sequence may sit stuck behind a missing frame, with
|
||||||
|
// later frames already buffered, before that frame is declared unrecoverable
|
||||||
|
// and skipped. Derived from the live RTO so it scales with the path instead of
|
||||||
|
// guessing, then clamped: the floor keeps a fast link from discarding a frame
|
||||||
|
// that is merely late, the ceiling bounds how long a stall can be visible to
|
||||||
|
// the user before the tunnel unblocks itself.
|
||||||
|
const GAP_RECOVERY_RTO_MULTIPLIER: u32 = 8;
|
||||||
|
const GAP_RECOVERY_MIN: Duration = Duration::from_secs(2);
|
||||||
|
const GAP_RECOVERY_MAX: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct SentFrame {
|
struct SentFrame {
|
||||||
nonce: u64,
|
nonce: u64,
|
||||||
|
|
@ -155,6 +166,19 @@ impl ProtocolMachine {
|
||||||
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
|
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sum of retry counters across in-flight frames. Test-only: lets a test
|
||||||
|
/// assert the core retransmit invariant (a retry is only ever charged to a
|
||||||
|
/// frame that was actually put on the wire) without needing to advance the
|
||||||
|
/// clock through several seconds of exponential backoff.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn total_retries(&self) -> usize {
|
||||||
|
self.sent_history
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.is_retransmittable)
|
||||||
|
.map(|f| f.retries as usize)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cwnd_packets(&self) -> usize {
|
pub fn cwnd_packets(&self) -> usize {
|
||||||
self.cc.cwnd_packets() as usize
|
self.cc.cwnd_packets() as usize
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +320,107 @@ impl ProtocolMachine {
|
||||||
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
|
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores liveness when the receive sequence is stuck behind a frame that
|
||||||
|
/// can never arrive.
|
||||||
|
///
|
||||||
|
/// Delivery is gated on `expected_recv_nonce`, so a single missing frame
|
||||||
|
/// holds back every later frame. That is correct *while the sender can still
|
||||||
|
/// retransmit* — but the sender drops a frame from `sent_history` once it
|
||||||
|
/// exceeds `max_retries + 2` attempts (see the zombie eviction in
|
||||||
|
/// `handle_tick`). After that the frame is gone for good and the two sides
|
||||||
|
/// deadlock: the receiver buffers forever and NACKs a nonce nobody can
|
||||||
|
/// resend.
|
||||||
|
///
|
||||||
|
/// That deadlock is invisible to the keepalive watchdog, which is why it
|
||||||
|
/// presented as a hard freeze rather than a reconnect: retransmits, ACKs and
|
||||||
|
/// NACKs keep flowing, so the client's `last_valid_recv` keeps refreshing and
|
||||||
|
/// its stall detector never fires. The RTT readout freezes at its last value
|
||||||
|
/// for the same reason — Pong rides in a Data frame stuck behind the gap.
|
||||||
|
///
|
||||||
|
/// So: once we have been stuck long enough that retransmission has provably
|
||||||
|
/// given up, skip to the lowest buffered nonce and drain. This drops the
|
||||||
|
/// missing frame's payload (one RelayMessage — a chunk of one stream), which
|
||||||
|
/// is a real cost, but the alternative is a permanently dead tunnel.
|
||||||
|
fn recover_stalled_gap(&mut self) -> Vec<ProtocolAction> {
|
||||||
|
let mut recovered = Vec::new();
|
||||||
|
if self.reorder_buffer.is_empty() {
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait out the sender's full retransmit budget before giving up, so a
|
||||||
|
// frame that is merely late is never discarded. The sender backs off
|
||||||
|
// exponentially, so key this off the live RTO estimate rather than a
|
||||||
|
// flat constant, with a floor that keeps low-RTT links from skipping
|
||||||
|
// too eagerly and a ceiling that bounds the visible freeze.
|
||||||
|
let timeout = self
|
||||||
|
.cc
|
||||||
|
.rto()
|
||||||
|
.saturating_mul(GAP_RECOVERY_RTO_MULTIPLIER)
|
||||||
|
.clamp(GAP_RECOVERY_MIN, GAP_RECOVERY_MAX);
|
||||||
|
if self.last_recv_advance.elapsed() < timeout {
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(&resume_at) = self.reorder_buffer.keys().next() else {
|
||||||
|
return recovered;
|
||||||
|
};
|
||||||
|
let skipped = resume_at.saturating_sub(self.expected_recv_nonce);
|
||||||
|
tracing::warn!(
|
||||||
|
"Gap recovery: no progress for {:?}; skipping {} unrecoverable frame(s) \
|
||||||
|
(nonce {} -> {}) to unblock the session",
|
||||||
|
self.last_recv_advance.elapsed(),
|
||||||
|
skipped,
|
||||||
|
self.expected_recv_nonce,
|
||||||
|
resume_at
|
||||||
|
);
|
||||||
|
|
||||||
|
self.expected_recv_nonce = resume_at;
|
||||||
|
while let Some(buffered) = self.reorder_buffer.remove(&self.expected_recv_nonce) {
|
||||||
|
recovered.push(buffered);
|
||||||
|
match self.expected_recv_nonce.checked_add(1) {
|
||||||
|
Some(next) => self.expected_recv_nonce = next,
|
||||||
|
// u64 nonce space exhausted: stop draining rather than wrap.
|
||||||
|
// The session is finished either way; the caller's next decrypt
|
||||||
|
// will fail and tear it down.
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_recv_advance = Instant::now();
|
||||||
|
// The peer must learn the sequence moved on, or it will keep
|
||||||
|
// retransmitting into the void.
|
||||||
|
self.ack_pending = true;
|
||||||
|
|
||||||
|
recovered
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||||
|
// Check for a stalled gap before classifying this frame, so the rest of
|
||||||
|
// the function sees an already-advanced `expected_recv_nonce`. Runs here
|
||||||
|
// rather than on Tick because both tick handlers discard DeliverApp
|
||||||
|
// actions, and because inbound frames keep arriving throughout the stall
|
||||||
|
// (retransmits/ACKs/NACKs/keepalives) — so this path is reliably reached.
|
||||||
|
let recovered = self.recover_stalled_gap();
|
||||||
|
let result = self.handle_data_inbound_frame(raw_vec)?;
|
||||||
|
if recovered.is_empty() {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovered payloads are older than anything this frame produces, so
|
||||||
|
// they go first to preserve delivery order.
|
||||||
|
let mut all = recovered;
|
||||||
|
match result {
|
||||||
|
ProtocolAction::Noop => {}
|
||||||
|
ProtocolAction::Multiple(list) => all.extend(list),
|
||||||
|
single => all.push(single),
|
||||||
|
}
|
||||||
|
Ok(if all.len() == 1 {
|
||||||
|
all.pop().unwrap()
|
||||||
|
} else {
|
||||||
|
ProtocolAction::Multiple(all)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_data_inbound_frame(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||||
if raw_vec.len() < 12 {
|
if raw_vec.len() < 12 {
|
||||||
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -543,20 +667,34 @@ impl ProtocolMachine {
|
||||||
if !frame.is_retransmittable {
|
if !frame.is_retransmittable {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Out of budget for this tick — stop scanning rather than walking the
|
||||||
|
// rest of the queue. sent_history is in send order, so everything we
|
||||||
|
// skip is strictly newer than what we already handled; deferring it to
|
||||||
|
// the next tick preserves oldest-first retransmit priority.
|
||||||
|
if retransmit_budget == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
|
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
|
||||||
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
|
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
|
||||||
|
|
||||||
if now.duration_since(frame.last_sent) >= effective_rto {
|
if now.duration_since(frame.last_sent) >= effective_rto {
|
||||||
|
// Only burn the retry counter and reset the RTO timer when the
|
||||||
|
// frame is ACTUALLY put on the wire. Doing it unconditionally
|
||||||
|
// meant that whenever the per-tick budget ran out — which is
|
||||||
|
// exactly when loss is heavy and retransmits matter most —
|
||||||
|
// frames accumulated "phantom retries" they never actually got,
|
||||||
|
// and the zombie eviction above then silently dropped them after
|
||||||
|
// `grace` such rounds. The peer never received that data and
|
||||||
|
// never would: that stream stalls forever while the session
|
||||||
|
// itself stays healthy, which is precisely the reported "tunnel
|
||||||
|
// frozen at 0 b/s but the session still up" symptom.
|
||||||
frame.last_sent = now;
|
frame.last_sent = now;
|
||||||
frame.retries = frame.retries.saturating_add(1);
|
frame.retries = frame.retries.saturating_add(1);
|
||||||
|
|
||||||
if retransmit_budget > 0 {
|
|
||||||
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
||||||
retransmit_budget -= 1;
|
retransmit_budget -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if actions.is_empty() {
|
if actions.is_empty() {
|
||||||
Ok(ProtocolAction::Noop)
|
Ok(ProtocolAction::Noop)
|
||||||
|
|
@ -971,4 +1109,154 @@ mod tests {
|
||||||
let _ = client.on_event(OstpEvent::Tick).unwrap();
|
let _ = client.on_event(OstpEvent::Tick).unwrap();
|
||||||
let _ = server.on_event(OstpEvent::Tick).unwrap();
|
let _ = server.on_event(OstpEvent::Tick).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A retry may only be charged to a frame that was actually retransmitted.
|
||||||
|
///
|
||||||
|
/// The retransmit loop is budget-limited per tick. It used to bump
|
||||||
|
/// `retries` and reset `last_sent` for every due frame regardless of
|
||||||
|
/// whether the budget allowed it to actually send — so under heavy loss
|
||||||
|
/// (exactly when the budget runs out) frames racked up retries they never
|
||||||
|
/// received, and the zombie eviction dropped them after `max_retries + 2`
|
||||||
|
/// such rounds. That data was never delivered and never would be: the
|
||||||
|
/// stream stalls permanently while the session itself stays up.
|
||||||
|
#[test]
|
||||||
|
fn test_retransmit_budget_charges_retries_only_for_frames_actually_sent() {
|
||||||
|
let (mut client, _server) = do_handshake();
|
||||||
|
|
||||||
|
// Queue far more in-flight frames than a single tick's budget allows.
|
||||||
|
const FRAMES: usize = 40;
|
||||||
|
for i in 0..FRAMES {
|
||||||
|
let payload = Bytes::from(vec![i as u8; 200]);
|
||||||
|
client.on_event(OstpEvent::Outbound(1, payload)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(client.in_flight_count(), FRAMES);
|
||||||
|
assert_eq!(client.total_retries(), 0, "nothing retransmitted yet");
|
||||||
|
|
||||||
|
// Let every frame's RTO lapse so that on the next tick all FRAMES frames
|
||||||
|
// are due at once and the per-tick budget is guaranteed to run out. The
|
||||||
|
// effective RTO here is max(cc.rto(), config rto_ms) = 100ms at retries=0.
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
|
||||||
|
let sent = count_datagrams(&client.on_event(OstpEvent::Tick).unwrap());
|
||||||
|
|
||||||
|
assert!(sent > 0, "expected some retransmits after the RTO lapsed");
|
||||||
|
assert!(
|
||||||
|
sent < FRAMES,
|
||||||
|
"budget should have capped this tick below the {FRAMES} due frames, got {sent}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.total_retries(),
|
||||||
|
sent,
|
||||||
|
"charged {} retries but only put {} frames on the wire — the \
|
||||||
|
difference is phantom retries that will silently evict live data",
|
||||||
|
client.total_retries(),
|
||||||
|
sent
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.in_flight_count(),
|
||||||
|
FRAMES,
|
||||||
|
"nothing was acked, so no frame may be evicted yet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how many datagrams an action tree actually puts on the wire.
|
||||||
|
fn count_datagrams(action: &ProtocolAction) -> usize {
|
||||||
|
match action {
|
||||||
|
ProtocolAction::SendDatagram(_) => 1,
|
||||||
|
ProtocolAction::Multiple(list) => list.iter().map(count_datagrams).sum(),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how many application payloads an action tree actually delivers.
|
||||||
|
fn delivered_payloads(action: &ProtocolAction) -> Vec<Bytes> {
|
||||||
|
match action {
|
||||||
|
ProtocolAction::DeliverApp(_, data) => vec![data.clone()],
|
||||||
|
ProtocolAction::Multiple(list) => list.iter().flat_map(delivered_payloads).collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build `count` data frames on `client`, returning them without delivering
|
||||||
|
/// any — lets a test choose which ones to "lose" in transit.
|
||||||
|
fn make_data_frames(client: &mut ProtocolMachine, count: u8) -> Vec<Bytes> {
|
||||||
|
(0..count)
|
||||||
|
.map(|i| {
|
||||||
|
let payload = Bytes::from(vec![i; 32]);
|
||||||
|
match client.on_event(OstpEvent::Outbound(1, payload)).unwrap() {
|
||||||
|
ProtocolAction::SendDatagram(d) => d,
|
||||||
|
_ => panic!("expected SendDatagram for frame {i}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The freeze this fixes: a frame is lost, the sender eventually stops
|
||||||
|
/// retransmitting it, and the receiver — which gates delivery on
|
||||||
|
/// `expected_recv_nonce` — waits for it forever. Every later frame piles up
|
||||||
|
/// undelivered while the transport itself stays healthy, so nothing upstream
|
||||||
|
/// notices. Recovery must eventually skip the hole and release the backlog.
|
||||||
|
#[test]
|
||||||
|
fn test_gap_recovery_releases_permanently_stalled_frames() {
|
||||||
|
let (mut client, mut server) = do_handshake();
|
||||||
|
let frames = make_data_frames(&mut client, 4);
|
||||||
|
|
||||||
|
// Frame 0 arrives in order and is delivered straight through.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
assert_eq!(delivered_payloads(&action).len(), 1, "in-order frame should deliver");
|
||||||
|
|
||||||
|
// Frame 1 is lost. 2 and 3 arrive but must be held back — delivering them
|
||||||
|
// now would reorder the stream.
|
||||||
|
for idx in [2usize, 3] {
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[idx].clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
delivered_payloads(&action).is_empty(),
|
||||||
|
"frame {idx} must stay buffered behind the missing frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand in for "the sender exhausted its retries and dropped frame 1":
|
||||||
|
// the sequence has not advanced for longer than the recovery timeout.
|
||||||
|
server.last_recv_advance = Instant::now() - GAP_RECOVERY_MAX - Duration::from_secs(1);
|
||||||
|
|
||||||
|
// The next inbound frame (a retransmitted duplicate, which is exactly what
|
||||||
|
// a real stalled session keeps receiving) must unblock the backlog.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
let delivered = delivered_payloads(&action);
|
||||||
|
assert_eq!(
|
||||||
|
delivered.len(),
|
||||||
|
2,
|
||||||
|
"both buffered frames must be released once the gap is declared unrecoverable"
|
||||||
|
);
|
||||||
|
// ...and in order: frame 2 before frame 3.
|
||||||
|
assert_eq!(delivered[0][0], 2);
|
||||||
|
assert_eq!(delivered[1][0], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovery must not be trigger-happy: a frame that is merely late still has
|
||||||
|
/// to be waited for, or we would discard data the sender is about to resend.
|
||||||
|
#[test]
|
||||||
|
fn test_gap_recovery_does_not_fire_before_timeout() {
|
||||||
|
let (mut client, mut server) = do_handshake();
|
||||||
|
let frames = make_data_frames(&mut client, 3);
|
||||||
|
|
||||||
|
server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[2].clone())).unwrap();
|
||||||
|
assert!(delivered_payloads(&action).is_empty());
|
||||||
|
|
||||||
|
// Well inside the timeout — the gap must still be respected.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
delivered_payloads(&action).is_empty(),
|
||||||
|
"must keep waiting while retransmission is still plausible"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And once the genuinely-late frame shows up, normal in-order delivery
|
||||||
|
// resumes with nothing dropped.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[1].clone())).unwrap();
|
||||||
|
let delivered = delivered_payloads(&action);
|
||||||
|
assert_eq!(delivered.len(), 2, "late frame plus the buffered one");
|
||||||
|
assert_eq!(delivered[0][0], 1);
|
||||||
|
assert_eq!(delivered[1][0], 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ import '../models/connection_state_enum.dart';
|
||||||
import '../models/ostp_profile.dart';
|
import '../models/ostp_profile.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
/// Success green for the "connected" state — the button aura/border/icon and
|
||||||
|
/// the top-bar status dot. The theme's `secondary` (#AAAAAA) reads as plain
|
||||||
|
/// white here, which gave no visual confirmation that the tunnel actually came
|
||||||
|
/// up. Reuses the same green already used for a healthy ping value, so
|
||||||
|
/// "green = good" stays consistent across the UI.
|
||||||
|
const Color kConnectedGreen = Color(0xFF22D3A5);
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final SharedPreferences prefs;
|
final SharedPreferences prefs;
|
||||||
const HomeScreen({super.key, required this.prefs});
|
const HomeScreen({super.key, required this.prefs});
|
||||||
|
|
@ -45,8 +52,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
late AnimationController _pulseController;
|
late AnimationController _pulseController;
|
||||||
late AnimationController _spinController;
|
late AnimationController _spinController;
|
||||||
|
|
||||||
bool _isCheckingPing = false;
|
String _pingText = '-- ms';
|
||||||
String _pingText = 'Target Ping: -- ms';
|
|
||||||
Color _pingColor = Colors.white54;
|
Color _pingColor = Colors.white54;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -420,8 +426,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_prevBytesSent = bytesSent;
|
_prevBytesSent = bytesSent;
|
||||||
_downSpeed = '${_formatBytes(dRecv)}/s';
|
_downSpeed = '${_formatBytes(dRecv)}/s';
|
||||||
_upSpeed = '${_formatBytes(dSent)}/s';
|
_upSpeed = '${_formatBytes(dSent)}/s';
|
||||||
if (rttMs > 0 && !_isCheckingPing) {
|
if (rttMs > 0) {
|
||||||
_pingText = 'Server Ping: $rttMs ms';
|
_pingText = '$rttMs ms';
|
||||||
if (rttMs < 100) {
|
if (rttMs < 100) {
|
||||||
_pingColor = const Color(0xFF22D3A5);
|
_pingColor = const Color(0xFF22D3A5);
|
||||||
} else if (rttMs < 250) {
|
} else if (rttMs < 250) {
|
||||||
|
|
@ -447,47 +453,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkConnectionLatency() async {
|
|
||||||
if (_state != ConnectionStateEnum.connected) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isCheckingPing = true;
|
|
||||||
_pingText = 'Updating...';
|
|
||||||
_pingColor = Colors.white70;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final metricsJson = await platform.invokeMethod('getMetrics');
|
|
||||||
if (metricsJson != null && metricsJson.isNotEmpty) {
|
|
||||||
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
|
||||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
if (rttMs > 0) {
|
|
||||||
_pingText = 'Server Ping: $rttMs ms';
|
|
||||||
_pingColor = rttMs < 100
|
|
||||||
? const Color(0xFF22D3A5)
|
|
||||||
: rttMs < 250
|
|
||||||
? Colors.amberAccent
|
|
||||||
: Colors.redAccent;
|
|
||||||
} else {
|
|
||||||
_pingText = 'Server Ping: -- ms';
|
|
||||||
_pingColor = Colors.white54;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Failed to check latency: $e");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setDisconnected() {
|
void _setDisconnected() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -498,9 +463,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_upSpeed = '0 B/s';
|
_upSpeed = '0 B/s';
|
||||||
_prevBytesRecv = 0;
|
_prevBytesRecv = 0;
|
||||||
_prevBytesSent = 0;
|
_prevBytesSent = 0;
|
||||||
_pingText = 'Target Ping: -- ms';
|
_pingText = '-- ms';
|
||||||
_pingColor = Colors.white54;
|
_pingColor = Colors.white54;
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
});
|
||||||
_pulseController.stop();
|
_pulseController.stop();
|
||||||
_pulseController.value = 0.0;
|
_pulseController.value = 0.0;
|
||||||
|
|
@ -578,12 +542,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary
|
? kConnectedGreen
|
||||||
: theme.colorScheme.primary,
|
: theme.colorScheme.primary,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary.withOpacity(0.5)
|
? kConnectedGreen.withOpacity(0.5)
|
||||||
: theme.colorScheme.primary.withOpacity(0.5),
|
: theme.colorScheme.primary.withOpacity(0.5),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
)
|
)
|
||||||
|
|
@ -637,7 +601,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
Widget _buildStage(ThemeData theme) {
|
Widget _buildStage(ThemeData theme) {
|
||||||
Color getAccentColor() {
|
Color getAccentColor() {
|
||||||
if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary;
|
if (_state == ConnectionStateEnum.connected) return kConnectedGreen;
|
||||||
return theme.colorScheme.primary;
|
return theme.colorScheme.primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -775,37 +739,23 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 10),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.03),
|
color: Colors.white.withOpacity(0.03),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Icon(Icons.speed_rounded, size: 13, color: _pingColor),
|
||||||
child: Column(
|
const SizedBox(width: 6),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'CONNECTION TEST',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white38,
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
Text(
|
||||||
_pingText,
|
_pingText,
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: _pingColor,
|
color: _pingColor,
|
||||||
),
|
),
|
||||||
|
|
@ -813,32 +763,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
_isCheckingPing
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20, height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70),
|
|
||||||
)
|
|
||||||
: TextButton.icon(
|
|
||||||
onPressed: _checkConnectionLatency,
|
|
||||||
icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary),
|
|
||||||
label: Text(
|
|
||||||
'Test Ping',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.4.2+23
|
version: 0.4.2+24
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ use clap::Parser;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use sha2::Digest;
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)]
|
#[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)]
|
||||||
|
|
@ -725,7 +724,14 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
// this used to be a DefaultHasher (SipHash) placeholder that produced a
|
// this used to be a DefaultHasher (SipHash) placeholder that produced a
|
||||||
// differently-shaped digest, so a password set up through this wizard could
|
// differently-shaped digest, so a password set up through this wizard could
|
||||||
// never actually log into the panel it just configured.
|
// never actually log into the panel it just configured.
|
||||||
let pass_hash = format!("{:x}", sha2::Sha256::digest(password.as_bytes()));
|
// Trait-qualified so this compiles whether or not `sha2::Digest` happens
|
||||||
|
// to be in scope: `digest` is a trait method, and relying on the import
|
||||||
|
// alone broke the CI build once (v0.4.2-beta.3) while resolving fine
|
||||||
|
// locally.
|
||||||
|
let pass_hash = format!(
|
||||||
|
"{:x}",
|
||||||
|
<sha2::Sha256 as sha2::Digest>::digest(password.as_bytes())
|
||||||
|
);
|
||||||
|
|
||||||
wizard_step(4, TOTAL, "Saving configuration");
|
wizard_step(4, TOTAL, "Saving configuration");
|
||||||
let panel_bind = format!("0.0.0.0:{}", panel_port);
|
let panel_bind = format!("0.0.0.0:{}", panel_port);
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,18 @@ if [ -n "$TARGET_VERSION" ]; then
|
||||||
fi
|
fi
|
||||||
echo "Fetching requested release $LATEST_RELEASE..."
|
echo "Fetching requested release $LATEST_RELEASE..."
|
||||||
else
|
else
|
||||||
if [ "$TARGET_BRANCH" == "alpha" ]; then
|
if [ "$TARGET_BRANCH" == "alpha" ] || [ "$TARGET_BRANCH" == "beta" ]; then
|
||||||
echo "Fetching alpha release..."
|
# There is no floating "alpha"/"beta" GitHub Release - gha.ps1 cuts a
|
||||||
LATEST_RELEASE="alpha"
|
# fresh versioned tag every time (v0.4.2-beta.4, v0.4.2-alpha.7, ...).
|
||||||
elif [ "$TARGET_BRANCH" == "beta" ]; then
|
# /releases/latest only ever returns the newest NON-prerelease
|
||||||
echo "Fetching beta release..."
|
# (stable) tag, so it can't find these. Query the full releases list
|
||||||
LATEST_RELEASE="beta"
|
# (newest first) and take the first tag_name containing "-$TARGET_BRANCH".
|
||||||
|
echo "Fetching latest ${TARGET_BRANCH} release..."
|
||||||
|
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases" \
|
||||||
|
| grep '"tag_name":' \
|
||||||
|
| grep -- "-${TARGET_BRANCH}" \
|
||||||
|
| head -1 \
|
||||||
|
| sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')
|
||||||
else
|
else
|
||||||
echo "Fetching latest stable release..."
|
echo "Fetching latest stable release..."
|
||||||
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue