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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import jsocket
class Echo(jsocket.ThreadedServer):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.timeout = 2.0
self.timeout = 2.0 # sets both accept and recv timeouts

# Return a dict to send a response back to the client
def _process_message(self, obj):
Expand Down Expand Up @@ -70,7 +70,7 @@ import jsocket
class Worker(jsocket.ServerFactoryThread):
def __init__(self):
super().__init__()
self.timeout = 2.0
self.timeout = 2.0 # sets recv timeout for this worker

def _process_message(self, obj):
if isinstance(obj, dict) and 'message' in obj:
Expand All @@ -89,7 +89,9 @@ API Highlights
- `connect()` returns True on success
- `send_obj(dict)` sends a JSON object
- `read_obj()` blocks until a full message is received; raises `socket.timeout` or `RuntimeError("socket connection broken")`
- `timeout` property controls socket timeouts
- `timeout` sets both accept and recv timeouts
- `accept_timeout` controls the server's accept timeout
- `recv_timeout` controls the connection read timeout

- ThreadedServer:
- Subclass and implement `_process_message(self, obj) -> Optional[dict]`
Expand Down
36 changes: 35 additions & 1 deletion features/steps/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ class MyServer(jsocket.ThreadedServer):

def __init__(self, **kwargs):
super().__init__(**kwargs)
self.timeout = 2.0
if not any(key in kwargs for key in ("timeout", "accept_timeout", "recv_timeout")):
self.timeout = 2.0

def _process_message(self, obj):
# Echo payloads that include 'echo' for verification
Expand All @@ -41,6 +42,33 @@ def start_server(context):
_, context.server_port = context.jsonserver.socket.getsockname()


@given(r"I start the server with accept timeout (\d+(?:\.\d+)?) seconds")
def start_server_with_accept_timeout(context, seconds):
# Bind to an ephemeral port to avoid port conflicts
context.jsonserver = MyServer(
address='127.0.0.1',
port=0,
accept_timeout=float(seconds),
)
context.jsonserver.start()
# Discover the chosen port
_, context.server_port = context.jsonserver.socket.getsockname()


@given(r"I start the server with accept timeout (\d+(?:\.\d+)?) seconds and recv timeout (\d+(?:\.\d+)?) seconds")
def start_server_with_accept_and_recv_timeout(context, accept_seconds, recv_seconds):
# Bind to an ephemeral port to avoid port conflicts
context.jsonserver = MyServer(
address='127.0.0.1',
port=0,
accept_timeout=float(accept_seconds),
recv_timeout=float(recv_seconds),
)
context.jsonserver.start()
# Discover the chosen port
_, context.server_port = context.jsonserver.socket.getsockname()


@given(r"I connect the client")
def connect_client(context):
port = getattr(context, 'server_port', None)
Expand All @@ -56,6 +84,11 @@ def client_sends_object(context, obj):
context.jsonclient.send_obj(json.loads(obj))


@when(r"I wait (\d+(?:\.\d+)?) seconds")
def wait_seconds(context, seconds):
time.sleep(float(seconds))


@when(r"the server sends the object (\{.*\})")
def server_sends_object(context, obj):
# Wait briefly until the server has an accepted connection
Expand Down Expand Up @@ -83,6 +116,7 @@ def within_seconds_server_connected(context, seconds):


@given(r"I stop the server")
@when(r"I stop the server")
def stop_server(context):
server = getattr(context, 'jsonserver', None)
if server is not None:
Expand Down
14 changes: 14 additions & 0 deletions features/timeout_behavior.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#@PydevCodeAnalysisIgnore
Feature: Timeout Behavior

Scenario: Stop server despite long accept timeout
Given I start the server with accept timeout 10.0 seconds
When I stop the server
Then the server is stopped

Scenario: Separate accept and recv timeouts
Given I start the server with accept timeout 10.0 seconds and recv timeout 0.2 seconds
And I connect the client
When I wait 0.3 seconds
And the client sends the object {"echo": "after-idle"}
Then the client sees a message {"echo": "after-idle"}
2 changes: 1 addition & 1 deletion jsocket/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Package version."""

__version__ = "2.0.0"
__version__ = "2.0.1"
90 changes: 77 additions & 13 deletions jsocket/jsocket_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,32 @@ def _socket_fileno(sock):
class JsonSocket:
"""Lightweight JSON-over-TCP socket wrapper with length-prefixed framing."""

def __init__(self, address='127.0.0.1', port=5489, timeout=2.0, max_message_size=DEFAULT_MAX_MESSAGE_SIZE):
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.conn = self.socket
def __init__(
self,
address='127.0.0.1',
port=5489,
timeout=2.0,
max_message_size=DEFAULT_MAX_MESSAGE_SIZE,
accept_timeout=None,
recv_timeout=None,
create_socket=True,
):
self.socket = None
self.conn = None
self._timeout = timeout
self._accept_timeout = timeout if accept_timeout is None else accept_timeout
self._recv_timeout = timeout if recv_timeout is None else recv_timeout
self._address = address
self._port = port
self._max_message_size = None
self.max_message_size = max_message_size
self._last_client_addr = None
# Ensure the primary socket respects timeout for accept/connect operations
self.socket.settimeout(self._timeout)
self._is_server = False
if create_socket:
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.conn = self.socket
# Primary socket timeout (accept for servers; connect/read for clients).
self.socket.settimeout(self._accept_timeout)

def send_obj(self, obj):
"""Send a JSON-serializable object over the connection."""
Expand Down Expand Up @@ -172,12 +187,48 @@ def _close_connection(self):

def _get_timeout(self):
"""Get the current socket timeout in seconds."""
return self._timeout
return getattr(self, "_timeout", None)

def _set_timeout(self, timeout):
"""Set the socket timeout in seconds and apply to the main socket."""
self._timeout = timeout
self.socket.settimeout(timeout)
self._accept_timeout = timeout
self._recv_timeout = timeout
sock = getattr(self, "socket", None)
if sock is not None:
sock.settimeout(self._accept_timeout)
conn = getattr(self, "conn", None)
if conn is not None:
if getattr(self, "_is_server", False):
if conn is not sock:
conn.settimeout(self._recv_timeout)
else:
conn.settimeout(self._recv_timeout)

def _get_accept_timeout(self):
"""Get the listening/accept timeout in seconds."""
return getattr(self, "_accept_timeout", getattr(self, "_timeout", None))

def _set_accept_timeout(self, timeout):
"""Set the listening/accept timeout in seconds."""
self._accept_timeout = timeout
sock = getattr(self, "socket", None)
if sock is not None:
sock.settimeout(timeout)

def _get_recv_timeout(self):
"""Get the connection recv timeout in seconds."""
return getattr(self, "_recv_timeout", getattr(self, "_timeout", None))

def _set_recv_timeout(self, timeout):
"""Set the connection recv timeout in seconds."""
self._recv_timeout = timeout
conn = getattr(self, "conn", None)
sock = getattr(self, "socket", None)
if conn is not None:
if getattr(self, "_is_server", False) and conn is sock:
return
conn.settimeout(timeout)

def _get_address(self):
"""Return the configured bind address."""
Expand Down Expand Up @@ -209,7 +260,9 @@ def _set_max_message_size(self, size):
raise ValueError("max_message_size must be positive")
self._max_message_size = size

timeout = property(_get_timeout, _set_timeout, doc='Get/set the socket timeout')
timeout = property(_get_timeout, _set_timeout, doc='Get/set accept/recv timeout together')
accept_timeout = property(_get_accept_timeout, _set_accept_timeout, doc='Get/set accept timeout')
recv_timeout = property(_get_recv_timeout, _set_recv_timeout, doc='Get/set recv timeout')
address = property(_get_address, _set_address, doc='read only property socket address')
port = property(_get_port, _set_port, doc='read only property socket port')
max_message_size = property(_get_max_message_size, _set_max_message_size, doc='Get/set max message size in bytes')
Expand All @@ -218,8 +271,15 @@ def _set_max_message_size(self, size):
class JsonServer(JsonSocket):
"""Server socket that accepts one connection at a time."""

def __init__(self, address='127.0.0.1', port=5489):
super().__init__(address, port)
def __init__(self, address='127.0.0.1', port=5489, timeout=2.0, accept_timeout=None, recv_timeout=None):
super().__init__(
address,
port,
timeout=timeout,
accept_timeout=accept_timeout,
recv_timeout=recv_timeout,
)
self._is_server = True
self._bind()

def _close_connection(self):
Expand Down Expand Up @@ -253,7 +313,7 @@ def accept_connection(self):
self._listen()
self.conn, addr = self._accept()
self._last_client_addr = addr
self.conn.settimeout(self.timeout)
self.conn.settimeout(self.recv_timeout)
logger.debug(
"connection accepted, conn socket (%s,%d,%s)", addr[0], addr[1], str(self.conn.gettimeout())
)
Expand All @@ -274,8 +334,10 @@ def _is_connected(self):
class JsonClient(JsonSocket):
"""Client socket for connecting to a JsonServer and exchanging JSON messages."""

def __init__(self, address='127.0.0.1', port=5489):
super().__init__(address, port)
def __init__(self, address='127.0.0.1', port=5489, timeout=2.0, recv_timeout=None):
super().__init__(address, port, timeout=timeout, recv_timeout=recv_timeout)
if self.socket is not None:
self.socket.settimeout(self._timeout)

def connect(self):
"""Attempt to connect to the server up to 10 times with backoff."""
Expand All @@ -294,5 +356,7 @@ def connect(self):
time.sleep(3)
continue
logger.info("...Socket Connected")
# Switch to recv_timeout after successful connection
self.socket.settimeout(self._recv_timeout)
return True
return False
Loading