|
1 | 1 | import pytest |
2 | 2 | import os |
3 | 3 | import numpy as np |
| 4 | +from unittest.mock import patch |
4 | 5 |
|
5 | 6 |
|
6 | 7 | class TestSafeLiteralEval: |
@@ -450,3 +451,131 @@ def test_write_timestamp_matches_cpp_semantics(self, temp_dir): |
450 | 451 | "After 3 writes with delta=1 simtime must remain 0 " |
451 | 452 | "(matching C++/MATLAB/Verilog); got %s" % concore.simtime |
452 | 453 | ) |
| 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 | + # Save original global state to avoid leaking into other tests. |
| 503 | + had_inpath = hasattr(concore, "inpath") |
| 504 | + orig_inpath = concore.inpath if had_inpath else None |
| 505 | + orig_zmq_ports = dict(concore.zmq_ports) |
| 506 | + had_simtime = hasattr(concore, "simtime") |
| 507 | + orig_simtime = concore.simtime if had_simtime else None |
| 508 | + |
| 509 | + try: |
| 510 | + concore.inpath = os.path.join(temp_dir, "in") |
| 511 | + |
| 512 | + class TimeoutPort: |
| 513 | + address = "tcp://127.0.0.1:0" |
| 514 | + socket = None |
| 515 | + |
| 516 | + def recv_json_with_retry(self): |
| 517 | + raise TimeoutError("ZMQ recv failed after 5 retries") |
| 518 | + |
| 519 | + concore.zmq_ports["t_in"] = TimeoutPort() |
| 520 | + concore.simtime = 0 |
| 521 | + |
| 522 | + result, ok = concore.read("t_in", "x", "[0.0]") |
| 523 | + |
| 524 | + assert result == [0.0] |
| 525 | + assert ok is False |
| 526 | + assert concore_base.last_read_status == "TIMEOUT" |
| 527 | + finally: |
| 528 | + # Restore zmq_ports and other globals to their original state. |
| 529 | + concore.zmq_ports.clear() |
| 530 | + concore.zmq_ports.update(orig_zmq_ports) |
| 531 | + |
| 532 | + if had_inpath: |
| 533 | + concore.inpath = orig_inpath |
| 534 | + elif hasattr(concore, "inpath"): |
| 535 | + delattr(concore, "inpath") |
| 536 | + |
| 537 | + if had_simtime: |
| 538 | + concore.simtime = orig_simtime |
| 539 | + elif hasattr(concore, "simtime"): |
| 540 | + delattr(concore, "simtime") |
| 541 | + |
| 542 | + def test_write_does_not_crash_on_timeout(self, temp_dir): |
| 543 | + """write() must not propagate TimeoutError to the caller.""" |
| 544 | + import concore |
| 545 | + |
| 546 | + # Save original global state to avoid leaking into other tests. |
| 547 | + had_outpath = hasattr(concore, "outpath") |
| 548 | + orig_outpath = concore.outpath if had_outpath else None |
| 549 | + orig_zmq_ports = dict(concore.zmq_ports) |
| 550 | + had_simtime = hasattr(concore, "simtime") |
| 551 | + orig_simtime = concore.simtime if had_simtime else None |
| 552 | + |
| 553 | + try: |
| 554 | + concore.outpath = os.path.join(temp_dir, "out") |
| 555 | + os.makedirs(os.path.join(temp_dir, "out_t_out"), exist_ok=True) |
| 556 | + |
| 557 | + class TimeoutPort: |
| 558 | + address = "tcp://127.0.0.1:0" |
| 559 | + socket = None |
| 560 | + |
| 561 | + def send_json_with_retry(self, message): |
| 562 | + raise TimeoutError("ZMQ send failed after 5 retries") |
| 563 | + |
| 564 | + concore.zmq_ports["t_out"] = TimeoutPort() |
| 565 | + concore.simtime = 0 |
| 566 | + |
| 567 | + concore.write("t_out", "y", [1.0], delta=1) |
| 568 | + finally: |
| 569 | + # Restore zmq_ports and other globals to their original state. |
| 570 | + concore.zmq_ports.clear() |
| 571 | + concore.zmq_ports.update(orig_zmq_ports) |
| 572 | + |
| 573 | + if had_outpath: |
| 574 | + concore.outpath = orig_outpath |
| 575 | + elif hasattr(concore, "outpath"): |
| 576 | + delattr(concore, "outpath") |
| 577 | + |
| 578 | + if had_simtime: |
| 579 | + concore.simtime = orig_simtime |
| 580 | + elif hasattr(concore, "simtime"): |
| 581 | + delattr(concore, "simtime") |
0 commit comments