簡易TCPのシーケンス番号・再送・フロー制御と、自作TCPクライアントが踏む「自分のカーネルのRST」問題

TCPヘッダのparse/build自体はTCPヘッダのパース/構築と、3-wayハンドシェイクの実装で実装済みです。ここではその上に、実際にデータをやり取りするための最小限の仕組みを組み立てます。スコープは意図的に絞っていて、スライディングウィンドウではなくstop-and-wait方式(一度に1チャンクだけ送信し、ACKを受けてから次へ進む)、輻輳制御は無し、フロー制御は「相手が広告するウィンドウを超えて送らない」だけ、コネクション終了(FIN)も扱いません。

RawSocketにタイムアウト付き受信を追加する

再送を実装するには「一定時間ACKが来なければタイムアウトする受信」が要ります。これまでのl2arp::RawSocket::receiverecv()をそのまま呼ぶだけの、ブロックし続ける受信でした。

// l2-arp/src/capture.cpp(追加分)
std::expected<std::span<std::byte>, CaptureError> RawSocket::receive(
    std::span<std::byte> buffer,
    std::chrono::milliseconds timeout
)
{
    pollfd pfd{.fd = fd_, .events = POLLIN, .revents = 0};
    int result = poll(&pfd, 1, static_cast<int>(timeout.count()));

    if (result == 0)
    {
        return std::unexpected(CaptureError::Timeout);
    }
    if (result < 0)
    {
        return std::unexpected(CaptureError::ReceiveFailed);
    }

    return receive(buffer);
}

既存のreceive(buffer)はそのまま残し、poll()でタイムアウト付きの判定をしてから同じ実装を呼び出すオーバーロードを追加しただけです。timeoutに負の値を渡すとpoll()の仕様上「無限待ち」になるので、専用の「タイムアウト無し」版は用意していません。

TcpConnectionが持つ状態

3-wayハンドシェイクとデータ転送をまとめて扱うTcpConnectionを用意しました。

// tcp-tls13/include/tcptls13/tcp_connection.hpp
class TcpConnection
{
public:
    static std::expected<TcpConnection, ConnectionError> connect(/* ... */);

    std::expected<void, ConnectionError> send(std::span<const std::byte> data);
    std::expected<std::vector<std::byte>, ConnectionError> receive();

private:
    l2arp::RawSocket* socket_;
    l2arp::MacAddress local_mac_;
    l2arp::MacAddress peer_mac_;
    Ipv4Address local_ip_;
    Ipv4Address peer_ip_;
    std::uint16_t local_port_;
    std::uint16_t peer_port_;
    std::uint32_t send_next_;
    std::uint32_t recv_next_;
    std::uint16_t peer_window_;
};

send_next_は次に送るべきシーケンス番号、recv_next_は次に受け取るべき相手のシーケンス番号(=こちらが送るACK番号)、peer_window_は相手が最後に広告してきたウィンドウサイズです。connect()はこれまでのtcp_handshakeアプリと同じ手順(SYN送信→SYN-ACK待ち→ACK番号の一致確認→ACK送信)でハンドシェイクを行い、成功したらsend_next_/recv_next_/peer_window_を初期化したTcpConnectionを返します。

stop-and-waitによる送信とタイムアウト再送

// tcp-tls13/src/tcp_connection.cpp(send抜粋)
std::expected<void, ConnectionError> TcpConnection::send(std::span<const std::byte> data)
{
    std::size_t sent = 0;

    while (sent < data.size())
    {
        if (peer_window_ == 0)
        {
            return std::unexpected(ConnectionError::WindowClosed);
        }

        const auto chunk_size = std::min(
            {MaxSegmentPayload, static_cast<std::size_t>(peer_window_), data.size() - sent}
        );
        const auto chunk = data.subspan(sent, chunk_size);

        bool acked = false;
        for (int attempt = 0; attempt < MaxRetries && !acked; ++attempt)
        {
            if (!send_segment(TcpFlags{.ack = true}, chunk))
            {
                return std::unexpected(ConnectionError::SendFailed);
            }

            auto reply = receive_segment(RetransmitTimeout);
            if (!reply)
            {
                if (reply.error() == ConnectionError::Timeout)
                {
                    continue;
                }
                return std::unexpected(reply.error());
            }

            if (!reply->header.flags.ack
                || reply->header.acknowledgement_number != send_next_ + chunk.size())
            {
                continue;
            }

            send_next_ += chunk.size();
            peer_window_ = reply->header.window_size;
            acked = true;
        }

        if (!acked)
        {
            return std::unexpected(ConnectionError::Timeout);
        }

        sent += chunk_size;
    }

    return {};
}

1チャンクあたりのサイズは「固定の上限(MaxSegmentPayload)」「相手のウィンドウ」「残りデータ量」のうち一番小さいものに揃えています。これがそのままフロー制御になっていて、相手のウィンドウより大きなチャンクは決して作られません。送ったチャンクへの正しいACK(累積確認応答なので、送ったバイト数ぶん進んだACK番号)が一定時間内に返らなければ同じチャンクを送り直し、これを規定回数繰り返しても確認できなければタイムアウトとして打ち切ります。ウィンドウが0になった場合の「ウィンドウプローブ」(RFC 793が定める、0ウィンドウの相手に1バイトだけ送って開くのを待つ仕組み)は実装しておらず、WindowClosedという明示的なエラーとして扱っています。

受信側: 順序通りのデータだけを受理してACKする

// tcp-tls13/src/tcp_connection.cpp(receive抜粋)
std::expected<std::vector<std::byte>, ConnectionError> TcpConnection::receive()
{
    while (true)
    {
        auto segment = receive_segment(NoTimeout);
        if (!segment)
        {
            return std::unexpected(segment.error());
        }

        if (segment->header.sequence_number < recv_next_)
        {
            // 相手からのACK取りこぼしによる再送。読み捨てて自分のACKだけ返す。
            send_segment(TcpFlags{.ack = true}, {});
            continue;
        }

        if (segment->header.sequence_number != recv_next_ || segment->payload.empty())
        {
            continue;
        }

        recv_next_ += segment->payload.size();

        if (!send_segment(TcpFlags{.ack = true}, {}))
        {
            return std::unexpected(ConnectionError::SendFailed);
        }

        return segment->payload;
    }
}

受理する条件は「sequence_numberrecv_next_と完全に一致し、かつペイロードが空でない」ことだけです。recv_next_より小さいシーケンス番号は「相手がこちらのACKを受け取れずに再送してきたデータ」とみなし、中身は読み捨てて現在のrecv_next_でACKだけ返します(こちらは送信側のstop-and-waitがACKロスから回復できるように、確実に確認応答を返す責任があります)。逆にrecv_next_より大きいシーケンス番号は、まだ届くべきでないデータとして単純に無視します。並び替えバッファは持たないため、順序が入れ替わって届いた場合の救済はできません。この単純な条件のおかげで、recv_next_という1つのカウンタだけで「次に来るべきものは何か」を管理できています。

生ソケットで自作したTCPが踏む「自分のカーネルが割り込んでくる」問題

ここまでの実装を実機の別ホストに対して動かすと、ハンドシェイクは成立するのに、直後のデータ送信がタイムアウトし続けるという事象に出会います。両側でtcpdumpを取ると、原因が分かります。

client → target   SYN
target → client   SYN-ACK
client → target   RST   ← このマシン自身のOSが送っている
client → target   ACK   ← 自作アプリが送った、本来のハンドシェイクの締め
client → target   "hello"
target → client   RST   ← targetは3番目のRSTで接続を既に破棄済み

このTCPクライアントはOSの標準ソケットAPI(socket()/connect())を一切使わず、AF_PACKETの生ソケットで直接フレームを組み立てて送受信しています。そのため、送信元マシンのOS自身のTCP/IPスタックは、この接続の存在を一切知りません。SYN-ACKが届いたとき、こちらの生ソケット(AF_PACKETのタップ)はそれを正しく受け取れますが、それとは別にOSのカーネル自身もそのSYN-ACKを受信します。カーネルから見ると「身に覚えのないポートへの応答」であり、RFC 793 §3.4が規定する「存在しないコネクションへ届いたRSTでないセグメントには、RSTを送り返す」というルールに従って、カーネルは即座にRSTを自動送信します。これはユーザー空間のアプリケーションよりずっと速く、こちらのアプリが本来のACKを送るより先に相手へ届いてしまうため、相手側のコネクションはその時点で破棄され、以降こちらが送るACKやデータはすべて「存在しないコネクションへの到着」としてRSTで突き返されます。

対処は、送信元ポートからの送信RSTだけをこのマシンの外に出さないようにすることです。

sudo iptables -A OUTPUT -p tcp --sport 54322 --tcp-flags RST RST -j DROP

これにより、カーネル自身が生成するRSTが実際にワイヤに出ることを防ぎ、こちらの生ソケット実装が送るセグメントだけが相手に届くようになります。生ソケットでTCPを自作する際にほぼ必ず踏む制約で、実装のバグではなく、OSの標準的なTCP/IPスタックの仕様どおりの挙動です。

参考リンク