Skip to content

Commit 8e6f1d5

Browse files
zeevdrclaude
andauthored
docs: document fork-safety limitation for gRPC channels (#111)
gRPC channels are not fork-safe. Forking after creating a ConfigClient (or while a ConfigWatcher thread runs) results in undefined behavior — the child inherits the channel's internal threads and file descriptors in an unrecoverable state. Adds a "Fork safety" section to watching.md with an explanation, a multiprocessing.Pool example showing the correct pattern, and a note on preferring the spawn start method. Adds a brief callout to README.md linking to that section. Closes #69 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 65de401 commit 8e6f1d5

2 files changed

Lines changed: 38 additions & 0 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ with ConfigClient("localhost:9090", subject="myapp") as client:
5151
print(f"Fee changed: {old} -> {new}")
5252
```
5353

54+
> **Fork safety:** gRPC channels are not fork-safe. Create `ConfigClient` (and start any watcher)
55+
> *after* forking — not before. See [Fork safety](sdk/docs/watching.md#fork-safety) for details.
56+
5457
## Async
5558

5659
```python

sdk/docs/watching.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,41 @@ with ConfigClient("localhost:9090", subject="myapp") as client:
120120
print(fee_a.value, fee_b.value)
121121
```
122122

123+
## Fork safety
124+
125+
gRPC channels are **not fork-safe**. Do not create a `ConfigClient` (or `AsyncConfigClient`) before
126+
calling `os.fork()` — this includes implicit forks from `multiprocessing.Pool`, Gunicorn workers,
127+
and similar process-spawning frameworks.
128+
129+
After a fork, the child inherits the open gRPC channel. The channel's internal threads and file
130+
descriptors are in an undefined state, which can cause hangs, crashes, or silent data corruption.
131+
132+
**Fix: create the client inside the worker, not before forking.**
133+
134+
```python
135+
from multiprocessing import Pool
136+
from opendecree import ConfigClient
137+
138+
def worker(tenant_id: str) -> str:
139+
# Safe — client created after fork
140+
with ConfigClient("localhost:9090", subject="myapp") as client:
141+
return client.get(tenant_id, "payments.fee")
142+
143+
with Pool(4) as pool:
144+
results = pool.map(worker, ["tenant-a", "tenant-b"])
145+
```
146+
147+
If you must use `multiprocessing`, prefer the `spawn` start method (default on macOS and Windows)
148+
over `fork` — it avoids inheriting the parent's file descriptors entirely:
149+
150+
```python
151+
import multiprocessing
152+
multiprocessing.set_start_method("spawn")
153+
```
154+
155+
The same restriction applies to `ConfigWatcher`: the background thread does not survive a fork.
156+
Stop the watcher before forking, or start it inside the child process.
157+
123158
## Next steps
124159

125160
- [Async Usage](async.md) — async watcher with `async for` iteration

0 commit comments

Comments
 (0)