Author SHA1 Message Date
shadowdaoandClaude Opus 5 17540c75b0 test: assert the 2x-budget ceiling the watchdog now guarantees, not 4s
Build / macOS (macos-latest) (push) Successful in 53s
Build / Linux (ubuntu-24.04) (push) Successful in 1m23s
Build / macOS (macos-latest) (pull_request) Successful in 46s
Build / Linux (ubuntu-24.04) (pull_request) Successful in 1m19s
Build / Windows (windows-latest) (push) Successful in 3m57s
Build / Windows (windows-latest) (pull_request) Successful in 3m59s
The Windows job went green, but CTest prints test output only on failure, so a
pass says nothing about WHAT cancelled the request. Under the old 4000ms bound
a pass is ambiguous: the watchdog firing at ~1400ms and WinHTTP's own erratic
cancellation (measured at 1490-4506ms for this same 700ms budget) both fit
under it.

So assert the guarantee the code actually makes now -- a hard ceiling of twice
the caller's budget -- at 2500ms, which is 1400ms plus slack for a loaded
runner. If the watchdog stops doing the work, roughly half the attempts land
above this and print their elapsed time and error string, instead of quietly
passing.

Linux is unaffected: curl honours the 700ms budget exactly, 5/5 at ~701ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGnvQ6wfD7bw7PZN35ft9
2026-09-09 18:43:32 -07:00
shadowdaoandClaude Opus 5 b23644fa3e fix(http): enforce a hard deadline on WinHTTP by cancelling the request
Build / macOS (macos-latest) (push) Successful in 51s
Build / Linux (ubuntu-24.04) (push) Successful in 1m14s
Build / macOS (macos-latest) (pull_request) Successful in 48s
Build / Linux (ubuntu-24.04) (pull_request) Successful in 1m8s
Build / Windows (windows-latest) (push) Successful in 3m56s
Build / Windows (windows-latest) (pull_request) Successful in 3m56s
Setting WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT (previous commit) fixed the
original failure -- the request is now always cancelled instead of waiting out
a stall and returning 200 -- but the instrumented CI probe shows it is not
cancelled on TIME. Five attempts with a 700ms budget against a server that
accepts and then stalls 5s returned after 1490, 1529, 2485, 3493 and 4506ms,
every one of them ERROR_WINHTTP_TIMEOUT (12002). One exceeded the test's 4s
bound, which is why Windows CI was still red.

That is the documented behaviour, not a mystery: both receive timeouts are
"checked only when data is received from the socket", so an expired timeout is
not surfaced until the peer sends something. Neither option is a deadline.

It 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:486, kPropertiesTimeoutMs = 5000). At the
overshoot ratio measured above, a stalling server freezes that dialog for
something like half a minute -- the exact failure the shortened timeout there
was chosen to avoid.

So: a watchdog thread that closes the request handle once the deadline passes,
which is the documented way to cancel a WinHTTP operation. `RequestDeadline`
owns the handle and both threads close it through an
`atomic::exchange(nullptr)`, so exactly one close ever happens. Failures are
reported as a timeout rather than as a raw GetLastError when the deadline is
what fired.

The ceiling is twice the caller's budget, not the budget itself: resolve,
connect, send and receive each get `timeout` from WinHttpSetTimeouts, so a
slow-but-progressing exchange can legitimately exceed one budget and must not
be cancelled. There is one accepted race, documented at the class: the caller
can load the handle just before the watchdog closes it, turning the call into
ERROR_INVALID_HANDLE instead. Both mean the deadline expired.

Cross-compiled with mingw-w64 (`-fsyntax-only`) rather than waiting on CI to
find syntax errors; also confirmed by preprocessor probe that
WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT is defined in those headers, so the
#ifdef guard is not silently skipping the option. Linux: all 6 suites pass.
Real verification is the Windows job's probe output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGnvQ6wfD7bw7PZN35ft9
2026-09-09 18:37:58 -07:00
shadowdaoandClaude Opus 5 8888e57d08 fix(http): bound the WinHTTP response-header wait, and stop docs triggering builds
Build / macOS (macos-latest) (push) Successful in 50s
Build / Linux (ubuntu-24.04) (push) Successful in 1m14s
Build / macOS (macos-latest) (pull_request) Successful in 49s
Build / Linux (ubuntu-24.04) (pull_request) Successful in 1m16s
Build / Windows (windows-latest) (push) Failing after 3m55s
Build / Windows (windows-latest) (pull_request) Failing after 3m51s
Two things, both prompted by an intermittent Windows CI failure in
test_api_client's testPlatformBackendTimeout: roughly 2 runs in 6, both its
assertions failed together, meaning a request with timeout_ms=700 waited out a
5s server stall and returned 200. Same failure on 2026-09-07 (job 5834) and
2026-09-09 (job 5911), on code that passed on other runs -- pre-existing and
intermittent, not caused by a change.

1. The real bug. `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"). The wait for the response HEADERS is a separate option,
   WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT, which WinHttpSetTimeouts does not
   set and which defaults to 90 SECONDS. So a server that accepts, reads the
   request and then stalls could block the calling thread for a minute and a
   half no matter what the caller passed as timeout_ms -- precisely the
   "blocking an OBS thread indefinitely" failure that test exists to prevent.
   Now set explicitly, guarded by #ifdef so an older SDK still builds.

   That is a genuine defect on its own merits. Whether it is the whole
   explanation for the intermittency is NOT established: the same docs say
   this timeout "is checked only when data is received from the socket", so
   neither option guarantees a hard deadline -- that needs a watchdog calling
   WinHttpCloseHandle, deliberately not done here.

2. Evidence, so the next run says more than pass/fail. The probe now runs 5
   times and prints elapsed ms, ok, status, requests_seen and the backend's
   error string (carrying GetLastError) for every attempt, so one CI run
   yields a failure RATE and an error code. Each attempt gets a FRESH
   loopback server: the server handles one connection at a time on a single
   thread, so reusing it would leave attempts 2..n in the accept backlog --
   never accepted, a different scenario from the one that fails. Verified on
   Linux: 5/5 attempts give up at ~701ms.

Also: build.yml now has paths-ignore for **.md, LICENSE, NOTICE, and the two
release-only files. This is a full three-platform build behind a runner with
capacity:1, and six of them fired for one afternoon of documentation edits.
Nothing that feeds a build or a test is on that list. Tradeoff: a docs-only
push now shows no status at all rather than a green one.

The WinHTTP change cannot be compiled locally (Linux host); CI is its first
build. All 6 suites pass locally on Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGnvQ6wfD7bw7PZN35ft9
2026-09-09 18:30:02 -07:00
jknapp 5a39881af8 Merge pull request 'feat(release): publish releases directly, and fix the install path in the notes' (#4) from feat/publish-releases-directly into main
Build / macOS (macos-latest) (push) Successful in 27s
Build / Linux (ubuntu-24.04) (push) Successful in 45s
Build / Windows (windows-latest) (push) Failing after 4m59s
2026-09-09 23:51:05 +00:00
shadowdaoandClaude Opus 5 d9f73926e4 feat(release): publish releases directly, and fix the install path in the notes
Build / macOS (macos-latest) (push) Successful in 27s
Build / macOS (macos-latest) (pull_request) Successful in 26s
Build / Linux (ubuntu-24.04) (push) Successful in 56s
Build / Linux (ubuntu-24.04) (pull_request) Successful in 54s
Build / Windows (windows-latest) (push) Successful in 4m20s
Build / Windows (windows-latest) (pull_request) Successful in 3m55s
Releases were created as drafts for one stated reason: nobody had run the
plugin in the OBS GUI on any platform, so a human had to look before anything
became visible. The first confirmed GUI load (Windows, OBS 32.2.2 on Windows
11, 2026-09-09) retired that gate, so `publish-release.sh` now posts
`"draft": False` and the workflow no longer needs a human click.

The caveats did not go away, they moved: the generated release notes now lead
with what is actually confirmed (module loads and registers its source type,
Windows only) and what is not (video rendering, A/V sync, latency, mid-show
publisher restart, Linux and macOS in the GUI at all), and the per-platform
table carries the rest.

Also fixes the third and last copy of the wrong Windows install path. The
release notes template told every downloader to extract into
`%APPDATA%\obs-studio\plugins\`, which on Windows is OBS's config directory
and is never scanned for plugins -- that is what stopped a director's
correctly-shaped install from loading. The notes now carry a per-platform
table (`C:\ProgramData\obs-studio\plugins\` on Windows), the exact finished
path, a warning about Explorer's "Extract All..." wrapper folder, and how to
confirm the load in the OBS log. Both failure modes are silent, which is
precisely why they belong in the notes.

Note the tradeoff now that nothing is held back: assets upload after the
release row is created, so a release is briefly visible with no files
attached. Called out in the script header.

Verified by rendering the heredoc with a stub tag: backslash escaping survives
into correct markdown, and the YAML parses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGnvQ6wfD7bw7PZN35ft9
2026-09-09 16:50:44 -07:00
jknapp ee44fb6a73 Merge pull request 'docs: record the first confirmed OBS GUI load (Windows)' (#3) from docs/first-gui-load into main
Build / macOS (macos-latest) (push) Successful in 28s
Build / Linux (ubuntu-24.04) (push) Successful in 58s
Build / Windows (windows-latest) (push) Successful in 3m45s
2026-09-09 23:47:05 +00:00
6 changed files with 296 additions and 66 deletions
+43 -19
View File
@@ -1,9 +1,15 @@
#!/usr/bin/env bash
# Creates a (draft) Gitea Release for the tag that triggered
# Creates a published Gitea Release for the tag that triggered
# .gitea/workflows/release.yml, and uploads every archive in $DIST_DIR as a
# release asset. Draft because nobody has run this plugin in the OBS GUI on
# any platform yet -- a human still opens it and clicks Publish once that's
# no longer true (or once they're satisfied regardless).
# release asset.
#
# This used to create a DRAFT, on the grounds that nobody had run the plugin
# in the OBS GUI on any platform. That stopped being true on 2026-09-09, when
# the v0.1.0 Windows artifact loaded into OBS 32.2.2 on Windows 11 -- so the
# release publishes directly and the per-platform table below carries the
# remaining caveats instead. Assets upload AFTER the release row is created
# either way, so a release is briefly visible with no files attached; that is
# the tradeoff for not needing a human click.
#
# Required env: GITEA_TOKEN, SERVER, OWNER, REPO, TAG, SHA, DIST_DIR
# Optional env: MACOS_BUNDLE_FOUND ("true"/"false", default "false")
@@ -30,26 +36,44 @@ cat > "${NOTES_FILE}" <<EOF
Built from commit \`${SHA}\`.
**Nobody has yet run this plugin in the OBS GUI, on any platform.** See "What
is verified, and how" in \`README.md\` for exactly what has and has not been
checked, including which claims are backed by automated tests versus a human
watching OBS. This is why the release is a draft -- open it and click Publish
once you're satisfied.
**Read the per-platform notes below before relying on this.** The module has
been loaded in the OBS GUI exactly once -- Windows, OBS 32.2.2 on Windows 11,
2026-09-09 -- and only "it loads and registers its source type" is confirmed
there. Whether video renders correctly, A/V sync, end-to-end latency and
mid-show publisher restart are all still unverified on every platform. See
"What is verified, and how" in \`README.md\` for exactly what is backed by
automated tests versus a human watching OBS.
| Platform | Archive | Notes |
|---|---|---|
| Linux (x64) | \`streamer-tools-camera-${TAG}-linux-x64.zip\` | Functionally complete and verified end to end against a real LiveKit server and a real libobs (see README); OBS GUI itself still unverified |
| Windows (x64) | \`streamer-tools-camera-${TAG}-windows-x64.zip\` | Built and tested by this workflow's Windows job; the WinHTTP backend has never been exercised against a real streamer-tools server, only a loopback test server -- see README's Windows CI section |
| Windows (x64) | \`streamer-tools-camera-${TAG}-windows-x64.zip\` | Built and tested by this workflow's Windows job. Confirmed to load in the OBS GUI (32.2.2 / Windows 11, 2026-09-09); nothing past module load is verified. The WinHTTP backend has never been exercised against a real streamer-tools server, only a loopback test server -- see README's Windows CI section |
| macOS | \`streamer-tools-camera-${TAG}-macos.zip\` | Built and tested by this workflow's macOS job. ${MACOS_NOTE} |
## Installing
Extract the archive into your OBS plugins folder for your platform (the
default locations are easy to find online -- typically
\`~/.config/obs-studio/plugins/\` on Linux, \`%APPDATA%\\obs-studio\\plugins\\\`
on Windows, \`~/Library/Application Support/obs-studio/plugins/\` on macOS).
Each archive's top-level folder already matches the shape OBS expects there,
so extracting is the whole install step. Then in OBS: Sources -> \`+\` ->
Extract the archive into your OBS plugins folder. **The directory is not the
same shape on every platform, and picking the wrong one fails silently -- OBS
logs nothing at all for a plugin it never finds:**
| Platform | Extract into |
|---|---|
| Windows | \`C:\\ProgramData\\obs-studio\\plugins\\\` -- **not** \`%APPDATA%\\obs-studio\\\`, which is where OBS keeps its config and is never scanned for plugins |
| macOS | \`~/Library/Application Support/obs-studio/plugins/\` |
| Linux | \`~/.config/obs-studio/plugins/\` |
Each archive's top-level folder already matches the shape OBS expects, so
extracting is the whole install step -- but check the result is exactly one
folder deep. Windows Explorer's "Extract All..." adds a folder named after the
zip unless you clear it from the destination box, which nests it one level too
far and is equally silent. On Windows the finished path must be:
\`\`\`
C:\\ProgramData\\obs-studio\\plugins\\streamer-tools-camera\\bin\\64bit\\streamer-tools-camera.dll
\`\`\`
To confirm it loaded, restart OBS and check Help -> Log Files -> View Current
Log for \`streamer-tools-camera\` under "Loaded Modules". Then in OBS: Sources -> \`+\` ->
"streamer-tools Camera" -> fill in the server URL, room slug and read key
from the room's settings page -> "Refresh camera list" -> pick a camera.
@@ -73,7 +97,7 @@ print(json.dumps({
"tag_name": tag,
"name": tag,
"body": notes,
"draft": True,
"draft": False,
"prerelease": False,
}))
PYEOF
@@ -87,7 +111,7 @@ RESP="$(curl -sS -f -X POST \
"${SERVER}/api/v1/repos/${OWNER}/${REPO}/releases")"
RELEASE_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"${RESP}")"
echo "Created release id ${RELEASE_ID} (draft)."
echo "Created release id ${RELEASE_ID} (published; assets upload next)."
shopt -s nullglob
ASSETS=("${DIST_DIR}"/*)
@@ -106,4 +130,4 @@ for f in "${ASSETS[@]}"; do
> /dev/null
done
echo "Done. Draft release: ${SERVER}/${OWNER}/${REPO}/releases/${RELEASE_ID}"
echo "Done. Release: ${SERVER}/${OWNER}/${REPO}/releases/${RELEASE_ID}"
+22
View File
@@ -31,7 +31,29 @@ on:
# job is ordinary commits.
branches:
- "**"
# Documentation-only changes cannot break a build, and this workflow is a
# full three-platform build (Windows included) behind a runner with
# capacity:1. Six of these fired for one afternoon of README/release-notes
# edits on 2026-09-09. Anything that feeds a build or a test is absent
# from this list on purpose -- release.yml and publish-release.sh only run
# on a `v*` tag, via release.yml's own trigger.
#
# Tradeoff: a docs-only push now shows NO status at all on the branch,
# rather than a green one. If a required-status check is ever added, these
# paths have to be reconsidered.
paths-ignore:
- "**.md"
- "LICENSE"
- "NOTICE"
- ".gitea/workflows/release.yml"
- ".gitea/scripts/publish-release.sh"
pull_request:
paths-ignore:
- "**.md"
- "LICENSE"
- "NOTICE"
- ".gitea/workflows/release.yml"
- ".gitea/scripts/publish-release.sh"
jobs:
linux:
+9 -7
View File
@@ -1,14 +1,16 @@
name: Release
# Packages a build of each platform into a downloadable archive and creates
# a (draft) Gitea Release for it, so the project owner and other directors
# a published Gitea Release for it, so the project owner and other directors
# can grab a ready-to-use build instead of compiling from source.
#
# Runs only on a pushed version tag (see `on.push.tags` below) -- never on an
# ordinary push or PR, unlike build.yml. The release it creates is a DRAFT:
# it stays invisible to anyone without write access until a human explicitly
# opens it and clicks Publish, since nobody has run this plugin in the OBS
# GUI on any platform yet.
# ordinary push or PR, unlike build.yml. The release it creates is PUBLISHED
# immediately. It used to be a draft, gated on a human clicking Publish
# because nobody had run the plugin in the OBS GUI on any platform; the first
# confirmed GUI load (Windows, 2026-09-09) retired that. The caveats that
# remain live in the generated release notes, not in the draft flag -- see
# .gitea/scripts/publish-release.sh.
#
# The actual per-platform build commands live in .gitea/scripts/ and are the
# same scripts .gitea/workflows/build.yml uses, so this workflow can't drift
@@ -183,7 +185,7 @@ jobs:
path: dist
release:
name: Create Gitea Release (draft)
name: Create Gitea Release
needs: [linux, macos, windows]
runs-on: ubuntu-24.04
permissions:
@@ -214,7 +216,7 @@ jobs:
name: release-archive-windows-x64
path: dist
- name: Create draft release and upload assets
- name: Create release and upload assets
run: .gitea/scripts/publish-release.sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+5 -3
View File
@@ -14,8 +14,10 @@ match the vendored LiveKit binaries, which are also Apache-2.0 — see
`.gitea/workflows/build.yml` builds, tests, and uploads CI-internal build
artifacts on every push. `.gitea/workflows/release.yml` packages a tagged
build (`v*`) into a **draft** Gitea Release; a human still needs to open it
and click Publish.
build (`v*`) into a **published** Gitea Release. It created drafts until
2026-09-09, gated on a human clicking Publish because nobody had run the
plugin in the OBS GUI; the first confirmed GUI load retired that gate, and the
remaining caveats live in the generated release notes instead.
The plugin is **functionally complete on Linux and verified end to end there**
(module loads into real libobs, connects to a real LiveKit server through the
@@ -78,7 +80,7 @@ scripts/livekit-dev-room.py - mints tokens for the integration test
third_party/livekit/ - redistribution notices for the LiveKit binaries
.gitea/scripts/ - the actual per-platform build commands, shared by build.yml and release.yml
.gitea/workflows/build.yml - 3-platform CI matrix (every push/PR; never publishes)
.gitea/workflows/release.yml - packages + creates a draft Gitea Release (only on a `v*` tag push; see Status above)
.gitea/workflows/release.yml - packages + publishes a Gitea Release (only on a `v*` tag push; see Status above)
```
## How it works
+147 -23
View File
@@ -19,8 +19,13 @@ You may obtain a copy of the License at
#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 {
@@ -72,6 +77,79 @@ 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
@@ -116,6 +194,39 @@ public:
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");
@@ -126,13 +237,36 @@ public:
target += extra;
const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u;
Handle req(WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(), nullptr,
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags));
if (!req) {
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";
@@ -144,31 +278,23 @@ public:
: 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)) {
response.network_error = lastErrorMessage("WinHttpSendRequest");
return response;
}
if (!WinHttpReceiveResponse(req.get(), nullptr)) {
response.network_error = lastErrorMessage("WinHttpReceiveResponse");
return response;
}
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)) {
response.network_error = lastErrorMessage("WinHttpQueryHeaders");
return response;
}
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)) {
response.network_error = lastErrorMessage("WinHttpQueryDataAvailable");
return response;
}
if (!WinHttpQueryDataAvailable(req.get(), &available))
return fail("WinHttpQueryDataAvailable");
if (available == 0)
break;
if (body.size() + available > kMaxResponseBytes) {
@@ -177,10 +303,8 @@ public:
}
std::vector<char> chunk(available);
DWORD read = 0;
if (!WinHttpReadData(req.get(), chunk.data(), available, &read)) {
response.network_error = lastErrorMessage("WinHttpReadData");
return response;
}
if (!WinHttpReadData(req.get(), chunk.data(), available, &read))
return fail("WinHttpReadData");
if (read == 0)
break;
body.append(chunk.data(), read);
+60 -4
View File
@@ -16,6 +16,7 @@ You may obtain a copy of the License at
// three runners rather than assumed to work.
#include <chrono>
#include <cstdio>
#include <memory>
#include <string>
#include <thread>
@@ -422,8 +423,32 @@ void testPlatformBackendTimeout()
{
// A server that accepts and then stalls. The plugin must give up on its
// own timeout rather than blocking an OBS thread indefinitely.
sttest::LoopbackServer server([](const std::string &) {
std::this_thread::sleep_for(std::chrono::seconds(5));
//
// INSTRUMENTED (2026-09-09) while chasing an intermittent Windows-only
// failure: on roughly 2 of 6 CI runs both assertions below fail together,
// meaning the request waited out the full 5s stall and returned 200 --
// the timeout did not fire at all. Same failure seen on 2026-09-07
// (job 5834) and 2026-09-09 (job 5911), on identical code that passed on
// other runs, so it is not a code change that caused it.
//
// The probe runs kProbes times and prints one line per attempt so a
// single CI run yields a failure RATE and the WinHTTP error code, rather
// than one bit. `ST_ASSERT` records and continues, so every attempt is
// reported even when one fails. Remove the loop and this comment once the
// mechanism is understood and fixed.
constexpr int kProbes = 5;
constexpr long long kStallMs = 5000;
constexpr long kTimeoutMs = 700;
int timed_out = 0;
for (int i = 0; i < kProbes; ++i) {
// A FRESH server per attempt, deliberately. `LoopbackServer` accepts
// and handles one connection at a time on a single thread, so reusing
// one server across attempts would leave attempts 2..n sitting in the
// accept backlog -- a different scenario (never accepted) from the one
// that fails on Windows (accepted, request read, then stalled).
sttest::LoopbackServer server([kStallMs](const std::string &) {
std::this_thread::sleep_for(std::chrono::milliseconds(kStallMs));
return sttest::httpResponse(200, "OK", R"({"slots":[]})");
});
ST_ASSERT(server.valid());
@@ -431,13 +456,44 @@ void testPlatformBackendTimeout()
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
HttpRequest request;
request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k";
request.timeout_ms = 700;
request.timeout_ms = kTimeoutMs;
const auto start = std::chrono::steady_clock::now();
const HttpResponse response = http->send(request);
const auto elapsed = std::chrono::steady_clock::now() - start;
const long long ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
// 4000 was the old bound, chosen when nothing bounded the wait. The
// code now promises a hard ceiling of 2x the caller's budget
// (RequestDeadline in http_winhttp.cpp), so assert THAT -- 1400ms
// here, plus slack for a loaded runner. This is also the only signal
// that survives a green run: CTest prints nothing on success, so if
// WinHTTP's own erratic cancellation (measured at 1490-4506ms for
// this same 700ms budget) were doing the work instead of the
// watchdog, roughly half the attempts would land above this bound and
// say so, instead of quietly passing under a 4s ceiling.
constexpr long long kCeilingMs = 2500;
const bool gave_up = !response.ok() && ms < kCeilingMs;
if (gave_up)
++timed_out;
// Always printed, pass or fail: elapsed time and the backend's own
// error string (which carries GetLastError on Windows) are the
// evidence. requests_seen separates "the client never reached the
// server" (0) from "the server read the request and the client then
// waited it out" (1).
std::fprintf(stderr,
" [timeout-probe %d/%d] elapsed=%lldms ok=%d status=%ld "
"requests_seen=%d network_error='%s' -> %s\n",
i + 1, kProbes, ms, response.ok() ? 1 : 0, response.status,
server.requestCount(), response.network_error.c_str(),
gave_up ? "gave up (expected)" : "WAITED OUT THE STALL");
ST_ASSERT(!response.ok());
ST_ASSERT(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() < 4000);
ST_ASSERT(ms < kCeilingMs);
}
std::fprintf(stderr, " [timeout-probe] %d/%d attempts honoured the %ldms timeout\n",
timed_out, kProbes, kTimeoutMs);
}
} // namespace