|
1 | 1 | # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
2 | 2 | # SPDX-License-Identifier: Apache-2.0 |
3 | | -# |
4 | | -# Licensed under the Apache License, Version 2.0 (the "License"); |
5 | | -# you may not use this file except in compliance with the License. |
6 | | -# You may obtain a copy of the License at |
7 | | -# |
8 | | -# http://www.apache.org/licenses/LICENSE-2.0 |
9 | | -# |
10 | | -# Unless required by applicable law or agreed to in writing, software |
11 | | -# distributed under the License is distributed on an "AS IS" BASIS, |
12 | | -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
13 | | -# See the License for the specific language governing permissions and |
14 | | -# limitations under the License. |
15 | 3 |
|
16 | 4 | import logging |
17 | 5 | import os |
|
25 | 13 |
|
26 | 14 | from tests.utils.constants import TEST_MODELS |
27 | 15 | from tests.utils.managed_process import ManagedProcess |
| 16 | +from tests.utils.port_utils import ( |
| 17 | + allocate_free_port, |
| 18 | + allocate_free_ports, |
| 19 | + free_port, |
| 20 | + free_ports, |
| 21 | +) |
| 22 | + |
| 23 | +_logger = logging.getLogger(__name__) |
28 | 24 |
|
29 | 25 |
|
30 | 26 | def pytest_configure(config): |
@@ -227,44 +223,124 @@ def pytest_collection_modifyitems(config, items): |
227 | 223 |
|
228 | 224 |
|
229 | 225 | class EtcdServer(ManagedProcess): |
230 | | - def __init__(self, request, port=2379, timeout=300): |
| 226 | + def __init__(self, request, port=None, timeout=300): |
| 227 | + # Allocate free ports if not specified |
| 228 | + use_random_port = port is None |
| 229 | + if use_random_port: |
| 230 | + # Need two ports: client port and peer port for parallel execution |
| 231 | + # Start from 2380 (etcd default 2379 + 1) |
| 232 | + port, peer_port = allocate_free_ports(2, 2380) |
| 233 | + else: |
| 234 | + peer_port = None |
| 235 | + |
| 236 | + self.port = port |
| 237 | + self.peer_port = peer_port # Store for cleanup |
231 | 238 | port_string = str(port) |
232 | 239 | etcd_env = os.environ.copy() |
233 | 240 | etcd_env["ALLOW_NONE_AUTHENTICATION"] = "yes" |
234 | 241 | data_dir = tempfile.mkdtemp(prefix="etcd_") |
| 242 | + |
235 | 243 | command = [ |
236 | 244 | "etcd", |
237 | 245 | "--listen-client-urls", |
238 | 246 | f"http://0.0.0.0:{port_string}", |
239 | 247 | "--advertise-client-urls", |
240 | 248 | f"http://0.0.0.0:{port_string}", |
241 | | - "--data-dir", |
242 | | - data_dir, |
243 | 249 | ] |
| 250 | + |
| 251 | + # Add peer port configuration only for random ports (parallel execution) |
| 252 | + if peer_port is not None: |
| 253 | + peer_port_string = str(peer_port) |
| 254 | + command.extend( |
| 255 | + [ |
| 256 | + "--listen-peer-urls", |
| 257 | + f"http://0.0.0.0:{peer_port_string}", |
| 258 | + "--initial-advertise-peer-urls", |
| 259 | + f"http://localhost:{peer_port_string}", |
| 260 | + "--initial-cluster", |
| 261 | + f"default=http://localhost:{peer_port_string}", |
| 262 | + ] |
| 263 | + ) |
| 264 | + |
| 265 | + command.extend( |
| 266 | + [ |
| 267 | + "--data-dir", |
| 268 | + data_dir, |
| 269 | + ] |
| 270 | + ) |
244 | 271 | super().__init__( |
245 | 272 | env=etcd_env, |
246 | 273 | command=command, |
247 | 274 | timeout=timeout, |
248 | 275 | display_output=False, |
| 276 | + terminate_existing=not use_random_port, # Disabled for parallel test execution with random ports |
249 | 277 | health_check_ports=[port], |
250 | 278 | data_dir=data_dir, |
251 | 279 | log_dir=request.node.name, |
252 | 280 | ) |
253 | 281 |
|
| 282 | + def __exit__(self, exc_type, exc_val, exc_tb): |
| 283 | + """Release allocated ports when server exits.""" |
| 284 | + ports_to_release = [] |
| 285 | + try: |
| 286 | + # Release allocated ports BEFORE calling parent __exit__ |
| 287 | + if hasattr(self, "port") and self.port is not None: |
| 288 | + ports_to_release.append(self.port) |
| 289 | + if hasattr(self, "peer_port") and self.peer_port is not None: |
| 290 | + ports_to_release.append(self.peer_port) |
| 291 | + |
| 292 | + if ports_to_release: |
| 293 | + free_ports(ports_to_release) |
| 294 | + except Exception: |
| 295 | + # Silently continue if port release fails |
| 296 | + pass |
| 297 | + finally: |
| 298 | + # Always call parent __exit__ to terminate the process |
| 299 | + return super().__exit__(exc_type, exc_val, exc_tb) |
| 300 | + |
254 | 301 |
|
255 | 302 | class NatsServer(ManagedProcess): |
256 | | - def __init__(self, request, port=4222, timeout=300): |
| 303 | + def __init__(self, request, port=None, timeout=300): |
| 304 | + # Allocate a free port if not specified |
| 305 | + use_random_port = port is None |
| 306 | + if use_random_port: |
| 307 | + # Start from 4223 (nats-server default 4222 + 1) |
| 308 | + port = allocate_free_port(4223) |
| 309 | + |
| 310 | + self.port = port |
257 | 311 | data_dir = tempfile.mkdtemp(prefix="nats_") |
258 | | - command = ["nats-server", "-js", "--trace", "--store_dir", data_dir] |
| 312 | + command = [ |
| 313 | + "nats-server", |
| 314 | + "-js", |
| 315 | + "--trace", |
| 316 | + "--store_dir", |
| 317 | + data_dir, |
| 318 | + "-p", |
| 319 | + str(port), |
| 320 | + ] |
259 | 321 | super().__init__( |
260 | 322 | command=command, |
261 | 323 | timeout=timeout, |
262 | 324 | display_output=False, |
| 325 | + terminate_existing=not use_random_port, # Disabled for parallel test execution with random ports |
263 | 326 | data_dir=data_dir, |
264 | 327 | health_check_ports=[port], |
265 | 328 | log_dir=request.node.name, |
266 | 329 | ) |
267 | 330 |
|
| 331 | + def __exit__(self, exc_type, exc_val, exc_tb): |
| 332 | + """Release allocated port when server exits.""" |
| 333 | + try: |
| 334 | + # Release allocated port BEFORE calling parent __exit__ |
| 335 | + if hasattr(self, "port") and self.port is not None: |
| 336 | + free_port(self.port) |
| 337 | + except Exception: |
| 338 | + # Silently continue if port release fails |
| 339 | + pass |
| 340 | + finally: |
| 341 | + # Always call parent __exit__ to terminate the process |
| 342 | + return super().__exit__(exc_type, exc_val, exc_tb) |
| 343 | + |
268 | 344 |
|
269 | 345 | class SharedManagedProcess: |
270 | 346 | """Base class for ManagedProcess with file-based reference counting for multi-process sharing.""" |
@@ -393,6 +469,13 @@ def _create_server(self) -> ManagedProcess: |
393 | 469 |
|
394 | 470 | @pytest.fixture() |
395 | 471 | def runtime_services(request): |
| 472 | + """Provide NATS and Etcd servers with dynamically allocated ports. |
| 473 | +
|
| 474 | + Returns a tuple of (nats_process, etcd_process) where each has a .port attribute. |
| 475 | + Tests should set NATS_SERVER and ETCD_ENDPOINTS environment variables in their |
| 476 | + subprocess environments using these ports. |
| 477 | + """ |
| 478 | + # Port cleanup is now handled in NatsServer and EtcdServer __exit__ methods |
396 | 479 | with NatsServer(request) as nats_process: |
397 | 480 | with EtcdServer(request) as etcd_process: |
398 | 481 | yield nats_process, etcd_process |
|
0 commit comments