Skip to content

fix: gRPC fork safety for Gunicorn prefork (grpcio >= 1.83), sw_grpc aio compatibility, websockets >= 13 support - #409

Merged
wu-sheng merged 7 commits into
masterfrom
fix/gunicorn-prefork-grpc
Aug 1, 2026
Merged

fix: gRPC fork safety for Gunicorn prefork (grpcio >= 1.83), sw_grpc aio compatibility, websockets >= 13 support#409
wu-sheng merged 7 commits into
masterfrom
fix/gunicorn-prefork-grpc

Conversation

@wu-sheng

@wu-sheng wu-sheng commented Aug 1, 2026

Copy link
Copy Markdown
Member

Fixes apache/skywalking#13958

Root cause

sw-python run -p gunicorn started a full agent in the Gunicorn master, creating a gRPC channel before fork(). gRPC Python has been migrating to the EventEngine poller since grpcio 1.80.0, and a channel that lives across fork() is no longer safe: the at-fork handlers self-skip while EventEngine threads are inside gRPC (fork_posix.cc:71), leaving stale poll-engine eventfds that produce the reported continuous Kick Failure (eventfd_write: Bad file descriptor) spam — and, worse, a racy silent deadlock of forked workers inside gRPC's own at-fork handlers (worker never boots, gunicorn never notices). grpcio 1.83.0 removed the last legacy-poller opt-out (grpc/grpc#42828); upstream reports grpc/grpc#43055 and grpc/grpc#43062 are open and unfixed. The error is client-side only — unrelated to the OAP version, reproduced against a mock collector with no OAP.

Changes

Gunicorn prefork fix

  • Master = instrumentation only (SkyWalkingAgent.start_prefork_master()): config finalize + plugin/log patching + at-fork hooks; queues, reporter threads and the gRPC channel are created only in forked workers — the same model the uWSGI path has always used. The master no longer registers as a service instance.
  • agent.started() guard: instrumented code becomes a no-op (NoopSpan, dropped logs/meters) in a process whose reporters are not active — protects gunicorn --preload app imports in the master (covered by the test, which preloads and performs an instrumented call at module import).
  • GRPC_POLL_STRATEGY is no longer set; grpcio floor raised to >= 1.83 (wheels' generated stubs already hard-require it at import via GRPC_GENERATED_VERSION), and codegen grpcio-tools is pinned in lockstep in the Makefile so future wheels cannot silently raise the runtime floor.
  • Gunicorn prefork + SW_AGENT_ASYNCIO_ENHANCEMENT is rejected with a clear error instead of starting an unsafe pre-fork agent (the async agent has no fork support); the incompatibility is documented in the Gunicorn FAQ, AsyncEnhancement.md and the config reference.
  • Explicit os.fork() with the gRPC reporter is documented (and warned at runtime) as unreliable on grpcio >= 1.80 — background reporters enter gRPC independent of application requests, and upstream races remain open; forking applications are directed to SW_AGENT_PROTOCOL=http/kafka. Gunicorn via -p is the supported channel-after-fork model.
  • Fixed duplicate at-fork/atexit registrations in forked workers (both now register once per process lineage); hardened stop()/__fini; the worker rebuilds the profiling executor instead of inheriting a fork-dead one.
    CI repairs (pre-existing master failures, no support-scope changes)
  • sw_grpc / grpcio 1.83.0: grpcio 1.83.0 validates isinstance(x, Channel) against the grpc.aio._channel module global, which the plugin had rebound to a factory functionTypeError on every aio stub creation. Replaced with a real Channel subclass. Verified on grpcio 1.83.0; also exercised against 1.82.1 in an isolated harness purely to confirm the subclass changes no pre-1.83 behavior (the agent package itself now requires >= 1.83, and the plugin note documents that effective floor alongside the 1.* instrumentation range).
  • sw_websockets harness: uvicorn 0.50.0 removed its legacy websockets fallback (websockets.server.ServerProtocol is websockets >= 11 only), crashing the test provider with websockets 10.x. Pinned uvicorn < 0.50 in the test compose only; websockets 10.3/10.4 remain tested.
  • Mock-collector first-insert race discovered: SegmentItems.addSegmentItem is check-then-act on the first segment for a service name — two processes posting their first segments concurrently (the fork test's parent+child share one service name) silently lose one while both get HTTP 200 (15/200 reproduction with barrier-synchronized POSTs; 0/200 once the key exists). The fork test seeds the service name with a confirmed warm-up segment before the concurrent pair; the real computeIfAbsent fix belongs in skywalking-agent-test-tool.
    websockets >= 13 support (new)
  • The plugin now instruments both client implementations: legacy websockets.legacy and the new websockets.asyncio ClientConnection.handshake (websockets >= 13, the default since 14) — previously apps on the modern API silently got no spans. Handles the sans-io wsuri->uri attribute rename across 13.x-17.x. Support matrix: 10.3, 10.4, 13.1 on all Pythons plus 17.0.1 on >= 3.11 (upstream requires it), so CI exercises the legacy path on 10.x and the new path on 13.1/17.0.1 across the version range.

Tests

  • tests/plugin/web/sw_gunicorn: full cross-process trace — consumer (entry + requests exit) -> provider under gunicorn --preload -w 2 prefork (entry with CrossProcess ref) — validated against the mock collector, plus log assertions: exact worker-boot counts (catches the silent deadlock), agent spawned only in workers, master instrumented-only, async-rejection case, zero Kick Failure.
  • tests/plugin/web/sw_fork_support: explicit os.fork() with SW_AGENT_EXPERIMENTAL_FORK_SUPPORT over the HTTP reporter (deterministic — no gRPC at-fork hazard), validating the complete cross-fork trace: parent entry/exit -> child entry with CrossProcess ref.
  • Both auto-collect into the existing CI matrix (no CI.yaml changes).

Validation

Docker matrix on the patched agent, grpcio 1.80.0 / 1.82.1 / 1.83.0 x 8-10 boots each: zero error spam, zero worker deadlocks (the old agent deadlocked a worker in ~1/2 to ~1/24 forks), all segments delivered. fd-level inspection: the gunicorn master holds no collector connection; each worker holds exactly one. websockets validated end-to-end on 10.4 (legacy path), 13.1 and 17.0.1 (new asyncio path) with full span validation.

With grpcio >= 1.80 (EventEngine) a gRPC channel that lives across
fork() breaks: continuous `Kick Failure (eventfd_write: Bad file
descriptor)` stderr spam and a racy silent deadlock of forked workers
inside gRPC's own at-fork handlers; grpcio 1.83.0 removed the last
legacy-poller opt-out (grpc/grpc#42828; upstream reports grpc/grpc#43055
and grpc/grpc#43062, both open and unfixed).

- `sw-python run -p gunicorn`: the master now only installs
  instrumentation (start_prefork_master) and arms the fork hooks;
  queues, reporter threads and the gRPC channel are created in each
  forked worker only (the model uWSGI has always used). The master no
  longer registers as a service instance.
- New agent.started() guard: instrumented code no-ops (NoopSpan,
  dropped logs/meters) in a process whose reporters are not active,
  protecting `gunicorn --preload` app imports in the master.
- GRPC_POLL_STRATEGY is no longer set; the grpcio floor is raised to
  >= 1.83 (generated stubs already require it at import) and codegen
  grpcio-tools is pinned in lockstep so future wheels cannot silently
  raise the runtime floor.
- Gunicorn prefork + SW_AGENT_ASYNCIO_ENHANCEMENT is rejected instead
  of starting an unsafe pre-fork agent.
- New plugin tests: sw_gunicorn (full consumer -> gunicorn-provider
  trace with CrossProcess ref, exact worker-boot-count and
  no-Kick-Failure log assertions, async-rejection case) and
  sw_fork_support (explicit os.fork() with a continuous cross-fork
  trace via SW_AGENT_EXPERIMENTAL_FORK_SUPPORT).

Validated in Docker on grpcio 1.80.0 / 1.82.1 / 1.83.0 x 8-10 boots
each: zero error spam, zero worker deadlocks, all segments delivered;
fd-level inspection confirms the master holds no collector connection
while each worker holds exactly one.

Fixes apache/skywalking#13958

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wu-sheng wu-sheng added this to the 1.3.0 milestone Aug 1, 2026
wu-sheng and others added 2 commits August 1, 2026 17:16
…n >= 0.50

Both plugin tests have been failing on every master CI run since 88d30ab;
neither failure changes the supported version scope.

- sw_grpc (grpcio == 1.*): grpcio 1.83.0 added an isinstance(x, Channel)
  validation in grpc.aio._channel resolved from the module global, which
  the plugin had rebound to a factory function -> TypeError on every aio
  stub creation. Replace the factory with a Channel subclass; verified
  against grpcio 1.83.0 and 1.82.1 with full trace validation.
- sw_websockets (10.3/10.4): uvicorn 0.50.0 (2026-07-04) removed its
  legacy websockets fallback and unconditionally imports
  websockets.server.ServerProtocol (websockets >= 11 only), so the
  unpinned test harness crashed at startup with websockets 10.x. Pin
  uvicorn < 0.50 in the test compose; websockets 10.3/10.4 stay tested.
  Also extend the support matrix with websockets 17.0.1 (latest), which
  passes span validation with the plugin unmodified.

Note: both uvicorn < 0.50's server impl and the plugin's client
instrumentation rest on websockets.legacy (deprecated since websockets
14); when upstream removes it the plugin needs a rewrite against
websockets.asyncio.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- sw_websockets now instruments BOTH client implementations: the legacy
  websockets.legacy client (kept until upstream removes it) and the new
  websockets.asyncio ClientConnection.handshake (websockets >= 13, the
  default since 14) — previously apps on the modern API silently got no
  spans. Same span shape; sw8 injected via additional_headers. The test
  consumer prefers the new API, so CI exercises the legacy path on
  10.3/10.4 and the new path on 17.0.1.
- websockets 17 requires Python >= 3.11, so the support matrix is split
  per python version (17.0.1 not attempted on 3.10) — fixes the 3.10
  http job.
- sw_fork_support: forking with a live parent gRPC channel is subject to
  upstream at-fork races (grpc/grpc#43055) that can silently break the
  child's reporting, which flaked CI on 3.12/3.13. The child's segment
  is now best-effort (segmentSize: ge 1, parent segment asserted); the
  parent also waits for the child's port before serving so readiness
  probes cannot create error spans. Deterministic cross-process trace
  validation remains in sw_gunicorn.

All paths re-validated against the mock collector: websockets 10.4
(legacy path), 17.0.1 (new asyncio path), and the fork case — full
dataValidate pass each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wu-sheng wu-sheng changed the title fix: never create a gRPC channel in the Gunicorn prefork master (grpcio >= 1.83) fix: gRPC fork safety for Gunicorn prefork (grpcio >= 1.83), sw_grpc aio compatibility, websockets >= 13 support Aug 1, 2026
wu-sheng and others added 3 commits August 1, 2026 18:46
…ver HTTP

- The sans-io ClientProtocol exposes the parsed URI as `wsuri` in
  websockets 13.x and `uri` in later releases; the new asyncio wrapper
  now falls back accordingly (it crashed stub creation on 13.x-16.x).
  websockets 13.1 added to the support matrix on all Python versions, so
  the new-API path is also exercised on Python 3.10 (websockets 17
  requires >= 3.11).
- sw_fork_support now reports over the HTTP protocol: forking with a
  live gRPC channel is subject to upstream at-fork races
  (grpc/grpc#43055) that can silently drop either side's segments (both
  directions were observed in CI). HTTP has no at-fork hazard, so the
  test deterministically validates the complete cross-fork trace again
  (parent entry/exit -> child entry with CrossProcess ref). The gRPC
  transport path remains covered by sw_gunicorn.

Locally validated: websockets 10.4 (legacy), 13.1 and 17.0.1 (new
asyncio path), and the fork case — full dataValidate pass each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orts

The mock collector's first-ever insert for a service name is a
check-then-act race (SegmentItems.addSegmentItem): when the fork test's
parent and child post their first segments concurrently under the same
service name, one segment is silently dropped while both reporters get
HTTP 200 — reproduced 15/200 with barrier-synchronized first POSTs and
0/200 once the key exists. This was the remaining sw_fork_support CI
flake (either side's segment could vanish, transport-independent).

Seed the service name via a parent-only /ping warm-up (which also serves
as the readiness probe) before /users triggers the concurrent pair; the
expected data now validates all three segments. The real fix belongs in
skywalking-agent-test-tool (computeIfAbsent) and an image repin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt pair

The reporter thread delivers with up to ~20ms poll latency while the test
body fires /users milliseconds after prepare, so on slow runners the
/ping seed could still be in flight together with both /users reports and
lose the collector's first-insert race itself. Poll /receiveData (read-
only) until the seed is registered before triggering the concurrent
reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the SkyWalking Python agent’s compatibility with modern gRPC (grpcio >= 1.83) in pre-fork server environments (Gunicorn), fixes sw_grpc asyncio-channel instrumentation for grpcio 1.83, and extends the sw_websockets plugin to support websockets >= 13’s new asyncio client API.

Changes:

  • Reworks Gunicorn prefork startup to avoid creating gRPC channels before fork() (master becomes “instrumentation-only”, workers run full agent) and adds fork-safety regression tests.
  • Fixes sw_grpc aio channel patching to remain compatible with grpcio 1.83’s Channel type checks.
  • Extends websockets client instrumentation to cover both legacy and new asyncio implementations; pins uvicorn in the websockets test harness for stability.

Reviewed changes

Copilot reviewed 28 out of 29 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/plugin/web/sw_gunicorn/test_gunicorn.py Adds regression test assertions around worker boot, agent start behavior, and absence of grpc fork error signatures.
tests/plugin/web/sw_gunicorn/services/provider.py Adds minimal Flask provider endpoint for Gunicorn prefork testing.
tests/plugin/web/sw_gunicorn/services/consumer.py Adds consumer service issuing traced HTTP call into the Gunicorn provider.
tests/plugin/web/sw_gunicorn/services/init.py Package marker for test services.
tests/plugin/web/sw_gunicorn/expected.data.yml Expected cross-process trace snapshot for Gunicorn prefork scenario.
tests/plugin/web/sw_gunicorn/docker-compose.yml Defines Gunicorn master/worker setup plus an asyncio-enhancement rejection case.
tests/plugin/web/sw_gunicorn/init.py Package marker for the new plugin test.
tests/plugin/web/sw_fork_support/test_fork_support.py Adds explicit os.fork() regression coverage under experimental fork support.
tests/plugin/web/sw_fork_support/services/app.py Implements parent/child forked services and endpoints used by the fork-support test.
tests/plugin/web/sw_fork_support/services/init.py Package marker for test services.
tests/plugin/web/sw_fork_support/expected.data.yml Expected span/segment snapshot for fork-support scenario.
tests/plugin/web/sw_fork_support/docker-compose.yml Runs fork-support scenario over HTTP reporter for determinism.
tests/plugin/web/sw_fork_support/init.py Package marker for the new plugin test.
tests/plugin/http/sw_websockets/services/consumer.py Updates test consumer to import/connect via websockets’ new asyncio API when available.
tests/plugin/http/sw_websockets/docker-compose.yml Pins uvicorn<0.50 in test harness to avoid provider crashes with older websockets.
skywalking/plugins/sw_websockets.py Instruments both legacy and new asyncio websockets client handshake paths.
skywalking/plugins/sw_grpc.py Replaces aio channel factory rebinding with a Channel subclass to satisfy grpcio 1.83 type checks.
skywalking/config.py Updates fork-support config commentary to reflect Gunicorn prefork behavior.
skywalking/bootstrap/loader/sitecustomize.py Starts “instrumentation-only” in Gunicorn master and rejects asyncio enhancement under prefork.
skywalking/bootstrap/cli/utility/runner.py Updates Gunicorn prefork messaging to reflect master instrumentation-only behavior.
skywalking/agent/init.py Adds prefork-master start path, reporting/no-op guard, and fork-hook registration hardening.
pyproject.toml Raises grpcio and grpcio-tools dependency floor to >= 1.83.
poetry.lock Updates locked versions for grpcio/grpcio-tools and transitive dependencies (e.g., protobuf).
Makefile Pins grpcio-tools for codegen in lockstep with the runtime grpcio floor.
docs/en/setup/Plugins.md Updates websockets support matrix and notes to include new asyncio API instrumentation.
docs/en/setup/faq/How-to-use-with-uwsgi.md Clarifies master-process monitoring limitations for uWSGI and Gunicorn.
docs/en/setup/faq/How-to-use-with-gunicorn.md Documents fork-safe Gunicorn behavior and grpcio >= 1.80 issue background.
docs/en/setup/Configuration.md Updates fork-support configuration description to reflect prefork master/worker behavior.
docs/en/setup/CLI.md Clarifies prefork CLI behavior and adds fork timing guidance.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread skywalking/agent/__init__.py
Review follow-ups:
- Explicit os.fork() with a live gRPC channel is documented (and warned
  at runtime, outside the Gunicorn prefork path) as unreliable on
  grpcio >= 1.80 — background reporters enter gRPC independent of
  requests, upstream races grpc/grpc#43055/#43062 remain open; forking
  applications are directed to SW_AGENT_PROTOCOL=http/kafka. Gunicorn
  via `sw-python run -p` is called out as the supported channel-after-
  fork model.
- The asyncio enhancement + prefork incompatibility is now documented in
  the Gunicorn FAQ, AsyncEnhancement.md and the config reference.
- sw_grpc plugin note states the effective grpcio floor (>= 1.83 via the
  agent package) alongside the 1.* instrumentation range.
- __fini is registered once per process lineage (atexit registrations
  are fork-inherited), mirroring the at-fork hook guard.
- The sw_gunicorn provider now runs with --preload and performs an
  instrumented call at module import, covering the agent.started()
  no-op guard in the instrumentation-only master.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wu-sheng
wu-sheng merged commit 8020a1c into master Aug 1, 2026
147 of 149 checks passed
@wu-sheng
wu-sheng deleted the fix/gunicorn-prefork-grpc branch August 1, 2026 14:03
wu-sheng added a commit to apache/skywalking-agent-test-tool that referenced this pull request Aug 1, 2026
…service concurrently (#65)

* fix: drop segments/logs when two services report concurrently for the first time

addSegmentItem and addLogItem look the service name up and then put it
back as two separate operations. When two agent processes report their
FIRST data for the same service name concurrently, both threads observe
null, both construct an item, and the second put overwrites the first —
the segments already appended to the discarded item are lost while both
reporters receive HTTP 200. Once the key exists there is no loss, since
SegmentItem/LogItem hold CopyOnWriteArrayList, so only the very first
insert per service name is affected.

This surfaced in apache/skywalking-python#409, where a plugin test forks
a worker: parent and child share one service name and post their first
segments milliseconds apart, so a segment vanished in roughly 2 of 5 CI
runs. Reproduced directly against this collector with barrier-
synchronized first-time POSTs: 15 of 200 rounds lost a segment, and 0 of
200 once the key had been seeded.

Use computeIfAbsent, which performs the whole get-or-create atomically —
the pattern MeterItems.addMeter already uses in this package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): repair the macOS job, broken by the arm64 runner migration

CI-on-MacOS has failed since GitHub moved macos-latest to arm64: with
actions/setup-java@v1, JAVA_HOME points at a hosted-toolcache path that
lacks the macOS Contents/Home layout, so $JAVA_HOME/bin/java does not
exist and mvnw aborts with "JAVA_HOME is not defined correctly" before
compiling anything. setup-java@v1 also runs on a Node runtime GitHub has
already removed.

Move both jobs to actions/setup-java@v4 (and checkout@v4). The
distribution must be zulu rather than the more common temurin: JDK 8 has
no macOS aarch64 temurin build, while zulu publishes one. Both actions
are in the actions/* namespace, which the ASF allow-list permits without
a SHA pin.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] should fix the version of gprc

3 participants