diff --git a/CHANGELOG.md b/CHANGELOG.md index a7dab6d..5cc6343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.2.2] - 2026-03-18 + +- Reset inherited singleton state after `fork()` by registering a child-only `os.register_at_fork()` handler [#26] +- Add POSIX fork tests covering inherited lock state and fresh singleton reinitialization [#26] + ## [1.2.1] - 2026-02-25 - Fix SDK version header: use correct header name `X-Reforge-SDK-Version` and value format `sdk-python-{version}` to match all other SDKs [#25] diff --git a/pyproject.toml b/pyproject.toml index e3f175c..e95b714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "sdk-reforge" -version = "1.2.1" +version = "1.2.2" description = "Python sdk for Reforge Feature Flags and Config as a Service: https://www.reforge.com" license = "MIT" authors = ["Michael Berkowitz ", "James Kebinger "] diff --git a/sdk_reforge/VERSION b/sdk_reforge/VERSION index 6085e94..23aa839 100644 --- a/sdk_reforge/VERSION +++ b/sdk_reforge/VERSION @@ -1 +1 @@ -1.2.1 +1.2.2 diff --git a/sdk_reforge/__init__.py b/sdk_reforge/__init__.py index 4c6414f..bb8437b 100644 --- a/sdk_reforge/__init__.py +++ b/sdk_reforge/__init__.py @@ -81,6 +81,18 @@ def _get_version() -> str: __lock = _ReadWriteLock() +def _reset_singleton_after_fork() -> None: + """Drop inherited singleton state in forked children.""" + global __base_sdk + global __lock + __base_sdk = None + __lock = _ReadWriteLock() + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_reset_singleton_after_fork) + + def set_options(options: Options) -> None: """Configure the SDK. SDK will be instantiated lazily with these options. Setting them again will have no effect unless reset_instance is called""" global __options diff --git a/sdk_reforge/_sse_watchdog.py b/sdk_reforge/_sse_watchdog.py index e39eff6..642a010 100644 --- a/sdk_reforge/_sse_watchdog.py +++ b/sdk_reforge/_sse_watchdog.py @@ -82,8 +82,9 @@ def start(self) -> None: def stop(self) -> None: """Stop the watchdog thread.""" self._stop.set() - if self._thread: - self._thread.join(timeout=5) + thread = self._thread + if thread and thread.is_alive(): + thread.join(timeout=5) def _run(self) -> None: """Main watchdog loop.""" diff --git a/tests/test_forking.py b/tests/test_forking.py new file mode 100644 index 0000000..6a8aadb --- /dev/null +++ b/tests/test_forking.py @@ -0,0 +1,98 @@ +import os +import threading + +import pytest + +import sdk_reforge +from sdk_reforge import Options + + +def _read_exact(fd: int, size: int) -> bytes: + chunks = [] + remaining = size + while remaining > 0: + chunk = os.read(fd, remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +@pytest.mark.skipif(not hasattr(os, "fork"), reason="requires os.fork") +def test_child_process_replaces_inherited_singleton_lock() -> None: + sdk_reforge.reset_instance() + + lock = getattr(sdk_reforge, "__lock") + ready = threading.Event() + release = threading.Event() + + def hold_lock() -> None: + with lock.write_locked(): + ready.set() + release.wait(timeout=5) + + holder = threading.Thread(target=hold_lock) + holder.start() + ready.wait(timeout=2) + + read_fd, write_fd = os.pipe() + pid = os.fork() + if pid == 0: + try: + os.close(read_fd) + sdk_reforge.set_options( + Options( + reforge_datasources="LOCAL_ONLY", + collect_sync_interval=None, + ) + ) + os.write(write_fd, b"ok") + finally: + os.close(write_fd) + os._exit(0) + + os.close(write_fd) + try: + os.waitpid(pid, 0) + assert _read_exact(read_fd, 2) == b"ok" + finally: + os.close(read_fd) + release.set() + holder.join(timeout=2) + sdk_reforge.reset_instance() + + +@pytest.mark.skipif(not hasattr(os, "fork"), reason="requires os.fork") +def test_child_process_creates_fresh_singleton_sdk_instance() -> None: + sdk_reforge.reset_instance() + sdk_reforge.set_options( + Options( + reforge_datasources="LOCAL_ONLY", + collect_sync_interval=None, + ) + ) + parent_sdk = sdk_reforge.get_sdk() + parent_hash = parent_sdk.instance_hash + + read_fd, write_fd = os.pipe() + pid = os.fork() + if pid == 0: + try: + os.close(read_fd) + child_sdk = sdk_reforge.get_sdk() + payload = child_sdk.instance_hash.encode("utf-8") + os.write(write_fd, payload) + finally: + os.close(write_fd) + os._exit(0) + + os.close(write_fd) + try: + os.waitpid(pid, 0) + child_hash = _read_exact(read_fd, len(parent_hash)).decode("utf-8") + assert child_hash + assert child_hash != parent_hash + finally: + os.close(read_fd) + sdk_reforge.reset_instance() diff --git a/tests/test_sse_watchdog.py b/tests/test_sse_watchdog.py index 2112ec6..e2fe759 100644 --- a/tests/test_sse_watchdog.py +++ b/tests/test_sse_watchdog.py @@ -209,6 +209,18 @@ def test_stop_terminates_thread(self) -> None: watchdog.stop() self.assertFalse(watchdog._thread.is_alive()) + def test_stop_before_start_is_noop(self) -> None: + """Verify stop() is safe before the watchdog thread is started""" + watchdog = SSEWatchdog( + self.config_client, + self.poll_fallback_fn, + self.get_sse_client_fn, + ) + + watchdog.stop() + + self.poll_fallback_fn.assert_not_called() + def test_stops_when_shutting_down(self) -> None: """Verify watchdog stops when config_client is shutting down""" self.config_client.is_shutting_down.return_value = True