[BUG] TunnelServer deletes session immediately after handshake, breaking TCP tunnel functionality

Resolved 💬 2 comments Opened Jan 18, 2026 by GeorgyBatalov Closed Feb 12, 2026

Problem Summary

TunnelServer deletes sessions immediately after handshake, causing TCP tunnels to receive different ports on each reconnection and making the system unstable.

Observed behavior:

  • Client connects to TunnelServer → gets TCP port 40000
  • Client reconnects → gets TCP port 40001
  • Next reconnect → gets TCP port 40002
  • Pattern continues indefinitely

Root cause: ClientConnectionHandler calls CleanupAsync() in the finally block after every handshake, even successful ones.

---

Architecture Context

Tunnel2 uses split-plane architecture:

  1. TunnelServer (Control Plane) - Manages handshake, JWT tokens, routing
  • Client connects to TunnelServer:12002
  • Handshake is a short-lived connection
  • Server returns: SessionId, JWT token, PublicTcpPort
  • Connection closes after handshake
  1. ProxyEntry (Data Plane) - Routes traffic through active tunnels
  • Client connects to ProxyEntry:12001 (uplink)
  • Uplink is a long-lived connection
  • ProxyEntry creates TCP listener on PublicTcpPort
  • ProxyEntry expects session to exist in PostgreSQL

---

Root Cause Analysis

File: tunnel2-server/src/Tunnel2.TunnelServer.ConsoleApp/ConnectionHandlers/ClientConnectionHandler.cs:101-116

finally
{
    // Cleanup: session store, resource tracking, TCP ports, events
    try
    {
        await _cleanupService.CleanupAsync(context);  // ❌ ALWAYS called!
        _logger.LogDebug("[PIPELINE] Cleanup completed for SessionId={SessionId}", context.SessionId);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "[PIPELINE] Cleanup failed for SessionId={SessionId}", context.SessionId);
    }

    // Dispose TCP client
    context.TcpClient?.Dispose();
}

Problem: CleanupAsync() is always invoked in the finally block, even after successful handshakes.

---

What CleanupAsync Does

File: tunnel2-server/src/Tunnel2.TunnelServer.Infrastructure/Services/ClientConnectionCleanupService.cs:49-134

When called, it:

  1. Deletes session from PostgreSQL (DeactivateSessionAsync)
  2. Releases TCP port (ReleasePortAsync)
  3. Removes TCP routing (UnregisterAsync)
  4. Publishes SessionClosed event to RabbitMQ

---

Evidence from Logs

{"@t":"2026-01-18T07:15:12.0528820Z","@mt":"[PUBLISH-STEP] Published SessionCreated: SessionId={SessionId}, Protocol={Protocol}, PublicTcpPort={Port}","Port":40001}
{"@t":"2026-01-18T07:15:12.0568699Z","@mt":"[CLEANUP] TCP resources released: Port={Port}, SessionId={SessionId}","Port":40001}

Timeline:

  1. 07:15:12.0528 - Session created with port 40001
  2. 07:15:12.0568 - Same port released 40ms later!
  3. Next handshake allocates port 40002

---

Expected Behavior

In split-plane architecture, session should persist after handshake:

  1. Client → TunnelServer (handshake)
  • Create session in PostgreSQL
  • Allocate TCP port
  • Return SessionId + JWT + PublicTcpPort
  • Close connection (short-lived)
  • DO NOT delete session!
  1. Client → ProxyEntry (uplink)
  • Attach uplink using JWT
  • Create TCP listener on PublicTcpPort
  • Session remains active
  1. Session cleanup should happen when:
  • Uplink disconnects (ProxyEntry detects)
  • Session expires (TTL)
  • Explicit client disconnect

---

Current Workaround

None available. TCP tunnels are currently broken.

---

Possible Solutions

Option 1: Remove CleanupAsync from finally block (risky)

finally
{
    // Only dispose TCP client, don't cleanup session
    context.TcpClient?.Dispose();
}

Concern: Who will cleanup sessions if handshake fails?

Option 2: Conditional cleanup based on success

finally
{
    if (!context.HandshakeSucceeded)
    {
        await _cleanupService.CleanupAsync(context);
    }
    context.TcpClient?.Dispose();
}

Option 3: Separate cleanup for handshake vs uplink

  • Handshake cleanup: Only on failure
  • Uplink cleanup: ProxyEntry triggers via RabbitMQ event

---

Impact

Critical:

  • TCP tunnels completely broken
  • Ports change on every reconnect
  • ProxyEntry cannot find sessions
  • Users cannot rely on stable TCP endpoints

---

Related Files

  • tunnel2-server/src/Tunnel2.TunnelServer.ConsoleApp/ConnectionHandlers/ClientConnectionHandler.cs:101-116
  • tunnel2-server/src/Tunnel2.TunnelServer.Infrastructure/Services/ClientConnectionCleanupService.cs
  • tunnel2-server/src/Tunnel2.TunnelServer.Infrastructure/PipelineSteps/ProvisionSessionStep.cs

---

Commit History

Introduced in refactor: 4f4346e - refactor(phase-4): Refactor ClientConnectionHandler as pipeline orchestrator (691 LOC → 133 LOC, 81% reduction)

Likely unintended side-effect of pipeline refactoring.

---

Labels

  • bug
  • critical
  • tcp-tunnels
  • control-plane
  • session-management

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗