Skip to content

ASR realtime WebSocket connections drop under load #72

Open

Description

@VitorAMJorge

Symptom: In our deployment for a realtime ASR/translation, a streaming client can suddenly lose the connection, with no close frame ("transport dropped"). It happens when the server is busy and audio frames start to pile up.
Cause: the package cpp-httplib v0.50.1 reads each frame-header field with a single read. When a field straddles the end of the server's 4 KiB buffer, the read comes back short, and the frame is rejected as if broken.

Environment

  • NeMo-Speech.cpp main @ 2bdfb03 (current HEAD as of 2026-10-22), bundled cpp-httplib 0.50.1 (62d899f)
  • Ubuntu 24.04.2, g++ 13.3.0, x86_64; CPU only, no model needed

Steps to reproduce

No model or GPU needed; Linux, any C++17 compiler. The program below starts a real httplib::Server with a WebSocket route and connects a raw TCP client. After the upgrade the client writes, in one burst, 64 masked 640-byte binary frames (20 ms of 16 kHz s16le audio, as realtime clients send), preceded by a lead frame whose length shifts where the server's 4 KiB read-buffer boundaries fall. The handler starts reading only after the whole burst has been written, as a server that fell behind does. This is repeated for all 648 alignments.

  1. Save the program below as test_websocket_frames.cpp in the repository root.
  2. Build it against the bundled cpp-httplib and run it:
g++ -std=c++17 -O1 -pthread -Ithird_party/cpp-httplib test_websocket_frames.cpp -o test_websocket_frames
./test_websocket_frames

Actual (main @ 2bdfb03, cpp-httplib 0.50.1):

FAIL: cpp-httplib 0.50.1 dropped frames at 50 of 648 alignments

Expected: every frame received at every alignment. With cpp-httplib 0.63.0:

PASS: cpp-httplib 0.63.0, 648 alignments, every frame received
test_websocket_frames.cpp
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// WebSocket frames that queue up faster than the server reads them. The server
// reads its socket through a 4 KiB buffer, so once frames queue up a frame
// header field can straddle the end of what the buffer holds and one read
// returns part of it. cpp-httplib before 0.59 rejected such a frame and dropped
// the connection without a close frame; realtime clients saw it as a transport
// drop under load.
//
// A real server and a raw TCP client. After the upgrade the client writes, in
// one burst, a lead frame whose length shifts where the 4 KiB boundaries fall,
// then the masked 640-byte binary frames the realtime endpoints receive (20 ms
// of 16 kHz s16le). The handler reads only after the whole burst was written,
// as a server that fell behind does, and must receive every frame, at every
// alignment.
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <atomic>
#include <chrono>
#include <iostream>
#include <string>
#include <thread>
#include <vector>

#include "httplib.h"

namespace {

constexpr size_t kPayload = 640;
constexpr int kFrames = 64;
constexpr size_t kFrameSize = 2 + 2 + 4 + kPayload;  // header, 16-bit length, mask, payload

void
append_frame(std::string& out, size_t payload_len) {
    const char mask[4] = {0x11, 0x22, 0x33, 0x44};
    out.push_back(static_cast<char>(0x82));        // FIN + binary
    out.push_back(static_cast<char>(0x80 | 126));  // masked, 16-bit length follows
    out.push_back(static_cast<char>((payload_len >> 8) & 0xFF));
    out.push_back(static_cast<char>(payload_len & 0xFF));
    out.append(mask, 4);
    for (size_t i = 0; i < payload_len; ++i)
        out.push_back(static_cast<char>((i & 0xFF) ^ mask[i % 4]));
}

bool
write_all(int fd, const std::string& data) {
    size_t sent = 0;
    while (sent < data.size()) {
        const ssize_t n = ::send(fd, data.data() + sent, data.size() - sent, 0);
        if (n <= 0)
            return false;
        sent += static_cast<size_t>(n);
    }
    return true;
}

// Connects, upgrades, and returns the socket, or -1.
int
open_websocket(int port) {
    const 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));
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    if (fd < 0 || ::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
        if (fd >= 0)
            ::close(fd);
        return -1;
    }
    const std::string upgrade =
        "GET /ws HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
        "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n";
    std::string response;
    char c;
    if (!write_all(fd, upgrade))
        response.clear();
    else
        while (response.find("\r\n\r\n") == std::string::npos && ::recv(fd, &c, 1, 0) == 1)
            response.push_back(c);
    if (response.rfind("HTTP/1.1 101", 0) != 0) {
        ::close(fd);
        return -1;
    }
    return fd;
}

}  // namespace

int
main() {
    std::atomic<bool> burst_written{false};
    std::atomic<int> received{-1};

    httplib::Server server;
    server.WebSocket("/ws", [&](const httplib::Request&, httplib::ws::WebSocket& socket) {
        const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
        while (!burst_written.load() && std::chrono::steady_clock::now() < deadline)
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
        int frames = 0;
        std::string message;
        while (frames < kFrames + 1 && socket.read(message) == httplib::ws::ReadResult::Binary)
            ++frames;
        received.store(frames);
    });
    const int port = server.bind_to_any_port("127.0.0.1");
    if (port <= 0) {
        std::cerr << "FAIL: could not bind a port\n";
        return 1;
    }
    std::thread listener([&] { server.listen_after_bind(); });
    server.wait_until_ready();

    // Every phase of the frame period against the 4 KiB buffer.
    int failed = 0;
    bool upgraded = true;
    for (size_t lead = 8; lead < 8 + kFrameSize; ++lead) {
        burst_written.store(false);
        received.store(-1);
        const int fd = open_websocket(port);
        if (fd < 0) {
            upgraded = false;
            break;
        }
        std::string burst;
        append_frame(burst, lead - 8);
        for (int f = 0; f < kFrames; ++f) append_frame(burst, kPayload);
        const bool written = write_all(fd, burst);
        burst_written.store(true);
        const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
        while (received.load() < 0 && std::chrono::steady_clock::now() < deadline)
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
        ::close(fd);
        if (!written || received.load() != kFrames + 1) {
            if (failed < 3)
                std::cerr << "lead " << lead << ": received " << received.load() << " of "
                          << kFrames + 1 << " frames\n";
            ++failed;
        }
    }

    server.stop();
    listener.join();
    if (!upgraded) {
        std::cerr << "FAIL: WebSocket upgrade failed\n";
        return 1;
    }
    if (failed != 0) {
        std::cerr << "FAIL: cpp-httplib " << CPPHTTPLIB_VERSION << " dropped frames at " << failed
                  << " of " << kFrameSize << " alignments\n";
        return 1;
    }
    std::cout << "PASS: cpp-httplib " << CPPHTTPLIB_VERSION << ", " << kFrameSize
              << " alignments, every frame received\n";
    return 0;
}

To check with the newer release:
git -C third_party/cpp-httplib fetch --tags && git -C third_party/cpp-httplib checkout v0.63.0, then rebuild and rerun.

Fix

cpp-httplib fixed this in v0.59.0 (each header field is read in full, read_exact).
Updating third_party/cpp-httplib to v0.59.0 or later resolves it; the public
WebSocket API and CMake options used by server/ are unchanged. I am opening a PR
updating to v0.63.0 (current release) with this program as a regression test.

Impact observed

Under load (2 live realtime streams plus an offline request), 26 of 36 streams
were reset with v0.50.1; with the fix backported, 1 of 36.

Activity

  1. VitorAMJorge commented on Oct 22, 2026

    @VitorAMJorge
    Author

    The pre-commit.ci failure is pre-existing on main: license-headers reports missing license header: src/runtime/ggml/llama_log_shim.cpp, which this PR does not touch. The same check fails on main at 2bdfb03 (pre-commit.ci run). The checker requires files under src/runtime/ggml/ to carry the parakeet.cpp-derived notice, and llama_log_shim.cpp (added in #47) only has the NVIDIA header. All other hooks pass on this PR.

  2. VitorAMJorge commented on Oct 22, 2026

    @VitorAMJorge
    Author

    Important: the pre-commit failure is fixed by #75

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions