Repository navigation
ASR realtime WebSocket connections drop under load #72
Description
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.
- Save the program below as
test_websocket_frames.cppin the repository root. - 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_framesActual (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
VitorAMJorge commentedon Oct 22, 2026
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.
VitorAMJorge commentedon Oct 22, 2026
Important: the pre-commit failure is fixed by #75