Skip to content
Merged
7 changes: 6 additions & 1 deletion ci/run_cuopt_pytests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ rc=0
if [ "${IS_NIGHTLY}" = "nightly" ]; then
pytest -s --cache-clear --reruns 2 --reruns-delay 5 -p cuopt_rerun_xml "$@" tests || rc=$?
else
pytest -s --cache-clear -n 2 "$@" tests || rc=$?
# --dist loadgroup keeps @pytest.mark.xdist_group tests on one worker, so
# all cuopt_grpc_server-backed classes share a single worker instead of
# duplicating a GPU server per worker (without loadgroup the markers are
# silently ignored). --max-worker-restart=0 disables respawning a crashed
# worker, so a crash fails those tests once rather than cascading.
pytest -s --cache-clear -n 4 --dist loadgroup --max-worker-restart=0 "$@" tests || rc=$?
fi

# If not a crash, exit normally
Expand Down
79 changes: 63 additions & 16 deletions python/cuopt/cuopt/tests/fixtures/grpc_server_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,65 @@ def server_env():
return env


def _set_pdeathsig():
"""Ask the kernel to SIGKILL this child if the spawning (pytest worker)
process dies. Ensures a crashed/killed worker can't orphan a GPU-holding
server. Linux-only; a no-op (best effort) elsewhere.
"""
try:
import ctypes

PR_SET_PDEATHSIG = 1
ctypes.CDLL("libc.so.6", use_errno=True).prctl(
PR_SET_PDEATHSIG, signal.SIGKILL
)
except Exception:
pass


def spawn_server(cmd, env=None):
"""Start ``cuopt_grpc_server`` in its own process group with parent-death
cleanup.

``start_new_session=True`` makes the server a process-group leader, so its
``--workers`` child shares the group and both can be reaped together (see
``kill_server``). ``preexec_fn`` arms PR_SET_PDEATHSIG so an abnormally
exiting worker (e.g. GPU OOM abort) doesn't leave the server holding GPU
memory. Without this, servers leaked across tests/runs and stacked up until
the device OOMed.
"""
return subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
start_new_session=True,
preexec_fn=_set_pdeathsig,
)


def kill_server(proc):
"""Terminate the server *and its whole process group* (parent + ``--workers``
child). SIGTERM the group, wait, then SIGKILL any survivors.
"""
if proc is None:
return
try:
pgid = os.getpgid(proc.pid)
except (ProcessLookupError, OSError):
return
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(pgid, sig)
except (ProcessLookupError, OSError):
return
try:
proc.wait(timeout=5)
return
except subprocess.TimeoutExpired:
continue


# Backward-compatible alias used by tests that yield client env dicts.
cpu_only_env = client_remote_env

Expand All @@ -128,7 +187,7 @@ def start_grpc_server(port_offset):

port = int(os.environ.get("CUOPT_TEST_PORT_BASE", "18000")) + port_offset
client_env = client_remote_env(port)
proc = subprocess.Popen(
proc = spawn_server(
[
server_bin,
"--port",
Expand All @@ -137,8 +196,6 @@ def start_grpc_server(port_offset):
"1",
"--log-to-console",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=server_env(),
)
time.sleep(0.5)
Expand All @@ -148,8 +205,7 @@ def start_grpc_server(port_offset):
"binary may be unable to load shared libraries in this environment"
)
if not wait_for_grpc_client(port, timeout=30):
proc.kill()
proc.wait()
kill_server(proc)
pytest.fail(
"cuopt_grpc_server TCP port opened but gRPC client could not connect "
"within 30s"
Expand All @@ -159,17 +215,8 @@ def start_grpc_server(port_offset):


def stop_grpc_server(proc):
"""Gracefully shut down a server process."""
if proc.poll() is not None:
proc.wait()
return

proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
"""Gracefully shut down a server process and its worker child."""
kill_server(proc)


@pytest.fixture(scope="class")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
Expand All @@ -28,6 +27,15 @@
from cuopt import linear_programming
from cuopt.linear_programming.solver.solver_parameters import CUOPT_TIME_LIMIT

# This module is also re-executed as a plain script by _run_in_subprocess
# (python test_cpu_only_execution.py _impl_...), where conftest has not put
# the tests fixtures/ dir on sys.path. Add it so the import works in both the
# pytest and standalone-subprocess contexts.
sys.path.insert(
0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "fixtures")
)
from grpc_server_fixtures import kill_server, spawn_server # noqa: E402

logger = logging.getLogger(__name__)

RAPIDS_DATASET_ROOT_DIR = os.environ.get(
Expand Down Expand Up @@ -79,7 +87,7 @@ def _wait_for_port(port, timeout=15):


def _cpu_only_env(port):
"""Return an env dict that hides all GPUs and enables remote mode."""
"""Return a *client* env dict that hides all GPUs and enables remote mode."""
env = os.environ.copy()
for key in [k for k in env if k.startswith("CUOPT_TLS_")]:
env.pop(key)
Expand Down Expand Up @@ -435,10 +443,8 @@ def _start_grpc_server_fixture(port_offset):
+ port_offset
+ worker_id
)
proc = subprocess.Popen(
proc = spawn_server(
[server_bin, "--port", str(port), "--workers", "1"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(0.5)
if proc.poll() is not None:
Expand All @@ -447,28 +453,23 @@ def _start_grpc_server_fixture(port_offset):
"binary may be unable to load shared libraries in this environment"
)
if not _wait_for_port(port, timeout=15):
proc.kill()
proc.wait()
kill_server(proc)
pytest.fail("cuopt_grpc_server failed to start within 15s")

return proc, _cpu_only_env(port)


def _stop_grpc_server(proc):
"""Gracefully shut down a server process."""
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
"""Gracefully shut down a server process and its worker child."""
kill_server(proc)


# ---------------------------------------------------------------------------
# CPU-only Python tests (subprocess required)
# ---------------------------------------------------------------------------


@pytest.mark.xdist_group(name="grpc_server")
class TestCPUOnlyExecution:
"""Tests that run with CUDA_VISIBLE_DEVICES='' to simulate CPU-only hosts.

Expand Down Expand Up @@ -515,6 +516,7 @@ def test_warmstart_cpu_only(self, cpu_only_env_with_server):
# ---------------------------------------------------------------------------


@pytest.mark.xdist_group(name="grpc_server")
class TestCuoptCliCPUOnly:
"""Test that cuopt_cli runs without CUDA in remote-execution mode.

Expand Down Expand Up @@ -711,6 +713,7 @@ def test_mip_solution_values(self):
# ---------------------------------------------------------------------------


@pytest.mark.xdist_group(name="grpc_server")
class TestTLSExecution:
"""Test remote execution over a TLS-encrypted channel.

Expand All @@ -729,7 +732,7 @@ def tls_env_with_server(self, tmp_path_factory):
pytest.skip("cuopt_grpc_server not found")

port = int(os.environ.get("CUOPT_TEST_PORT_BASE", "18000")) + 850
proc = subprocess.Popen(
proc = spawn_server(
[
server_bin,
"--port",
Expand All @@ -742,12 +745,9 @@ def tls_env_with_server(self, tmp_path_factory):
"--tls-key",
os.path.join(cert_dir, "server.key"),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if not _wait_for_port(port, timeout=15):
proc.kill()
proc.wait()
kill_server(proc)
pytest.fail("TLS cuopt_grpc_server failed to start within 15s")

env = _tls_env(port, cert_dir, mtls=False)
Expand All @@ -768,6 +768,7 @@ def test_lp_solve_tls(self, tls_env_with_server):
# ---------------------------------------------------------------------------


@pytest.mark.xdist_group(name="grpc_server")
class TestMTLSExecution:
"""Test remote execution over an mTLS-encrypted channel.

Expand All @@ -787,7 +788,7 @@ def mtls_server_info(self, tmp_path_factory):
pytest.skip("cuopt_grpc_server not found")

port = int(os.environ.get("CUOPT_TEST_PORT_BASE", "18000")) + 900
proc = subprocess.Popen(
proc = spawn_server(
[
server_bin,
"--port",
Expand All @@ -803,22 +804,14 @@ def mtls_server_info(self, tmp_path_factory):
os.path.join(cert_dir, "ca.crt"),
"--require-client-cert",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if not _wait_for_port(port, timeout=15):
proc.kill()
proc.wait()
kill_server(proc)
pytest.fail("mTLS cuopt_grpc_server failed to start within 15s")

yield {"port": port, "cert_dir": cert_dir}

proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
kill_server(proc)

def test_lp_solve_mtls(self, mtls_server_info):
"""LP solve succeeds over an mTLS channel with valid client cert."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def _infeasible_lp_problem():
return problem


@pytest.mark.xdist_group(name="grpc_server")
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
class TestGrpcClient:
grpc_port_offset = GRPC_PORT_OFFSET_CLIENT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,23 @@ def set_solution(
settings.set_mip_callback(set_callback, user_data)
solution = solver.Solve(data_model_obj, settings)

assert get_callback.n_callbacks > 0
termination = solution.get_termination_status()
assert termination in (
MILPTerminationStatus.FeasibleFound,
MILPTerminationStatus.Optimal,
)

# Incumbent callbacks only fire during branch-and-bound. If the problem is
# solved at the root node (presolve or integral LP relaxation), no
# callbacks are triggered — skip rather than fail in that case.
if get_callback.n_callbacks == 0:
pytest.skip(
f"No incumbent callbacks fired (termination={termination}); "
"problem was likely solved at the root node without branching"
)

if include_set_callback:
assert set_callback.n_callbacks > 0
assert (
solution.get_termination_status()
== MILPTerminationStatus.FeasibleFound
or MILPTerminationStatus.Optimal
)

for sol in get_callback.solutions:
utils.check_solution(
Expand Down
Loading