add L2 reliability: seq + cumulative ACK + retransmit (protocol v2)

DATA frames now carry seq:4 and ack_seq:4 in a 16-byte extended
header. Both sides maintain per-session send/recv state:

Sender:
- Monotonic seq counter, retransmit buffer (seq -> frame bytes)
- Retransmit timer: 5ms timeout, 10 max retries -> CLOSE
- Window advances on cumulative ACK

Receiver:
- In-order delivery to TCP socket (expected_seq)
- Out-of-order buffering (SortedList by seq)
- Duplicate detection (seq < expected -> discard + re-ACK)
- Pure ACK frames (empty-payload DATA) for duplicate/OOO responses

This prevents lost Ethernet frames from permanently corrupting TCP
sessions, which was the key v1 limitation. The local kernel TCP stack
ACKs data before we chunk it into DATA frames; without L2 reliability
a dropped frame creates an unrecoverable gap.

Version bumped to 2. Both sides must speak v2; no negotiation.

Updated: PROTOCOL.md (full v2 spec), README.md, Rust frame.rs/
session.rs/main.rs, C# Frame.cs/SessionManager.cs/TunnelLink.cs.
This commit is contained in:
2026-08-13 09:08:05 +00:00
parent 2f61f2bb1b
commit eb8994d1e1
8 changed files with 721 additions and 102 deletions
+58 -21
View File
@@ -6,8 +6,9 @@ static class Proto
{
public const ushort EtherType = 0x6969;
public const int EthHeaderLen = 14;
public const byte Version = 1;
public const byte Version = 2;
public const int HeaderLen = 8;
public const int DataHeaderLen = 16; // 8 common + 4 seq + 4 ack_seq
public const int MaxPayload = 1480;
public const byte TypeDiscover = 0x01;
@@ -28,6 +29,7 @@ static class Proto
public const byte ReasonConnectFailed = 2;
public const byte ReasonOversize = 3;
public const byte ReasonUnknownSession = 4;
public const byte ReasonMaxRetries = 5;
}
readonly record struct UpstreamEntry(
@@ -46,7 +48,7 @@ abstract record Frame
internal record Open(byte UpstreamId) : Frame;
internal record OpenAck(uint SessionId, byte UpstreamId) : Frame;
internal record OpenNak(byte UpstreamId, byte Reason) : Frame;
internal record Data(uint SessionId, byte[] Payload) : Frame;
internal record Data(uint SessionId, uint Seq, uint AckSeq, byte[] Payload) : Frame;
internal record Close(uint SessionId, byte? Reason) : Frame;
internal record Ping(ulong Nonce) : Frame;
internal record Pong(ulong Nonce) : Frame;
@@ -54,8 +56,7 @@ abstract record Frame
static class FrameCodec
{
/// Build the 8-byte header + payload. payload_len records the exact
/// payload length so the receiver can ignore Ethernet padding.
/// Build a non-DATA frame: 8-byte common header + payload.
static byte[] Build(byte type, uint sessionId, byte[] payload)
{
var buf = new byte[Proto.HeaderLen + payload.Length];
@@ -71,6 +72,31 @@ static class FrameCodec
return buf;
}
/// Build a DATA frame: 8-byte common header + seq + ack_seq + payload.
/// payload_len counts only the raw bytes, not seq/ack_seq.
static byte[] BuildData(uint sessionId, uint seq, uint ackSeq, byte[] payload)
{
var buf = new byte[Proto.DataHeaderLen + payload.Length];
buf[0] = Proto.Version;
buf[1] = Proto.TypeData;
buf[2] = (byte)(sessionId >> 24);
buf[3] = (byte)(sessionId >> 16);
buf[4] = (byte)(sessionId >> 8);
buf[5] = (byte)(sessionId & 0xFF);
buf[6] = (byte)(payload.Length >> 8);
buf[7] = (byte)(payload.Length & 0xFF);
buf[8] = (byte)(seq >> 24);
buf[9] = (byte)(seq >> 16);
buf[10] = (byte)(seq >> 8);
buf[11] = (byte)(seq & 0xFF);
buf[12] = (byte)(ackSeq >> 24);
buf[13] = (byte)(ackSeq >> 16);
buf[14] = (byte)(ackSeq >> 8);
buf[15] = (byte)(ackSeq & 0xFF);
Buffer.BlockCopy(payload, 0, buf, Proto.DataHeaderLen, payload.Length);
return buf;
}
public static byte[] Encode(Frame frame)
{
return frame switch
@@ -86,7 +112,7 @@ static class FrameCodec
Frame.OpenNak nak =>
Build(Proto.TypeOpenNak, 0, [nak.UpstreamId, nak.Reason]),
Frame.Data data =>
Build(Proto.TypeData, data.SessionId, data.Payload),
BuildData(data.SessionId, data.Seq, data.AckSeq, data.Payload),
Frame.Close close =>
Build(Proto.TypeClose, close.SessionId,
close.Reason.HasValue ? [close.Reason.Value] : []),
@@ -130,27 +156,38 @@ static class FrameCodec
var type = buf[1];
var sessionId = (uint)(buf[2] << 24 | buf[3] << 16 | buf[4] << 8 | buf[5]);
var payloadLen = (ushort)(buf[6] << 8 | buf[7]);
// DATA frames have seq + ack_seq after the common header.
if (type == Proto.TypeData)
{
if (buf.Length < Proto.DataHeaderLen + payloadLen)
return null;
var seq = (uint)(buf[8] << 24 | buf[9] << 16 | buf[10] << 8 | buf[11]);
var ackSeq = (uint)(buf[12] << 24 | buf[13] << 16 | buf[14] << 8 | buf[15]);
var payload = buf.Slice(Proto.DataHeaderLen, payloadLen);
if (payload.Length > Proto.MaxPayload)
return null;
return new Frame.Data(sessionId, seq, ackSeq, payload.ToArray());
}
if (buf.Length < Proto.HeaderLen + payloadLen)
return null;
// Slice exactly payloadLen bytes, ignoring any trailing Ethernet padding.
var payload = buf.Slice(Proto.HeaderLen, payloadLen);
var payload2 = buf.Slice(Proto.HeaderLen, payloadLen);
return type switch
{
Proto.TypeManifest => ParseManifest(payload),
Proto.TypeOpenAck when payload.Length == 1 =>
new Frame.OpenAck(sessionId, payload[0]),
Proto.TypeOpenNak when payload.Length == 2 =>
new Frame.OpenNak(payload[0], payload[1]),
Proto.TypeData when payload.Length <= Proto.MaxPayload =>
new Frame.Data(sessionId, payload.ToArray()),
Proto.TypeClose when payload.Length is 0 or 1 =>
new Frame.Close(sessionId, payload.Length == 1 ? payload[0] : null),
Proto.TypePong when payload.Length == 8 =>
new Frame.Pong(ParseNonce(payload)),
Proto.TypePing when payload.Length == 8 =>
new Frame.Ping(ParseNonce(payload)),
Proto.TypeDiscover when payload.Length == 0 =>
Proto.TypeManifest => ParseManifest(payload2),
Proto.TypeOpenAck when payload2.Length == 1 =>
new Frame.OpenAck(sessionId, payload2[0]),
Proto.TypeOpenNak when payload2.Length == 2 =>
new Frame.OpenNak(payload2[0], payload2[1]),
Proto.TypeClose when payload2.Length is 0 or 1 =>
new Frame.Close(sessionId, payload2.Length == 1 ? payload2[0] : null),
Proto.TypePong when payload2.Length == 8 =>
new Frame.Pong(ParseNonce(payload2)),
Proto.TypePing when payload2.Length == 8 =>
new Frame.Ping(ParseNonce(payload2)),
Proto.TypeDiscover when payload2.Length == 0 =>
new Frame.Discover(),
_ => null,
};