fix: gRPC fork safety for Gunicorn prefork (grpcio >= 1.83), sw_grpc aio compatibility, websockets >= 13 support - #409
Merged
Conversation
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>
…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>
…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>
3 tasks
There was a problem hiding this comment.
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_grpcaio channel patching to remain compatible with grpcio 1.83’sChanneltype 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.
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>
kezhenxu94
approved these changes
Aug 1, 2026
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.
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.
Fixes apache/skywalking#13958
Root cause
sw-python run -p gunicornstarted a full agent in the Gunicorn master, creating a gRPC channel beforefork(). gRPC Python has been migrating to the EventEngine poller since grpcio 1.80.0, and a channel that lives acrossfork()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 continuousKick 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
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 — protectsgunicorn --preloadapp imports in the master (covered by the test, which preloads and performs an instrumented call at module import).GRPC_POLL_STRATEGYis no longer set; grpcio floor raised to>= 1.83(wheels' generated stubs already hard-require it at import viaGRPC_GENERATED_VERSION), and codegengrpcio-toolsis pinned in lockstep in the Makefile so future wheels cannot silently raise the runtime floor.SW_AGENT_ASYNCIO_ENHANCEMENTis 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.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 toSW_AGENT_PROTOCOL=http/kafka. Gunicorn via-pis the supported channel-after-fork model.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)
isinstance(x, Channel)against thegrpc.aio._channelmodule global, which the plugin had rebound to a factory function →TypeErroron every aio stub creation. Replaced with a realChannelsubclass. 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 the1.*instrumentation range).websockets.server.ServerProtocolis websockets >= 11 only), crashing the test provider with websockets 10.x. Pinneduvicorn < 0.50in the test compose only; websockets 10.3/10.4 remain tested.SegmentItems.addSegmentItemis 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 realcomputeIfAbsentfix belongs in skywalking-agent-test-tool.websockets >= 13 support (new)
websockets.legacyand the newwebsockets.asyncioClientConnection.handshake(websockets >= 13, the default since 14) — previously apps on the modern API silently got no spans. Handles the sans-iowsuri->uriattribute rename across 13.x-17.x. Support matrix:10.3, 10.4, 13.1on all Pythons plus17.0.1on >= 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 +requestsexit) -> provider undergunicorn --preload -w 2prefork (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, zeroKick Failure.tests/plugin/web/sw_fork_support: explicitos.fork()withSW_AGENT_EXPERIMENTAL_FORK_SUPPORTover the HTTP reporter (deterministic — no gRPC at-fork hazard), validating the complete cross-fork trace: parent entry/exit -> child entry with CrossProcess ref.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.