fix(client): drop unsendable TAP frames locally

The Windows TAP pump used the same send helper as direct callers, so malformed,
jumbo, or over-budget TAP frames were reported as errors that stopped the
bridge. That is too brittle for the no-fragmentation MVP: local frames that
cannot fit the tunnel should be counted and dropped, while real QUIC send
failures should still surface as fatal.

Add a client send outcome that reports whether a frame was sent or locally
dropped. The existing send_ethernet API still returns an error for direct
callers when the outcome is a local drop. The live TAP pump uses the outcome API
so it can log the drop reason and keep forwarding later frames.

Document the new client behavior in the README.

Test Plan:
- cargo fmt --check
- git diff --check
- cargo test -p lanparty-client-core
- cargo test -p lanparty-client-win
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings

Attempted:
- cargo check -p lanparty-client-win --target x86_64-pc-windows-msvc
  (blocked by missing MSVC lib.exe on this Linux host)

Refs: PLAN.md
This commit is contained in:
2026-05-21 22:47:08 +02:00
parent 022e74d62b
commit cd8a536771
3 changed files with 70 additions and 18 deletions
+56 -13
View File
@@ -25,7 +25,7 @@ use lanparty_ctrl::{
MAX_CONTROL_MESSAGE_LEN, RELAY_ALPN, RoomCode, ServerWelcome, decode_control_frame,
encode_control_message,
};
use lanparty_obs::{QuicDiagnostics, TunnelStats};
use lanparty_obs::{DropReason, QuicDiagnostics, TunnelStats};
use lanparty_proto::{
EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, validate_datagram_budget,
};
@@ -242,6 +242,12 @@ impl ReceivedEthernetFrame {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientSendOutcome {
Sent,
Dropped(DropReason),
}
impl ClientSession {
#[must_use]
pub const fn config(&self) -> &ClientSessionConfig {
@@ -277,6 +283,10 @@ impl ClientSession {
self.relay_io().send_ethernet(frame)
}
pub fn send_ethernet_with_outcome(&self, frame: &[u8]) -> Result<ClientSendOutcome> {
self.relay_io().send_ethernet_with_outcome(frame)
}
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
self.relay_io().recv_ethernet().await
}
@@ -339,13 +349,33 @@ impl ClientRelayIo {
}
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
match self.send_ethernet_with_outcome(frame)? {
ClientSendOutcome::Sent => Ok(()),
ClientSendOutcome::Dropped(DropReason::DatagramBudget) => {
bail!("client Ethernet datagram exceeds negotiated QUIC budget")
}
ClientSendOutcome::Dropped(DropReason::Malformed) => {
bail!("client Ethernet frame is malformed")
}
ClientSendOutcome::Dropped(drop_reason) => {
bail!("client Ethernet frame was dropped: {drop_reason:?}")
}
}
}
pub fn send_ethernet_with_outcome(&self, frame: &[u8]) -> Result<ClientSendOutcome> {
let ethernet_frame = match EthernetFrame::parse(frame) {
Ok(frame) => frame,
Err(error) => {
Err(_) => {
self.stats.record_malformed_frame();
return Err(error).context("client Ethernet frame is malformed");
return Ok(ClientSendOutcome::Dropped(DropReason::Malformed));
}
};
if ethernet_frame.is_jumbo() {
self.stats.record_dropped_frame();
return Ok(ClientSendOutcome::Dropped(DropReason::JumboFrame));
}
let datagram = encode_datagram(
FrameType::Ethernet,
self.welcome.room_id(),
@@ -354,11 +384,11 @@ impl ClientRelayIo {
frame,
)
.context("failed to encode client Ethernet datagram")?;
if let Err(error) =
validate_datagram_budget(datagram.len(), usize::from(self.quic_max_datagram_size))
if validate_datagram_budget(datagram.len(), usize::from(self.quic_max_datagram_size))
.is_err()
{
self.stats.record_dropped_frame();
return Err(error).context("client Ethernet datagram exceeds negotiated QUIC budget");
return Ok(ClientSendOutcome::Dropped(DropReason::DatagramBudget));
}
self.connection
@@ -366,7 +396,7 @@ impl ClientRelayIo {
.context("failed to send client Ethernet datagram")?;
self.stats.record_ethernet_tx(ethernet_frame);
Ok(())
Ok(ClientSendOutcome::Sent)
}
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
@@ -770,7 +800,7 @@ mod tests {
let ControlMessage::Stats(stats) = stats_message else {
panic!("expected client stats event");
};
assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 1, 1));
assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 2, 1));
stats_received_tx.send(()).unwrap();
let mut disconnect_recv = connection.accept_uni().await.unwrap();
@@ -821,9 +851,12 @@ mod tests {
client.quic_max_datagram_size()
);
relay_io
.send_ethernet(&ethernet_frame(b"to relay"))
.unwrap();
assert_eq!(
relay_io
.send_ethernet_with_outcome(&ethernet_frame(b"to relay"))
.unwrap(),
ClientSendOutcome::Sent
);
let received = tokio::time::timeout(Duration::from_secs(5), relay_io.recv_ethernet())
.await
.unwrap()
@@ -841,7 +874,17 @@ mod tests {
assert_eq!(peer.peer_id(), 1);
assert_eq!(peer.role(), Role::Gateway);
assert!(relay_io.send_ethernet(&[0; 4]).is_err());
let oversized_payload = vec![0; usize::from(relay_io.quic_max_datagram_size())];
assert_eq!(
relay_io
.send_ethernet_with_outcome(&ethernet_frame(&oversized_payload))
.unwrap(),
ClientSendOutcome::Dropped(DropReason::DatagramBudget)
);
assert_eq!(
relay_io.send_ethernet_with_outcome(&[0; 4]).unwrap(),
ClientSendOutcome::Dropped(DropReason::Malformed)
);
let stats = relay_io.stats_snapshot();
assert_eq!(stats.ethernet_frames_tx(), 1);
assert_eq!(stats.ethernet_frames_rx(), 1);
@@ -849,7 +892,7 @@ mod tests {
assert_eq!(stats.broadcast_frames_rx(), 0);
assert_eq!(stats.datagrams_tx(), 1);
assert_eq!(stats.datagrams_rx(), 1);
assert_eq!(stats.dropped_frames(), 1);
assert_eq!(stats.dropped_frames(), 2);
assert_eq!(stats.malformed_frames(), 1);
assert_eq!(client.stats_snapshot(), stats);