Commit 68f4a184 authored by jan.koester's avatar jan.koester
Browse files

test

parent 172ddb03
Loading
Loading
Loading
Loading
+12 −0
Changes for test/CMakeLists.txt: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -26,6 +26,18 @@ endif()

add_test(httpd_h2_concurrency_test httpd_h2_concurrency_test)

add_executable(httpd_h2_window_credit_race_test
    httpd_h2_window_credit_race_test.cpp
)

if(WIN32)
    target_link_libraries (httpd_h2_window_credit_race_test httppp-static ws2_32)
else()
    target_link_libraries (httpd_h2_window_credit_race_test httppp-static)
endif()

add_test(httpd_h2_window_credit_race_test httpd_h2_window_credit_race_test)

add_executable(url_test
    url.cpp
)
+258 −0
Changes for test/httpd_h2_window_credit_race_test.cpp: 258 added lines, 0 removed lines.
Original line number Diff line number Diff line
/*******************************************************************************
Copyright (c) 2026, Jan Koester jan.koester@gmx.net
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name of the <organization> nor the
      names of its contributors may be used to endorse or promote products
      derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*******************************************************************************/

// Regression test for a WINDOW_UPDATE/offload race: when a stream's response
// is dispatched to the H2 offload pool (shouldOffloadH2Dispatch() == true),
// _finishH2Dispatch() — which seeds peerStreamWindows[sid] — only runs once
// the background render completes. A peer that proactively sends a
// WINDOW_UPDATE for that stream before the render finishes (legitimate,
// RFC-7540-compliant behavior; real HTTP/2 clients such as curl/nghttp2 do
// this) used to find no entry in peerStreamWindows yet and have its credit
// silently discarded — permanently, since a well-behaved peer never repeats
// a grant it believes already landed. Any response body larger than the
// default window (65535 bytes) then stalled forever with no further
// WINDOW_UPDATE ever arriving to unstick it. Root-caused live against a
// production mediadb instance where a real preview request hung
// indefinitely after sending exactly one window's worth of data.
//
// Fixed by seeding peerStreamWindows[sid] synchronously in
// _dispatchH2Stream() (before the request is handed to the offload pool)
// and having _finishH2Dispatch() only fill in the default if no entry
// exists yet, instead of unconditionally overwriting it.

#include <chrono>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <thread>
#include <vector>

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <netplus/socket.h>

#include "hpack.h"
#include "http.h"
#include "httpd.h"

namespace {

constexpr uint8_t FRAME_DATA = 0x00;
constexpr uint8_t FRAME_HEADERS = 0x01;
constexpr uint8_t FRAME_SETTINGS = 0x04;
constexpr uint8_t FRAME_WINDOW_UPDATE = 0x08;
constexpr uint8_t FLAG_END_STREAM = 0x01;
constexpr uint8_t FLAG_END_HEADERS = 0x04;
constexpr size_t FRAME_HEADER_LEN = 9;
constexpr char CLIENT_PREFACE[] = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";

// A body comfortably larger than the RFC 7540 default 65535-byte window,
// so a stall at the default window is unambiguous.
constexpr size_t BIG_BODY_SIZE = 200000;

void appendFrameHeader(std::vector<char> &out, uint32_t len, uint8_t type,
                        uint8_t flags, uint32_t streamId) {
    out.push_back(static_cast<char>((len >> 16) & 0xFF));
    out.push_back(static_cast<char>((len >> 8) & 0xFF));
    out.push_back(static_cast<char>(len & 0xFF));
    out.push_back(static_cast<char>(type));
    out.push_back(static_cast<char>(flags));
    out.push_back(static_cast<char>((streamId >> 24) & 0x7F));
    out.push_back(static_cast<char>((streamId >> 16) & 0xFF));
    out.push_back(static_cast<char>((streamId >> 8) & 0xFF));
    out.push_back(static_cast<char>(streamId & 0xFF));
}

void appendWindowUpdate(std::vector<char> &out, uint32_t streamId, uint32_t increment) {
    appendFrameHeader(out, 4, FRAME_WINDOW_UPDATE, 0, streamId);
    out.push_back(static_cast<char>((increment >> 24) & 0x7F));
    out.push_back(static_cast<char>((increment >> 16) & 0xFF));
    out.push_back(static_cast<char>((increment >> 8) & 0xFF));
    out.push_back(static_cast<char>(increment & 0xFF));
}

// Offloads every request (mirrors mediadb's real MediaHttpEvent, which
// offloads all /media/ GET requests) and sleeps briefly before responding,
// widening the window in which a client-sent WINDOW_UPDATE for this stream
// can race ahead of _finishH2Dispatch() — deterministically exercising the
// race instead of depending on scheduler luck.
class BigBodyServer : public libhttppp::HttpEvent {
public:
    BigBodyServer(std::vector<netplus::socket*> serversocket, size_t h2OffloadThreads)
        : HttpEvent(serversocket, 1000, h2OffloadThreads) {}

    bool shouldOffloadH2Dispatch(libhttppp::HttpRequest&, uint32_t) const override {
        return true;
    }

    void RequestEvent(libhttppp::HttpRequest &req, const int, ULONG_PTR) override {
        std::this_thread::sleep_for(std::chrono::milliseconds(30));
        std::string body(BIG_BODY_SIZE, '\0');
        for (size_t i = 0; i < body.size(); ++i)
            body[i] = static_cast<char>('a' + (i % 26));
        libhttppp::HttpResponse res;
        res.setContentType("application/octet-stream");
        res.send(req, body, static_cast<int>(body.size()));
    }
};

struct RunningServer {
    std::unique_ptr<netplus::tcp> sock;
    std::unique_ptr<BigBodyServer> server;
    std::thread thread;
};

RunningServer startServer(int &port) {
    RunningServer rs;
    rs.sock = std::make_unique<netplus::tcp>("127.0.0.1", 0, 16, -1);
    rs.sock->bind();

    sockaddr_in bound{};
    socklen_t blen = sizeof(bound);
    ::getsockname(rs.sock->fd(), reinterpret_cast<sockaddr*>(&bound), &blen);
    port = ntohs(bound.sin_port);

    rs.sock->listen();

    rs.server = std::make_unique<BigBodyServer>(
        std::vector<netplus::socket*>{rs.sock.get()}, /*h2OffloadThreads=*/4);
    netplus::event::Running = true;
    BigBodyServer *serverPtr = rs.server.get();
    rs.thread = std::thread([serverPtr] { serverPtr->runEventloop(); });
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    return rs;
}

void stopServer(RunningServer &rs) {
    netplus::event::requestStop();
    if (rs.thread.joinable())
        rs.thread.join();
}

// Connects, sends preface + empty SETTINGS + a HEADERS frame for GET /big
// on stream 1, IMMEDIATELY followed (same write, before reading anything
// back) by a WINDOW_UPDATE for stream 1 — simulating a real client that
// proactively grants extra per-stream credit right after opening the
// stream. Returns the total DATA-frame bytes received for stream 1.
size_t runRaceRequest(int port) {
    int fd = ::socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(static_cast<uint16_t>(port));
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
    if (::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
        ::close(fd);
        throw std::runtime_error("connect failed");
    }

    std::string hpack_out = libhttppp::hpack::Encoder::encodeRequestHeaders(
        "GET", "/big", "http", "127.0.0.1");

    std::vector<char> req(CLIENT_PREFACE, CLIENT_PREFACE + sizeof(CLIENT_PREFACE) - 1);
    appendFrameHeader(req, 0, FRAME_SETTINGS, 0, 0); // empty client SETTINGS
    appendFrameHeader(req, static_cast<uint32_t>(hpack_out.size()), FRAME_HEADERS,
                       FLAG_END_HEADERS | FLAG_END_STREAM, 1);
    req.insert(req.end(), hpack_out.begin(), hpack_out.end());
    // The race: grant extra per-stream credit for stream 1 right away,
    // well beyond the default 65535-byte window, before any response byte
    // has arrived. A correct server credits this to stream 1's window
    // whenever it gets processed, even if that's before the offloaded
    // render has produced a response at all.
    appendWindowUpdate(req, 1, 1'000'000);

    size_t sent = 0;
    while (sent < req.size()) {
        ssize_t n = ::send(fd, req.data() + sent, req.size() - sent, 0);
        if (n <= 0) { ::close(fd); throw std::runtime_error("send failed"); }
        sent += static_cast<size_t>(n);
    }

    size_t dataBytes = 0;
    bool sawEndStream = false;
    std::vector<char> buf;
    char tmp[8192];
    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
    while (!sawEndStream && std::chrono::steady_clock::now() < deadline) {
        ssize_t n = ::recv(fd, tmp, sizeof(tmp), 0);
        if (n <= 0) break;
        buf.insert(buf.end(), tmp, tmp + n);

        size_t off = 0;
        while (off + FRAME_HEADER_LEN <= buf.size()) {
            uint32_t len = (static_cast<uint8_t>(buf[off]) << 16) |
                           (static_cast<uint8_t>(buf[off + 1]) << 8) |
                           static_cast<uint8_t>(buf[off + 2]);
            uint8_t type = static_cast<uint8_t>(buf[off + 3]);
            uint8_t flags = static_cast<uint8_t>(buf[off + 4]);
            uint32_t sid = ((static_cast<uint8_t>(buf[off + 5]) & 0x7F) << 24) |
                           (static_cast<uint8_t>(buf[off + 6]) << 16) |
                           (static_cast<uint8_t>(buf[off + 7]) << 8) |
                           static_cast<uint8_t>(buf[off + 8]);
            if (off + FRAME_HEADER_LEN + len > buf.size())
                break; // incomplete frame, wait for more bytes
            if (type == FRAME_DATA && sid == 1) {
                dataBytes += len;
                if (flags & FLAG_END_STREAM) sawEndStream = true;
            }
            off += FRAME_HEADER_LEN + len;
        }
        buf.erase(buf.begin(), buf.begin() + off);
    }
    ::close(fd);
    return dataBytes;
}

} // namespace

int main() {
    int port = 0;
    RunningServer rs = startServer(port);
    size_t received;
    try {
        received = runRaceRequest(port);
    } catch (...) {
        stopServer(rs);
        throw;
    }
    stopServer(rs);

    std::cout << "received=" << received << " expected=" << BIG_BODY_SIZE << std::endl;

    if (received != BIG_BODY_SIZE) {
        std::cerr << "FAIL: response stalled at " << received << " of "
                  << BIG_BODY_SIZE << " bytes — a WINDOW_UPDATE that arrived "
                     "before the offloaded render finished was lost"
                  << std::endl;
        return 1;
    }

    std::cout << "PASS" << std::endl;
    return 0;
}