A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 2, 19:30 UTC
[Bugfix][V1] Fix TOCTOU race causing intermittent `EADDRINUSE` on multi-API-server DP startup (#42585)
Signed-off-by: Vadim Gimpelson <vadim.gimpelson@gmail.com> Signed-off-by: Vadim Gimpelson <156319763+vadiklyutiy@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Vadim Gimpelson committed
May 27, 2026 at 01:06 UTC
812e7e73648573142808fdeea0ffc30adb247ba3
7 files changed
+316
-50
tests/entrypoints/test_api_server_process_manager.py
+129
-1
@@ -8,8 +8,14 @@ import time
8
from unittest.mock import patch
9
10
import pytest
11
+import zmq
12
12
-from vllm.v1.utils import APIServerProcessManager, wait_for_completion_or_failure
13
+from vllm.utils.network_utils import make_zmq_socket, split_zmq_path
14
+from vllm.v1.utils import (
15
+ APIServerProcessManager,
16
+ get_engine_client_zmq_addr,
17
+ wait_for_completion_or_failure,
18
+)
19
20
# Global variables to control worker behavior
21
WORKER_RUNTIME_SECONDS = 0.5
@@ -23,6 +29,39 @@ def mock_run_api_server_worker(listen_address, sock, args, client_config=None):
29
print("Mock worker completed successfully")
30
31
32
+# Module-level stub for the gather_actual_addresses test. Must be
33
+# importable by `multiprocessing.spawn` (no closures, no nesting).
34
+def defer_addresses_stub_worker(listen_address, sock, args, client_config):
35
+ """Bind ROUTER/PULL with a kernel-assigned port, report the actual
36
+ endpoints back via the pipe, then exit."""
37
+ ctx = zmq.Context()
38
+ try:
39
+ in_sock = make_zmq_socket(
40
+ ctx, client_config["input_address"], zmq.ROUTER, bind=True
41
+ )
42
+ out_sock = make_zmq_socket(
43
+ ctx, client_config["output_address"], zmq.PULL, bind=True
44
+ )
45
+ try:
46
+ pipe = client_config["actual_address_pipe"]
47
+ try:
48
+ pipe.send(
49
+ {
50
+ "input_address": in_sock.getsockopt(zmq.LAST_ENDPOINT).decode(),
51
+ "output_address": out_sock.getsockopt(
52
+ zmq.LAST_ENDPOINT
53
+ ).decode(),
54
+ }
55
+ )
56
+ finally:
57
+ pipe.close()
58
+ finally:
59
+ in_sock.close(linger=0)
60
+ out_sock.close(linger=0)
61
+ finally:
62
+ ctx.term()
63
+
64
+
65
@pytest.fixture
66
def api_server_args():
67
"""Fixture to provide arguments for APIServerProcessManager."""
@@ -268,3 +307,92 @@ def test_external_process_monitoring(api_server_args):
307
manager.shutdown()
308
mock_coordinator.shutdown()
309
time.sleep(0.2)
310
+
311
+
312
+@pytest.mark.timeout(60)
313
+def test_gather_actual_addresses_end_to_end():
314
+ """Each child binds ROUTER/PULL with a kernel-picked port and reports
315
+ the bound endpoints back via its per-child pipe; the manager surfaces
316
+ them via :py:meth:`gather_actual_addresses`."""
317
+ host = "127.0.0.1"
318
+ num_servers = 4
319
+
320
+ placeholder_inputs = [
321
+ get_engine_client_zmq_addr(local_only=False, host=host)
322
+ for _ in range(num_servers)
323
+ ]
324
+ placeholder_outputs = [
325
+ get_engine_client_zmq_addr(local_only=False, host=host)
326
+ for _ in range(num_servers)
327
+ ]
328
+ for addr in placeholder_inputs + placeholder_outputs:
329
+ assert addr == f"tcp://{host}:0", addr
330
+
331
+ sock = socket.socket()
332
+ manager = APIServerProcessManager(
333
+ listen_address=f"tcp://{host}:0",
334
+ sock=sock,
335
+ args="test_args",
336
+ num_servers=num_servers,
337
+ input_addresses=placeholder_inputs,
338
+ output_addresses=placeholder_outputs,
339
+ target_server_fn=defer_addresses_stub_worker,
340
+ )
341
+
342
+ try:
343
+ assert len(manager.processes) == num_servers
344
+ actual_inputs, actual_outputs = manager.gather_actual_addresses(timeout=15.0)
345
+ finally:
346
+ manager.shutdown()
347
+ time.sleep(0.2)
348
+ sock.close()
349
+
350
+ assert len(actual_inputs) == num_servers
351
+ assert len(actual_outputs) == num_servers
352
+
353
+ for addr in actual_inputs + actual_outputs:
354
+ scheme, parsed_host, port = split_zmq_path(addr)
355
+ assert scheme == "tcp", addr
356
+ assert parsed_host == host, addr
357
+ assert port and int(port) > 0, addr
358
+
359
+ all_addrs = actual_inputs + actual_outputs
360
+ assert len(set(all_addrs)) == len(all_addrs), all_addrs
361
+
362
+
363
+@pytest.mark.timeout(30)
364
+def test_gather_actual_addresses_child_crash_before_report():
365
+ """A child that exits before sending its endpoints must surface a
366
+ clear ``RuntimeError`` rather than hang or return ``None`` slots."""
367
+ host = "127.0.0.1"
368
+ num_servers = 2
369
+ placeholder_inputs = [
370
+ get_engine_client_zmq_addr(local_only=False, host=host)
371
+ for _ in range(num_servers)
372
+ ]
373
+ placeholder_outputs = [
374
+ get_engine_client_zmq_addr(local_only=False, host=host)
375
+ for _ in range(num_servers)
376
+ ]
377
+
378
+ sock = socket.socket()
379
+ manager = APIServerProcessManager(
380
+ listen_address=f"tcp://{host}:0",
381
+ sock=sock,
382
+ args="test_args",
383
+ num_servers=num_servers,
384
+ input_addresses=placeholder_inputs,
385
+ output_addresses=placeholder_outputs,
386
+ # mock_run_api_server_worker exits without touching
387
+ # ``actual_address_pipe`` — simulates a child that dies before
388
+ # reporting its bound addresses.
389
+ target_server_fn=mock_run_api_server_worker,
390
+ )
391
+ try:
392
+ # Sentinel-first vs pipe-EOF-first both produce "reporting".
393
+ with pytest.raises(RuntimeError, match="reporting"):
394
+ manager.gather_actual_addresses(timeout=10.0)
395
+ finally:
396
+ manager.shutdown()
397
+ time.sleep(0.2)
398
+ sock.close()
vllm/entrypoints/cli/serve.py
+14
-1
@@ -308,7 +308,14 @@ def run_multi_api_server(args: argparse.Namespace):
308
309
from vllm.v1.engine.utils import get_engine_zmq_addresses
310
311
- addresses = get_engine_zmq_addresses(vllm_config, num_api_servers)
311
+ # Per-API-server ports are picked by the kernel at each child's bind()
312
+ # to avoid parent-probe vs child-bind TOCTOU; Rust front-end opts out
313
+ # because it has no port-report-back channel.
314
+ addresses = get_engine_zmq_addresses(
315
+ vllm_config,
316
+ num_api_servers,
317
+ defer_api_server_ports=not rust_frontend_path,
318
+ )
319
320
with launch_core_engines(
321
vllm_config, executor_class, log_stats, addresses, num_api_servers
@@ -341,6 +348,12 @@ def run_multi_api_server(args: argparse.Namespace):
348
tensor_queue=tensor_queue,
349
)
350
351
+ # Forward each child's bound endpoints to the engine handshake
352
+ # (runs on ``with`` exit).
353
+ actual_inputs, actual_outputs = api_server_manager.gather_actual_addresses()
354
+ addresses.inputs = actual_inputs
355
+ addresses.outputs = actual_outputs
356
+
357
# Wait for API servers.
358
try:
359
wait_for_completion_or_failure(
vllm/v1/engine/async_llm.py
+2
-2
@@ -81,7 +81,7 @@ class AsyncLLM(EngineClient):
81
start_engine_loop: bool = True,
82
stat_loggers: list[StatLoggerFactory] | None = None,
83
aggregate_engine_logging: bool = False,
84
- client_addresses: dict[str, str] | None = None,
84
+ client_addresses: dict[str, Any] | None = None,
85
client_count: int = 1,
86
client_index: int = 0,
87
) -> None:
@@ -209,7 +209,7 @@ class AsyncLLM(EngineClient):
209
enable_log_requests: bool = False,
210
aggregate_engine_logging: bool = False,
211
disable_log_stats: bool = False,
212
- client_addresses: dict[str, str] | None = None,
212
+ client_addresses: dict[str, Any] | None = None,
213
client_count: int = 1,
214
client_index: int = 0,
215
) -> "AsyncLLM":
vllm/v1/engine/coordinator.py
+4
-11
@@ -11,7 +11,7 @@ import zmq
11
12
from vllm.config import ParallelConfig
13
from vllm.logger import init_logger
14
-from vllm.utils.network_utils import get_tcp_uri, make_zmq_socket
14
+from vllm.utils.network_utils import make_zmq_socket
15
from vllm.utils.system_utils import get_mp_context, set_process_title
16
from vllm.v1.engine import EngineCoreOutputs, EngineCoreRequestType
17
from vllm.v1.serial_utils import MsgpackDecoder
@@ -91,16 +91,9 @@ class DPCoordinator:
91
if parallel_config.enable_elastic_ep:
92
local_only_eng = False
93
94
- def bind_address(local_only: bool) -> str:
95
- return (
96
- get_engine_client_zmq_addr(local_only=True, host=host)
97
- if local_only
98
- else get_tcp_uri(host, 0)
99
- )
100
-
101
- front_publish_address = bind_address(local_only)
102
- back_publish_address = bind_address(local_only_eng)
103
- back_output_address = bind_address(local_only_eng)
94
+ front_publish_address = get_engine_client_zmq_addr(local_only, host=host)
95
+ back_publish_address = get_engine_client_zmq_addr(local_only_eng, host=host)
96
+ back_output_address = get_engine_client_zmq_addr(local_only_eng, host=host)
97
98
context = get_mp_context()
99
parent_zmq_addr_pipe, child_zmq_addr_pipe = context.Pipe(duplex=False)
vllm/v1/engine/core_client.py
+38
-6
@@ -11,6 +11,7 @@ from collections import defaultdict, deque
11
from collections.abc import Awaitable, Callable, Sequence
12
from concurrent.futures import Future
13
from dataclasses import dataclass
14
+from multiprocessing.connection import Connection
15
from multiprocessing.queues import Queue
16
from threading import Thread
17
from typing import Any, TypeAlias, TypeVar
@@ -108,7 +109,7 @@ class EngineCoreClient(ABC):
109
vllm_config: VllmConfig,
110
executor_class: type[Executor],
111
log_stats: bool,
111
- client_addresses: dict[str, str] | None = None,
112
+ client_addresses: dict[str, Any] | None = None,
113
client_count: int = 1,
114
client_index: int = 0,
115
) -> "AsyncMPClient":
@@ -476,7 +477,7 @@ class MPClient(EngineCoreClient):
477
vllm_config: VllmConfig,
478
executor_class: type[Executor],
479
log_stats: bool,
479
- client_addresses: dict[str, str] | None = None,
480
+ client_addresses: dict[str, Any] | None = None,
481
):
482
self.vllm_config = vllm_config
483
@@ -507,7 +508,7 @@ class MPClient(EngineCoreClient):
508
output_address = client_addresses["output_address"]
509
self.stats_update_address = client_addresses.get("stats_update_address")
510
# Tensor queues passed via client_addresses for multi-API-server case
510
- tensor_queue = client_addresses.get("tensor_queue") # type: ignore[assignment]
511
+ tensor_queue = client_addresses.get("tensor_queue")
512
self.input_socket = self.resources.input_socket = make_zmq_socket(
513
self.ctx,
514
input_address,
@@ -518,6 +519,28 @@ class MPClient(EngineCoreClient):
519
self.resources.output_socket = make_zmq_socket(
520
self.ctx, output_address, zmq.PULL
521
)
522
+
523
+ # Report bound endpoints back so the parent can forward
524
+ # them to engines (mirrors the DPCoordinator pattern).
525
+ actual_address_pipe: Connection | None = client_addresses.get(
526
+ "actual_address_pipe"
527
+ )
528
+ if actual_address_pipe is not None:
529
+ try:
530
+ actual_input = self.input_socket.getsockopt(
531
+ zmq.LAST_ENDPOINT
532
+ ).decode()
533
+ actual_output = self.resources.output_socket.getsockopt(
534
+ zmq.LAST_ENDPOINT
535
+ ).decode()
536
+ actual_address_pipe.send(
537
+ {
538
+ "input_address": actual_input,
539
+ "output_address": actual_output,
540
+ }
541
+ )
542
+ finally:
543
+ actual_address_pipe.close()
544
else:
545
# Engines are managed by this client.
546
addresses = get_engine_zmq_addresses(vllm_config)
@@ -532,6 +555,15 @@ class MPClient(EngineCoreClient):
555
self.ctx, addresses.outputs[0], zmq.PULL
556
)
557
558
+ # Resolve ``tcp://host:0`` placeholders to bound endpoints
559
+ # before engines DEALER-connect. No-op for IPC.
560
+ addresses.inputs[0] = self.input_socket.getsockopt(
561
+ zmq.LAST_ENDPOINT
562
+ ).decode()
563
+ addresses.outputs[0] = self.resources.output_socket.getsockopt(
564
+ zmq.LAST_ENDPOINT
565
+ ).decode()
566
+
567
with launch_core_engines(
568
vllm_config, executor_class, log_stats, addresses
569
) as (engine_manager, coordinator, addresses, tensor_queue):
@@ -893,7 +925,7 @@ class AsyncMPClient(MPClient):
925
vllm_config: VllmConfig,
926
executor_class: type[Executor],
927
log_stats: bool,
896
- client_addresses: dict[str, str] | None = None,
928
+ client_addresses: dict[str, Any] | None = None,
929
client_count: int = 1,
930
client_index: int = 0,
931
):
@@ -1143,7 +1175,7 @@ class DPAsyncMPClient(AsyncMPClient):
1175
vllm_config: VllmConfig,
1176
executor_class: type[Executor],
1177
log_stats: bool,
1146
- client_addresses: dict[str, str] | None = None,
1178
+ client_addresses: dict[str, Any] | None = None,
1179
client_count: int = 1,
1180
client_index: int = 0,
1181
):
@@ -1323,7 +1355,7 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1355
vllm_config: VllmConfig,
1356
executor_class: type[Executor],
1357
log_stats: bool,
1326
- client_addresses: dict[str, str] | None = None,
1358
+ client_addresses: dict[str, Any] | None = None,
1359
client_count: int = 1,
1360
client_index: int = 0,
1361
):
vllm/v1/engine/utils.py
+30
-13
@@ -23,7 +23,12 @@ from vllm.logger import init_logger
23
from vllm.platforms import current_platform
24
from vllm.ray.ray_env import get_env_vars_to_copy
25
from vllm.utils import numa_utils
26
-from vllm.utils.network_utils import get_open_zmq_ipc_path, zmq_socket_ctx
26
+from vllm.utils.network_utils import (
27
+ get_open_port,
28
+ get_open_zmq_ipc_path,
29
+ get_tcp_uri,
30
+ zmq_socket_ctx,
31
+)
32
from vllm.utils.system_utils import get_mp_context
33
from vllm.v1.engine.coordinator import DPCoordinator
34
from vllm.v1.executor import Executor
@@ -955,8 +960,19 @@ class CoreEngineActorManager:
960
def get_engine_zmq_addresses(
961
vllm_config: VllmConfig,
962
num_api_servers: int = 1,
963
+ *,
964
+ defer_api_server_ports: bool = True,
965
) -> EngineZmqAddresses:
959
- """Allocate ZMQ addresses for engine-client communication."""
966
+ """Allocate ZMQ addresses for engine-client communication.
967
+
968
+ By default each TCP address is a ``tcp://host:0`` placeholder; the
969
+ consumer (API-server child or single-process ``MPClient``) binds, then
970
+ recovers the kernel-assigned port via ``getsockopt(zmq.LAST_ENDPOINT)``
971
+ and writes it back into ``addresses`` before the engine handshake.
972
+
973
+ Set ``defer_api_server_ports=False`` only when the consumer cannot
974
+ report a bound port back (e.g. the Rust front-end). IPC paths are
975
+ unaffected."""
976
parallel_config = vllm_config.parallel_config
977
local_engine_count = parallel_config.data_parallel_size_local
978
local_start_index = parallel_config.data_parallel_rank_local
@@ -978,15 +994,14 @@ def get_engine_zmq_addresses(
994
if parallel_config.enable_elastic_ep:
995
client_local_only = False
996
997
+ def _addr() -> str:
998
+ if client_local_only:
999
+ return get_open_zmq_ipc_path()
1000
+ return get_tcp_uri(host, 0 if defer_api_server_ports else get_open_port())
1001
+
1002
return EngineZmqAddresses(
982
- inputs=[
983
- get_engine_client_zmq_addr(client_local_only, host)
984
- for _ in range(num_api_servers)
985
- ],
986
- outputs=[
987
- get_engine_client_zmq_addr(client_local_only, host)
988
- for _ in range(num_api_servers)
989
- ],
1003
+ inputs=[_addr() for _ in range(num_api_servers)],
1004
+ outputs=[_addr() for _ in range(num_api_servers)],
1005
)
1006
1007
@@ -1095,9 +1110,11 @@ def launch_core_engines(
1110
if parallel_config.enable_elastic_ep:
1111
handshake_local_only = False
1112
1098
- handshake_address = get_engine_client_zmq_addr(
1099
- handshake_local_only, host, parallel_config.data_parallel_rpc_port
1100
- )
1113
+ # Preserve "port=0 means auto-pick" for the handshake address, which
1114
+ # is consumed by engines spawned in this process and so cannot defer
1115
+ # port resolution to bind time.
1116
+ rpc_port = parallel_config.data_parallel_rpc_port or get_open_port()
1117
+ handshake_address = get_engine_client_zmq_addr(handshake_local_only, host, rpc_port)
1118
1119
if local_engines_only and dp_rank > 0:
1120
assert not handshake_local_only
vllm/v1/utils.py
+99
-16
@@ -29,7 +29,7 @@ from torch.autograd.profiler import record_function
29
import vllm.envs as envs
30
from vllm.logger import init_logger
31
from vllm.usage.usage_lib import UsageContext, is_usage_stats_enabled, usage_message
32
-from vllm.utils.network_utils import get_open_port, get_open_zmq_ipc_path, get_tcp_uri
32
+from vllm.utils.network_utils import get_open_zmq_ipc_path, get_tcp_uri
33
from vllm.utils.system_utils import decorate_logs, kill_process_tree, set_process_title
34
from vllm.v1.core.sched.output import SchedulerOutput
35
@@ -144,20 +144,18 @@ class CpuGpuBuffer:
144
return self.cpu[:n].copy_(self.gpu[:n], non_blocking=True)
145
146
147
-def get_engine_client_zmq_addr(local_only: bool, host: str, port: int = 0) -> str:
148
- """Assign a new ZMQ socket address.
147
+def get_engine_client_zmq_addr(
148
+ local_only: bool,
149
+ host: str,
150
+ port: int = 0,
151
+) -> str:
152
+ """Return an IPC path (``local_only=True``) or ``tcp://host:port``.
153
150
- If local_only is True, participants are colocated and so a unique IPC
151
- address will be returned.
152
-
153
- Otherwise, the provided host and port will be used to construct a TCP
154
- address (port == 0 means assign an available port)."""
155
-
156
- return (
157
- get_open_zmq_ipc_path()
158
- if local_only
159
- else (get_tcp_uri(host, port or get_open_port()))
160
- )
154
+ ``port=0`` lets the kernel assign the port at ``bind()`` time; the
155
+ caller must recover it via ``getsockopt(zmq.LAST_ENDPOINT)``."""
156
+ if local_only:
157
+ return get_open_zmq_ipc_path()
158
+ return get_tcp_uri(host, port)
159
160
161
class APIServerProcessManager:
@@ -181,6 +179,12 @@ class APIServerProcessManager:
179
):
180
"""Initialize and start API server worker processes.
181
182
+ ``input_addresses``/``output_addresses`` may contain
183
+ ``tcp://host:0`` placeholders; each child must report the actual
184
+ bound endpoint over its ``actual_address_pipe`` in ``client_config``
185
+ and the parent collects them via
186
+ :py:meth:`gather_actual_addresses`.
187
+
188
Args:
189
target_server_fn: Override function to call for each API server process
190
listen_address: Address to listen for client connections
@@ -196,14 +200,14 @@ class APIServerProcessManager:
200
self.sock = sock
201
self.args = args
202
199
- # Start API servers
203
spawn_context = multiprocessing.get_context("spawn")
204
self.processes: list[BaseProcess] = []
205
+ self._address_pipes: list[connection.Connection] = []
206
207
for i, in_addr, out_addr in zip(
208
range(num_servers), input_addresses, output_addresses
209
):
206
- client_config = {
210
+ client_config: dict[str, Any] = {
211
"input_address": in_addr,
212
"output_address": out_addr,
213
"client_count": num_servers,
@@ -214,6 +218,10 @@ class APIServerProcessManager:
218
if tensor_queue is not None:
219
client_config["tensor_queue"] = tensor_queue
220
221
+ parent_recv, child_send = spawn_context.Pipe(duplex=False)
222
+ self._address_pipes.append(parent_recv)
223
+ client_config["actual_address_pipe"] = child_send
224
+
225
proc = spawn_context.Process(
226
target=target_server_fn or run_api_server_worker_proc,
227
name=f"ApiServer_{i}",
@@ -222,14 +230,89 @@ class APIServerProcessManager:
230
self.processes.append(proc)
231
proc.start()
232
233
+ # Drop parent's write end so reader sees EOF on child death.
234
+ child_send.close()
235
+
236
logger.info("Started %d API server processes", len(self.processes))
237
238
# Shutdown only the API server processes on garbage collection
239
# The extra processes are managed by their owners
240
self._finalizer = weakref.finalize(self, shutdown, self.processes)
241
242
+ def gather_actual_addresses(
243
+ self,
244
+ timeout: float = 60.0,
245
+ ) -> tuple[list[str], list[str]]:
246
+ """Return (inputs, outputs) reported by each child, indexed by
247
+ ``client_index``. Raises ``RuntimeError`` on timeout or premature
248
+ child exit."""
249
+ n = len(self._address_pipes)
250
+ inputs: list[str | None] = [None] * n
251
+ outputs: list[str | None] = [None] * n
252
+ pending: dict[connection.Connection, int] = {
253
+ pipe: i for i, pipe in enumerate(self._address_pipes)
254
+ }
255
+ sentinel_to_idx: dict[Any, int] = {
256
+ proc.sentinel: i for i, proc in enumerate(self.processes)
257
+ }
258
+
259
+ deadline = time.monotonic() + timeout
260
+ try:
261
+ while pending:
262
+ remaining = deadline - time.monotonic()
263
+ if remaining <= 0:
264
+ missing = [self.processes[i].name for i in pending.values()]
265
+ raise RuntimeError(
266
+ f"Timed out after {timeout:.1f}s waiting for "
267
+ f"API server(s) to report bound ZMQ addresses: "
268
+ f"{missing}"
269
+ )
270
+ waitables: list[Any] = list(pending.keys()) + list(
271
+ sentinel_to_idx.keys()
272
+ )
273
+ ready = connection.wait(waitables, timeout=remaining)
274
+ # Drain pipes before checking sentinels: a child that sent
275
+ # its message and then exited can surface both events in
276
+ # the same poll, and we must record the success first.
277
+ for item in ready:
278
+ if isinstance(item, connection.Connection) and item in pending:
279
+ idx = pending.pop(item)
280
+ try:
281
+ msg: dict[str, str] = item.recv()
282
+ except EOFError as e:
283
+ raise RuntimeError(
284
+ f"API server {self.processes[idx].name} "
285
+ f"closed its address pipe without "
286
+ f"reporting its bound ZMQ addresses"
287
+ ) from e
288
+ inputs[idx] = msg["input_address"]
289
+ outputs[idx] = msg["output_address"]
290
+ item.close()
291
+ for item in ready:
292
+ if item in sentinel_to_idx:
293
+ idx = sentinel_to_idx.pop(item)
294
+ pipe = self._address_pipes[idx]
295
+ if pipe in pending:
296
+ proc = self.processes[idx]
297
+ raise RuntimeError(
298
+ f"API server process {proc.name} exited "
299
+ f"(code={proc.exitcode}) before reporting "
300
+ f"its bound ZMQ addresses"
301
+ )
302
+ finally:
303
+ for pipe in pending:
304
+ with contextlib.suppress(Exception):
305
+ pipe.close()
306
+
307
+ return inputs, outputs # type: ignore[return-value]
308
+
309
def shutdown(self, timeout: float | None = None) -> None:
310
"""Shutdown API server processes with configurable timeout"""
311
+ for pipe in self._address_pipes:
312
+ with contextlib.suppress(Exception):
313
+ pipe.close()
314
+ self._address_pipes = []
315
+
316
if self._finalizer.detach() is not None:
317
shutdown(self.processes, timeout=timeout)
318