File: Connection\DirectTlsConnection.cs
Web Access
Project: src\aspnetcore\src\Servers\Kestrel\Transport.DirectTls\src\Microsoft.AspNetCore.Server.Kestrel.Transport.DirectTls.csproj (Microsoft.AspNetCore.Server.Kestrel.Transport.DirectTls)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Buffers;
using System.IO.Pipelines;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Connections.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Server.Kestrel.Core.Features;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.AspNetCore.Server.Kestrel.Transport.DirectTls.Connection;
 
/// <summary>
/// Connection context for an established DirectTls connection.
///
/// After the handshake completes:
/// - Owns the <see cref="ConnectionIoState"/> (which drives the connection's <see cref="System.Net.Security.TlsSocketSession"/>)
/// - Uses the assigned <see cref="TlsEventPump"/> for all I/O (read/write via epoll)
/// - Backend operations happen on the pump's dedicated thread
/// - Completions are dispatched to the ThreadPool where pipelines run
/// </summary>
internal sealed partial class DirectTlsConnection : TransportConnection
{
    // Mirrors the sockets transport's SocketConnection.MinAllocBufferSize (PinnedBlockMemoryPool.BlockSize / 2).
    // Avoids defragmentation of the transport's shared memory pool
    private const int MinAllocBufferSize = 4096 / 2;
 
    private readonly ConnectionIoState _connectionState;
    private readonly TlsEventPump _pump;
    private readonly ILogger _logger;
    private readonly MemoryPool<byte> _memoryPool;
    private readonly CancellationTokenSource _connectionClosedTokenSource = new();
 
    private Task? _receiveTask;
    private Task? _sendTask;
    private volatile bool _aborted;
    private int _disposed; // 0 = not disposed, 1 = disposed (for thread-safe Compare-And-Swap)
 
    // The accepted client leaf certificate whose lifetime this connection owns, so it can be disposed
    // exactly once on teardown. On the accept path the runtime's TlsSession transfers ownership of the leaf
    // to the caller: SetRemoteCertificateValidationResult(None) promotes it into TlsSession._remoteCertificate,
    // which TlsSession.Dispose() deliberately never frees (unlike _externalPendingCert and the intermediates).
    // Tracked separately from the settable ITlsConnectionFeature.ClientCertificate property so that we always
    // dispose the cert the runtime handed us - and never an object an app may have reassigned onto the feature.
    private X509Certificate2? _ownedClientCertificate;
 
    public DirectTlsConnection(
        ConnectionIoState connectionState,
        TlsEventPump pump,
        EndPoint? localEndPoint,
        EndPoint? remoteEndPoint,
        MemoryPool<byte> memoryPool,
        long maxReadBufferSize,
        long maxWriteBufferSize,
        ILogger logger,
        SslApplicationProtocol negotiatedApplicationProtocol = default,
        X509Certificate2? clientCertificate = null)
    {
        _connectionState = connectionState;
        _pump = pump;
        _memoryPool = memoryPool;
        _logger = logger;
 
        LocalEndPoint = localEndPoint;
        RemoteEndPoint = remoteEndPoint;
        ConnectionClosed = _connectionClosedTokenSource.Token;
 
        // A managed Socket over the raw fd (non-owning) is offered, matching the standard sockets
        // transport's IConnectionSocketFeature.
        Features.Set<IConnectionSocketFeature>(this);
 
        // Mark the connection as TLS-secured. The TLS feature interfaces are implemented directly on this
        // connection (see DirectTlsConnection.FeatureCollection.cs), mirroring how SocketConnection exposes
        // IConnectionSocketFeature and how the SslStream path's TlsConnectionFeature backs every TLS feature
        // off a single object. Their presence makes the UseHttps middleware no-op (it sees an existing
        // ITlsConnectionFeature instead of wrapping the transport in a second SslStream) and makes Kestrel
        // resolve the request scheme as https. The handshake is already complete here. When the endpoint
        // requested a client certificate (mTLS) and the peer presented one that passed validation, it is
        // stored in ClientCertificate so HttpContext.Connection.ClientCertificate resolves.
        ClientCertificate = clientCertificate;
        _ownedClientCertificate = clientCertificate;
        Features.Set<ITlsConnectionFeature>(this);
        Features.Set<ITlsHandshakeFeature>(this);
 
        // Publish the ALPN protocol negotiated during the handshake. This transport references Kestrel.Core,
        // so ITlsApplicationProtocolFeature is set directly (read live from _negotiatedApplicationProtocol),
        // and HttpConnection.SelectProtocol can pick HTTP/2 without the UseHttps middleware on the path. The
        // value is refreshed at CompleteHandshake for connections allocated early (at NeedsTlsContext).
        _negotiatedApplicationProtocol = negotiatedApplicationProtocol;
        Features.Set<ITlsApplicationProtocolFeature>(this);
 
        // Subscribe to fatal errors from the connection I/O state
        // This ensures we get notified even if no read/write is pending when peer disconnects
        _connectionState.OnFatalError = OnTlsFatalError;
 
        // Create duplex pipe pair for Kestrel. MaxReadBufferSize / MaxWriteBufferSize become the writer
        // backpressure thresholds so a slow app (input pipe) or a slow/blocked peer (output pipe) can't force
        // unbounded server buffering - matching the sockets transport.
        var (inputOptions, outputOptions) = CreatePipeOptions(memoryPool, maxReadBufferSize, maxWriteBufferSize);
 
        var pair = DuplexPipe.CreateConnectionPair(inputOptions, outputOptions);
        Transport = pair.Transport;
        Application = pair.Application;
    }
 
    /// <summary>
    /// Builds the input/output <see cref="PipeOptions"/> for the connection's duplex pipe pair.
    /// <paramref name="maxReadBufferSize"/> and <paramref name="maxWriteBufferSize"/> are applied as writer
    /// backpressure thresholds - the writer pauses at the maximum and resumes once drained to half of it. A
    /// size of 0 disables backpressure for that direction (unbounded buffering), matching the sockets
    /// transport's <c>MaxReadBufferSize</c> / <c>MaxWriteBufferSize</c> semantics.
    /// </summary>
    /// <remarks>
    /// The scheduler split matches the inline-transport sockets path: the input pipe (decrypted reads the app
    /// consumes) dispatches reader continuations to the thread pool and writes inline on the pump thread; the
    /// output pipe (app writes the pump encrypts) does the reverse.
    /// </remarks>
    internal static (PipeOptions InputOptions, PipeOptions OutputOptions) CreatePipeOptions(
        MemoryPool<byte> memoryPool,
        long maxReadBufferSize,
        long maxWriteBufferSize)
    {
        var inputOptions = new PipeOptions(
            pool: memoryPool,
            readerScheduler: PipeScheduler.ThreadPool,
            writerScheduler: PipeScheduler.Inline,
            pauseWriterThreshold: maxReadBufferSize,
            resumeWriterThreshold: maxReadBufferSize / 2,
            useSynchronizationContext: false);
 
        var outputOptions = new PipeOptions(
            pool: memoryPool,
            readerScheduler: PipeScheduler.Inline,
            writerScheduler: PipeScheduler.ThreadPool,
            pauseWriterThreshold: maxWriteBufferSize,
            resumeWriterThreshold: maxWriteBufferSize / 2,
            useSynchronizationContext: false);
 
        return (inputOptions, outputOptions);
    }
 
    public override MemoryPool<byte> MemoryPool => _memoryPool;
 
    /// <summary>
    /// The underlying per-connection TLS state. Exposed so the pump can promote a connection that was
    /// allocated early (at NeedsTlsContext, for the ClientHello listener) to an established connection.
    /// </summary>
    internal ConnectionIoState ConnectionState => _connectionState;
 
    /// <summary>
    /// Promotes a connection that was allocated early (at NeedsTlsContext, so the ClientHello listener
    /// had a stable <see cref="ConnectionContext"/>) to a fully-established connection by publishing the
    /// values that were unknown at allocation time: the negotiated ALPN protocol and any validated client
    /// certificate. The TLS feature interfaces were already wired in the constructor and read the
    /// remaining negotiated values live from the session, so nothing else needs updating.
    /// </summary>
    internal void CompleteHandshake(SslApplicationProtocol negotiatedApplicationProtocol, X509Certificate2? clientCertificate)
    {
        ClientCertificate = clientCertificate;
        _ownedClientCertificate = clientCertificate;
        _negotiatedApplicationProtocol = negotiatedApplicationProtocol;
    }
 
    /// <summary>
    /// Tears down a connection that was allocated early (at NeedsTlsContext) but whose handshake never
    /// completed - for example the ClientHello listener fired and then a later handshake step failed.
    /// The receive/send loops were never <see cref="Start"/>ed, so this just completes the (idle) pipes
    /// and closes the socket fd directly. It deliberately does NOT go through
    /// <see cref="ConnectionIoState.Dispose"/>, which would attempt a graceful close_notify
    /// (<c>Shutdown()</c>) that a half-open session cannot perform cleanly.
    /// </summary>
    internal void AbortBeforeStart()
    {
        if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0)
        {
            return;
        }
        _aborted = true;
 
        Application.Input.Complete();
        Application.Output.Complete();
        Transport.Input.Complete();
        Transport.Output.Complete();
 
        try
        {
            // Close the socket fd of the half-open handshake without the graceful close_notify.
            lock (_socketLock)
            {
                _connectionState.Session.Dispose();
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to dispose half-open connection backend for fd={Fd}", _connectionState.Fd);
        }
 
        _connectionClosedTokenSource.Cancel();
 
        // A half-open handshake never reached mTLS validation, so _ownedClientCertificate is normally null
        // here; dispose defensively (no-op when null) to keep both teardown paths symmetric.
        _ownedClientCertificate?.Dispose();
 
        // Dispose the cached IConnectionSocketFeature wrapper if one was materialized (non-owning, so this
        // never closes the fd) to keep both teardown paths symmetric.
        _socket?.Dispose();
    }
 
    /// <summary>
    /// Start the receive and send loops.
    /// </summary>
    public void Start()
    {
        _receiveTask = ReceiveLoopAsync();
        _sendTask = SendLoopAsync();
    }
 
    /// <summary>
    /// Receive loop: SSL_read -> write to Application.Output (Kestrel reads from Transport.Input)
    /// Uses the pump's epoll-based async SSL_read.
    /// </summary>
    private async Task ReceiveLoopAsync()
    {
        Exception? error = null;
 
        try
        {
            while (!_aborted)
            {
                var memory = Application.Output.GetMemory(MinAllocBufferSize);
 
                // Use pump's async SSL_read (waits for epoll event, does SSL_read on pump thread)
                int bytesRead = await _connectionState.ReadAsync(memory);
 
                if (bytesRead > 0)
                {
                    Application.Output.Advance(bytesRead);
 
                    var flushTask = Application.Output.FlushAsync();
                    FlushResult flushResult;
                    if (flushTask.IsCompleted)
                    {
                        flushResult = await flushTask;
                    }
                    else
                    {
                        // Backpressure: the application isn't draining the input pipe, so no read is pending. Suspend
                        // readable interest while we wait - otherwise still-buffered ciphertext keeps the level-triggered
                        // pump returning EPOLLIN every loop and spins the worker. Re-arm once the flush unblocks.
                        _connectionState.SuspendReadInterest();
                        try
                        {
                            flushResult = await flushTask;
                        }
                        finally
                        {
                            _connectionState.ResumeReadInterest();
                        }
                    }
 
                    if (flushResult.IsCompleted || flushResult.IsCanceled)
                    {
                        break;
                    }
                }
                else if (bytesRead == 0)
                {
                    // Connection closed (EOF)
                    break;
                }
                else
                {
                    // Negative = error (shouldn't happen with async API, but handle it)
                    error = new IOException($"SSL_read failed with {bytesRead}");
                    break;
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Expected during shutdown
        }
        catch (Exception ex)
        {
            error = ex;
        }
        finally
        {
            Application.Output.Complete(error);
        }
    }
 
    /// <summary>
    /// Send loop: read from Application.Input (Kestrel writes to Transport.Output) -> SSL_write
    /// Uses the pump's epoll-based async SSL_write.
    /// </summary>
    private async Task SendLoopAsync()
    {
        Exception? error = null;
 
        try
        {
            while (!_aborted)
            {
                var result = await Application.Input.ReadAsync();
 
                // Check for cancellation first
                if (result.IsCanceled)
                {
                    break;
                }
 
                var buffer = result.Buffer;
 
                // Process buffer data BEFORE checking IsCompleted
                // This ensures the final chunk (e.g., "0\\r\\n\\r\\n" terminator) is sent
                if (!buffer.IsEmpty)
                {
                    foreach (var segment in buffer)
                    {
                        if (segment.Length > 0)
                        {
                            // Use pump's async SSL_write (waits for epoll event if needed)
                            // Pump handles the SSL_write on its dedicated thread
                            var written = await _connectionState.WriteAsync(segment);
                            if (written == 0)
                            {
                                // Peer closed the connection mid-send.
                                Application.Input.AdvanceTo(buffer.End);
                                return;
                            }
                        }
                    }
                }
 
                Application.Input.AdvanceTo(buffer.End);
 
                // Check completion AFTER processing and advancing (matches Kestrel's DoSend pattern)
                if (result.IsCompleted)
                {
                    break;
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Expected during shutdown
        }
        catch (Exception ex)
        {
            error = ex;
        }
        finally
        {
            Application.Input.Complete(error);
        }
    }
 
    public override void Abort(ConnectionAbortedException abortReason)
    {
        if (_aborted)
        {
            return;
        }
        _aborted = true;
 
        // Unblock BOTH loops so the connection tears down immediately instead of leaving the socket
        // half-open until the peer times out. SocketConnection.Abort closes the socket synchronously
        // (RST/FIN); DirectTls instead closes it later in DisposeAsync, so Abort must make Kestrel
        // progress to that point right away.
        //
        // The receive loop is the key: on an app-initiated abort mid-response (e.g. HttpContext.Abort),
        // it is parked in ReadAsync waiting for peer bytes while the peer is parked waiting for response
        // bytes - a standoff only the peer's request timeout can break. Cancelling the TLS operations
        // completes that parked read; the receive loop then completes Application.Output, signalling
        // Kestrel that the transport is done so it disposes the connection (closing the socket) without
        // waiting. Cancelling the pending flush covers a receive loop parked on Application.Output
        // backpressure, and CancelPendingRead unblocks the send loop as before.
        _connectionState.Cancel();
        Application.Input.CancelPendingRead();
        Application.Output.CancelPendingFlush();
    }
 
    /// <summary>
    /// Called when the TLS connection encounters a fatal error (e.g., peer disconnect via EPOLLRDHUP).
    /// This just aborts the connection - disposal will happen when Kestrel calls DisposeAsync.
    /// </summary>
    private void OnTlsFatalError(Exception ex)
    {
        if (_aborted || Volatile.Read(ref _disposed) != 0)
        {
            return;
        }
 
        _logger.LogDebug(ex, "TLS fatal error for fd={Fd}, aborting connection", _connectionState.Fd);
 
        // Just abort to cancel pending operations - don't trigger disposal here. Kestrel calls DisposeAsync
        // when it's done with the connection, which prevents premature disposal while SendLoop is still writing.
        // Abort only cancels awaitables and pipe reads/flushes (none of which throw), so no guard is needed here.
        Abort(new ConnectionAbortedException("TLS connection error", ex));
    }
 
    public override async ValueTask DisposeAsync()
    {
        // Thread-safe check: only one call to DisposeAsync proceeds
        if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0)
        {
            return;
        }
 
        // 1. Cancel pending TLS operations (unblocks ReadAsync/WriteAsync TCS)
        _connectionState.Cancel();
 
        // 2. Unregister from pump (removes from epoll, prevents new events)
        _pump.Unregister(_connectionState.Fd);
 
        // 3. Cancel pending pipeline operations to unblock our loops
        Application.Input.CancelPendingRead();
        Application.Output.CancelPendingFlush();
 
        // 4. Wait for loops to finish (they should complete quickly now)
        if (_receiveTask != null)
        {
            await _receiveTask.ConfigureAwait(false);
        }
        if (_sendTask != null)
        {
            await _sendTask.ConfigureAwait(false);
        }
 
        // 5. Complete the transport pipes (signals to Kestrel)
        Transport.Input.Complete();
        Transport.Output.Complete();
 
        // 6. Graceful TLS and socket shutdown. Disposing the connection state sends
        //    close_notify via the TLS session and closes the underlying socket fd
        //    (the session owns the SafeSocketHandle), so no manual shutdown/close here.
        try
        {
            lock (_socketLock)
            {
                _connectionState.Dispose();
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "TLS shutdown failed for fd={Fd}", _connectionState.Fd);
        }
 
        // 7. Signal connection closed
        _connectionClosedTokenSource.Cancel();
        _connectionClosedTokenSource.Dispose();
 
        // 8. Dispose the accepted client certificate. The runtime's TlsSession transferred ownership of the
        //    leaf to us on the accept path (see _ownedClientCertificate) and never frees it itself, so the
        //    transport must, or the native key handle leaks once per accepted mTLS connection. Kestrel is done
        //    with the connection by now, mirroring how SslStream disposes RemoteCertificate with the stream.
        _ownedClientCertificate?.Dispose();
 
        // 9. Dispose the cached IConnectionSocketFeature wrapper, if one was ever materialized. It is non-owning
        //    so this never closes the fd (the session already did, above), but it marks the wrapper disposed so
        //    any late metadata read fails loudly instead of operating on a descriptor the OS may have recycled.
        _socket?.Dispose();
    }
}