File: ConnectionIoState.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.Net.Security;
using Microsoft.Extensions.Logging;
 
#pragma warning disable SYSLIB5007 // TlsSocketSession/TlsOperationStatus are experimental.
 
namespace Microsoft.AspNetCore.Server.Kestrel.Transport.DirectTls;
 
/// <summary>
/// Per-connection state for an established DirectTls connection. Drives non-blocking application
/// reads/writes directly over the runtime's socket-bound <see cref="TlsSocketSession"/>, which runs
/// <c>SSL_read</c>/<c>SSL_write</c> over the connection's file descriptor and returns a
/// <see cref="TlsOperationStatus"/>. The pump maps that status straight to an epoll interest:
/// <see cref="TlsOperationStatus.NeedMoreData"/> waits for the socket to become readable (<c>EPOLLIN</c>)
/// and <see cref="TlsOperationStatus.DestinationTooSmall"/> waits for it to become writable (<c>EPOLLOUT</c>),
/// for both reads and writes (a renegotiation can flip the direction). The handshake is completed by
/// <see cref="TlsEventPump"/> before this state is created. DirectTls terminates TLS for every connection,
/// so the session is the only byte-stream backend.
/// </summary>
/// <remarks>
/// Not <c>sealed</c> so tests can subclass it to script the raw session results via
/// <see cref="RawRead"/>/<see cref="RawWrite"/> and observe the epoll interest via <see cref="ApplyEvents"/>
/// without a live socket or pump. Those three members are the only <c>virtual</c> seams - they wrap just the
/// native <c>SSL_read</c>/<c>SSL_write</c> calls and the <c>epoll_ctl</c> syscall; everything above them
/// (status validation, abrupt-close translation, the read/write state machine and epoll-interest logic) is
/// the real code exercised end to end.
/// </remarks>
internal class ConnectionIoState : IDisposable
{
    private readonly ILogger _logger;
    private readonly TlsSocketSession _session;
 
    // Serializes every native SSL operation (SSL_read / SSL_write / SSL_shutdown) on this connection's
    // session. TlsSocketSession is not safe for concurrent Read/Write from different threads - one SSL*
    // and its scratch buffers back both directions, and in TLS 1.3 post-handshake messages make reads and
    // writes touch shared state. Yet the receive loop, the send loop, and the pump thread can each drive a
    // native call simultaneously (most often under HTTP/2 duplex traffic, where a request body is read while
    // a response body is written). Without this gate, concurrent SSL_read/SSL_write corrupt the TLS state
    // machine and surface a spurious error that TlsRead translates to EOF, closing a live connection
    // mid-stream.
    //
    // It also serializes the read/write state machine itself - the initiating ReadAsync/WriteAsync (on the
    // receive/send loop threads) and the completing OnReadable/OnWritable (on the pump thread) both mutate
    // the epoll-interest flags (_readWantsWrite/_writeWantsWrite) and issue an *absolute* EPOLL_CTL_MOD via
    // UpdateEvents. Without a common gate those two sides race: the later epoll_ctl wins and can drop an
    // EPOLLOUT the other side is still waiting on, stranding a parked write (a WouldBlock'd final response
    // byte) with no wakeup until the peer times out. Holding this lock across each side's whole transition
    // makes the flag reads and the epoll_ctl atomic with respect to the other side. Because the sockets are
    // non-blocking every native call returns promptly and completions run continuations asynchronously, so
    // the lock is still held only briefly.
    //
    // Each side's transition (ReadAsync/WriteAsync/OnReadable/OnWritable) is written *inline* inside its lock
    // and must stay fully synchronous - it is deliberately not delegated to an async helper. A lock (Monitor)
    // is released when its synchronous scope returns, so an 'await' in the body would drop the lock at the
    // suspension point and let the continuation keep mutating this state unguarded. Keeping the body lexically
    // inside the lock turns any such 'await' into a compile error (CS1996); a plain helper call could hide an
    // await behind the method boundary and silently defeat that guarantee.
    private readonly object _sslLock = new();
 
    public readonly int Fd;
 
    /// <summary>The underlying TLS session, exposed so the connection can publish negotiated TLS features.</summary>
    public TlsSocketSession Session => _session;
 
    // Reference to pump for dynamic event modification
    internal TlsEventPump? Pump { get; set; }
 
    // Callback for fatal errors (e.g., peer disconnect) - allows owner to trigger disposal
    internal Action<Exception>? OnFatalError { get; set; }
 
    public bool IsHandshaked { get; private set; }
 
    // Read - reusable awaitable to avoid TCS allocations
    private readonly TlsAwaitable<int> _readAwaitable = new();
    private Memory<byte> _readBuffer;
    private bool _readWantsWrite;  // Read needs the socket to become writable (renegotiation)
 
    // Write - reusable awaitable to avoid TCS allocations
    private readonly TlsAwaitable<int> _writeAwaitable = new();
    private ReadOnlyMemory<byte> _writeBuffer;  // Remaining (unwritten) application bytes
    private int _writeTotal;                     // Original request length to report on completion
    private bool _writeWantsRead;                // Write needs the socket to become readable (renegotiation)
    private bool _writeWantsWrite;               // Write hit WouldBlock and needs the socket to become writable
    private bool _readInterestSuspended;         // Receive loop parked on backpressure; drop EPOLLIN to avoid a level-triggered spin
 
    public ConnectionIoState(int fd, TlsSocketSession session, ILogger logger)
    {
        _logger = logger;
 
        Fd = fd;
        _session = session;
    }
 
    /// <summary>
    /// Mark the handshake as complete. The TLS handshake is performed by the pump before this state
    /// exists, so this simply records that the connection is ready for application I/O.
    /// </summary>
    internal void SetHandshakeComplete()
    {
        IsHandshaked = true;
    }
 
    // ═══════════════════════════════════════════════════════════════
    // TLS BYTE-STREAM I/O (SSL_read / SSL_write over the fd)
    // ═══════════════════════════════════════════════════════════════
 
    // The only native session calls, isolated as the test seam (see class remarks). Virtual so tests can
    // script raw statuses or simulate an abrupt close without a live TlsSocketSession.
    internal virtual TlsOperationStatus RawRead(Span<byte> buffer, out int bytesRead)
    {
        lock (_sslLock)
        {
            return _session.Read(buffer, out bytesRead);
        }
    }
 
    internal virtual TlsOperationStatus RawWrite(ReadOnlySpan<byte> buffer, out int bytesWritten)
    {
        lock (_sslLock)
        {
            return _session.Write(buffer, out bytesWritten);
        }
    }
 
    internal virtual void ApplyEvents(uint events)
    {
        if (Pump is { } pump && !pump.ModifyEvents(Fd, events))
        {
            throw new TlsException($"Failed to update epoll interest for fd={Fd}");
        }
    }
 
    internal virtual void ShutdownSession() => _session.Shutdown();
 
    internal virtual void DisposeSession() => _session.Dispose();
 
    // Reads decrypted application bytes into buffer. NeedMoreData -> need more ciphertext (wait readable);
    // DestinationTooSmall -> renegotiation must flush handshake output (wait writable).
    private TlsOperationStatus TlsRead(Span<byte> buffer, out int bytesRead)
    {
        var status = RawRead(buffer, out bytesRead);
        return status is TlsOperationStatus.Complete or TlsOperationStatus.NeedMoreData
            or TlsOperationStatus.DestinationTooSmall or TlsOperationStatus.Closed
            ? status
            : throw new TlsException($"TLS read failed: {status}");
    }
 
    // Writes application bytes from buffer. DestinationTooSmall -> socket WouldBlock (wait writable);
    // NeedMoreData -> renegotiation must read peer ciphertext first (wait readable).
    private TlsOperationStatus TlsWrite(ReadOnlySpan<byte> buffer, out int bytesWritten)
    {
        var status = RawWrite(buffer, out bytesWritten);
        return status is TlsOperationStatus.Complete or TlsOperationStatus.NeedMoreData
            or TlsOperationStatus.DestinationTooSmall or TlsOperationStatus.Closed
            ? status
            : throw new TlsException($"TLS write failed: {status}");
    }
 
    // ═══════════════════════════════════════════════════════════════
    // EPOLL INTEREST
    // ═══════════════════════════════════════════════════════════════
 
    // Applies the epoll interest as the UNION of what the read and write sides currently need. EPOLLIN is
    // requested whenever the receive loop is reading (the steady state) or a write is parked waiting to read peer
    // ciphertext for renegotiation (_writeWantsRead); it is dropped only while the receive loop is blocked on
    // backpressure (_readInterestSuspended), so still-buffered ciphertext can't make the level-triggered pump
    // spin. EPOLLOUT is requested whenever either side waits for the socket to become writable - a write that hit
    // WouldBlock (_writeWantsWrite) or a read flushing renegotiation output (_readWantsWrite). Because ModifyEvents
    // sets the interest absolutely (EPOLL_CTL_MOD replaces, it does not OR), computing the combined mask in one
    // place is what prevents one side's transition from silently dropping an interest the other side is still
    // waiting on, which would otherwise wedge that operation until the connection dies.
    private void UpdateEvents()
    {
        uint events = 0;
 
        if (!_readInterestSuspended || _writeWantsRead)
        {
            events |= NativeTls.EPOLLIN;
        }
 
        if (_readWantsWrite || _writeWantsWrite)
        {
            events |= NativeTls.EPOLLOUT;
        }
 
        ApplyEvents(events);
    }
 
    // Called by the receive loop when a FlushAsync to the application blocks on backpressure. While suspended,
    // UpdateEvents drops EPOLLIN so still-buffered ciphertext can't spin the level-triggered pump; a parked write
    // that still needs to read (_writeWantsRead) keeps EPOLLIN armed. Re-armed by ResumeReadInterest.
    internal void SuspendReadInterest()
    {
        lock (_sslLock)
        {
            if (_readInterestSuspended)
            {
                return;
            }
 
            _readInterestSuspended = true;
            UpdateEvents();
        }
    }
 
    internal void ResumeReadInterest()
    {
        lock (_sslLock)
        {
            if (!_readInterestSuspended)
            {
                return;
            }
 
            _readInterestSuspended = false;
            UpdateEvents();
        }
    }
 
    // ═══════════════════════════════════════════════════════════════
    // READ
    // ═══════════════════════════════════════════════════════════════
 
    public ValueTask<int> ReadAsync(Memory<byte> buffer)
    {
        // Serialize the whole transition (see _sslLock remarks): the flag updates and the epoll_ctl issued
        // here must be atomic with respect to the pump thread's OnReadable/OnWritable. The body is inlined
        // here rather than delegated to a helper so it stays synchronous - a stray 'await' would be a CS1996
        // compile error instead of silently releasing the lock mid-transition.
        lock (_sslLock)
        {
            if (!IsHandshaked)
            {
                throw new InvalidOperationException("Handshake not complete");
            }
 
            if (_readAwaitable.IsActive)
            {
                throw new InvalidOperationException("Read already pending");
            }
 
            var status = TlsRead(buffer.Span, out var read);
 
            switch (status)
            {
                case TlsOperationStatus.Complete:
                    return new ValueTask<int>(read);
 
                case TlsOperationStatus.NeedMoreData:
                    // Need more ciphertext; wait for the socket to become readable.
                    _readBuffer = buffer;
                    _readWantsWrite = false;
                    return _readAwaitable.Reset();
 
                case TlsOperationStatus.DestinationTooSmall:
                    // Renegotiation: the read needs to send handshake output. Wait for writable.
                    _readBuffer = buffer;
                    _readWantsWrite = true;
                    var pending = _readAwaitable.Reset();
                    UpdateEvents();
                    return pending;
 
                case TlsOperationStatus.Closed:
                default:
                    return new ValueTask<int>(0); // EOF
            }
        }
    }
 
    /// <summary>
    /// Advances the pending read against the TLS session when the socket signals readable/writable, either
    /// completing the read awaitable or re-arming epoll interest for more ciphertext / renegotiation output.
    /// </summary>
    /// <remarks>
    /// Unsynchronized: the caller must already hold <c>_sslLock</c> - this method never takes it. It mutates the
    /// read state machine (<c>_readBuffer</c>, <c>_readWantsWrite</c>, the read awaitable) and issues the absolute
    /// <c>epoll_ctl</c> via <see cref="UpdateEvents"/>, all of which must stay atomic with respect to
    /// <see cref="ReadAsync"/> and the write side (see the <c>_sslLock</c> remarks). Its only call sites are inside
    /// the <see cref="OnReadable"/>/<see cref="OnWritable"/> lock blocks.
    /// </remarks>
    private void TryCompleteReadUnsynchronized()
    {
        if (!_readAwaitable.IsActive)
        {
            _logger.LogDebug("TryCompleteReadUnsynchronized called but no read is pending");
            return; // Race: canceled or completed between check and call
        }
 
        var status = TlsRead(_readBuffer.Span, out var read);
 
        switch (status)
        {
            case TlsOperationStatus.Complete:
            {
                var wasWaitingForWrite = _readWantsWrite;
                _readBuffer = default;
                _readWantsWrite = false;
 
                if (wasWaitingForWrite)
                {
                    UpdateEvents();
                }
 
                _readAwaitable.TrySetResult(read);
                return;
            }
 
            case TlsOperationStatus.NeedMoreData:
                // Still need more ciphertext - if we were flushing renegotiation output, switch back to read.
                if (_readWantsWrite)
                {
                    _readWantsWrite = false;
                    UpdateEvents();
                }
                return;
 
            case TlsOperationStatus.DestinationTooSmall:
                // Renegotiation: need to write - request EPOLLOUT if not already.
                if (!_readWantsWrite)
                {
                    _readWantsWrite = true;
                    UpdateEvents();
                }
                return;
 
            case TlsOperationStatus.Closed:
            default:
            {
                // If this read was flushing renegotiation output, EPOLLOUT is armed. Recompute interest after
                // clearing the flag so the read side's writable interest is dropped - otherwise OnWritable keeps
                // firing (level-triggered) with no active read until the connection is disposed.
                var wasWaitingForWrite = _readWantsWrite;
                _readBuffer = default;
                _readWantsWrite = false;
 
                if (wasWaitingForWrite)
                {
                    UpdateEvents();
                }
 
                _readAwaitable.TrySetResult(0); // EOF
                return;
            }
        }
    }
 
    // ═══════════════════════════════════════════════════════════════
    // WRITE
    // ═══════════════════════════════════════════════════════════════
 
    public ValueTask<int> WriteAsync(ReadOnlyMemory<byte> buffer)
    {
        // Serialize the whole transition (see _sslLock remarks): the flag updates and the epoll_ctl issued
        // here must be atomic with respect to the pump thread's OnReadable/OnWritable. The body is inlined
        // here rather than delegated to a helper so it stays synchronous - a stray 'await' would be a CS1996
        // compile error instead of silently releasing the lock mid-transition.
        lock (_sslLock)
        {
            if (!IsHandshaked)
            {
                throw new InvalidOperationException("Handshake not complete");
            }
 
            if (_writeAwaitable.IsActive)
            {
                throw new InvalidOperationException("Write already pending");
            }
 
            _writeBuffer = buffer;
            _writeTotal = buffer.Length;
            _writeWantsRead = false;
            _writeWantsWrite = false;
 
            var status = TlsWrite(_writeBuffer.Span, out var written);
 
            switch (status)
            {
                case TlsOperationStatus.Complete:
                    _writeBuffer = default;
                    return new ValueTask<int>(_writeTotal);
 
                case TlsOperationStatus.DestinationTooSmall:
                    // Socket WouldBlock mid-write. 'written' application bytes were consumed;
                    // retry the remainder once the socket is writable.
                    _writeBuffer = _writeBuffer.Slice(written);
                    _writeWantsWrite = true;
                    var pending = _writeAwaitable.Reset();
                    UpdateEvents();
                    return pending;
 
                case TlsOperationStatus.NeedMoreData:
                    // Renegotiation: the write needs to read peer ciphertext first. Re-arm interest so EPOLLIN is
                    // present even if the receive loop has suspended it for backpressure.
                    _writeBuffer = _writeBuffer.Slice(written);
                    _writeWantsRead = true;
                    UpdateEvents();
                    return _writeAwaitable.Reset();
 
                case TlsOperationStatus.Closed:
                default:
                    _writeBuffer = default;
                    return new ValueTask<int>(0); // EOF
            }
        }
    }
 
    /// <summary>
    /// Advances the pending write against the TLS session when the socket signals writable/readable, either
    /// completing the write awaitable or re-arming epoll interest for a WouldBlock'd remainder / renegotiation input.
    /// </summary>
    /// <remarks>
    /// Unsynchronized: the caller must already hold <c>_sslLock</c> - this method never takes it. It mutates the
    /// write state machine (<c>_writeBuffer</c>, <c>_writeWantsRead</c>/<c>_writeWantsWrite</c>, the write awaitable)
    /// and issues the absolute <c>epoll_ctl</c> via <see cref="UpdateEvents"/>, all of which must stay atomic with
    /// respect to <see cref="WriteAsync"/> and the read side (see the <c>_sslLock</c> remarks). Its only call sites
    /// are inside the <see cref="OnReadable"/>/<see cref="OnWritable"/> lock blocks.
    /// </remarks>
    private void TryCompleteWriteUnsynchronized()
    {
        if (!_writeAwaitable.IsActive)
        {
            // Spurious EPOLLOUT - no write is pending, so drop the write side's writable interest.
            _writeWantsWrite = false;
            UpdateEvents();
            return;
        }
 
        var status = TlsWrite(_writeBuffer.Span, out var written);
 
        switch (status)
        {
            case TlsOperationStatus.Complete:
                _writeBuffer = default;
                _writeWantsRead = false;
                _writeWantsWrite = false;
                UpdateEvents();
                _writeAwaitable.TrySetResult(_writeTotal);
                return;
 
            case TlsOperationStatus.DestinationTooSmall:
                // Still WouldBlock. Advance past what was written and keep waiting for writable.
                _writeBuffer = _writeBuffer.Slice(written);
                if (_writeWantsRead || !_writeWantsWrite)
                {
                    _writeWantsRead = false;
                    _writeWantsWrite = true;
                    UpdateEvents();
                }
                return;
 
            case TlsOperationStatus.NeedMoreData:
                // Renegotiation: need to read - drop the write side's EPOLLOUT, stay on baseline EPOLLIN.
                _writeBuffer = _writeBuffer.Slice(written);
                if (!_writeWantsRead)
                {
                    _writeWantsRead = true;
                    _writeWantsWrite = false;
                    UpdateEvents();
                }
                return;
 
            case TlsOperationStatus.Closed:
            default:
                _writeBuffer = default;
                _writeWantsRead = false;
                _writeWantsWrite = false;
                UpdateEvents();
                _writeAwaitable.TrySetResult(0); // EOF
                return;
        }
    }
 
    // ═══════════════════════════════════════════════════════════════
    // EVENT HANDLERS (called by pump)
    // ═══════════════════════════════════════════════════════════════
 
    internal void OnReadable()
    {
        // Inlined under _sslLock and kept synchronous on purpose (see _sslLock remarks): a stray 'await' here
        // would be a CS1996 compile error rather than silently releasing the lock mid-transition.
        lock (_sslLock)
        {
            // A pending write waiting for read (renegotiation) takes priority.
            if (_writeWantsRead && _writeAwaitable.IsActive)
            {
                TryCompleteWriteUnsynchronized();
                return;
            }
 
            if (_readAwaitable.IsActive)
            {
                TryCompleteReadUnsynchronized();
            }
        }
    }
 
    internal void OnWritable()
    {
        // Inlined under _sslLock and kept synchronous on purpose (see _sslLock remarks): a stray 'await' here
        // would be a CS1996 compile error rather than silently releasing the lock mid-transition.
        lock (_sslLock)
        {
            // A pending read waiting for write (renegotiation) takes priority.
            if (_readWantsWrite && _readAwaitable.IsActive)
            {
                TryCompleteReadUnsynchronized();
                return;
            }
 
            if (_writeAwaitable.IsActive)
            {
                TryCompleteWriteUnsynchronized();
            }
        }
    }
 
    internal void OnError(Exception ex)
    {
        _readAwaitable.TrySetException(ex);
        _writeAwaitable.TrySetException(ex);
 
        // Notify owner about fatal error so it can trigger disposal.
        OnFatalError?.Invoke(ex);
    }
 
    /// <summary>
    /// Cancel any pending async operations (read/write awaitables).
    /// Called during connection disposal to unblock waiting tasks.
    /// </summary>
    internal void Cancel()
    {
        // Hold _sslLock so cancellation waits out any in-flight SSL_read/SSL_write (both run under it):
        // completing the awaitable while the pump is mid SSL_read would let the receive loop complete the pipe
        // and recycle _readBuffer while the native write is still filling it (mirror race: SSL_write reading a
        // recycled output block). Once we hold the lock no native call is in flight, and the sticky Canceled
        // state set below stops the pump from starting a new one against the now-recycled buffer.
        lock (_sslLock)
        {
            // Sticky cancellation: completes any parked wait now and makes every future Reset() return an
            // already-cancelled result, so a loop that re-arms concurrently with disposal cannot park forever
            // on an fd DisposeAsync is about to unregister from epoll.
            _readAwaitable.Cancel();
            _writeAwaitable.Cancel();
        }
    }
 
    public void Dispose()
    {
        // Best-effort graceful write-side shutdown (TLS close_notify), then dispose the session, which
        // closes the underlying socket fd (the session owns the SafeSocketHandle).
        //
        // Concurrency note: this can race with the pump thread, which may still be inside RawRead/RawWrite
        // (i.e. TlsSocketSession.Read/Write) when DisposeAsync gets here. That is memory-safe because the
        // runtime's TlsSession passes its SSL* as a SafeSslHandle to every P/Invoke (SslRead/SslWrite), and
        // the socket fd is a SafeSocketHandle. Disposing those handles only *decrements* the ref count, so
        // the native SSL_free/close is deferred until any in-flight native call releases its DangerousAddRef.
        // The worst outcome of the race is a spurious ObjectDisposedException on the pump thread's next
        // native call, which TlsEventPump.PumpLoop already catches and turns into an ordinary connection drop.
        // Take _sslLock so the close_notify is not emitted while another thread is mid SSL_read/SSL_write,
        // which would otherwise corrupt the record stream (the "application data after close notify" alert).
        lock (_sslLock)
        {
            try
            {
                ShutdownSession();
            }
            finally
            {
                DisposeSession();
            }
        }
    }
}