Files
obs-streamer-tools-plugin/core/src/http_winhttp.cpp
T

326 lines
13 KiB
C++
Raw Normal View History

/*
streamer-tools OBS Camera Plugin - WinHTTP backend (Windows)
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
*/
// WinHTTP rather than libcurl on Windows: it ships with the OS, does TLS
// through SChannel (so no OpenSSL to build or ship), and needs no package
// manager on the self-hosted `winvm-builder` runner -- which, per the
// scaffold README, is a bare VM without even cmake preinstalled.
#include "stplugin/http.h"
#include <windows.h>
#include <winhttp.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace stplugin {
namespace {
constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u;
std::wstring widen(const std::string &s)
{
if (s.empty())
return std::wstring();
const int needed = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);
if (needed <= 0)
return std::wstring();
std::wstring out(static_cast<std::size_t>(needed), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), &out[0], needed);
return out;
}
std::string lastErrorMessage(const char *what)
{
return std::string(what) + " failed (GetLastError=" + std::to_string(GetLastError()) + ")";
}
/// RAII for the three WinHTTP handle kinds, which all close the same way.
class Handle {
public:
Handle() = default;
explicit Handle(HINTERNET h) : h_(h) {}
~Handle()
{
if (h_)
WinHttpCloseHandle(h_);
}
Handle(const Handle &) = delete;
Handle &operator=(const Handle &) = delete;
void reset(HINTERNET h)
{
if (h_)
WinHttpCloseHandle(h_);
h_ = h;
}
HINTERNET get() const { return h_; }
explicit operator bool() const { return h_ != nullptr; }
private:
HINTERNET h_ = nullptr;
};
/// Hard deadline for one WinHTTP exchange, enforced by cancelling it.
///
/// Neither receive timeout is a guaranteed deadline: Microsoft documents both
/// as "checked only when data is received from the socket", so an expired
/// timeout is not surfaced until the peer finally sends something. Measured on
/// the Windows CI runner against a server that accepts and then stalls 5s: a
/// 700ms budget returned after 1490, 1529, 2485, 3493 and 4506ms across five
/// attempts -- always cancelled, never on time.
///
/// That overshoot matters because `fetchSlots` is called synchronously on the
/// OBS UI thread behind the properties dialog's "Refresh camera list" button
/// (obs-adapter/src/plugin-main.cpp), with a 5s budget. At the ratio above
/// that is a frozen dialog for half a minute.
///
/// The documented way to force cancellation is to close the handle from
/// another thread; the pending call then fails with
/// ERROR_WINHTTP_OPERATION_CANCELLED. This owns the request handle so that
/// exactly one of the two threads ever closes it: `handle_.exchange(nullptr)`
/// hands the close to whichever gets there first.
///
/// Known, accepted race: the caller may load the handle and have the watchdog
/// close it before the WinHttp* call reads it, in which case the call fails
/// with ERROR_INVALID_HANDLE instead. Both outcomes are "the deadline
/// expired", which is what the caller is told either way.
class RequestDeadline {
public:
RequestDeadline(HINTERNET request, DWORD after_ms) : handle_(request)
{
watchdog_ = std::thread([this, after_ms] {
std::unique_lock<std::mutex> lock(mutex_);
if (cv_.wait_for(lock, std::chrono::milliseconds(after_ms), [this] { return finished_; }))
return; // exchange finished inside the deadline
if (closeOnce())
expired_.store(true);
});
}
~RequestDeadline()
{
{
std::lock_guard<std::mutex> lock(mutex_);
finished_ = true;
}
cv_.notify_all();
if (watchdog_.joinable())
watchdog_.join();
closeOnce(); // no-op if the watchdog got there first
}
RequestDeadline(const RequestDeadline &) = delete;
RequestDeadline &operator=(const RequestDeadline &) = delete;
HINTERNET get() const { return handle_.load(); }
bool expired() const { return expired_.load(); }
private:
bool closeOnce()
{
HINTERNET h = handle_.exchange(nullptr);
if (!h)
return false;
WinHttpCloseHandle(h);
return true;
}
std::atomic<HINTERNET> handle_;
std::atomic<bool> expired_{false};
std::mutex mutex_;
std::condition_variable cv_;
bool finished_ = false;
std::thread watchdog_;
};
class WinHttpClient : public HttpClient {
public:
HttpResponse send(const HttpRequest &request) override
{
HttpResponse response;
const std::wstring url = widen(request.url);
if (url.empty()) {
response.network_error = "empty or non-UTF-8 URL";
return response;
}
URL_COMPONENTS parts{};
parts.dwStructSize = sizeof(parts);
wchar_t host[256] = {0};
wchar_t path[4096] = {0};
wchar_t extra[4096] = {0};
parts.lpszHostName = host;
parts.dwHostNameLength = static_cast<DWORD>(sizeof(host) / sizeof(host[0]));
parts.lpszUrlPath = path;
parts.dwUrlPathLength = static_cast<DWORD>(sizeof(path) / sizeof(path[0]));
parts.lpszExtraInfo = extra;
parts.dwExtraInfoLength = static_cast<DWORD>(sizeof(extra) / sizeof(extra[0]));
if (!WinHttpCrackUrl(url.c_str(), static_cast<DWORD>(url.size()), 0, &parts)) {
response.network_error = lastErrorMessage("WinHttpCrackUrl");
return response;
}
if (parts.nScheme != INTERNET_SCHEME_HTTP && parts.nScheme != INTERNET_SCHEME_HTTPS) {
response.network_error = "unsupported URL scheme";
return response;
}
Handle session(WinHttpOpen(L"streamer-tools-obs-plugin/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0));
if (!session) {
response.network_error = lastErrorMessage("WinHttpOpen");
return response;
}
const DWORD timeout = static_cast<DWORD>(request.timeout_ms);
WinHttpSetTimeouts(session.get(), static_cast<int>(timeout), static_cast<int>(timeout),
static_cast<int>(timeout), static_cast<int>(timeout));
// WinHttpSetTimeouts' receive parameter maps to
// WINHTTP_OPTION_RECEIVE_TIMEOUT, which Microsoft documents as a
// PER-PACKET Winsock-layer read timeout ("applies to fetching each
// packet of data off the socket"), not a deadline on the response.
// The wait for the response HEADERS is a *separate* option,
// WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT ("to wait to receive all
// response headers to a request"), which WinHttpSetTimeouts does not
// touch and which defaults to 90 SECONDS. Without this call a server
// that accepts, reads the request and then stalls can hold this
// thread for a minute and a half regardless of request.timeout_ms --
// exactly the "blocking an OBS thread indefinitely" failure
// testPlatformBackendTimeout exists to prevent, and the likely
// mechanism behind that test's intermittent Windows failures.
//
// Caveat, also documented: this timeout "is checked only when data is
// received from the socket", so it bounds the wait but does not
// guarantee a hard deadline. A guaranteed deadline needs a watchdog
// thread calling WinHttpCloseHandle; not done here.
//
// Guarded because the constant postdates some Windows SDK headers; a
// toolchain without it keeps the previous (90s default) behaviour
// rather than failing to build.
#ifdef WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT
DWORD response_timeout = timeout;
// Return value deliberately unchecked: a rejected option leaves the
// documented default in place, which is degraded but still correct
// behaviour, and there is no logging sink in this layer to report it
// to. The timeout probe in test_api_client.cpp is what would catch a
// regression here.
WinHttpSetOption(session.get(), WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT, &response_timeout,
sizeof(response_timeout));
#endif
Handle connect(WinHttpConnect(session.get(), host, parts.nPort, 0));
if (!connect) {
response.network_error = lastErrorMessage("WinHttpConnect");
return response;
}
std::wstring target(path);
target += extra;
const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u;
HINTERNET raw_req = WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(),
nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
flags);
if (!raw_req) {
response.network_error = lastErrorMessage("WinHttpOpenRequest");
return response;
}
// Ceiling at twice the caller's budget: each of the four
// WinHttpSetTimeouts phases (resolve, connect, send, receive) is
// allowed `timeout` on its own, so a slow-but-progressing exchange can
// legitimately exceed one budget, and this must not cancel those. The
// floor keeps a very small timeout_ms from producing a deadline the
// exchange cannot meet on a cold connection.
const DWORD deadline_ms = (timeout > 500u) ? (timeout * 2u) : 1000u;
RequestDeadline req(raw_req, deadline_ms);
// From here on, `req.get()` can be closed underneath us by the
// watchdog; every WinHttp* failure below is therefore checked against
// req.expired() before its GetLastError text is reported, so an
// expired deadline reads as a timeout rather than as
// "WinHttpReceiveResponse failed (GetLastError=12017)".
const auto fail = [&](const char *what) -> HttpResponse {
if (req.expired())
response.network_error = "timed out after " + std::to_string(deadline_ms) + " ms";
else
response.network_error = lastErrorMessage(what);
return response;
};
std::wstring headers;
if (!request.content_type.empty())
headers = L"Content-Type: " + widen(request.content_type) + L"\r\n";
const LPCWSTR header_ptr = headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS : headers.c_str();
const DWORD header_len = headers.empty() ? 0u : static_cast<DWORD>(headers.size());
void *body_ptr = request.body.empty() ? WINHTTP_NO_REQUEST_DATA
: const_cast<char *>(request.body.data());
const DWORD body_len = static_cast<DWORD>(request.body.size());
if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0))
return fail("WinHttpSendRequest");
if (!WinHttpReceiveResponse(req.get(), nullptr))
return fail("WinHttpReceiveResponse");
DWORD status = 0;
DWORD status_size = sizeof(status);
if (!WinHttpQueryHeaders(req.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX))
return fail("WinHttpQueryHeaders");
response.status = static_cast<long>(status);
std::string body;
for (;;) {
DWORD available = 0;
if (!WinHttpQueryDataAvailable(req.get(), &available))
return fail("WinHttpQueryDataAvailable");
if (available == 0)
break;
if (body.size() + available > kMaxResponseBytes) {
response.network_error = "response body exceeded 4 MiB";
return response;
}
std::vector<char> chunk(available);
DWORD read = 0;
if (!WinHttpReadData(req.get(), chunk.data(), available, &read))
return fail("WinHttpReadData");
if (read == 0)
break;
body.append(chunk.data(), read);
}
response.body = std::move(body);
return response;
}
};
} // namespace
HttpClient *createPlatformHttpClient()
{
return new WinHttpClient();
}
} // namespace stplugin