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

Status Closed — not planned
Maintainer reply None cached
Activity 2 comments · opened Jan 18, 2026 · 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 ↗