Skip to content

Commit 6615699

Browse files
fix: raise TimeoutError on ZMQ retry exhaustion (#393)
send_json_with_retry() and recv_json_with_retry() now raise TimeoutError instead of silently returning None when all 5 retries are exhausted. read() and write() catch the new exception so callers never see an unhandled crash. - send_json_with_retry: raise TimeoutError (was: return) - recv_json_with_retry: raise TimeoutError (was: return None) - read(): except TimeoutError -> (default, False), status TIMEOUT - write(): except TimeoutError -> log and continue Tests added in test_concore.py and test_concoredocker.py. Closes #393
1 parent 01e0ec0 commit 6615699

3 files changed

Lines changed: 140 additions & 4 deletions

File tree

concore_base.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,7 @@ def send_json_with_retry(self, message):
5959
except zmq.Again:
6060
logger.warning(f"Send timeout (attempt {attempt + 1}/5)")
6161
time.sleep(0.5)
62-
logger.error("Failed to send after retries.")
63-
return
62+
raise TimeoutError(f"ZMQ send failed after 5 retries on {self.address}")
6463

6564
def recv_json_with_retry(self):
6665
"""Receive JSON message with retries if timeout occurs."""
@@ -70,8 +69,7 @@ def recv_json_with_retry(self):
7069
except zmq.Again:
7170
logger.warning(f"Receive timeout (attempt {attempt + 1}/5)")
7271
time.sleep(0.5)
73-
logger.error("Failed to receive after retries.")
74-
return None
72+
raise TimeoutError(f"ZMQ recv failed after 5 retries on {self.address}")
7573

7674

7775
def init_zmq_port(mod, port_name, port_type, address, socket_type_str):
@@ -282,6 +280,10 @@ def read(mod, port_identifier, name, initstr_val):
282280
return message[1:], True
283281
last_read_status = "SUCCESS"
284282
return message, True
283+
except TimeoutError as e:
284+
logger.error(f"ZMQ read timeout on port {port_identifier} (name: {name}): {e}. Returning default.")
285+
last_read_status = "TIMEOUT"
286+
return default_return_val, False
285287
except zmq.error.ZMQError as e:
286288
logger.error(f"ZMQ read error on port {port_identifier} (name: {name}): {e}. Returning default.")
287289
last_read_status = "TIMEOUT"
@@ -384,6 +386,8 @@ def write(mod, port_identifier, name, val, delta=0):
384386
# Mutation breaks cross-language determinism (see issue #385).
385387
else:
386388
zmq_p.send_json_with_retry(zmq_val)
389+
except TimeoutError as e:
390+
logger.error(f"ZMQ write timeout on port {port_identifier} (name: {name}): {e}")
387391
except zmq.error.ZMQError as e:
388392
logger.error(f"ZMQ write error on port {port_identifier} (name: {name}): {e}")
389393
except Exception as e:

tests/test_concore.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
import os
33
import numpy as np
4+
from unittest.mock import patch
45

56

67
class TestSafeLiteralEval:
@@ -450,3 +451,87 @@ def test_write_timestamp_matches_cpp_semantics(self, temp_dir):
450451
"After 3 writes with delta=1 simtime must remain 0 "
451452
"(matching C++/MATLAB/Verilog); got %s" % concore.simtime
452453
)
454+
455+
456+
class TestZMQRetryExhaustion:
457+
"""Issue #393 – recv_json_with_retry / send_json_with_retry must raise
458+
TimeoutError instead of silently returning None when retries are
459+
exhausted, and callers (read / write) must handle it gracefully."""
460+
461+
@patch("concore_base.time.sleep")
462+
def test_recv_raises_timeout_error(self, _mock_sleep):
463+
"""recv_json_with_retry must raise TimeoutError after 5 failed attempts."""
464+
import concore_base
465+
466+
port = concore_base.ZeroMQPort.__new__(concore_base.ZeroMQPort)
467+
port.address = "tcp://127.0.0.1:9999"
468+
469+
class FakeSocket:
470+
def recv_json(self, flags=0):
471+
import zmq
472+
473+
raise zmq.Again("Resource temporarily unavailable")
474+
475+
port.socket = FakeSocket()
476+
with pytest.raises(TimeoutError):
477+
port.recv_json_with_retry()
478+
479+
@patch("concore_base.time.sleep")
480+
def test_send_raises_timeout_error(self, _mock_sleep):
481+
"""send_json_with_retry must raise TimeoutError after 5 failed attempts."""
482+
import concore_base
483+
484+
port = concore_base.ZeroMQPort.__new__(concore_base.ZeroMQPort)
485+
port.address = "tcp://127.0.0.1:9999"
486+
487+
class FakeSocket:
488+
def send_json(self, data, flags=0):
489+
import zmq
490+
491+
raise zmq.Again("Resource temporarily unavailable")
492+
493+
port.socket = FakeSocket()
494+
with pytest.raises(TimeoutError):
495+
port.send_json_with_retry([42])
496+
497+
def test_read_returns_default_on_timeout(self, temp_dir):
498+
"""read() must return (default, False) when ZMQ recv times out."""
499+
import concore
500+
import concore_base
501+
502+
concore.inpath = os.path.join(temp_dir, "in")
503+
504+
class TimeoutPort:
505+
address = "tcp://127.0.0.1:0"
506+
socket = None
507+
508+
def recv_json_with_retry(self):
509+
raise TimeoutError("ZMQ recv failed after 5 retries")
510+
511+
concore.zmq_ports["t_in"] = TimeoutPort()
512+
concore.simtime = 0
513+
514+
result, ok = concore.read("t_in", "x", "[0.0]")
515+
516+
assert result == [0.0]
517+
assert ok is False
518+
assert concore_base.last_read_status == "TIMEOUT"
519+
520+
def test_write_does_not_crash_on_timeout(self, temp_dir):
521+
"""write() must not propagate TimeoutError to the caller."""
522+
import concore
523+
524+
concore.outpath = os.path.join(temp_dir, "out")
525+
os.makedirs(os.path.join(temp_dir, "out_t_out"), exist_ok=True)
526+
527+
class TimeoutPort:
528+
address = "tcp://127.0.0.1:0"
529+
socket = None
530+
531+
def send_json_with_retry(self, message):
532+
raise TimeoutError("ZMQ send failed after 5 retries")
533+
534+
concore.zmq_ports["t_out"] = TimeoutPort()
535+
concore.simtime = 0
536+
537+
concore.write("t_out", "y", [1.0], delta=1)

tests/test_concoredocker.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,50 @@ def recv_json_with_retry(self):
247247

248248
assert result == original
249249
assert ok is True
250+
251+
252+
class TestZMQRetryExhaustion:
253+
"""Issue #393 – concoredocker read/write must handle TimeoutError
254+
raised by ZMQ retry exhaustion without crashing."""
255+
256+
def test_read_returns_default_on_timeout(self, temp_dir):
257+
"""read() must return (default, False) when ZMQ recv times out."""
258+
import concoredocker
259+
import concore_base
260+
261+
concoredocker.inpath = os.path.join(temp_dir, "in")
262+
263+
class TimeoutPort:
264+
address = "tcp://127.0.0.1:0"
265+
socket = None
266+
267+
def recv_json_with_retry(self):
268+
raise TimeoutError("ZMQ recv failed after 5 retries")
269+
270+
concoredocker.zmq_ports["t_in"] = TimeoutPort()
271+
concoredocker.simtime = 0
272+
273+
result, ok = concoredocker.read("t_in", "x", "[0.0]")
274+
275+
assert result == [0.0]
276+
assert ok is False
277+
assert concore_base.last_read_status == "TIMEOUT"
278+
279+
def test_write_does_not_crash_on_timeout(self, temp_dir):
280+
"""write() must not propagate TimeoutError to the caller."""
281+
import concoredocker
282+
283+
concoredocker.outpath = os.path.join(temp_dir, "out")
284+
os.makedirs(os.path.join(temp_dir, "out_t_out"), exist_ok=True)
285+
286+
class TimeoutPort:
287+
address = "tcp://127.0.0.1:0"
288+
socket = None
289+
290+
def send_json_with_retry(self, message):
291+
raise TimeoutError("ZMQ send failed after 5 retries")
292+
293+
concoredocker.zmq_ports["t_out"] = TimeoutPort()
294+
concoredocker.simtime = 0
295+
296+
concoredocker.write("t_out", "y", [1.0], delta=1)

0 commit comments

Comments
 (0)