voice_audio_multi/app/server/parallel_http.cpp
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

1038 lines
42 KiB
C++

#include "parallel_http.h"
// Transport framing copied from the legacy listener at 612c5e9f. The new
// listener owns and drains workers; the original translation unit is unchanged.
#include <atomic>
#include <memory>
#include <mutex>
#include "engine/framework/debug/trace.h"
#include "engine/framework/io/json.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <cerrno>
#include <iostream>
#include <istream>
#include <limits>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <streambuf>
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
using SocketHandle = SOCKET;
constexpr SocketHandle kInvalidSocket = INVALID_SOCKET;
#else
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
using SocketHandle = int;
constexpr SocketHandle kInvalidSocket = -1;
#endif
namespace minitts::server {
namespace {
std::string lower_ascii(std::string value) {
for (char & ch : value) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return value;
}
std::string trim(std::string value) {
auto is_space = [](unsigned char ch) { return std::isspace(ch) != 0; };
value.erase(value.begin(), std::find_if(value.begin(), value.end(), [&](char ch) { return !is_space(ch); }));
value.erase(std::find_if(value.rbegin(), value.rend(), [&](char ch) { return !is_space(ch); }).base(), value.end());
return value;
}
std::string json_quote(std::string_view value) {
return engine::io::json::stringify_string(value);
}
const char * status_text(int status) noexcept {
switch (status) {
case 200:
return "OK";
case 204:
return "No Content";
case 400:
return "Bad Request";
case 403:
return "Forbidden";
case 404:
return "Not Found";
case 405:
return "Method Not Allowed";
case 413:
return "Payload Too Large";
case 500:
return "Internal Server Error";
case 503:
return "Service Unavailable";
default:
return "Error";
}
}
class SocketRuntime {
public:
SocketRuntime() {
#ifdef _WIN32
WSADATA data;
const int rc = WSAStartup(MAKEWORD(2, 2), &data);
if (rc != 0) {
throw std::runtime_error("WSAStartup failed: " + std::to_string(rc));
}
#endif
}
~SocketRuntime() {
#ifdef _WIN32
WSACleanup();
#endif
}
};
void close_socket(SocketHandle socket) {
if (socket == kInvalidSocket) {
return;
}
#ifdef _WIN32
closesocket(socket);
#else
close(socket);
#endif
}
class UniqueSocket {
public:
UniqueSocket() = default;
explicit UniqueSocket(SocketHandle socket)
: socket_(socket) {}
UniqueSocket(const UniqueSocket &) = delete;
UniqueSocket & operator=(const UniqueSocket &) = delete;
UniqueSocket(UniqueSocket && other) noexcept
: socket_(std::exchange(other.socket_, kInvalidSocket)) {}
UniqueSocket & operator=(UniqueSocket && other) noexcept {
if (this != &other) {
close_socket(socket_);
socket_ = std::exchange(other.socket_, kInvalidSocket);
}
return *this;
}
~UniqueSocket() {
close_socket(socket_);
}
SocketHandle get() const noexcept {
return socket_;
}
private:
SocketHandle socket_ = kInvalidSocket;
};
thread_local const std::atomic<bool> * connection_stopping = nullptr;
thread_local int connection_send_timeout_ms = 0;
void check_connection_stopping() {
if (connection_stopping && connection_stopping->load(std::memory_order_acquire)) {
throw std::runtime_error("server is shutting down");
}
}
bool retry_socket_error() {
#ifdef _WIN32
const int error = WSAGetLastError();
return error == WSAEWOULDBLOCK || error == WSAEINTR;
#else
return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR;
#endif
}
void send_all(SocketHandle socket, const std::string & data) {
size_t offset = 0;
auto progress = std::chrono::steady_clock::now();
while (offset < data.size()) {
check_connection_stopping();
int wait_ms = 250;
if (connection_send_timeout_ms > 0) {
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - progress).count();
if (elapsed >= connection_send_timeout_ms) { throw std::runtime_error("socket send timed out"); }
wait_ms = static_cast<int>(std::min<int64_t>(wait_ms, connection_send_timeout_ms - elapsed));
}
#ifdef _WIN32
fd_set writable; FD_ZERO(&writable); FD_SET(socket, &writable);
timeval timeout{}; timeout.tv_usec = wait_ms * 1000;
const int ready = select(0, nullptr, &writable, nullptr, &timeout);
#else
pollfd writable{socket, POLLOUT, 0};
const int ready = poll(&writable, 1, wait_ms);
#endif
if (ready == 0) { continue; }
if (ready < 0) {
if (retry_socket_error()) { continue; }
throw std::runtime_error("socket send poll failed");
}
check_connection_stopping();
const int written = static_cast<int>(send(socket, data.data() + offset,
static_cast<int>(std::min<size_t>(data.size() - offset, std::numeric_limits<int>::max())), 0));
if (written < 0 && retry_socket_error()) { continue; }
if (written <= 0) { throw std::runtime_error("socket send failed"); }
offset += static_cast<size_t>(written);
progress = std::chrono::steady_clock::now();
}
}
// Windows shutdown() need not wake a recv already blocked on another
// thread. Poll for readiness/cancellation before receiving, without imposing
// an ordinary-request deadline or changing the legacy transport.
int receive_some(SocketHandle socket, char * data, size_t size) {
for (;;) {
check_connection_stopping();
#ifdef _WIN32
fd_set readable; FD_ZERO(&readable); FD_SET(socket, &readable);
timeval timeout{}; timeout.tv_usec = 250000;
const int ready = select(0, &readable, nullptr, nullptr, &timeout);
if (ready < 0 && WSAGetLastError() == WSAEINTR) { continue; }
#else
pollfd readable{socket, POLLIN, 0};
const int ready = poll(&readable, 1, 250);
if (ready < 0 && errno == EINTR) { continue; }
#endif
if (ready < 0) { return -1; }
if (ready == 0) { continue; }
check_connection_stopping();
const int received = static_cast<int>(recv(socket, data, static_cast<int>(size), 0));
if (received < 0 && retry_socket_error()) { continue; }
return received;
}
}
// An SSE body is written while a model lock is held, so an unbounded blocking
// send() lets a client that uploads but never reads fill the kernel send buffer
// and pin that model indefinitely. A send timeout turns it into a failed write.
void set_send_timeout(SocketHandle socket, int timeout_ms) {
(void) socket;
connection_send_timeout_ms = timeout_ms;
}
// The endpoints that consume their body incrementally. Gating on the path as
// well as the encoding keeps every other endpoint's request handling bit-for-bit
// unchanged, instead of silently altering how any chunked request is read.
constexpr std::string_view kLiveTranscriptionPath = "/v1/audio/transcriptions/live";
constexpr std::string_view kLiveSpeechPath = "/v1/audio/speech/live";
// True only when the header names exactly one transfer-coding and that coding is
// "chunked". A substring test would accept "notchunked" as well as chains like
// "gzip, chunked" — and for the latter, stripping the chunk framing would hand the
// PCM decoder compressed bytes, which it would happily interpret as audio.
bool is_chunked_only(std::string_view value) {
bool saw_coding = false;
size_t start = 0;
while (start <= value.size()) {
const size_t comma = value.find(',', start);
const auto end = comma == std::string_view::npos ? value.size() : comma;
const std::string coding = trim(std::string(value.substr(start, end - start)));
if (!coding.empty()) {
if (saw_coding || lower_ascii(coding) != "chunked") {
return false;
}
saw_coding = true;
}
if (comma == std::string_view::npos) {
break;
}
start = comma + 1;
}
return saw_coding;
}
bool wants_incremental_body(const HttpRequest & request) {
if (request.path != kLiveTranscriptionPath && request.path != kLiveSpeechPath) {
return false;
}
const auto it = request.headers.find("transfer-encoding");
if (it == request.headers.end()) {
return false;
}
return is_chunked_only(it->second);
}
// De-frames an HTTP/1.1 chunked request body off a live socket into a byte
// stream. `underflow()` blocks in recv() waiting for the next chunk, which is
// precisely the contract `AudioChunkReader` documents for a live source — so a
// streaming task can pull capture-time audio straight through it without the
// body ever being fully materialized.
//
// Bounded on four axes, because this stream is read while a model lock is held:
// an idle timeout between reads, an absolute deadline for the whole body, a cap on
// total bytes, and a cap on any single chunk. A client that opens a body and then
// stalls, never stops, or names an enormous chunk therefore cannot pin the model
// indefinitely or exhaust the host. The values come from `LiveIngestLimits`
// (app/server/http.h), which the handler resolves per request.
class ChunkedSocketStreambuf final : public std::streambuf {
public:
ChunkedSocketStreambuf(SocketHandle socket, std::string prefetched, LiveIngestLimits limits)
: socket_(socket),
pending_(std::move(prefetched)),
limits_(limits),
started_(std::chrono::steady_clock::now()),
// Bytes that arrived alongside the headers still count toward the total,
// or the cap could be overshot by one receive before it is ever consulted.
received_bytes_(pending_.size()) {}
protected:
int_type underflow() override {
if (gptr() < egptr()) {
return traits_type::to_int_type(*gptr());
}
// Covers the bytes that arrived alongside the headers. A body short enough to
// fit in that first receive never calls receive_more(), so a cap enforced only
// there would be skipped by the single request most likely to be probing it.
require_within_body_cap();
// A chunk is handed out in windows rather than all at once. Reads that stay
// inside the published get area never re-enter underflow(), so publishing a
// whole 8 MiB chunk would let the body keep advancing across many model
// iterations without the deadline ever being consulted again. Windowing puts
// a bound on how far past the deadline consumption can get.
if (publish_window()) {
return traits_type::to_int_type(*gptr());
}
if (!next_chunk()) {
return traits_type::eof();
}
return traits_type::to_int_type(*gptr());
}
private:
// poll() rather than select(): an accepted descriptor can be >= FD_SETSIZE
// once enough connections are open, and FD_SET on such a descriptor is
// undefined behaviour that corrupts the stack.
bool wait_readable() const {
int wait_ms = limits_.idle_timeout_ms;
// Clamp to whatever is left of the total deadline, so a read starting just
// before it expires cannot overshoot by a further idle period.
if (limits_.total_timeout_ms > 0) {
const auto remaining = limits_.total_timeout_ms -
static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - started_)
.count());
if (remaining <= 0) {
return false;
}
wait_ms = (wait_ms <= 0) ? remaining : std::min(wait_ms, remaining);
}
if (wait_ms <= 0) {
return true;
}
pollfd descriptor{};
descriptor.fd = socket_;
descriptor.events = POLLIN;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(wait_ms);
for (;;) {
if (connection_stopping && connection_stopping->load(std::memory_order_acquire)) {
throw std::runtime_error("server is shutting down");
}
const auto remaining = std::chrono::ceil<std::chrono::milliseconds>(
deadline - std::chrono::steady_clock::now()).count();
if (remaining <= 0) { return false; }
const int slice_ms = static_cast<int>(std::min<int64_t>(250, remaining));
descriptor.revents = 0;
#ifdef _WIN32
const int ready = WSAPoll(&descriptor, 1, slice_ms);
if (ready < 0 && WSAGetLastError() == WSAEINTR) { continue; }
#else
const int ready = poll(&descriptor, 1, slice_ms);
if (ready < 0 && errno == EINTR) { continue; }
#endif
if (ready != 0) { return ready > 0; }
}
}
void require_within_body_cap() const {
if (limits_.max_body_bytes > 0 && received_bytes_ > limits_.max_body_bytes) {
throw std::runtime_error("live request body exceeded its maximum size");
}
}
// Enforced at every point where the body advances, not only before a receive:
// checking it in receive_more() alone lets an already-buffered tail — the rest
// of a large chunk, trailers, the terminating chunk — be consumed after the
// deadline has passed, which is not what "deadline" means to a caller reasoning
// about how long the model can be held.
void require_within_deadline() const {
if (limits_.total_timeout_ms <= 0) {
return;
}
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - started_)
.count();
if (elapsed >= limits_.total_timeout_ms) {
throw std::runtime_error("live request body exceeded its total deadline");
}
}
// Returns false on a clean half-close; throws when a bound is exceeded, so a
// stalled client surfaces as a stream error rather than a silent truncation
// that would look like a legitimate end of audio.
bool receive_more() {
require_within_deadline();
if (!wait_readable()) {
// The wait is clamped to whatever is left of the total deadline, so it can
// end because that expired rather than because the peer went quiet. Report
// which one it was instead of always blaming the idle timeout.
require_within_deadline();
throw std::runtime_error("live request body stalled: no data within the idle timeout");
}
std::array<char, 8192> buffer{};
const int received = receive_some(socket_, buffer.data(), buffer.size());
// Re-checked after the wait: poll() can return readable just before the
// deadline, so without this a final receive would be processed past it.
require_within_deadline();
if (received <= 0) {
return false;
}
received_bytes_ += static_cast<size_t>(received);
require_within_body_cap();
pending_.append(buffer.data(), static_cast<size_t>(received));
return true;
}
bool ensure_available(size_t count) {
while (pending_.size() - pending_pos_ < count) {
if (!receive_more()) {
return false;
}
}
return true;
}
static constexpr size_t kMaxLineBytes = 8192;
bool read_line(std::string & line) {
for (;;) {
const auto eol = pending_.find("\r\n", pending_pos_);
if (eol != std::string::npos) {
// Checked on the found line too, not only while still searching: a
// receive can deliver the terminator along with enough bytes to put
// the line well past the cap, which would otherwise be accepted and
// make the stated bound roughly double what it claims.
if (eol - pending_pos_ > kMaxLineBytes) {
throw std::runtime_error("chunked request body: oversized chunk header");
}
line = pending_.substr(pending_pos_, eol - pending_pos_);
pending_pos_ = eol + 2;
return true;
}
if (pending_.size() - pending_pos_ > kMaxLineBytes) {
throw std::runtime_error("chunked request body: oversized chunk header");
}
if (!receive_more()) {
return false;
}
}
}
bool next_chunk() {
if (finished_) {
return false;
}
// Bounds consumption of already-buffered framing as well as waiting for more,
// so the deadline holds even for a body that arrives faster than it is read.
require_within_deadline();
std::string header;
if (!read_line(header)) {
// The peer closed without the mandatory terminating 0-chunk. Treating
// that as a clean end would hand the caller a truncated body that looks
// byte-for-byte like a complete one — for audio, a cut-off sentence
// indistinguishable from the speaker stopping. Fail instead.
throw std::runtime_error(
"chunked request body: connection closed before the terminating chunk");
}
if (const auto extension = header.find(';'); extension != std::string::npos) {
header.resize(extension); // chunk extensions are unused here
}
header = trim(header);
// Parsed by hand rather than with stoull: the size is attacker-controlled, and
// "ffffffffffffffff" would otherwise yield SIZE_MAX, whose `size + 2` wraps to
// 1 and slips past the availability check into invalid iterator arithmetic.
if (header.empty()) {
throw std::runtime_error("chunked request body: empty chunk size");
}
// Unlike the other bounds, a per-chunk cap cannot be disabled: the chunk is
// materialized in memory, so "unbounded" is not implementable. 0 falls back
// to the default rather than meaning "no limit".
const size_t chunk_cap =
limits_.max_chunk_bytes > 0 ? limits_.max_chunk_bytes : LiveIngestLimits{}.max_chunk_bytes;
size_t size = 0;
for (const char digit : header) {
int value = 0;
if (digit >= '0' && digit <= '9') {
value = digit - '0';
} else if (digit >= 'a' && digit <= 'f') {
value = digit - 'a' + 10;
} else if (digit >= 'A' && digit <= 'F') {
value = digit - 'A' + 10;
} else {
throw std::runtime_error(
"chunked request body: invalid chunk size \"" + header + "\"");
}
// Checked in two steps, each on operands already known to be in range.
// The single-expression form `size > (cap - value) / 16` underflows
// whenever cap < value — at cap = 1 a digit of 'f' wraps to a huge
// quotient and is accepted — which is the same class of bug this
// hand-rolled parse exists to prevent.
if (size > chunk_cap / 16) {
throw std::runtime_error("chunked request body: chunk size exceeds the maximum");
}
size *= 16;
// size <= (cap / 16) * 16 <= cap here, so the subtraction cannot wrap.
if (static_cast<size_t>(value) > chunk_cap - size) {
throw std::runtime_error("chunked request body: chunk size exceeds the maximum");
}
size += static_cast<size_t>(value);
}
if (size == 0) {
finished_ = true;
// Trailers are bounded: they arrive after the terminating chunk, so
// without a cap a peer could stream them indefinitely into pending_,
// which is never compacted on this path.
size_t trailer_bytes = 0;
std::string trailer;
for (;;) {
if (!read_line(trailer)) {
throw std::runtime_error(
"chunked request body: connection closed inside the trailer");
}
if (trailer.empty()) {
break;
}
trailer_bytes += trailer.size();
if (trailer_bytes > 8192) {
throw std::runtime_error("chunked request body: trailer section too large");
}
}
return false;
}
if (!ensure_available(size + 2)) {
throw std::runtime_error("chunked request body: connection closed mid-chunk");
}
// The two bytes after chunk data must be CRLF; accepting anything else lets a
// desynchronised sender's payload be silently reinterpreted as framing.
if (pending_.compare(pending_pos_ + size, 2, "\r\n") != 0) {
throw std::runtime_error("chunked request body: chunk data not terminated by CRLF");
}
// Dropped before the assign, which may reallocate and free what the get area
// still points at. publish_window() can throw on the deadline before it calls
// setg(), and leaving stale pointers across that throw would hand anyone who
// caught the exception and read again a dangling comparison.
setg(nullptr, nullptr, nullptr);
chunk_.assign(
pending_.begin() + static_cast<std::ptrdiff_t>(pending_pos_),
pending_.begin() + static_cast<std::ptrdiff_t>(pending_pos_ + size));
pending_pos_ += size + 2; // also skip the chunk's trailing CRLF
if (pending_pos_ > 64 * 1024) {
pending_.erase(0, pending_pos_);
pending_pos_ = 0;
}
chunk_pos_ = 0;
publish_window();
return true;
}
// Exposes the next slice of the current chunk, re-checking the deadline each
// time. Returns false once the chunk is spent, which is the caller's signal to
// read the next one off the socket.
bool publish_window() {
if (chunk_pos_ >= chunk_.size()) {
return false;
}
require_within_deadline();
const size_t window = std::min(kWindowBytes, chunk_.size() - chunk_pos_);
setg(chunk_.data() + chunk_pos_,
chunk_.data() + chunk_pos_,
chunk_.data() + chunk_pos_ + window);
chunk_pos_ += window;
return true;
}
static constexpr size_t kWindowBytes = 64 * 1024;
SocketHandle socket_;
std::string pending_; // received but not yet de-framed
size_t pending_pos_ = 0;
std::vector<char> chunk_; // the de-framed chunk currently being served
size_t chunk_pos_ = 0; // how much of chunk_ has been published so far
LiveIngestLimits limits_;
std::chrono::steady_clock::time_point started_;
size_t received_bytes_;
bool finished_ = false;
};
// `leftover` receives any body bytes that arrived alongside the headers, but only
// for a chunked body — that case returns with the socket deliberately undrained
// so the handler can consume the rest as it is sent.
HttpRequest read_http_request(
SocketHandle socket,
uint64_t max_request_body_bytes,
std::string & leftover) {
std::string data;
std::array<char, 8192> buffer{};
size_t header_end = std::string::npos;
while (header_end == std::string::npos) {
const int received = receive_some(socket, buffer.data(), buffer.size());
if (received <= 0) {
throw std::runtime_error("socket receive failed before HTTP headers");
}
data.append(buffer.data(), static_cast<size_t>(received));
header_end = data.find("\r\n\r\n");
if (data.size() > 1024 * 1024 && header_end == std::string::npos) {
throw std::runtime_error("HTTP headers exceed 1 MiB");
}
}
std::istringstream header_stream(data.substr(0, header_end));
HttpRequest request;
std::string line;
if (!std::getline(header_stream, line)) {
throw std::runtime_error("empty HTTP request");
}
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
std::istringstream request_line(line);
request_line >> request.method >> request.path;
if (request.method.empty() || request.path.empty()) {
throw std::runtime_error("invalid HTTP request line");
}
const auto query = request.path.find('?');
if (query != std::string::npos) {
request.query = request.path.substr(query + 1);
request.path = request.path.substr(0, query);
}
while (std::getline(header_stream, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
const auto pos = line.find(':');
if (pos == std::string::npos) {
continue;
}
const std::string name = lower_ascii(trim(line.substr(0, pos)));
const std::string value = trim(line.substr(pos + 1));
// Repeated field lines are equivalent to one comma-separated list (RFC 9110
// 5.2), and for Transfer-Encoding that equivalence is load-bearing: splitting
// "gzip, chunked" across two lines would otherwise overwrite the first with
// the second and leave a bare "chunked" that passes the coding check. Only
// this header is combined, so no other endpoint's parsing changes.
if (name == "transfer-encoding") {
auto & slot = request.headers[name];
// Appended in place rather than rebuilt: `slot = slot + ", " + value`
// recopies everything accumulated so far on each line, so a header block
// packed with repeated fields costs quadratic time — and this runs before
// dispatch, on every endpoint, on attacker-supplied input.
if (!slot.empty()) {
slot.append(", ");
}
slot.append(value);
continue;
}
request.headers[name] = value;
}
const auto transfer_encoding_it = request.headers.find("transfer-encoding");
const bool chunked_body =
transfer_encoding_it != request.headers.end() &&
is_chunked_only(transfer_encoding_it->second);
if (engine::debug::log_enabled()) {
const auto content_length_it = request.headers.find("content-length");
engine::debug::log_message(
"[SERVER_HTTP_DEBUG] http.headers method=" + request.method +
" path=" + request.path +
" content_length=" +
(content_length_it == request.headers.end() ? std::string("<none>") : content_length_it->second) +
" transfer_encoding=" +
(transfer_encoding_it == request.headers.end() ? std::string("<none>") : transfer_encoding_it->second) +
" incremental=" + (wants_incremental_body(request) ? "true" : "false") +
" prefetched_body_bytes=" + std::to_string(data.size() - header_end - 4));
}
if (wants_incremental_body(request)) {
leftover = data.substr(header_end + 4);
if (engine::debug::log_enabled()) {
engine::debug::log_message(
"[SERVER_HTTP_DEBUG] http.body_deferred path=" + request.path +
" leftover_bytes=" + std::to_string(leftover.size()));
}
return request;
}
if (chunked_body) {
LiveIngestLimits limits;
limits.max_body_bytes = static_cast<size_t>(std::min<uint64_t>(
max_request_body_bytes,
static_cast<uint64_t>(std::numeric_limits<size_t>::max())));
ChunkedSocketStreambuf body_buffer(
socket,
data.substr(header_end + 4),
limits);
std::istream body_stream(&body_buffer);
body_stream.exceptions(std::ios::badbit);
std::array<char, 8192> chunk_buffer{};
while (body_stream) {
body_stream.read(chunk_buffer.data(), static_cast<std::streamsize>(chunk_buffer.size()));
request.body.append(chunk_buffer.data(), static_cast<size_t>(body_stream.gcount()));
}
return request;
}
size_t content_length = 0;
if (const auto it = request.headers.find("content-length"); it != request.headers.end()) {
// std::stoull throws on a non-numeric or overflowing header, and the
// body loop below grows request.body until it reaches whatever this
// says. An absurd Content-Length is therefore an unbounded in-memory
// accumulation driven by a single request.
//
const std::string & raw = it->second;
unsigned long long parsed = 0;
const auto * first = raw.data();
const auto * last = raw.data() + raw.size();
const auto [ptr, ec] = std::from_chars(first, last, parsed);
if (ec != std::errc{} || ptr != last) {
throw std::runtime_error("invalid Content-Length header");
}
if (parsed > max_request_body_bytes) {
throw std::runtime_error(
"request body exceeds the maximum of " + std::to_string(max_request_body_bytes) + " bytes");
}
if (parsed > std::numeric_limits<size_t>::max()) {
throw std::runtime_error("request body exceeds the maximum addressable size");
}
content_length = static_cast<size_t>(parsed);
}
request.body = data.substr(header_end + 4);
while (request.body.size() < content_length) {
const int received = receive_some(socket, buffer.data(), buffer.size());
if (received <= 0) {
throw std::runtime_error("socket receive failed while reading HTTP body");
}
request.body.append(buffer.data(), static_cast<size_t>(received));
}
if (request.body.size() > content_length) {
request.body.resize(content_length);
}
if (engine::debug::log_enabled()) {
engine::debug::log_message(
"[SERVER_HTTP_DEBUG] http.body_ready path=" + request.path +
" body_bytes=" + std::to_string(request.body.size()));
}
return request;
}
std::string serialize_response(const HttpResponse & response) {
std::ostringstream out;
out << "HTTP/1.1 " << response.status << " " << status_text(response.status) << "\r\n"
<< "Content-Type: " << response.content_type << "\r\n"
<< "Content-Length: " << response.body.size() << "\r\n"
<< "Connection: close\r\n";
for (const auto & [key, value] : response.headers) {
out << key << ": " << value << "\r\n";
}
out << "\r\n";
std::string header = out.str();
header += response.body;
return header;
}
std::string serialize_stream_headers(const HttpResponse & response) {
std::ostringstream out;
out << "HTTP/1.1 " << response.status << " " << status_text(response.status) << "\r\n"
<< "Content-Type: " << response.content_type << "\r\n"
<< "Transfer-Encoding: chunked\r\n"
<< "Cache-Control: no-cache\r\n"
<< "Connection: close\r\n";
for (const auto & [key, value] : response.headers) {
out << key << ": " << value << "\r\n";
}
out << "\r\n";
return out.str();
}
class ChunkedHttpStreamWriter final : public HttpStreamWriter {
public:
explicit ChunkedHttpStreamWriter(SocketHandle socket)
: socket_(socket) {}
void write(std::string_view data) override {
if (data.empty()) {
return;
}
std::ostringstream header;
header << std::hex << data.size() << "\r\n";
send_all(socket_, header.str());
send_all(socket_, std::string(data));
send_all(socket_, "\r\n");
}
void finish() {
send_all(socket_, "0\r\n\r\n");
}
private:
SocketHandle socket_;
};
UniqueSocket bind_listen_socket(const std::string & host, int port) {
UniqueSocket socket_handle(socket(AF_INET, SOCK_STREAM, 0));
if (socket_handle.get() == kInvalidSocket) {
throw std::runtime_error("could not create listen socket");
}
int yes = 1;
#ifdef _WIN32
setsockopt(socket_handle.get(), SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char *>(&yes), sizeof(yes));
#else
setsockopt(socket_handle.get(), SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
#endif
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(port));
if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
throw std::runtime_error("server host must be an IPv4 address: " + host);
}
if (bind(socket_handle.get(), reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) != 0) {
throw std::runtime_error("could not bind " + host + ":" + std::to_string(port));
}
if (listen(socket_handle.get(), 16) != 0) {
throw std::runtime_error("could not listen on " + host + ":" + std::to_string(port));
}
return socket_handle;
}
void handle_client(SocketHandle client, IHttpHandler & handler, uint64_t max_request_body_bytes) {
try {
std::string leftover;
auto request = read_http_request(client, max_request_body_bytes, leftover);
const bool incremental_body = wants_incremental_body(request);
// Asked for once, before the stream exists, because the streambuf takes its
// bounds at construction. The handler resolves them from server config and
// any per-model override — the transport has no notion of models.
const LiveIngestLimits limits =
incremental_body ? handler.live_ingest_limits(request) : LiveIngestLimits{};
if (incremental_body && limits.send_timeout_ms > 0) {
// Scoped to this endpoint: it is the only one whose response is written
// while a model lock is held, so it is the only one where a client that
// stops reading can pin a model. Applying it server-wide would risk
// truncating a large ordinary response to a merely slow client.
set_send_timeout(client, limits.send_timeout_ms);
}
// Constructed unconditionally so it outlives the handler call, but only
// published on `request` when the client actually declared a chunked body.
ChunkedSocketStreambuf body_buffer(client, std::move(leftover), limits);
std::istream body_stream(&body_buffer);
// Without this, a throw from underflow() is caught by istream and turned into
// badbit. A reader that checks gcount() then sees a short read and reports a
// clean end of input, so a stall, an oversize chunk or a mid-body disconnect
// would all be delivered as a successful, silently truncated body.
body_stream.exceptions(std::ios::badbit);
if (incremental_body) {
request.body_stream = &body_stream;
}
const auto response = handler.handle(request);
if (response.stream_body) {
send_all(client, serialize_stream_headers(response));
ChunkedHttpStreamWriter writer(client);
try {
response.stream_body(writer);
} catch (const std::exception & ex) {
if (response.content_type.rfind("text/event-stream", 0) == 0) {
const std::string data =
"data: {\"type\":\"error\",\"error\":{\"message\":" +
json_quote(ex.what()) +
"}}\n\n";
writer.write(data);
} else {
std::cerr << "audiocpp_server streaming response failed: " << ex.what() << "\n";
}
}
writer.finish();
} else {
send_all(client, serialize_response(response));
}
} catch (const std::exception & ex) {
if (engine::debug::log_enabled()) {
engine::debug::log_message(std::string("[SERVER_HTTP_DEBUG] http.error ") + ex.what());
}
try {
send_all(client, serialize_response(error_response(500, ex.what(), "server_error")));
} catch (const std::exception & send_error) {
std::cerr << "audiocpp_server failed to send error response: " << send_error.what() << "\n";
}
}
}
bool wait_for_client(SocketHandle socket, int timeout_ms) {
fd_set read_set;
FD_ZERO(&read_set);
FD_SET(socket, &read_set);
timeval timeout{};
timeout.tv_sec = timeout_ms / 1000;
timeout.tv_usec = (timeout_ms % 1000) * 1000;
#ifdef _WIN32
const int ready = select(0, &read_set, nullptr, nullptr, &timeout);
#else
const int ready = select(socket + 1, &read_set, nullptr, nullptr, &timeout);
#endif
if (ready < 0) {
#ifdef _WIN32
if (WSAGetLastError() == WSAEINTR) {
#else
if (errno == EINTR) {
#endif
return false;
}
throw std::runtime_error("server select failed");
}
return ready > 0 && FD_ISSET(socket, &read_set);
}
} // namespace
namespace {
class OwnedHttpWorkers {
struct Worker {
explicit Worker(UniqueSocket socket) : socket(std::move(socket)) {}
UniqueSocket socket;
std::mutex socket_mutex;
std::thread thread;
std::atomic<bool> done{false};
std::atomic<bool> stopping{false};
};
public:
~OwnedHttpWorkers() { drain(); }
void start(UniqueSocket socket, IHttpHandler & handler, uint64_t max_body_bytes) {
auto worker = std::make_unique<Worker>(std::move(socket));
auto * pointer = worker.get();
// Publish ownership before creating a thread. Allocation/thread creation
// failure closes the accepted connection and drains existing workers.
workers_.push_back(std::move(worker));
// Nonblocking sends/receives ensure cancellation polls cannot enter an
// uninterruptible socket call after a readiness check.
#ifdef _WIN32
u_long nonblocking = 1;
if (ioctlsocket(pointer->socket.get(), FIONBIO, &nonblocking) != 0) {
throw std::runtime_error("could not configure parallel HTTP socket");
}
#else
const int flags = fcntl(pointer->socket.get(), F_GETFL, 0);
if (flags < 0 || fcntl(pointer->socket.get(), F_SETFL, flags | O_NONBLOCK) < 0) {
throw std::runtime_error("could not configure parallel HTTP socket");
}
#endif
pointer->thread = std::thread([pointer, &handler, max_body_bytes] {
connection_stopping = &pointer->stopping;
connection_send_timeout_ms = 0;
try { handle_client(pointer->socket.get(), handler, max_body_bytes); }
catch (...) { std::cerr << "parallel HTTP worker failed while handling an error\n"; }
{
std::lock_guard<std::mutex> lock(pointer->socket_mutex);
pointer->socket = UniqueSocket{};
}
connection_stopping = nullptr;
pointer->done.store(true, std::memory_order_release);
});
}
void reap() {
auto it = workers_.begin();
while (it != workers_.end()) {
if (!(*it)->done.load(std::memory_order_acquire)) { ++it; continue; }
if ((*it)->thread.joinable()) { (*it)->thread.join(); }
it = workers_.erase(it);
}
}
void drain() noexcept {
// Interrupt socket reads/writes before joining. The per-worker mutex
// prevents shutdown racing close and hitting a reused socket handle.
for (const auto & worker : workers_) {
worker->stopping.store(true, std::memory_order_release);
std::lock_guard<std::mutex> lock(worker->socket_mutex);
if (worker->socket.get() == kInvalidSocket) { continue; }
#ifdef _WIN32
shutdown(worker->socket.get(), SD_BOTH);
#else
shutdown(worker->socket.get(), SHUT_RDWR);
#endif
}
// No worker/registry/model lock is held while waiting for inference or
// a deferred callback. The runtime outlives all these joined workers.
for (const auto & worker : workers_) {
if (worker->thread.joinable()) { worker->thread.join(); }
}
workers_.clear();
}
private:
std::vector<std::unique_ptr<Worker>> workers_;
};
} // namespace
void serve_parallel_http(const std::string & host, int port, IHttpHandler & handler, ShutdownRequested shutdown_requested, uint64_t max_request_body_bytes) {
SocketRuntime sockets;
auto listen_socket = bind_listen_socket(host, port);
std::cout << "audiocpp_server listening on http://" << host << ":" << port << "\n";
OwnedHttpWorkers workers;
while (!shutdown_requested()) {
workers.reap();
if (!wait_for_client(listen_socket.get(), 250)) {
continue;
}
sockaddr_in client_addr{};
#ifdef _WIN32
int client_len = sizeof(client_addr);
#else
socklen_t client_len = sizeof(client_addr);
#endif
const SocketHandle client = accept(
listen_socket.get(),
reinterpret_cast<sockaddr *>(&client_addr),
&client_len);
if (client == kInvalidSocket) {
#ifdef _WIN32
const int error = WSAGetLastError();
const bool transient = error == WSAEINTR || error == WSAEWOULDBLOCK;
#else
const bool transient = errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK;
#endif
if (shutdown_requested() || transient) {
continue;
}
throw std::runtime_error("accept failed");
}
workers.start(UniqueSocket(client), handler, max_request_body_bytes);
}
workers.drain();
std::cout << "audiocpp_server stopped\n";
}
} // namespace minitts::server