/* streamer-tools OBS Camera Plugin - WinHTTP backend (Windows) Copyright (C) 2026 CyberCoveLLC 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 #include #include #include #include #include #include #include #include #include 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(s.size()), nullptr, 0); if (needed <= 0) return std::wstring(); std::wstring out(static_cast(needed), L'\0'); MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast(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 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 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 handle_; std::atomic 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(sizeof(host) / sizeof(host[0])); parts.lpszUrlPath = path; parts.dwUrlPathLength = static_cast(sizeof(path) / sizeof(path[0])); parts.lpszExtraInfo = extra; parts.dwExtraInfoLength = static_cast(sizeof(extra) / sizeof(extra[0])); if (!WinHttpCrackUrl(url.c_str(), static_cast(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(request.timeout_ms); WinHttpSetTimeouts(session.get(), static_cast(timeout), static_cast(timeout), static_cast(timeout), static_cast(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(headers.size()); void *body_ptr = request.body.empty() ? WINHTTP_NO_REQUEST_DATA : const_cast(request.body.data()); const DWORD body_len = static_cast(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(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 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