Skip to content

fix(feature-flags): Poll in uWSGI workers forked without Python's fork hooks - #772

Merged
gjtorikian merged 2 commits into
mainfrom
fix/runtime-client-fork-without-hooks
Nov 5, 2026
Merged

gjtorikian merged 2 commits into
mainfrom
fix/runtime-client-fork-without-hooks

Conversation

@stanleyphu

@stanleyphu stanleyphu commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

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 with os.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, and flags._thread.is_alive() still says True, because that Thread object 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-apps and --py-call-uwsgi-fork-hooks both avoid it.

  • Noticing the fork. Each runtime client records the process it belongs to, and every public call compares that with 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.
  • Same rules. It's the same rebuild, so chore(feature-flags): Keep the runtime clients safe across fork() #767's rules apply unchanged: start() and refresh() raise when the parent used a caller-supplied HTTP client or the AsyncWorkOSClient, and on macOS start() warns instead. The async client's start(), refresh(), wait_until_ready() and close(), the sync close(), and adding or removing a listener run the check too. So close() in a worker doesn't wait on the parent's poller thread or close the parent's HTTP client.
  • Requests the worker sends itself. The rebuild runs on first use rather than at the fork, so by then the worker may have sent its own SDK calls through the same HTTP client. To keep "the parent used it" meaning the parent, the SDK records which process sent the first request through each WorkOS client and each HTTP client: _first_request_pid replaces the _sent_requests flag, and sent_requests() becomes sent_requests_from_another_process(). With a plain "any request" check, a worker that called organizations.list_organizations() before first using the runtime client was refused as though the parent had used the HTTP client. I checked: the new test_requests_the_child_sent_itself_do_not_stop_it_polling fails that way.
  • Rebuilding once. The at-fork hook records the new process ID before rebuilding, so after 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.
  • Cost. Evaluation now pays one 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, where getpid() is a syscall (151 ns there), it went from 1,735–1,790 ns to 1,953–2,010 ns, and is_enabled("f") without a context from about 545 ns to 790 ns. If that's too much, the check could be throttled, or read a MADV_WIPEONFORK page on Linux. I kept the plain check because it's exact on every platform.

Where to look:

  1. _core.py: _reinitialize_after_unhooked_fork, _after_fork_in_child and used_by_another_process.
  2. _sync.py and _async.py: where the check runs.
  3. _http.py: record_request and sent_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:

    uwsgi --master --processes 2 --enable-threads
      main:   pid=27 ready=False refreshes=0 poller_alive=True
              pid=28 ready=False refreshes=0 poller_alive=True
              polls in 10 s, from the master and both workers: 2
      branch: pid=27 ready=True refreshes=2 poller_alive=True
              pid=28 ready=True refreshes=2 poller_alive=True
              polls in 10 s, from the master and both workers: 6
    
    uwsgi --master --processes 2 --enable-threads --threads 4
      main:   both workers ready=False refreshes=0, 2 polls in 10 s
      branch: both workers ready=True refreshes=2, 6 polls in 10 s
    

    With 4 request threads per worker the count stays at 6, one poller per process. --lazy-apps and --py-call-uwsgi-fork-hooks give the same results on main and on this branch.

  • test_fork.py now runs every subprocess scenario twice: with os.fork(), and with libc's fork() through ctypes.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 changes os.getpid(). New tests cover:

    • a worker that sent its own SDK call first, sync and async;
    • each client prepared exactly once after either kind of fork;
    • eight threads racing to first use;
    • a rebuild that fails: logged once, and evaluation keeps answering;
    • 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 LocalHTTPServer sleeps 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_collected covers it.

  • ruff format --check, ruff check and pyright pass. 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 main as well as here. uWSGI forks right after importing the app, which is when the poller sends its first request.

  • With --py-call-uwsgi-fork-hooks, 1 of 26 starts on main left one worker's poller blocked for good in httpx2.Client() → create_ssl_context: the master's poller was building its SSL context, holding an OpenSSL lock, at the fork. gunicorn --preload forks the same way, so it's probably exposed too; I didn't test that. This branch had 0 of 26.
  • Without the hooks, uWSGI doesn't take the GIL before forking (the hooks option exists to "acquire the gil and import lock before forking in order to avoid deadlocks in workers"), so a worker can hang outright. That happened in 1 of 21 starts on main and 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-apps for uWSGI would avoid both.

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[x] Yes

The Python runtime client docs in progress should say that uWSGI's default settings work, and can recommend --lazy-apps.

🤖 Generated with Claude Code

…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>
@stanleyphu
stanleyphu marked this pull request as ready for review November 5, 2026 17:17
@stanleyphu
stanleyphu requested review from a team as code owners November 5, 2026 17:17
@stanleyphu
stanleyphu requested a review from awolfden November 5, 2026 17:17
@greptile-apps

greptile-apps Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge.

Summary

Adds first-use fork checks so feature flag clients recover when workers skip Python's fork hooks.

  • Feature-flag clients rebuild themselves after hookless worker forks.
  • Fork checks distinguish parent requests from worker requests.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Worker uses runtime client] --> B{Process changed?}
  B -->|No| E[Continue public call]
  B -->|Yes| C[Rebuild inherited state once]
  C --> D{Polling is safe?}
  D -->|Yes| E
  D -->|No| F[Keep saved flags and block polling]
Loading

Reviews (2) · Last reviewed commit: "fix(feature-flags): Drop collected HTTP ..." · Reviewed by Greptile

Comment thread src/workos/_http.py Resolved
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>
@stanleyphu

Copy link
Copy Markdown
Contributor Author

@greptileai review

@gjtorikian
gjtorikian merged commit 4d4fb82 into main Nov 5, 2026
14 checks passed
@gjtorikian
gjtorikian deleted the fix/runtime-client-fork-without-hooks branch November 5, 2026 19:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants