Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <michael.berkowitz@gmail.com>", "James Kebinger <james.kebinger@reforge.com>"]
Expand Down
2 changes: 1 addition & 1 deletion sdk_reforge/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.1
1.2.2
12 changes: 12 additions & 0 deletions sdk_reforge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions sdk_reforge/_sse_watchdog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
98 changes: 98 additions & 0 deletions tests/test_forking.py
Original file line number Diff line number Diff line change
@@ -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()
12 changes: 12 additions & 0 deletions tests/test_sse_watchdog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading