Repository navigation
fix(feature-flags): Poll in uWSGI workers forked without Python's fork hooks - #772
Merged
Merged
Conversation
…k hooks uWSGI forks its workers from C and, unless told to with --py-call-uwsgi-fork-hooks, never runs the hooks Python registers with os.register_at_fork. A runtime client created in the master then never polled in the workers: it stayed unready, and the stale poller Thread still reported itself alive. Each client now records the process it belongs to and, on its first use in another process, runs the same rebuild the at-fork hook runs, with the same rules. The SDK also records which process sent the first request through each HTTP client, so requests a worker sends itself before that first use don't count as the parent's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
[Medium impact] The PR appears safe to merge. SummaryAdds first-use fork checks so feature flag clients recover when workers skip Python's fork hooks.
|
The process ID of each HTTP client's first request was kept in a plain dict next to the weak references, so it outlived the client. Hold both in one entry that a weak-reference callback removes when the client is collected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
|
@greptileai review |
Deborah-Digges
approved these changes
Nov 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
uWSGI forks its workers from C, and unless you pass
--py-call-uwsgi-fork-hooks(or--py-call-osafterfork) it never runs the hooks Python registers withos.register_at_fork. It also imports the app in the master before forking, unless you pass--lazy-apps. The runtime clients learn about a fork only through that hook (#767), so with uWSGI's defaults a client created and started at import never polls in a worker. There it stays unready and serves your defaults, andflags._thread.is_alive()still saysTrue, because thatThreadobject is the parent's. I found this while fact-checking the Python runtime client docs.That hits anyone who runs uWSGI with its defaults (
--master --processes N --enable-threads, the setup the README asks for) and creates the runtime client before the fork.--lazy-appsand--py-call-uwsgi-fork-hooksboth avoid it.os.getpid()first. When they differ, the client runs the rebuild the at-fork hook would have run, then carries on. A sync client that was polling resumes on its first use in the worker, as it does under gunicorn--preload.start()andrefresh()raise when the parent used a caller-supplied HTTP client or theAsyncWorkOSClient, and on macOSstart()warns instead. The async client'sstart(),refresh(),wait_until_ready()andclose(), the syncclose(), and adding or removing a listener run the check too. Soclose()in a worker doesn't wait on the parent's poller thread or close the parent's HTTP client._first_request_pidreplaces the_sent_requestsflag, andsent_requests()becomessent_requests_from_another_process(). With a plain "any request" check, a worker that calledorganizations.list_organizations()before first using the runtime client was refused as though the parent had used the HTTP client. I checked: the newtest_requests_the_child_sent_itself_do_not_stop_it_pollingfails that way.os.fork()the check never fires. When several threads of a worker reach first use together, the first claims the process and rebuilds the client, and the others wait for it.os.getpid()call and a compare. On macOS, which caches the PID,is_enabled("f", context)went from 1,552 ns to 1,598 ns. On Linux in Docker, wheregetpid()is a syscall (151 ns there), it went from 1,735–1,790 ns to 1,953–2,010 ns, andis_enabled("f")without a context from about 545 ns to 790 ns. If that's too much, the check could be throttled, or read aMADV_WIPEONFORKpage on Linux. I kept the plain check because it's exact on every platform.Where to look:
_core.py:_reinitialize_after_unhooked_fork,_after_fork_in_childandused_by_another_process._sync.pyand_async.py: where the check runs._http.py:record_requestandsent_requests_from_another_process.How I checked it:
I ran a uWSGI app in Docker (
python:3.12-bookworm, pyuwsgi 2.0.30, a mock flags server) that creates and starts the runtime client at import, polling every 5 seconds. Each run sends concurrent requests 3–5 s and 12–14 s after start, then counts the polls the server sees over the next 10 s. These are each worker's last answers at 12–14 s:With 4 request threads per worker the count stays at 6, one poller per process.
--lazy-appsand--py-call-uwsgi-fork-hooksgive the same results onmainand on this branch.test_fork.pynow runs every subprocess scenario twice: withos.fork(), and with libc'sfork()throughctypes.PyDLL, which skips Python's hooks the way uWSGI does. The in-process tests run with the at-fork hook and without it, through a fixture that changesos.getpid(). New tests cover:close()leaving the parent's poller and HTTP client alone.Against
main's source, 29 fail and 37 pass, and every failure is a variant without the hooks. On this branch all 66 pass.A child forked without the hooks while another parent thread waits for the GIL can hang, because CPython rebuilds the GIL's locks only in its own after-fork handling. The new tests hit that about once in 40 runs on Linux. So the hookless scenarios idle the parent's pollers before forking, and
LocalHTTPServersleeps until a connection arrives instead of waking every 0.5 s. After that, 60 runs of the hookless tests in four parallel loops on Linux had no failures.An HTTP client's tracking entry now goes away when the client is collected. In a script that keeps 500 WorkOS clients alive and replaces one at random 50,000 times, the tracking dictionary reached 861 entries on this PR's first commit and kept growing; it now holds exactly the 500 live clients.
test_forgets_an_http_client_once_it_is_collectedcovers it.ruff format --check,ruff checkandpyrightpass. The full suite passes on macOS with Python 3.14 (3,783 tests). Before that last fix it also passed on macOS with Python 3.10 (3,782 tests) and on Linux in Docker with Python 3.12 (3,778 passed, 4 macOS-only tests skipped).Not in this PR. Repeating the uWSGI start turned up two ways a fork can catch the master's poller mid-poll, on
mainas well as here. uWSGI forks right after importing the app, which is when the poller sends its first request.--py-call-uwsgi-fork-hooks, 1 of 26 starts onmainleft one worker's poller blocked for good inhttpx2.Client()→create_ssl_context: the master's poller was building its SSL context, holding an OpenSSL lock, at the fork. gunicorn--preloadforks the same way, so it's probably exposed too; I didn't test that. This branch had 0 of 26.mainand 1 of 58 on this branch, plus 2 starts on this branch where neither worker reported ready and I didn't capture why.Building the poller's HTTP client in
start()instead of in the poller thread would close the first window. Recommending--lazy-appsfor uWSGI would avoid both.Documentation
Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.
The Python runtime client docs in progress should say that uWSGI's default settings work, and can recommend
--lazy-apps.🤖 Generated with Claude Code