fix: premature socket teardown on CLOSE + duplicate CLOSE echo
build / build (push) Has been cancelled

Two bugs when upstream closes:

1. Data loss: gatuna received CLOSE from gatunad and immediately
   RST'd the socket to curl while the drain pump still had queued
   payloads in _deliverChannel. Fix: OnRemoteClose() completes the
   channel writer and cancels the read pump, but lets the drain pump
   finish naturally. Drain pump no longer takes _cts.Token — it stops
   on channel completion and closes the socket gracefully (FIN, OS
   flushes in background).

2. Duplicate CLOSE: gatuna echoed CLOSE back to gatunad (server
   already knows — it initiated). SendClose() now guarded by
   _closeSent flag. OnRemoteClose() never sends CLOSE at all.

Server-side: remove session from store before sending CLOSE so the
retransmit timer stops before CLOSE is queued. Only log CLOSE if
the session actually existed (dedup).
This commit is contained in:
2026-08-17 07:27:50 +00:00
parent b4222df349
commit fcd172f341
3 changed files with 30 additions and 9 deletions
+25 -5
View File
@@ -100,7 +100,7 @@ sealed class SessionManager : IDisposable
case Frame.Close close:
if (_sessions.TryRemove(close.SessionId, out var s))
s.Dispose();
s.OnRemoteClose();
break;
case Frame.Pong pong:
@@ -394,6 +394,7 @@ sealed class Session(
readonly RecvState _recv = new();
readonly object _sendLock = new();
readonly object _recvLock = new();
volatile bool _closeSent;
public void Start()
{
@@ -403,6 +404,17 @@ sealed class Session(
_ = RetransmitTimer();
}
/// <summary>
/// Called when a CLOSE frame arrives from the server. The server has
/// already torn down its side — we just need to flush queued data to
/// the local socket and close gracefully. Do NOT echo CLOSE back.
/// </summary>
public void OnRemoteClose()
{
_cts.Cancel();
_deliverChannel.Writer.TryComplete();
}
/// Handle a DATA frame from the tunnel.
public void HandleData(Frame.Data data)
{
@@ -440,10 +452,10 @@ sealed class Session(
async Task PumpSocketToTunnel()
{
var buf = new byte[Proto.MaxPayload];
try
{
var stream = client.GetStream();
var buf = new byte[Proto.MaxPayload];
using var reg = _cts.Token.Register(() => client.Dispose());
while (!_cts.IsCancellationRequested)
{
@@ -476,6 +488,8 @@ sealed class Session(
}
}
catch { }
_cts.Cancel();
_deliverChannel.Writer.TryComplete();
SendClose();
onClosed();
}
@@ -485,10 +499,11 @@ sealed class Session(
try
{
var stream = client.GetStream();
await foreach (var payload in _deliverChannel.Reader.ReadAllAsync(_cts.Token))
await stream.WriteAsync(payload, _cts.Token);
await foreach (var payload in _deliverChannel.Reader.ReadAllAsync())
await stream.WriteAsync(payload);
}
catch { }
finally { client.Close(); }
}
async Task RetransmitTimer()
@@ -539,7 +554,12 @@ sealed class Session(
link.SendTo(serverMac, new Frame.Data(sessionId, seq, ackSeq, []));
}
void SendClose() => link.SendTo(serverMac, new Frame.Close(sessionId, null));
void SendClose()
{
if (_closeSent) return;
_closeSent = true;
link.SendTo(serverMac, new Frame.Close(sessionId, null));
}
public void Dispose()
{