|
9 | 9 | - Sender (Worker): Creates shared memory, sends reference via ZMQ, closes handle (does NOT unlink) |
10 | 10 | - Receiver (Napari Server): Attaches to shared memory, copies data, closes handle, unlinks |
11 | 11 | - Only receiver calls unlink() to prevent FileNotFoundError |
12 | | -- PUB/SUB socket pattern is non-blocking; receiver must copy data before sender closes handle |
| 12 | +- REQ/REP socket pattern is blocking; worker waits for acknowledgment before closing shared memory |
13 | 13 | """ |
14 | 14 |
|
15 | 15 | import logging |
| 16 | +import time |
16 | 17 | from pathlib import Path |
17 | 18 | from typing import Any, List, Union |
18 | 19 |
|
|
22 | 23 | from .streaming_constants import StreamingDataType |
23 | 24 | from .streaming import StreamingBackend |
24 | 25 | from .roi_converters import NapariROIConverter |
| 26 | +from zmqruntime.transport import get_zmq_transport_url, coerce_transport_mode |
25 | 27 |
|
26 | 28 | logger = logging.getLogger(__name__) |
27 | 29 |
|
@@ -82,7 +84,6 @@ def save_batch(self, data_list: List[Any], file_paths: List[Union[str, Path]], * |
82 | 84 | port = kwargs['port'] |
83 | 85 | transport_mode = kwargs['transport_mode'] |
84 | 86 | transport_config = kwargs.get('transport_config') |
85 | | - publisher = self._get_publisher(host, port, transport_mode, transport_config=transport_config) |
86 | 87 | display_config = kwargs['display_config'] |
87 | 88 | microscope_handler = kwargs['microscope_handler'] |
88 | 89 | source = kwargs.get('source', 'unknown_source') # Pre-built source value |
@@ -114,22 +115,43 @@ def save_batch(self, data_list: List[Any], file_paths: List[Union[str, Path]], * |
114 | 115 | transport_config=transport_config, |
115 | 116 | ) |
116 | 117 |
|
117 | | - # Send non-blocking to prevent hanging if Napari is slow to process (matches Fiji pattern) |
118 | | - send_succeeded = False |
119 | | - try: |
120 | | - publisher.send_json(message, flags=zmq.NOBLOCK) |
121 | | - send_succeeded = True |
| 118 | + # Create FRESH REQ socket for each send - REQ sockets cannot be reused |
| 119 | + # This prevents the "Operation cannot be accomplished in current state" error |
| 120 | + # when multiple streams happen concurrently |
| 121 | + transport_config = transport_config or self._transport_config |
| 122 | + url = get_zmq_transport_url( |
| 123 | + port, |
| 124 | + host=host, |
| 125 | + mode=coerce_transport_mode(transport_mode), |
| 126 | + config=transport_config, |
| 127 | + ) |
122 | 128 |
|
123 | | - except zmq.Again: |
124 | | - logger.warning(f"Napari viewer busy, dropped batch of {len(batch_images)} images (port {port})") |
| 129 | + if self._context is None: |
| 130 | + self._context = zmq.Context() |
125 | 131 |
|
126 | | - except Exception as e: |
127 | | - logger.error(f"Failed to send batch to Napari on port {port}: {e}", exc_info=True) |
128 | | - raise # Re-raise the exception so the pipeline knows it failed |
| 132 | + socket = self._context.socket(zmq.REQ) |
| 133 | + socket.connect(url) |
| 134 | + time.sleep(0.1) # Brief delay for connection to establish |
| 135 | + |
| 136 | + try: |
| 137 | + # Send with REQ socket (BLOCKING - worker waits for Napari to acknowledge) |
| 138 | + # Worker blocks until Napari receives, copies data from shared memory, and sends ack |
| 139 | + # This guarantees no messages are lost and shared memory is only closed after Napari is done |
| 140 | + logger.info(f"📤 NAPARI BACKEND: Sending batch of {len(batch_images)} images to Napari on port {port} (REQ/REP - blocking until ack)") |
| 141 | + socket.send_json(message) # Blocking send |
| 142 | + |
| 143 | + # Wait for acknowledgment from Napari (REP socket) |
| 144 | + # Napari will only reply after it has copied all data from shared memory |
| 145 | + ack_response = socket.recv_json() |
| 146 | + logger.info(f"✅ NAPARI BACKEND: Received ack from Napari: {ack_response.get('status', 'unknown')}") |
129 | 147 |
|
130 | 148 | finally: |
131 | | - # Unified cleanup: close our handle after successful send, close+unlink after failure |
132 | | - self._cleanup_shared_memory_blocks(batch_images, unlink=not send_succeeded) |
| 149 | + # Always close the socket - never reuse REQ sockets |
| 150 | + socket.close() |
| 151 | + |
| 152 | + # Clean up publisher's handles after successful send |
| 153 | + # Receiver will unlink the shared memory after copying the data |
| 154 | + self._cleanup_shared_memory_blocks(batch_images, unlink=False) |
133 | 155 |
|
134 | 156 | # cleanup() now inherited from ABC |
135 | 157 |
|
|
0 commit comments