Skip to content

Commit a644e35

Browse files
authored
Fix fork safety for singleton SDK (#26)
* Fix fork safety for singleton SDK * Fix mypy annotations in fork tests * Guard watchdog stop before thread start
1 parent 15cebec commit a644e35

File tree

7 files changed

+132
-4
lines changed

7 files changed

+132
-4
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## [1.2.2] - 2026-03-18
4+
5+
- Reset inherited singleton state after `fork()` by registering a child-only `os.register_at_fork()` handler [#26]
6+
- Add POSIX fork tests covering inherited lock state and fresh singleton reinitialization [#26]
7+
38
## [1.2.1] - 2026-02-25
49

510
- Fix SDK version header: use correct header name `X-Reforge-SDK-Version` and value format `sdk-python-{version}` to match all other SDKs [#25]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "sdk-reforge"
3-
version = "1.2.1"
3+
version = "1.2.2"
44
description = "Python sdk for Reforge Feature Flags and Config as a Service: https://www.reforge.com"
55
license = "MIT"
66
authors = ["Michael Berkowitz <michael.berkowitz@gmail.com>", "James Kebinger <james.kebinger@reforge.com>"]

sdk_reforge/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.1
1+
1.2.2

sdk_reforge/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ def _get_version() -> str:
8181
__lock = _ReadWriteLock()
8282

8383

84+
def _reset_singleton_after_fork() -> None:
85+
"""Drop inherited singleton state in forked children."""
86+
global __base_sdk
87+
global __lock
88+
__base_sdk = None
89+
__lock = _ReadWriteLock()
90+
91+
92+
if hasattr(os, "register_at_fork"):
93+
os.register_at_fork(after_in_child=_reset_singleton_after_fork)
94+
95+
8496
def set_options(options: Options) -> None:
8597
"""Configure the SDK. SDK will be instantiated lazily with these options. Setting them again will have no effect unless reset_instance is called"""
8698
global __options

sdk_reforge/_sse_watchdog.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ def start(self) -> None:
8282
def stop(self) -> None:
8383
"""Stop the watchdog thread."""
8484
self._stop.set()
85-
if self._thread:
86-
self._thread.join(timeout=5)
85+
thread = self._thread
86+
if thread and thread.is_alive():
87+
thread.join(timeout=5)
8788

8889
def _run(self) -> None:
8990
"""Main watchdog loop."""

tests/test_forking.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import os
2+
import threading
3+
4+
import pytest
5+
6+
import sdk_reforge
7+
from sdk_reforge import Options
8+
9+
10+
def _read_exact(fd: int, size: int) -> bytes:
11+
chunks = []
12+
remaining = size
13+
while remaining > 0:
14+
chunk = os.read(fd, remaining)
15+
if not chunk:
16+
break
17+
chunks.append(chunk)
18+
remaining -= len(chunk)
19+
return b"".join(chunks)
20+
21+
22+
@pytest.mark.skipif(not hasattr(os, "fork"), reason="requires os.fork")
23+
def test_child_process_replaces_inherited_singleton_lock() -> None:
24+
sdk_reforge.reset_instance()
25+
26+
lock = getattr(sdk_reforge, "__lock")
27+
ready = threading.Event()
28+
release = threading.Event()
29+
30+
def hold_lock() -> None:
31+
with lock.write_locked():
32+
ready.set()
33+
release.wait(timeout=5)
34+
35+
holder = threading.Thread(target=hold_lock)
36+
holder.start()
37+
ready.wait(timeout=2)
38+
39+
read_fd, write_fd = os.pipe()
40+
pid = os.fork()
41+
if pid == 0:
42+
try:
43+
os.close(read_fd)
44+
sdk_reforge.set_options(
45+
Options(
46+
reforge_datasources="LOCAL_ONLY",
47+
collect_sync_interval=None,
48+
)
49+
)
50+
os.write(write_fd, b"ok")
51+
finally:
52+
os.close(write_fd)
53+
os._exit(0)
54+
55+
os.close(write_fd)
56+
try:
57+
os.waitpid(pid, 0)
58+
assert _read_exact(read_fd, 2) == b"ok"
59+
finally:
60+
os.close(read_fd)
61+
release.set()
62+
holder.join(timeout=2)
63+
sdk_reforge.reset_instance()
64+
65+
66+
@pytest.mark.skipif(not hasattr(os, "fork"), reason="requires os.fork")
67+
def test_child_process_creates_fresh_singleton_sdk_instance() -> None:
68+
sdk_reforge.reset_instance()
69+
sdk_reforge.set_options(
70+
Options(
71+
reforge_datasources="LOCAL_ONLY",
72+
collect_sync_interval=None,
73+
)
74+
)
75+
parent_sdk = sdk_reforge.get_sdk()
76+
parent_hash = parent_sdk.instance_hash
77+
78+
read_fd, write_fd = os.pipe()
79+
pid = os.fork()
80+
if pid == 0:
81+
try:
82+
os.close(read_fd)
83+
child_sdk = sdk_reforge.get_sdk()
84+
payload = child_sdk.instance_hash.encode("utf-8")
85+
os.write(write_fd, payload)
86+
finally:
87+
os.close(write_fd)
88+
os._exit(0)
89+
90+
os.close(write_fd)
91+
try:
92+
os.waitpid(pid, 0)
93+
child_hash = _read_exact(read_fd, len(parent_hash)).decode("utf-8")
94+
assert child_hash
95+
assert child_hash != parent_hash
96+
finally:
97+
os.close(read_fd)
98+
sdk_reforge.reset_instance()

tests/test_sse_watchdog.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,18 @@ def test_stop_terminates_thread(self) -> None:
209209
watchdog.stop()
210210
self.assertFalse(watchdog._thread.is_alive())
211211

212+
def test_stop_before_start_is_noop(self) -> None:
213+
"""Verify stop() is safe before the watchdog thread is started"""
214+
watchdog = SSEWatchdog(
215+
self.config_client,
216+
self.poll_fallback_fn,
217+
self.get_sse_client_fn,
218+
)
219+
220+
watchdog.stop()
221+
222+
self.poll_fallback_fn.assert_not_called()
223+
212224
def test_stops_when_shutting_down(self) -> None:
213225
"""Verify watchdog stops when config_client is shutting down"""
214226
self.config_client.is_shutting_down.return_value = True

0 commit comments

Comments
 (0)