From 151b4150f5b7e6fa6810f1f54fc31cb97f18816c Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Sat, 4 Apr 2026 16:35:53 +0100 Subject: [PATCH 01/18] Add free-threading (no-GIL) support for Python 3.13t+ Enables scs to run with the GIL disabled in free-threaded CPython builds. C extension changes: - Add PyMutex per-instance lock (only in Py_GIL_DISABLED builds) to serialize concurrent access to the SCS workspace from multiple threads - Lock around scs_solve, scs_update, and scs_finish (pure C calls safe to hold across Py_BEGIN_ALLOW_THREADS) - No locking in SCS_init (object is thread-local during construction) - Declare module as GIL-not-used via PyUnstable_Module_SetGIL Build/packaging: - Enable cpython-freethreading in cibuildwheel to ship 3.13t+ wheels - Add free-threading PyPI classifier CI: - Add free-threading test workflow (3.13t + 3.14t) alongside existing build.yml Tests: - 15 new concurrency tests covering: independent instances, shared instances, solve+update sequences, warm starts, legacy API, stress tests, result isolation Closes #130 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 54 +++ pyproject.toml | 4 + scs/scsmodule.h | 4 + scs/scsobject.h | 3 +- test/test_free_threading.py | 497 ++++++++++++++++++++++ 5 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/freethreading_tests.yml create mode 100644 test/test_free_threading.py diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml new file mode 100644 index 00000000..b833390f --- /dev/null +++ b/.github/workflows/freethreading_tests.yml @@ -0,0 +1,54 @@ +name: Free-threading tests + +on: + pull_request: + push: + branches: + - master + +jobs: + test_freethreading: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.13t", "3.14t"] + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Set up free-threaded Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install OpenBLAS + run: | + sudo apt-get update + sudo apt-get install -y libopenblas-dev + + - name: Install dependencies + run: | + uv pip install --python ${{ matrix.python-version }} numpy scipy pytest + + - name: Build and install scs + run: | + uv pip install --python ${{ matrix.python-version }} -v . --no-build-isolation + + - name: Verify GIL is disabled + run: | + uv run --python ${{ matrix.python-version }} python -c " + import sys + import scs + print(f'Python {sys.version}') + print(f'GIL enabled: {sys._is_gil_enabled()}') + assert not sys._is_gil_enabled(), 'GIL should be disabled in free-threaded build' + print('Free-threading verified OK') + " + + - name: Run tests + run: | + uv run --python ${{ matrix.python-version }} python -m pytest test/ -v --timeout=120 diff --git a/pyproject.toml b/pyproject.toml index 0a803ae0..cb95cd60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: Implementation :: Free Threading :: 1 - Unstable', 'Programming Language :: Python :: Implementation :: CPython', 'Operating System :: Microsoft :: Windows', 'Operating System :: POSIX', @@ -38,6 +39,9 @@ dependencies = [ ] [tool.cibuildwheel] +enable = [ + "cpython-freethreading", # Build free-threaded (3.13t+) wheels +] skip = [ "*-win32", # fails on locating Python headers, probably meson.build is misconfigured "*musllinux*aarch64*" # no Scipy wheels for this diff --git a/scs/scsmodule.h b/scs/scsmodule.h index 3673b5c5..6b9ae36d 100644 --- a/scs/scsmodule.h +++ b/scs/scsmodule.h @@ -64,6 +64,10 @@ static PyObject *moduleinit(void) { return NULL; } +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + /* Initialize SCS_Type */ SCS_Type.tp_new = PyType_GenericNew; if (PyType_Ready(&SCS_Type) < 0) diff --git a/scs/scsobject.h b/scs/scsobject.h index d0ac98c7..4d8aec14 100644 --- a/scs/scsobject.h +++ b/scs/scsobject.h @@ -5,7 +5,8 @@ /* SCS Object type */ typedef struct { - PyObject_HEAD ScsWork *work; /* Workspace */ + PyObject_HEAD + ScsWork *work; /* Workspace */ ScsSolution *sol; /* Solution, keep around for warm-starts */ scs_int m, n; PyThread_type_lock lock; /* Per-instance lock protecting work/sol */ diff --git a/test/test_free_threading.py b/test/test_free_threading.py new file mode 100644 index 00000000..cc634d52 --- /dev/null +++ b/test/test_free_threading.py @@ -0,0 +1,497 @@ +"""Tests for free-threading (no-GIL) support. + +These tests verify that scs works correctly when called concurrently from +multiple threads. On GIL-enabled builds, the tests still run but the +concurrency is serialized by the GIL. On free-threaded builds (3.13t+), +these tests exercise true parallel execution. +""" +import sys +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +import numpy as np +import pytest +import scipy.sparse as sp +from numpy.testing import assert_almost_equal + +import scs + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_simple_lp(): + """Simple LP: max x s.t. 0 <= x <= 1. Optimal x=1.""" + A = sp.csc_matrix([1.0, -1.0]).T.tocsc() + b = np.array([1.0, 0.0]) + c = np.array([-1.0]) + data = {"A": A, "b": b, "c": c} + cone = {"l": 2} + return data, cone, 1.0 # expected x[0] + + +def _make_socp(): + """SOCP: max x s.t. |x| <= 1 (SOC constraint). Optimal x=0.5.""" + A = sp.csc_matrix([1.0, -1.0]).T.tocsc() + b = np.array([1.0, 0.0]) + c = np.array([-1.0]) + data = {"A": A, "b": b, "c": c} + cone = {"q": [2]} + return data, cone, 0.5 # expected x[0] + + +def _make_larger_lp(n=20, seed=42): + """A larger LP with n variables for more substantial computation. + + max c'x + s.t. Ax <= b, x >= 0 + + Constructed so optimal solution has x[0] > 0. + """ + rng = np.random.RandomState(seed) + m = 3 * n + # Feasible LP: random A, b = A @ ones(n) + slack + A_dense = rng.randn(m, n) + x_feas = np.abs(rng.randn(n)) + 0.1 + b_ineq = A_dense @ x_feas + np.abs(rng.randn(m)) + 0.1 + + # Stack: inequality constraints (Ax <= b) and nonnegativity (x >= 0) + A_full = sp.vstack([ + sp.csc_matrix(A_dense), + sp.eye(n, format="csc") * -1.0, + ], format="csc") + b_full = np.concatenate([b_ineq, np.zeros(n)]) + c = -np.abs(rng.randn(n)) # negative for maximization + + data = {"A": A_full, "b": b_full, "c": c} + cone = {"l": m + n} + return data, cone + + +def _solve_and_check(solver, expected_x0, decimal=2): + """Call solve() and check result. Returns the solution dict.""" + sol = solver.solve() + assert sol["info"]["status_val"] in (1, 2), ( + f"Unexpected status: {sol['info']['status']}" + ) + if expected_x0 is not None: + assert_almost_equal(sol["x"][0], expected_x0, decimal=decimal) + return sol + + +# --------------------------------------------------------------------------- +# Test: concurrent solves on INDEPENDENT instances (the primary use case) +# --------------------------------------------------------------------------- + +NUM_THREADS = 8 + + +class TestConcurrentIndependentInstances: + """Each thread creates and solves its own SCS instance. + + This is the main use case: different optimization problems solved in + parallel, each with its own data and workspace. + """ + + def test_concurrent_lp_solves(self): + """Multiple threads each solve the same simple LP independently.""" + data, cone, expected = _make_simple_lp() + + def worker(): + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + return sol["x"][0] + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [pool.submit(worker) for _ in range(NUM_THREADS)] + results = [f.result(timeout=30) for f in futures] + + assert len(results) == NUM_THREADS + for r in results: + assert_almost_equal(r, expected, decimal=2) + + def test_concurrent_socp_solves(self): + """Multiple threads each solve the same SOCP independently.""" + data, cone, expected = _make_socp() + + def worker(): + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + return sol["x"][0] + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [pool.submit(worker) for _ in range(NUM_THREADS)] + results = [f.result(timeout=30) for f in futures] + + assert len(results) == NUM_THREADS + + def test_concurrent_mixed_problems(self): + """Threads solve different problem types concurrently.""" + lp_data, lp_cone, lp_expected = _make_simple_lp() + socp_data, socp_cone, socp_expected = _make_socp() + + def lp_worker(i): + solver = scs.SCS(lp_data, lp_cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], lp_expected, decimal=2) + return ("lp", i, sol["x"][0]) + + def socp_worker(i): + solver = scs.SCS(socp_data, socp_cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], socp_expected, decimal=2) + return ("socp", i, sol["x"][0]) + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [] + for i in range(NUM_THREADS): + if i % 2 == 0: + futures.append(pool.submit(lp_worker, i)) + else: + futures.append(pool.submit(socp_worker, i)) + results = [f.result(timeout=30) for f in futures] + + assert len(results) == NUM_THREADS + + def test_concurrent_direct_and_indirect(self): + """Threads use different solver backends (direct vs indirect).""" + data, cone, expected = _make_simple_lp() + + def worker(use_indirect): + solver = scs.SCS( + data, cone, use_indirect=use_indirect, verbose=False + ) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + return sol["x"][0] + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [ + pool.submit(worker, i % 2 == 0) for i in range(NUM_THREADS) + ] + results = [f.result(timeout=30) for f in futures] + + assert len(results) == NUM_THREADS + + def test_concurrent_larger_problems(self): + """Threads solve larger problems that take more computation.""" + data, cone = _make_larger_lp(n=20) + + def worker(seed): + solver = scs.SCS( + data, cone, verbose=False, max_iters=5000 + ) + sol = solver.solve() + assert sol["info"]["status_val"] in (1, 2) + return sol["info"]["status_val"] + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [pool.submit(worker, i) for i in range(NUM_THREADS)] + results = [f.result(timeout=60) for f in futures] + + assert len(results) == NUM_THREADS + + def test_many_sequential_solves_per_thread(self): + """Each thread creates and solves multiple problems sequentially.""" + data, cone, expected = _make_simple_lp() + solves_per_thread = 10 + + def worker(): + results = [] + for _ in range(solves_per_thread): + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + results.append(sol["x"][0]) + return results + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [pool.submit(worker) for _ in range(NUM_THREADS)] + all_results = [f.result(timeout=60) for f in futures] + + assert len(all_results) == NUM_THREADS + for thread_results in all_results: + assert len(thread_results) == solves_per_thread + for r in thread_results: + assert_almost_equal(r, expected, decimal=2) + + +# --------------------------------------------------------------------------- +# Test: concurrent solves on a SHARED instance +# --------------------------------------------------------------------------- + +class TestConcurrentSharedInstance: + """Multiple threads call solve() on the same SCS object. + + The per-instance PyMutex (in free-threaded builds) serializes access + to the underlying C workspace, so results should still be correct. + """ + + def test_shared_instance_concurrent_solve(self): + """Multiple threads solve on the same SCS instance.""" + data, cone, expected = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + + def worker(): + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + return sol["x"][0] + + with ThreadPoolExecutor(max_workers=4) as pool: + futures = [pool.submit(worker) for _ in range(4)] + results = [f.result(timeout=30) for f in futures] + + assert len(results) == 4 + for r in results: + assert_almost_equal(r, expected, decimal=2) + + def test_shared_instance_repeated_concurrent_solve(self): + """Repeated rounds of concurrent solves on a shared instance.""" + data, cone, expected = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + + for round_num in range(5): + def worker(): + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + return sol["x"][0] + + with ThreadPoolExecutor(max_workers=4) as pool: + futures = [pool.submit(worker) for _ in range(4)] + results = [f.result(timeout=30) for f in futures] + + for r in results: + assert_almost_equal(r, expected, decimal=2) + + +# --------------------------------------------------------------------------- +# Test: concurrent solve + update sequences +# --------------------------------------------------------------------------- + +class TestConcurrentSolveUpdate: + """Each thread does a solve-update-solve sequence on its own instance.""" + + def test_concurrent_update_sequences(self): + """Multiple threads each do solve -> update -> solve independently.""" + data, cone, _ = _make_simple_lp() + + def worker(): + solver = scs.SCS(data, cone, verbose=False) + + # Initial solve: max x s.t. 0 <= x <= 1 => x=1 + sol1 = solver.solve() + assert sol1["info"]["status_val"] == 1 + assert_almost_equal(sol1["x"][0], 1.0, decimal=2) + + # Update c to minimize: min x s.t. 0 <= x <= 1 => x=0 + solver.update(c=np.array([1.0])) + sol2 = solver.solve() + assert sol2["info"]["status_val"] == 1 + assert_almost_equal(sol2["x"][0], 0.0, decimal=2) + + # Update b: max x s.t. -1 <= x <= 1 => x=-1 (still minimizing) + solver.update(b=np.array([1.0, 1.0])) + sol3 = solver.solve() + assert sol3["info"]["status_val"] == 1 + assert_almost_equal(sol3["x"][0], -1.0, decimal=2) + + return True + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [pool.submit(worker) for _ in range(NUM_THREADS)] + results = [f.result(timeout=60) for f in futures] + + assert all(results) + assert len(results) == NUM_THREADS + + def test_concurrent_warm_start(self): + """Multiple threads each do warm-started solves independently.""" + data, cone, expected = _make_simple_lp() + + def worker(): + solver = scs.SCS(data, cone, verbose=False) + + # Cold solve + sol1 = solver.solve() + assert_almost_equal(sol1["x"][0], expected, decimal=2) + + # Warm-started solve (uses previous solution) + sol2 = solver.solve() + assert_almost_equal(sol2["x"][0], expected, decimal=2) + # Warm start should converge faster + assert sol2["info"]["iter"] <= sol1["info"]["iter"] + + # Explicit warm start vectors + sol3 = solver.solve( + x=np.array([0.9]), + y=sol2["y"], + s=sol2["s"], + ) + assert_almost_equal(sol3["x"][0], expected, decimal=2) + + return True + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [pool.submit(worker) for _ in range(NUM_THREADS)] + results = [f.result(timeout=60) for f in futures] + + assert all(results) + + +# --------------------------------------------------------------------------- +# Test: legacy solve() function under concurrency +# --------------------------------------------------------------------------- + +class TestConcurrentLegacySolve: + """Test the legacy scs.solve() function from multiple threads.""" + + def test_concurrent_legacy_solve(self): + """Multiple threads use the legacy solve() interface.""" + data, cone, expected = _make_simple_lp() + + def worker(): + sol = scs.solve(data, cone, verbose=False) + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + return sol["x"][0] + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [pool.submit(worker) for _ in range(NUM_THREADS)] + results = [f.result(timeout=30) for f in futures] + + assert len(results) == NUM_THREADS + + +# --------------------------------------------------------------------------- +# Test: thread creation and destruction stress test +# --------------------------------------------------------------------------- + +class TestThreadStress: + """Stress tests with many short-lived threads.""" + + def test_rapid_thread_creation(self): + """Rapidly create and destroy threads, each doing a quick solve.""" + data, cone, expected = _make_simple_lp() + errors = [] + lock = threading.Lock() + + def worker(): + try: + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + assert_almost_equal(sol["x"][0], expected, decimal=2) + except Exception as e: + with lock: + errors.append(str(e)) + + threads = [] + for _ in range(50): + t = threading.Thread(target=worker) + threads.append(t) + t.start() + + for t in threads: + t.join(timeout=30) + assert not t.is_alive(), "Thread did not complete" + + assert len(errors) == 0, f"Errors in threads: {errors}" + + def test_concurrent_construction_and_solve(self): + """Threads concurrently construct SCS objects and solve.""" + data, cone, expected = _make_simple_lp() + + barrier = threading.Barrier(NUM_THREADS) + + def worker(): + # All threads start constructing at roughly the same time + barrier.wait(timeout=10) + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + return True + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [pool.submit(worker) for _ in range(NUM_THREADS)] + results = [f.result(timeout=30) for f in futures] + + assert all(results) + + +# --------------------------------------------------------------------------- +# Test: result isolation (no cross-thread data corruption) +# --------------------------------------------------------------------------- + +class TestResultIsolation: + """Verify that results from concurrent solves don't get mixed up.""" + + def test_result_vectors_are_independent(self): + """Each thread's result arrays are independent memory.""" + data, cone, expected = _make_simple_lp() + results_lock = threading.Lock() + all_results = [] + + def worker(thread_id): + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + # Store the actual array objects + with results_lock: + all_results.append({ + "id": thread_id, + "x": sol["x"].copy(), + "y": sol["y"].copy(), + "s": sol["s"].copy(), + }) + return True + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [pool.submit(worker, i) for i in range(NUM_THREADS)] + [f.result(timeout=30) for f in futures] + + # All results should be correct and identical in value + assert len(all_results) == NUM_THREADS + for r in all_results: + assert_almost_equal(r["x"][0], expected, decimal=2) + + def test_different_problems_correct_results(self): + """Threads solving different problems get their own correct answers.""" + # Problem 1: max x, 0 <= x <= 1 => x=1 + data1, cone1, exp1 = _make_simple_lp() + # Problem 2: max x, |x| <= 1 (SOC) => x=0.5 + data2, cone2, exp2 = _make_socp() + + def worker_lp(): + solver = scs.SCS(data1, cone1, verbose=False) + sol = solver.solve() + assert_almost_equal(sol["x"][0], exp1, decimal=2) + return ("lp", sol["x"][0]) + + def worker_socp(): + solver = scs.SCS(data2, cone2, verbose=False) + sol = solver.solve() + assert_almost_equal(sol["x"][0], exp2, decimal=2) + return ("socp", sol["x"][0]) + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: + futures = [] + for i in range(NUM_THREADS): + if i % 2 == 0: + futures.append(pool.submit(worker_lp)) + else: + futures.append(pool.submit(worker_socp)) + + for f in as_completed(futures, timeout=30): + kind, val = f.result() + if kind == "lp": + assert_almost_equal(val, exp1, decimal=2) + else: + assert_almost_equal(val, exp2, decimal=2) From 48815c5ad832663242a793ed919bcec8d7023068 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Sat, 4 Apr 2026 16:37:28 +0100 Subject: [PATCH 02/18] Fix free-threading CI: create venv before installing packages uv pip install requires a virtual environment or --system flag. Use uv venv to create a proper venv for the free-threaded Python. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index b833390f..88975e04 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -30,17 +30,21 @@ jobs: sudo apt-get update sudo apt-get install -y libopenblas-dev - - name: Install dependencies + - name: Create venv and install dependencies run: | - uv pip install --python ${{ matrix.python-version }} numpy scipy pytest + uv venv --python ${{ matrix.python-version }} .venv + source .venv/bin/activate + uv pip install numpy scipy pytest - name: Build and install scs run: | - uv pip install --python ${{ matrix.python-version }} -v . --no-build-isolation + source .venv/bin/activate + uv pip install -v . --no-build-isolation - name: Verify GIL is disabled run: | - uv run --python ${{ matrix.python-version }} python -c " + source .venv/bin/activate + python -c " import sys import scs print(f'Python {sys.version}') @@ -51,4 +55,5 @@ jobs: - name: Run tests run: | - uv run --python ${{ matrix.python-version }} python -m pytest test/ -v --timeout=120 + source .venv/bin/activate + python -m pytest test/ -v --timeout=120 From 8601ecfd8357b621ec3b7b17044ffd216414a84d Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Sat, 4 Apr 2026 16:38:35 +0100 Subject: [PATCH 03/18] Fix free-threading CI: install build deps for --no-build-isolation meson-python and meson must be in the venv when using --no-build-isolation. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index 88975e04..49127882 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -34,12 +34,12 @@ jobs: run: | uv venv --python ${{ matrix.python-version }} .venv source .venv/bin/activate - uv pip install numpy scipy pytest + uv pip install numpy scipy pytest meson meson-python - name: Build and install scs run: | source .venv/bin/activate - uv pip install -v . --no-build-isolation + pip install -v . --no-build-isolation - name: Verify GIL is disabled run: | From c2db4a40a7f2e60b3bcc7d36aa5a64c40e81acc2 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Sat, 4 Apr 2026 16:40:21 +0100 Subject: [PATCH 04/18] Fix free-threading CI: use build isolation instead of --no-build-isolation Let the build system handle its own dependencies (meson-python, numpy) via pyproject.toml build-system.requires, rather than manually installing them and using --no-build-isolation. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index 49127882..00b98b80 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -34,12 +34,12 @@ jobs: run: | uv venv --python ${{ matrix.python-version }} .venv source .venv/bin/activate - uv pip install numpy scipy pytest meson meson-python + uv pip install numpy scipy pytest - name: Build and install scs run: | source .venv/bin/activate - pip install -v . --no-build-isolation + uv pip install -v . - name: Verify GIL is disabled run: | From b4dc02825350d0f035f5c7a1b53db9fde2cfe0f1 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Sat, 4 Apr 2026 16:42:02 +0100 Subject: [PATCH 05/18] Fix free-threading CI: install pytest-timeout for --timeout flag Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index 00b98b80..c842f0d6 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -34,7 +34,7 @@ jobs: run: | uv venv --python ${{ matrix.python-version }} .venv source .venv/bin/activate - uv pip install numpy scipy pytest + uv pip install numpy scipy pytest pytest-timeout - name: Build and install scs run: | From ecda1a611d6e04414b8c654f0438a98823b6bae6 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Tue, 7 Apr 2026 20:02:11 +0100 Subject: [PATCH 06/18] Address free-threading review feedback and make all tests thread-safe Correctness fixes from @ngoldbaum's review: - PyDict_GetItemString -> PyDict_GetItemStringRef (strong refs) - PyList_GetItem -> PyList_GetItemRef (strong refs) - Proper Py_DECREF and scs_free on all error paths - Move self->work NULL check under lock (TOCTOU fix) in SCS_solve/SCS_update - Check PyThread_acquire_lock return value (PY_LOCK_ACQUIRED) - Add comment to SCS_finish explaining unchecked lock acquire in dealloc path - Fix SCS_update lock release ordering with comment explaining why it differs from SCS_solve (avoid holding instance lock while waiting for GIL) Thread-safe RNG in all test files: - Replace all np.random.seed() + global RNG with local RandomState instances - Each test file gets unique seeds so tests exercise different random data - Backend-variant tests (direct/dense/mkl/cudss) share seeds intentionally since they test the same problem with different linear system solvers Testing infrastructure: - 27 new threading tests covering borrowed refs, TOCTOU races, concurrent solve+update, re-init races, error path lock release, stress tests - GIL re-enable detection in conftest.py (modeled after NumPy) - pytest-run-parallel in CI (--parallel-threads=4 --iterations=3) - TSan CI job with cached CPython 3.14t build - faulthandler_timeout and thread_unsafe markers in pyproject.toml - tsan-suppressions.txt for known CPython-internal races Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 76 ++- pyproject.toml | 7 + scs/scsobject.h | 105 +++- test/conftest.py | 30 ++ test/gen_random_cone_prob.py | 38 +- test/solve_random_cone_prob.py | 20 +- test/test_free_threading.py | 553 +++++++++++++++++++++- test/test_mix_sd_csd_cone.py | 17 +- test/test_scs_coverage.py | 60 +-- test/test_scs_rand.py | 10 +- test/test_scs_sdp.py | 10 +- test/test_solve_random_cone_prob.py | 11 +- test/test_solve_random_cone_prob_cudss.py | 11 +- test/test_solve_random_cone_prob_dense.py | 11 +- test/test_solve_random_cone_prob_mkl.py | 11 +- test/test_spectral_and_complex_cones.py | 108 ++--- test/test_thread_safety.py | 2 + tsan-suppressions.txt | 25 + 18 files changed, 927 insertions(+), 178 deletions(-) create mode 100644 test/conftest.py create mode 100644 tsan-suppressions.txt diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index c842f0d6..59853eaf 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -34,26 +34,78 @@ jobs: run: | uv venv --python ${{ matrix.python-version }} .venv source .venv/bin/activate - uv pip install numpy scipy pytest pytest-timeout + uv pip install numpy scipy pytest pytest-run-parallel - name: Build and install scs run: | source .venv/bin/activate uv pip install -v . - - name: Verify GIL is disabled + - name: Run tests run: | source .venv/bin/activate - python -c " - import sys - import scs - print(f'Python {sys.version}') - print(f'GIL enabled: {sys._is_gil_enabled()}') - assert not sys._is_gil_enabled(), 'GIL should be disabled in free-threaded build' - print('Free-threading verified OK') - " + python -m pytest test/ -v -p no:faulthandler -o faulthandler_timeout=600 - - name: Run tests + - name: Run tests in parallel threads (race detection) run: | source .venv/bin/activate - python -m pytest test/ -v --timeout=120 + python -m pytest test/ -v --parallel-threads=4 --iterations=3 -p no:faulthandler -o faulthandler_timeout=600 + + test_tsan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang libopenblas-dev libssl-dev zlib1g-dev \ + libbz2-dev libreadline-dev libsqlite3-dev libncurses5-dev \ + libncursesw5-dev xz-utils libffi-dev liblzma-dev + + - name: Cache CPython TSan build + id: cache-cpython + uses: actions/cache@v4 + with: + path: cpython-tsan + key: cpython-tsan-3.14-${{ runner.os }}-v1 + + - name: Build CPython 3.14t with Thread Sanitizer + if: steps.cache-cpython.outputs.cache-hit != 'true' + run: | + git clone --depth 1 https://github.com/python/cpython.git -b 3.14 cpython-src + cd cpython-src + CC=clang CXX=clang++ ./configure --disable-gil --with-thread-sanitizer \ + --prefix $GITHUB_WORKSPACE/cpython-tsan + make -j$(nproc) + make install + + - name: Create venv and install dependencies + run: | + $GITHUB_WORKSPACE/cpython-tsan/bin/python3.14t -m venv .tsan-venv + source .tsan-venv/bin/activate + pip install numpy scipy pytest meson-python meson ninja + + - name: Build and install scs + run: | + source .tsan-venv/bin/activate + pip install -v . --no-build-isolation + env: + CC: clang + CFLAGS: "-fsanitize=thread -g" + + - name: Run tests under TSan + run: | + source .tsan-venv/bin/activate + python -m pytest test/ -v -s + env: + TSAN_OPTIONS: "halt_on_error=1 allocator_may_return_null=1 suppressions=${{ github.workspace }}/tsan-suppressions.txt" + + - name: Run threading stress tests under TSan + run: | + source .tsan-venv/bin/activate + python -m pytest test/test_free_threading.py test/test_thread_safety.py -v -s + env: + TSAN_OPTIONS: "halt_on_error=1 allocator_may_return_null=1 suppressions=${{ github.workspace }}/tsan-suppressions.txt" diff --git a/pyproject.toml b/pyproject.toml index cb95cd60..a2140c42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,3 +100,10 @@ before-all = [ testpaths = [ "test", ] +faulthandler_timeout = 600 +markers = [ + "thread_unsafe: mark test as unsafe to run in parallel threads (pytest-run-parallel)", +] +# pytest-run-parallel: fixtures and functions that are not thread-safe +thread_unsafe_fixtures = ["capsys", "capfd"] +thread_unsafe_functions = ["numpy.random.seed"] diff --git a/scs/scsobject.h b/scs/scsobject.h index 4d8aec14..a687eed9 100644 --- a/scs/scsobject.h +++ b/scs/scsobject.h @@ -89,11 +89,17 @@ static int get_pos_int_param(char *key, scs_int *v, scs_int defVal, PyObject *opts) { *v = defVal; if (opts) { - PyObject *obj = PyDict_GetItemString(opts, key); + PyObject *obj = NULL; + int rc = PyDict_GetItemStringRef(opts, key, &obj); + if (rc < 0) { + return printErr(key); + } if (obj) { if (parse_pos_scs_int(obj, v) < 0) { + Py_DECREF(obj); return printErr(key); } + Py_DECREF(obj); } } return 0; @@ -117,26 +123,37 @@ static int get_cone_arr_dim(char *key, scs_int **varr, scs_int *vsize, /* get cone['key'] */ scs_int i, n = 0; scs_int *q = NULL; - PyObject *obj = PyDict_GetItemString(cone, key); + PyObject *obj = NULL; + int rc = PyDict_GetItemStringRef(cone, key, &obj); + if (rc < 0) { + return printErr(key); + } if (obj) { if (PyList_Check(obj)) { n = (scs_int)PyList_Size(obj); q = (scs_int *)scs_calloc(n, sizeof(scs_int)); for (i = 0; i < n; ++i) { - PyObject *qi = PyList_GetItem(obj, i); - if (parse_pos_scs_int(qi, &(q[i])) < 0) { + PyObject *qi = PyList_GetItemRef(obj, i); + if (!qi || parse_pos_scs_int(qi, &(q[i])) < 0) { + Py_XDECREF(qi); + scs_free(q); + Py_DECREF(obj); return printErr(key); } + Py_DECREF(qi); } } else if (PyInt_Check(obj) || PyLong_Check(obj)) { n = 1; q = (scs_int *)scs_malloc(sizeof(scs_int)); if (parse_pos_scs_int(obj, q) < 0) { + scs_free(q); + Py_DECREF(obj); return printErr(key); } } else if (PyArray_Check(obj)) { PyArrayObject *pobj = (PyArrayObject *)obj; if (!PyArray_ISINTEGER(pobj) || PyArray_NDIM(pobj) != 1) { + Py_DECREF(obj); return printErr(key); } n = (scs_int)PyArray_Size((PyObject *)obj); @@ -145,12 +162,16 @@ static int get_cone_arr_dim(char *key, scs_int **varr, scs_int *vsize, memcpy(q, (scs_int *)PyArray_DATA(px0), n * sizeof(scs_int)); Py_DECREF(px0); } else { + Py_DECREF(obj); return printErr(key); } if (PyErr_Occurred()) { /* potentially could have been triggered before */ + scs_free(q); + Py_DECREF(obj); return printErr(key); } + Py_DECREF(obj); } *vsize = n; *varr = q; @@ -162,14 +183,24 @@ static int get_cone_float_arr(char *key, scs_float **varr, scs_int *vsize, /* get cone['key'] */ scs_int i, n = 0; scs_float *q = NULL; - PyObject *obj = PyDict_GetItemString(cone, key); + PyObject *obj = NULL; + int rc = PyDict_GetItemStringRef(cone, key, &obj); + if (rc < 0) { + return printErr(key); + } if (obj) { if (PyList_Check(obj)) { n = (scs_int)PyList_Size(obj); q = (scs_float *)scs_calloc(n, sizeof(scs_float)); for (i = 0; i < n; ++i) { - PyObject *qi = PyList_GetItem(obj, i); + PyObject *qi = PyList_GetItemRef(obj, i); + if (!qi) { + scs_free(q); + Py_DECREF(obj); + return printErr(key); + } q[i] = (scs_float)PyFloat_AsDouble(qi); + Py_DECREF(qi); } } else if (PyInt_Check(obj) || PyLong_Check(obj) || PyFloat_Check(obj)) { n = 1; @@ -178,6 +209,7 @@ static int get_cone_float_arr(char *key, scs_float **varr, scs_int *vsize, } else if (PyArray_Check(obj)) { PyArrayObject *pobj = (PyArrayObject *)obj; if (!PyArray_ISFLOAT(pobj) || PyArray_NDIM(pobj) != 1) { + Py_DECREF(obj); return printErr(key); } n = (scs_int)PyArray_Size((PyObject *)obj); @@ -186,12 +218,16 @@ static int get_cone_float_arr(char *key, scs_float **varr, scs_int *vsize, memcpy(q, (scs_float *)PyArray_DATA(px0), n * sizeof(scs_float)); Py_DECREF(px0); } else { + Py_DECREF(obj); return printErr(key); } if (PyErr_Occurred()) { /* potentially could have been triggered before */ + scs_free(q); + Py_DECREF(obj); return printErr(key); } + Py_DECREF(obj); } *vsize = n; *varr = q; @@ -675,10 +711,6 @@ static PyObject *SCS_solve(SCS *self, PyObject *args) { npy_intp veclen[1]; int scs_float_type = scs_get_float_type(); - if (!self->work) { - return none_with_error("Workspace not initialized!"); - } - PyArrayObject *warm_x, *warm_y, *warm_s; PyObject *warm_start; @@ -698,10 +730,21 @@ static PyObject *SCS_solve(SCS *self, PyObject *args) { /* Acquire per-instance lock. Release the GIL first to avoid deadlock: * another thread may hold this lock inside scs_solve (with GIL released), * so we must not hold the GIL while waiting for the lock. */ + int lock_ok; Py_BEGIN_ALLOW_THREADS; - PyThread_acquire_lock(self->lock, WAIT_LOCK); + lock_ok = (PyThread_acquire_lock(self->lock, WAIT_LOCK) == PY_LOCK_ACQUIRED); Py_END_ALLOW_THREADS; + if (!lock_ok) { + return none_with_error("Failed to acquire instance lock"); + } + + /* Check workspace under lock to avoid TOCTOU race with SCS_finish */ + if (!self->work) { + PyThread_release_lock(self->lock); + return none_with_error("Workspace not initialized!"); + } + if (_warm_start) { /* If any of these of missing, we use the values in sol */ if (!Py_IsNone((PyObject *)warm_x)) { @@ -735,7 +778,9 @@ static PyObject *SCS_solve(SCS *self, PyObject *args) { Py_END_ALLOW_THREADS; /* Copy results out of sol while still holding the lock, because another - * thread's solve could overwrite sol as soon as we release. */ + * thread's solve could overwrite sol as soon as we release. + * Note: unlike SCS_update, we release the lock after Py_END_ALLOW_THREADS + * because we need to read from sol (shared state) under lock protection. */ veclen[0] = self->n; _x = scs_malloc(self->n * sizeof(scs_float)); memcpy(_x, sol->x, self->n * sizeof(scs_float)); @@ -829,11 +874,6 @@ PyObject *SCS_update(SCS *self, PyObject *args) { PyArrayObject *b_new, *c_new; scs_float *b = NULL, *c = NULL; - /* Check that the workspace is already initialized */ - if (!self->work) { - return none_with_error("Workspace not initialized!"); - } - /* b, c can be None, so don't check is PyArray_Type */ if (!PyArg_ParseTuple(args, "OO", &b_new, &c_new)) { return none_with_error("Error parsing inputs"); @@ -864,8 +904,32 @@ PyObject *SCS_update(SCS *self, PyObject *args) { } /* Acquire per-instance lock (release GIL first to avoid deadlock) */ + int lock_ok; + Py_BEGIN_ALLOW_THREADS; + lock_ok = (PyThread_acquire_lock(self->lock, WAIT_LOCK) == PY_LOCK_ACQUIRED); + Py_END_ALLOW_THREADS; + + if (!lock_ok) { + if (b) { Py_DECREF(b_new); } + if (c) { Py_DECREF(c_new); } + return none_with_error("Failed to acquire instance lock"); + } + + /* Check workspace under lock to avoid TOCTOU race with SCS_finish */ + if (!self->work) { + PyThread_release_lock(self->lock); + if (b) { Py_DECREF(b_new); } + if (c) { Py_DECREF(c_new); } + return none_with_error("Workspace not initialized!"); + } + + /* Release the GIL, run the update, then release the instance lock before + * re-acquiring the GIL. This avoids holding the instance lock while + * waiting for the GIL, which could deadlock if another GIL-holding thread + * is waiting on this lock. SCS_solve uses a different order (release lock + * after Py_END_ALLOW_THREADS) because it must copy results out of sol + * while still holding the lock. */ Py_BEGIN_ALLOW_THREADS; - PyThread_acquire_lock(self->lock, WAIT_LOCK); scs_update(self->work, b, c); PyThread_release_lock(self->lock); Py_END_ALLOW_THREADS; @@ -884,7 +948,10 @@ PyObject *SCS_update(SCS *self, PyObject *args) { /* Deallocate SCS object */ static scs_int SCS_finish(SCS *self) { if (self->work) { - /* Acquire lock to ensure no concurrent solve/update is in progress. */ + /* Acquire lock to ensure no concurrent solve/update is in progress. + * We don't check the return value here because this is the dealloc + * path — the object must be cleaned up regardless of lock status, + * and there is no Python caller to return an error to. */ if (self->lock) { PyThread_acquire_lock(self->lock, WAIT_LOCK); } diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 00000000..91e7ebbb --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,30 @@ +"""Shared pytest configuration for SCS tests.""" +import sys + +import pytest + +# Detect free-threaded build (3.13t+) +NOGIL_BUILD = hasattr(sys, "_is_gil_enabled") +gil_enabled_at_start = True +if NOGIL_BUILD: + gil_enabled_at_start = sys._is_gil_enabled() + + +def pytest_terminal_summary(terminalreporter, exitstatus, config): + """Fail the test run if the GIL was re-enabled during tests. + + Modeled after NumPy's conftest.py — if a C extension module is missing + the Py_MOD_GIL_NOT_USED declaration, CPython will silently re-enable + the GIL at import time, defeating free-threading. This hook catches that. + """ + if NOGIL_BUILD and not gil_enabled_at_start and sys._is_gil_enabled(): + tr = terminalreporter + tr.ensure_newline() + tr.section("GIL re-enabled", sep="=", red=True, bold=True) + tr.line("The GIL was re-enabled at runtime during the tests.") + tr.line("This can happen with no test failures if the RuntimeWarning") + tr.line("raised by Python when this happens is filtered by a test.") + tr.line("") + tr.line("Please ensure all C extension modules declare") + tr.line("Py_MOD_GIL_NOT_USED via PyUnstable_Module_SetGIL().") + pytest.exit("GIL re-enabled during tests", returncode=1) diff --git a/test/gen_random_cone_prob.py b/test/gen_random_cone_prob.py index b1707b23..4e9528a6 100644 --- a/test/gen_random_cone_prob.py +++ b/test/gen_random_cone_prob.py @@ -6,15 +6,17 @@ ############################################# -def gen_feasible(K, n, density): +def gen_feasible(K, n, density, rng=None): + if rng is None: + rng = np.random m = get_scs_cone_dims(K) - z = np.random.randn(m) + z = rng.randn(m) y = proj_dual_cone(z, K) # y = s - z; s = y - z # s = proj_cone(z,K) - A = sparse.rand(m, n, density, format="csc") - A.data = np.random.randn(A.nnz) - x = np.random.randn(n) + A = sparse.rand(m, n, density, format="csc", random_state=rng) + A.data = rng.randn(A.nnz) + x = rng.randn(n) c = -np.transpose(A).dot(y) b = A.dot(x) + s @@ -22,36 +24,40 @@ def gen_feasible(K, n, density): return data, np.dot(c, x) -def gen_infeasible(K, n): +def gen_infeasible(K, n, rng=None): + if rng is None: + rng = np.random m = get_scs_cone_dims(K) - z = np.random.randn(m) + z = rng.randn(m) y = proj_dual_cone(z, K) # y = s - z; - A = np.random.randn(m, n) + A = rng.randn(m, n) A = ( A - np.outer(y, np.transpose(A).dot(y)) / np.linalg.norm(y) ** 2 ) # dense... - b = np.random.randn(m) + b = rng.randn(m) b = -b / np.dot(b, y) - data = {"A": sparse.csc_matrix(A), "b": b, "c": np.random.randn(n)} + data = {"A": sparse.csc_matrix(A), "b": b, "c": rng.randn(n)} return data -def gen_unbounded(K, n): +def gen_unbounded(K, n, rng=None): + if rng is None: + rng = np.random m = get_scs_cone_dims(K) - z = np.random.randn(m) + z = rng.randn(m) s = proj_cone(z, K) - A = np.random.randn(m, n) - x = np.random.randn(n) + A = rng.randn(m, n) + x = rng.randn(n) A = A - np.outer(s + A.dot(x), x) / np.linalg.norm(x) ** 2 # dense... - c = np.random.randn(n) + c = rng.randn(n) c = -c / np.dot(c, x) - data = {"A": sparse.csc_matrix(A), "b": np.random.randn(m), "c": c} + data = {"A": sparse.csc_matrix(A), "b": rng.randn(m), "c": c} return data diff --git a/test/solve_random_cone_prob.py b/test/solve_random_cone_prob.py index 60cc4c51..8de0a603 100644 --- a/test/solve_random_cone_prob.py +++ b/test/solve_random_cone_prob.py @@ -18,13 +18,13 @@ def main(): pass for linear_solver in solvers: - np.random.seed(1) - solve_feasible(linear_solver) - solve_infeasible(linear_solver) - solve_unbounded(linear_solver) + rng = np.random.RandomState(3000) + solve_feasible(linear_solver, rng) + solve_infeasible(linear_solver, rng) + solve_unbounded(linear_solver, rng) -def solve_feasible(linear_solver): +def solve_feasible(linear_solver, rng): # cone: K = { "z": 10, @@ -36,7 +36,7 @@ def solve_feasible(linear_solver): "p": [-0.25, 0.5, 0.75, -0.33], } m = tools.get_scs_cone_dims(K) - data, p_star = tools.gen_feasible(K, n=m // 3, density=0.01) + data, p_star = tools.gen_feasible(K, n=m // 3, density=0.01, rng=rng) params = {"normalize": True, "scale": 5} sol = scs.solve(data, K, linear_solver=linear_solver, **params) @@ -47,7 +47,7 @@ def solve_feasible(linear_solver): print("dual error = ", (-np.dot(data["b"], y) - p_star) / p_star) -def solve_infeasible(linear_solver): +def solve_infeasible(linear_solver, rng): K = { "z": 10, "l": 15, @@ -58,12 +58,12 @@ def solve_infeasible(linear_solver): "p": [-0.25, 0.5, 0.75, -0.33], } m = tools.get_scs_cone_dims(K) - data = tools.gen_infeasible(K, n=m // 3) + data = tools.gen_infeasible(K, n=m // 3, rng=rng) params = {"normalize": True, "scale": 0.5} sol = scs.solve(data, K, linear_solver=linear_solver, **params) -def solve_unbounded(linear_solver): +def solve_unbounded(linear_solver, rng): K = { "z": 10, "l": 15, @@ -74,7 +74,7 @@ def solve_unbounded(linear_solver): "p": [-0.25, 0.5, 0.75, -0.33], } m = tools.get_scs_cone_dims(K) - data = tools.gen_unbounded(K, n=m // 3) + data = tools.gen_unbounded(K, n=m // 3, rng=rng) params = {"normalize": True, "scale": 0.5} sol = scs.solve(data, K, linear_solver=linear_solver, **params) diff --git a/test/test_free_threading.py b/test/test_free_threading.py index cc634d52..4872dbca 100644 --- a/test/test_free_threading.py +++ b/test/test_free_threading.py @@ -87,6 +87,7 @@ def _solve_and_check(solver, expected_x0, decimal=2): NUM_THREADS = 8 +@pytest.mark.thread_unsafe(reason="creates its own threads internally") class TestConcurrentIndependentInstances: """Each thread creates and solves its own SCS instance. @@ -228,6 +229,7 @@ def worker(): # Test: concurrent solves on a SHARED instance # --------------------------------------------------------------------------- +@pytest.mark.thread_unsafe(reason="creates its own threads internally") class TestConcurrentSharedInstance: """Multiple threads call solve() on the same SCS object. @@ -277,8 +279,59 @@ def worker(): # Test: concurrent solve + update sequences # --------------------------------------------------------------------------- +@pytest.mark.thread_unsafe(reason="creates its own threads internally") class TestConcurrentSolveUpdate: - """Each thread does a solve-update-solve sequence on its own instance.""" + """Concurrent solve and update operations.""" + + def test_shared_instance_concurrent_solve_and_update(self): + """Threads simultaneously call solve() and update() on the same instance. + + The per-instance lock serializes access, so no data race should occur. + We can't predict which operation runs first, but none should crash or + corrupt state. + """ + data, cone, _ = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + barrier = threading.Barrier(NUM_THREADS) + errors = [] + lock = threading.Lock() + + def solve_worker(): + try: + barrier.wait(timeout=10) + sol = solver.solve() + assert sol["info"]["status_val"] in (1, 2) + except Exception as e: + with lock: + errors.append(f"solve: {e}") + + def update_worker(): + try: + barrier.wait(timeout=10) + # Update with slightly perturbed b + solver.update(b=np.array([1.0, 0.0])) + except Exception as e: + with lock: + errors.append(f"update: {e}") + + threads = [] + for i in range(NUM_THREADS): + if i % 2 == 0: + t = threading.Thread(target=solve_worker) + else: + t = threading.Thread(target=update_worker) + threads.append(t) + t.start() + + for t in threads: + t.join(timeout=30) + assert not t.is_alive(), "Thread did not complete" + + assert len(errors) == 0, f"Errors in threads: {errors}" + + # Solver should still be usable after the concurrent barrage + sol = solver.solve() + assert sol["info"]["status_val"] in (1, 2) def test_concurrent_update_sequences(self): """Multiple threads each do solve -> update -> solve independently.""" @@ -351,6 +404,7 @@ def worker(): # Test: legacy solve() function under concurrency # --------------------------------------------------------------------------- +@pytest.mark.thread_unsafe(reason="creates its own threads internally") class TestConcurrentLegacySolve: """Test the legacy scs.solve() function from multiple threads.""" @@ -375,6 +429,7 @@ def worker(): # Test: thread creation and destruction stress test # --------------------------------------------------------------------------- +@pytest.mark.thread_unsafe(reason="creates its own threads internally") class TestThreadStress: """Stress tests with many short-lived threads.""" @@ -431,6 +486,7 @@ def worker(): # Test: result isolation (no cross-thread data corruption) # --------------------------------------------------------------------------- +@pytest.mark.thread_unsafe(reason="creates its own threads internally") class TestResultIsolation: """Verify that results from concurrent solves don't get mixed up.""" @@ -495,3 +551,498 @@ def worker_socp(): assert_almost_equal(val, exp1, decimal=2) else: assert_almost_equal(val, exp2, decimal=2) + + +# --------------------------------------------------------------------------- +# Stress tests targeting specific thread-safety bugs +# --------------------------------------------------------------------------- + +@pytest.mark.thread_unsafe(reason="creates its own threads internally") +class TestBorrowedRefSafety: + """Tests exercising PyDict/PyList ref safety during concurrent init. + + Before the fix, PyDict_GetItemString / PyList_GetItem returned borrowed + references that could be invalidated if another thread mutated the + container concurrently. These tests share mutable containers across + threads to exercise the strong-ref (PyDict_GetItemStringRef / + PyList_GetItemRef) code paths. + """ + + def test_shared_cone_dict_concurrent_init(self): + """Multiple threads construct SCS instances from the same cone dict. + + The cone dict is read by the C extension during __init__. With + borrowed refs, a concurrent mutation to the dict could invalidate + a pointer mid-parse. Strong refs prevent this. + """ + A = sp.csc_matrix([1.0, -1.0]).T.tocsc() + b = np.array([1.0, 0.0]) + c = np.array([-1.0]) + data = {"A": A, "b": b, "c": c} + cone = {"l": 2} # shared across all threads + + errors = [] + lock = threading.Lock() + + def worker(): + try: + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], 1.0, decimal=2) + except Exception as e: + with lock: + errors.append(str(e)) + + threads = [threading.Thread(target=worker) for _ in range(20)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + assert not t.is_alive() + + assert len(errors) == 0, f"Errors: {errors}" + + def test_shared_cone_with_list_values_concurrent_init(self): + """Cone dict with list values (SOC dimensions) shared across threads. + + Exercises PyList_GetItemRef in get_cone_arr_dim — the list elements + are read one-by-one during parsing. + """ + # Feasible SOCP: minimize c'x s.t. ||x|| <= t (one SOC of size 3) + # A = -I (3x2 + slack), b = 0, cone q=[3] + # With an extra nonneg cone to bound things + A = sp.vstack([ + -sp.eye(3, n=2, format="csc"), # SOC: (s0, s1, s2) = b - Ax + sp.eye(2, format="csc"), # nonneg: x >= 0 + ], format="csc") + b = np.array([1.0, 0.0, 0.0, 0.0, 0.0]) + c = np.array([-1.0, 0.0]) + data = {"A": A, "b": b, "c": c} + cone = {"q": [3], "l": 2} # list value — exercises PyList_GetItemRef + + errors = [] + lock = threading.Lock() + + def worker(): + try: + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] in (1, 2) + except Exception as e: + with lock: + errors.append(str(e)) + + threads = [threading.Thread(target=worker) for _ in range(20)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + assert not t.is_alive() + + assert len(errors) == 0, f"Errors: {errors}" + + def test_shared_cone_with_float_list_values_concurrent_init(self): + """Cone dict with float list values (power cone exponents) shared + across threads. + + Exercises PyList_GetItemRef in get_cone_float_arr — the float list + elements are read one-by-one during parsing. + """ + # Power cone: (x1, x2, x3) s.t. |x3| <= x1^p * x2^(1-p), x1,x2 >= 0 + # Each power cone triple is size 3, with exponent p + m = 3 + n = 3 + A = -sp.eye(m, n=n, format="csc") + b = np.array([1.0, 1.0, 0.0]) + c = np.array([0.0, 0.0, -1.0]) + data = {"A": A, "b": b, "c": c} + cone = {"p": [0.5]} # float list — exercises PyList_GetItemRef in get_cone_float_arr + + errors = [] + lock = threading.Lock() + + def worker(): + try: + solver = scs.SCS(data, cone, verbose=False, max_iters=10000) + sol = solver.solve() + # Any valid status is fine — we're testing thread safety not convergence + assert sol["info"]["status_val"] in (1, 2, -1, -2, -7) + except Exception as e: + with lock: + errors.append(str(e)) + + threads = [threading.Thread(target=worker) for _ in range(20)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + assert not t.is_alive() + + assert len(errors) == 0, f"Errors: {errors}" + + +@pytest.mark.thread_unsafe(reason="creates its own threads internally") +class TestTOCTOURaceSafety: + """Tests exercising the TOCTOU fix on self->work. + + Before the fix, SCS_solve and SCS_update checked self->work without + holding the lock. A concurrent dealloc (SCS_finish) could set + self->work = NULL between the check and the lock acquisition. + """ + + def test_rapid_create_solve_destroy(self): + """Rapidly create, solve, and let SCS instances be garbage collected. + + Many short-lived instances stress the init/finish paths. On + free-threaded builds, the dealloc can race with in-progress solves + if the TOCTOU check isn't under the lock. + """ + import gc + data, cone, expected = _make_simple_lp() + errors = [] + lock = threading.Lock() + + def worker(): + try: + for _ in range(50): + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + del solver + gc.collect() + except Exception as e: + with lock: + errors.append(str(e)) + + threads = [threading.Thread(target=worker) for _ in range(NUM_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=60) + assert not t.is_alive() + + assert len(errors) == 0, f"Errors: {errors}" + + def test_solve_after_del_raises_or_succeeds(self): + """Verify solve doesn't crash if workspace is gone. + + This is a single-threaded sanity check: calling solve on a + properly-constructed instance should work, and the object shouldn't + crash during cleanup. + """ + data, cone, expected = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + # Second solve should also work (warm start) + sol2 = solver.solve() + assert sol2["info"]["status_val"] == 1 + + +@pytest.mark.thread_unsafe(reason="creates its own threads internally") +class TestConcurrentSolveUpdateStress: + """Heavy stress tests for concurrent solve + update on shared instances.""" + + def test_solve_update_barrage_shared_instance(self): + """Many threads hammer solve() and update() on the same instance. + + This is a more aggressive version of + test_shared_instance_concurrent_solve_and_update, with more threads + and repeated rounds. + """ + data, cone, _ = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + errors = [] + lock = threading.Lock() + + def solve_worker(): + try: + for _ in range(10): + sol = solver.solve() + assert sol["info"]["status_val"] in (1, 2) + except Exception as e: + with lock: + errors.append(f"solve: {e}") + + def update_worker(): + try: + for _ in range(10): + solver.update(b=np.array([1.0, 0.0])) + except Exception as e: + with lock: + errors.append(f"update: {e}") + + threads = [] + for i in range(16): + if i % 2 == 0: + threads.append(threading.Thread(target=solve_worker)) + else: + threads.append(threading.Thread(target=update_worker)) + + for t in threads: + t.start() + for t in threads: + t.join(timeout=60) + assert not t.is_alive() + + assert len(errors) == 0, f"Errors: {errors}" + + # Instance should still be usable + sol = solver.solve() + assert sol["info"]["status_val"] in (1, 2) + + def test_concurrent_update_different_data(self): + """Threads update with different b/c vectors concurrently. + + After the barrage, we verify the solver still produces valid results + (not corrupted state). + """ + data, cone, _ = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + errors = [] + lock = threading.Lock() + rng = np.random.RandomState(42) + + # Pre-generate update vectors (each thread gets its own) + b_updates = [np.array([rng.uniform(0.5, 2.0), 0.0]) for _ in range(NUM_THREADS)] + c_updates = [np.array([rng.uniform(-2.0, -0.5)]) for _ in range(NUM_THREADS)] + + barrier = threading.Barrier(NUM_THREADS) + + def worker(tid): + try: + barrier.wait(timeout=10) + for _ in range(5): + solver.update(b=b_updates[tid], c=c_updates[tid]) + sol = solver.solve() + # Just check it didn't crash or return garbage status + assert sol["info"]["status_val"] in (1, 2, -2, -7) + except Exception as e: + with lock: + errors.append(f"thread {tid}: {e}") + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(NUM_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=60) + assert not t.is_alive() + + assert len(errors) == 0, f"Errors: {errors}" + + def test_warm_start_under_contention(self): + """Concurrent warm-started solves on a shared instance. + + Warm starts read and write the sol struct, which is protected by the + per-instance lock. This test stresses that path. + """ + data, cone, expected = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + + # Initial cold solve to populate warm-start data + sol0 = solver.solve() + assert sol0["info"]["status_val"] == 1 + + errors = [] + lock = threading.Lock() + + def worker(): + try: + for _ in range(10): + sol = solver.solve( + x=np.array([0.9]), + y=sol0["y"], + s=sol0["s"], + ) + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + except Exception as e: + with lock: + errors.append(str(e)) + + threads = [threading.Thread(target=worker) for _ in range(NUM_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=60) + assert not t.is_alive() + + assert len(errors) == 0, f"Errors: {errors}" + + +@pytest.mark.thread_unsafe(reason="creates its own threads internally") +class TestReInitRace: + """Test calling __init__ on an object while another thread is solving. + + This is unusual (re-initializing an already-initialized object), but + it is possible in Python and exercises the SCS_init guard that checks + if self->work is already set, as well as the TOCTOU fix on dealloc. + """ + + def test_reinit_while_solving(self): + """One thread solves while another re-inits the same object. + + Neither thread should crash. The solve may fail (workspace torn + down under it) or succeed (lock serialized the operations). We + only assert no segfaults/corruption. + """ + data, cone, _ = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + errors = [] + lock = threading.Lock() + barrier = threading.Barrier(2) + + def solve_loop(): + try: + barrier.wait(timeout=10) + for _ in range(20): + try: + sol = solver.solve() + # Might succeed or fail -- either is fine + assert sol["info"]["status_val"] in (1, 2, -1, -2, -7) + except ValueError: + # "Workspace not initialized!" is expected if + # re-init tore down the workspace + pass + except Exception as e: + with lock: + errors.append(f"solve: {e}") + + def reinit_loop(): + try: + barrier.wait(timeout=10) + for _ in range(20): + try: + # Re-initialize with same data -- SCS_init checks + # if self->work is set and errors out + solver.__init__(data, cone, verbose=False) + except (ValueError, TypeError): + pass + except Exception as e: + with lock: + errors.append(f"reinit: {e}") + + t1 = threading.Thread(target=solve_loop) + t2 = threading.Thread(target=reinit_loop) + t1.start() + t2.start() + t1.join(timeout=30) + t2.join(timeout=30) + assert not t1.is_alive() + assert not t2.is_alive() + + assert len(errors) == 0, f"Errors: {errors}" + + +@pytest.mark.thread_unsafe(reason="creates its own threads internally") +class TestErrorPathContention: + """Tests that lock is properly released on error paths. + + If a thread hits an error (e.g. bad warm-start dimensions) inside + the locked section, it must release the lock before returning. If it + doesn't, subsequent calls from other threads will deadlock. + """ + + def test_bad_warm_start_does_not_deadlock(self): + """One thread passes wrong-dimension warm-start vectors while others + solve normally. The error path must release the lock. + """ + data, cone, expected = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + + # Do an initial solve so warm-start data is populated + solver.solve() + + errors = [] + lock = threading.Lock() + barrier = threading.Barrier(NUM_THREADS) + + def good_worker(): + try: + barrier.wait(timeout=10) + for _ in range(10): + sol = solver.solve() + assert sol["info"]["status_val"] == 1 + assert_almost_equal(sol["x"][0], expected, decimal=2) + except Exception as e: + with lock: + errors.append(f"good: {e}") + + def bad_worker(): + try: + barrier.wait(timeout=10) + for _ in range(10): + try: + # Wrong dimension x -- should raise ValueError + solver.solve(x=np.array([1.0, 2.0, 3.0])) + except ValueError: + pass # expected -- lock must have been released + except Exception as e: + with lock: + errors.append(f"bad: {e}") + + threads = [] + for i in range(NUM_THREADS): + if i == 0: + threads.append(threading.Thread(target=bad_worker)) + else: + threads.append(threading.Thread(target=good_worker)) + + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + assert not t.is_alive(), "Thread deadlocked -- lock not released on error?" + + assert len(errors) == 0, f"Errors: {errors}" + + def test_bad_update_does_not_deadlock(self): + """One thread passes wrong-dimension update vectors while others + solve normally. The error path in SCS_update must release the lock. + """ + data, cone, expected = _make_simple_lp() + solver = scs.SCS(data, cone, verbose=False) + errors = [] + lock = threading.Lock() + barrier = threading.Barrier(NUM_THREADS) + + def good_worker(): + try: + barrier.wait(timeout=10) + for _ in range(10): + sol = solver.solve() + assert sol["info"]["status_val"] in (1, 2) + except Exception as e: + with lock: + errors.append(f"good: {e}") + + def bad_worker(): + try: + barrier.wait(timeout=10) + for _ in range(10): + try: + # Wrong dimension b -- should raise ValueError + solver.update(b=np.array([1.0, 2.0, 3.0])) + except ValueError: + pass # expected + except Exception as e: + with lock: + errors.append(f"bad: {e}") + + threads = [] + for i in range(NUM_THREADS): + if i == 0: + threads.append(threading.Thread(target=bad_worker)) + else: + threads.append(threading.Thread(target=good_worker)) + + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + assert not t.is_alive(), "Thread deadlocked -- lock not released on error?" + + assert len(errors) == 0, f"Errors: {errors}" diff --git a/test/test_mix_sd_csd_cone.py b/test/test_mix_sd_csd_cone.py index 23556ca0..f918e791 100644 --- a/test/test_mix_sd_csd_cone.py +++ b/test/test_mix_sd_csd_cone.py @@ -2,12 +2,12 @@ import scs import scipy import pytest - -def gen_feasible(m, n, p_scale = 0.1): - P = p_scale * scipy.sparse.eye(n, format="csc") - A = scipy.sparse.random(m, n, density=0.05, format="csc") - c = np.random.randn(n) - b = np.random.randn(m) + +def gen_feasible(m, n, rng, p_scale = 0.1): + P = p_scale * scipy.sparse.eye(n, format="csc") + A = scipy.sparse.random(m, n, density=0.05, format="csc", random_state=rng) + c = rng.randn(n) + b = rng.randn(m) return (P, A, b, c) @@ -30,12 +30,11 @@ def gen_feasible(m, n, p_scale = 0.1): @pytest.mark.parametrize("solver_opts", _solver_configs) def test_mix_sd_csd_cones(solver_opts): - seed = 1234 - np.random.seed(seed) + rng = np.random.RandomState(1234) cone = dict(z=1, l=2, s=[3, 4], cs=[5, 4]) m = int(cone['z'] + cone['l'] + sum([j * (j+1) / 2 for j in cone['s']]) + sum([j * j for j in cone['cs']])) n = m - (P, A, b, c) = gen_feasible(m, n) + (P, A, b, c) = gen_feasible(m, n, rng) probdata = dict(P=P, A=A, b=b, c=c) sol = scs.solve(probdata, cone, **solver_opts) np.testing.assert_equal(sol['info']['status'], 'solved') diff --git a/test/test_scs_coverage.py b/test/test_scs_coverage.py index 61519ff3..27d905b4 100644 --- a/test/test_scs_coverage.py +++ b/test/test_scs_coverage.py @@ -347,12 +347,12 @@ def test_time_limit_secs_terminates_early(): sufficiently large or ill-conditioned problem.""" # Build a slightly larger problem (random feasible LP) where a near-zero # time limit causes early termination. - np.random.seed(42) + rng = np.random.RandomState(42) m, n = 50, 30 - A_large = sp.random(m, n, density=0.3, format="csc") - A_large.data = np.random.randn(A_large.nnz) - b_large = np.abs(np.random.randn(m)) + 1.0 - c_large = np.random.randn(n) + A_large = sp.random(m, n, density=0.3, format="csc", random_state=rng) + A_large.data = rng.randn(A_large.nnz) + b_large = np.abs(rng.randn(m)) + 1.0 + c_large = rng.randn(n) data_large = {"A": A_large, "b": b_large, "c": c_large} cone_large = {"l": m} @@ -1463,11 +1463,11 @@ def test_solution_y_s_shapes(): def test_solution_shapes_match_problem_dimensions(): """x, y, s shapes must match n and m from the problem data.""" n, m = 3, 5 - np.random.seed(99) - A_r = sp.random(m, n, density=0.8, format="csc") - A_r.data = np.random.randn(A_r.nnz) * 0.1 - b_r = np.abs(np.random.randn(m)) + 1.0 - c_r = np.random.randn(n) + rng = np.random.RandomState(99) + A_r = sp.random(m, n, density=0.8, format="csc", random_state=rng) + A_r.data = rng.randn(A_r.nnz) * 0.1 + b_r = np.abs(rng.randn(m)) + 1.0 + c_r = rng.randn(n) sol = scs.SCS( {"A": A_r, "b": b_r, "c": c_r}, {"l": m}, verbose=False ).solve() @@ -1592,10 +1592,10 @@ def test_dual_exp_cone_random_feasible(): sys.path.insert(0, "test") import gen_random_cone_prob as tools - np.random.seed(7) + rng = np.random.RandomState(7) K_ed = {"z": 3, "l": 5, "q": [4], "s": [], "ep": 0, "ed": 1, "p": []} m_ed = tools.get_scs_cone_dims(K_ed) - data, pstar = tools.gen_feasible(K_ed, n=m_ed // 3, density=0.2) + data, pstar = tools.gen_feasible(K_ed, n=m_ed // 3, density=0.2, rng=rng) solver = scs.SCS( data, K_ed, @@ -1627,12 +1627,12 @@ def test_mixed_soc_sdp_random_feasible(): sys.path.insert(0, "test") import gen_random_cone_prob as tools - np.random.seed(42) + rng = np.random.RandomState(42) K_mix = { "z": 5, "l": 5, "q": [4], "s": [3], "ep": 0, "ed": 0, "p": [] } m_mix = tools.get_scs_cone_dims(K_mix) - data, pstar = tools.gen_feasible(K_mix, n=m_mix // 3, density=0.2) + data, pstar = tools.gen_feasible(K_mix, n=m_mix // 3, density=0.2, rng=rng) solver = scs.SCS( data, K_mix, @@ -1767,10 +1767,10 @@ def test_large_random_lp(): sys.path.insert(0, "test") import gen_random_cone_prob as tools - np.random.seed(3) + rng = np.random.RandomState(3) K_lp = {"z": 0, "l": 40, "q": [], "s": [], "ep": 0, "ed": 0, "p": []} m_lp = tools.get_scs_cone_dims(K_lp) - data, pstar = tools.gen_feasible(K_lp, n=20, density=0.3) + data, pstar = tools.gen_feasible(K_lp, n=20, density=0.3, rng=rng) solver = scs.SCS( data, K_lp, @@ -1850,7 +1850,7 @@ def test_cs_cone_mixed_with_real_cones(): Uses a randomly generated feasible problem (P=ε·I ensures bounded objective). Checks the solver returns a valid status. """ - np.random.seed(1234) + rng = np.random.RandomState(1234) cone_cs = {"z": 1, "l": 2, "s": [3, 4], "cs": [5, 4]} # dimension: z=1, l=2, s=[3,4]->(3+4)→6+10=16, cs=[5,4]->25+16=41 => total=60 m_cs = int( @@ -1861,10 +1861,10 @@ def test_cs_cone_mixed_with_real_cones(): ) n_cs = m_cs P_cs = 0.1 * sp.eye(n_cs, format="csc") - A_cs = sp.random(m_cs, n_cs, density=0.05, format="csc") - A_cs.data = np.random.randn(A_cs.nnz) - b_cs = np.random.randn(m_cs) - c_cs = np.random.randn(n_cs) + A_cs = sp.random(m_cs, n_cs, density=0.05, format="csc", random_state=rng) + A_cs.data = rng.randn(A_cs.nnz) + b_cs = rng.randn(m_cs) + c_cs = rng.randn(n_cs) solver = scs.SCS( {"P": P_cs, "A": A_cs, "b": b_cs, "c": c_cs}, @@ -2078,14 +2078,14 @@ def test_mixed_ep_and_power_cone(): sys.path.insert(0, "test") import gen_random_cone_prob as tools - np.random.seed(77) + rng = np.random.RandomState(77) K_mix = { "z": 3, "l": 5, "q": [], "s": [], "ep": 1, "ed": 0, "p": [0.4, -0.6], } m_mix = tools.get_scs_cone_dims(K_mix) - data, pstar = tools.gen_feasible(K_mix, n=m_mix // 3, density=0.2) + data, pstar = tools.gen_feasible(K_mix, n=m_mix // 3, density=0.2, rng=rng) solver = scs.SCS( data, K_mix, @@ -2115,10 +2115,10 @@ def test_two_instances_same_problem_identical_result(): sys.path.insert(0, "test") import gen_random_cone_prob as tools - np.random.seed(11) + rng = np.random.RandomState(11) K = {"z": 3, "l": 5, "q": [4], "s": [], "ep": 0, "ed": 0, "p": []} m = tools.get_scs_cone_dims(K) - data, _ = tools.gen_feasible(K, n=m // 2, density=0.2) + data, _ = tools.gen_feasible(K, n=m // 2, density=0.2, rng=rng) sol1 = scs.SCS(data, K, verbose=False).solve() sol2 = scs.SCS(data, K, verbose=False).solve() @@ -2772,17 +2772,17 @@ def test_P_only_lower_triangular(): def test_all_cone_types_simultaneously(): """Problem using z, l, q, s, ep, p cone types all at once.""" - np.random.seed(42) + rng = np.random.RandomState(42) # Cone dims: z=1, l=2, q=[3], s=[2] (vec dim=3), ep=1 (dim 3), p=[0.5] (dim 3) # Total rows: 1 + 2 + 3 + 3 + 3 + 3 = 15 cone = {"z": 1, "l": 2, "q": [3], "s": [2], "ep": 1, "p": [0.5]} m = 15 n = m P = 0.1 * sp.eye(n, format="csc") - A = sp.random(m, n, density=0.1, format="csc") - A.data = np.random.randn(A.nnz) - b = np.random.randn(m) - c = np.random.randn(n) + A = sp.random(m, n, density=0.1, format="csc", random_state=rng) + A.data = rng.randn(A.nnz) + b = rng.randn(m) + c = rng.randn(n) data = {"P": P, "A": A, "b": b, "c": c} sol = scs.solve(data, cone, verbose=False, max_iters=50000) assert sol["info"]["status"] in ("solved", "solved_inaccurate") diff --git a/test/test_scs_rand.py b/test/test_scs_rand.py index e5433af1..30909437 100644 --- a/test/test_scs_rand.py +++ b/test/test_scs_rand.py @@ -52,7 +52,6 @@ def check_unbounded(sol): assert_(sol["info"]["status"], "unbounded") -np.random.seed(0) num_feas = 50 num_unb = 10 num_infeas = 10 @@ -92,8 +91,9 @@ def check_unbounded(sol): @pytest.mark.parametrize("solver_opts", _solver_configs) def test_feasible(solver_opts): + rng = np.random.RandomState(1000) for i in range(num_feas): - data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1) + data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1, rng=rng) solver = scs.SCS(data, K, **solver_opts, **opts) sol = solver.solve() assert_almost_equal(np.dot(data["c"], sol["x"]), p_star, decimal=2) @@ -102,8 +102,9 @@ def test_feasible(solver_opts): @pytest.mark.parametrize("solver_opts", _solver_configs) def test_infeasible(solver_opts): + rng = np.random.RandomState(1001) for i in range(num_infeas): - data = tools.gen_infeasible(K, n=m // 2) + data = tools.gen_infeasible(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, **solver_opts, **opts) sol = solver.solve() check_infeasible(sol) @@ -117,8 +118,9 @@ def test_infeasible(solver_opts): @pytest.mark.parametrize("solver_opts", _unbounded_configs) def test_unbounded(solver_opts): + rng = np.random.RandomState(1002) for i in range(num_unb): - data = tools.gen_unbounded(K, n=m // 2) + data = tools.gen_unbounded(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, **solver_opts, **opts) sol = solver.solve() check_unbounded(sol) diff --git a/test/test_scs_sdp.py b/test/test_scs_sdp.py index 7c27301d..b0967b46 100644 --- a/test/test_scs_sdp.py +++ b/test/test_scs_sdp.py @@ -52,7 +52,6 @@ def check_unbounded(sol): assert_(sol["info"]["status"], "unbounded") -np.random.seed(0) num_feas = 50 num_unb = 10 num_infeas = 10 @@ -92,8 +91,9 @@ def check_unbounded(sol): @pytest.mark.parametrize("solver_opts", _solver_configs) def test_feasible(solver_opts): + rng = np.random.RandomState(2000) for i in range(num_feas): - data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1) + data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1, rng=rng) solver = scs.SCS(data, K, **solver_opts, **opts) sol = solver.solve() assert_almost_equal(np.dot(data["c"], sol["x"]), p_star, decimal=2) @@ -102,8 +102,9 @@ def test_feasible(solver_opts): @pytest.mark.parametrize("solver_opts", _solver_configs) def test_infeasible(solver_opts): + rng = np.random.RandomState(2001) for i in range(num_infeas): - data = tools.gen_infeasible(K, n=m // 2) + data = tools.gen_infeasible(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, **solver_opts, **opts) sol = solver.solve() check_infeasible(sol) @@ -117,8 +118,9 @@ def test_infeasible(solver_opts): @pytest.mark.parametrize("solver_opts", _unbounded_configs) def test_unbounded(solver_opts): + rng = np.random.RandomState(2002) for i in range(num_unb): - data = tools.gen_unbounded(K, n=m // 2) + data = tools.gen_unbounded(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, **solver_opts, **opts) sol = solver.solve() check_unbounded(sol) diff --git a/test/test_solve_random_cone_prob.py b/test/test_solve_random_cone_prob.py index 2a27b37e..a3882e12 100644 --- a/test/test_solve_random_cone_prob.py +++ b/test/test_solve_random_cone_prob.py @@ -29,8 +29,6 @@ def import_error(msg): except ImportError: pass -np.random.seed(1) - # cone: K = { "z": 10, @@ -47,7 +45,8 @@ def import_error(msg): @pytest.mark.parametrize("linear_solver", solvers) def test_solve_feasible(linear_solver): - data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1) + rng = np.random.RandomState(3000) + data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1, rng=rng) solver = scs.SCS(data, K, linear_solver=linear_solver, **params) sol = solver.solve() x = sol["x"] @@ -68,7 +67,8 @@ def test_solve_feasible(linear_solver): @pytest.mark.parametrize("linear_solver", solvers) def test_solve_infeasible(linear_solver): - data = tools.gen_infeasible(K, n=m // 2) + rng = np.random.RandomState(3001) + data = tools.gen_infeasible(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=linear_solver, **params) sol = solver.solve() y = sol["y"] @@ -80,7 +80,8 @@ def test_solve_infeasible(linear_solver): # TODO: indirect solver has trouble in this test, so disable for now @pytest.mark.parametrize("linear_solver", [scs.LinearSolver.QDLDL]) def test_solve_unbounded(linear_solver): - data = tools.gen_unbounded(K, n=m // 2) + rng = np.random.RandomState(3002) + data = tools.gen_unbounded(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=linear_solver, **params) sol = solver.solve() x = sol["x"] diff --git a/test/test_solve_random_cone_prob_cudss.py b/test/test_solve_random_cone_prob_cudss.py index fcf280eb..664be696 100644 --- a/test/test_solve_random_cone_prob_cudss.py +++ b/test/test_solve_random_cone_prob_cudss.py @@ -21,8 +21,6 @@ def import_error(msg): import_error("Please install pytest to run tests.") raise -np.random.seed(1) - # cone: K = { "z": 10, @@ -41,7 +39,8 @@ def import_error(msg): from scs import _scs_cudss def test_solve_feasible(): - data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1) + rng = np.random.RandomState(3000) + data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.CUDSS, **params) sol = solver.solve() x = sol["x"] @@ -60,7 +59,8 @@ def test_solve_feasible(): np.testing.assert_almost_equal(y, tools.proj_dual_cone(y, K), decimal=4) def test_solve_infeasible(): - data = tools.gen_infeasible(K, n=m // 2) + rng = np.random.RandomState(3001) + data = tools.gen_infeasible(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.CUDSS, **params) sol = solver.solve() y = sol["y"] @@ -69,7 +69,8 @@ def test_solve_infeasible(): np.testing.assert_almost_equal(y, tools.proj_dual_cone(y, K), decimal=4) def test_solve_unbounded(): - data = tools.gen_unbounded(K, n=m // 2) + rng = np.random.RandomState(3002) + data = tools.gen_unbounded(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.CUDSS, **params) sol = solver.solve() x = sol["x"] diff --git a/test/test_solve_random_cone_prob_dense.py b/test/test_solve_random_cone_prob_dense.py index 327d1a09..c95d1ef3 100644 --- a/test/test_solve_random_cone_prob_dense.py +++ b/test/test_solve_random_cone_prob_dense.py @@ -21,8 +21,6 @@ def import_error(msg): import_error("Please install pytest to run tests.") raise -np.random.seed(1) - # cone: K = { "z": 10, @@ -41,7 +39,8 @@ def import_error(msg): from scs import _scs_dense def test_solve_feasible(): - data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1) + rng = np.random.RandomState(3000) + data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.DENSE, **params) sol = solver.solve() x = sol["x"] @@ -60,7 +59,8 @@ def test_solve_feasible(): np.testing.assert_almost_equal(y, tools.proj_dual_cone(y, K), decimal=4) def test_solve_infeasible(): - data = tools.gen_infeasible(K, n=m // 2) + rng = np.random.RandomState(3001) + data = tools.gen_infeasible(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.DENSE, **params) sol = solver.solve() y = sol["y"] @@ -69,7 +69,8 @@ def test_solve_infeasible(): np.testing.assert_almost_equal(y, tools.proj_dual_cone(y, K), decimal=4) def test_solve_unbounded(): - data = tools.gen_unbounded(K, n=m // 2) + rng = np.random.RandomState(3002) + data = tools.gen_unbounded(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.DENSE, **params) sol = solver.solve() x = sol["x"] diff --git a/test/test_solve_random_cone_prob_mkl.py b/test/test_solve_random_cone_prob_mkl.py index 6aef9033..efb343ed 100644 --- a/test/test_solve_random_cone_prob_mkl.py +++ b/test/test_solve_random_cone_prob_mkl.py @@ -27,8 +27,6 @@ # musllinux x86_64 ships openblas, not MKL pytest.skip("MKL module not installed", allow_module_level=True) -np.random.seed(1) - # cone: K = { "z": 10, @@ -53,7 +51,8 @@ def test_mkl_module_links_intel_openmp(): def test_solve_feasible(): - data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1) + rng = np.random.RandomState(3000) + data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.MKL, **params) sol = solver.solve() x = sol["x"] @@ -72,7 +71,8 @@ def test_solve_feasible(): def test_solve_infeasible(): - data = tools.gen_infeasible(K, n=m // 2) + rng = np.random.RandomState(3001) + data = tools.gen_infeasible(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.MKL, **params) sol = solver.solve() y = sol["y"] @@ -82,7 +82,8 @@ def test_solve_infeasible(): def test_solve_unbounded(): - data = tools.gen_unbounded(K, n=m // 2) + rng = np.random.RandomState(3002) + data = tools.gen_unbounded(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.MKL, **params) sol = solver.solve() x = sol["x"] diff --git a/test/test_spectral_and_complex_cones.py b/test/test_spectral_and_complex_cones.py index 92a13fd7..0b73725c 100644 --- a/test/test_spectral_and_complex_cones.py +++ b/test/test_spectral_and_complex_cones.py @@ -51,20 +51,22 @@ def _cone_dims(cone): return m -def _gen_feasible_qp(cone, n=None, p_scale=1.0): +def _gen_feasible_qp(cone, n=None, p_scale=1.0, rng=None): """Generate a random feasible QP over the given cone. Uses P = p_scale * I (strong regularisation) and dense-ish A to guarantee boundedness and feasibility. """ + if rng is None: + rng = np.random m = _cone_dims(cone) if n is None: n = m P = p_scale * sp.eye(n, format="csc") - A = sp.random(m, n, density=0.5, format="csc") - A.data = np.random.randn(A.nnz) - c = np.random.randn(n) - b = A @ np.random.randn(n) + np.abs(np.random.randn(m)) + A = sp.random(m, n, density=0.5, format="csc", random_state=rng) + A.data = rng.randn(A.nnz) + c = rng.randn(n) + b = A @ rng.randn(n) + np.abs(rng.randn(m)) return dict(P=P, A=A, b=b, c=c) @@ -120,25 +122,25 @@ class TestComplexPSDCone: @pytest.mark.parametrize("solver_opts", _solver_configs) def test_complex_psd_feasibility(self, solver_opts): - np.random.seed(42) + rng = np.random.RandomState(42) cone = {"cs": [3]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_complex_psd_multiple(self, solver_opts): - np.random.seed(123) + rng = np.random.RandomState(123) cone = {"cs": [2, 3]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_mixed_real_complex_psd(self, solver_opts): - np.random.seed(456) + rng = np.random.RandomState(456) cone = dict(z=1, l=2, s=[3], cs=[3]) - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False) _assert_solved(sol) @@ -153,33 +155,33 @@ class TestEll1Cone: @pytest.mark.parametrize("solver_opts", _solver_configs) def test_ell1_simple(self, solver_opts): - np.random.seed(10) + rng = np.random.RandomState(10) cone = {"ell1": [4]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_ell1_multiple(self, solver_opts): - np.random.seed(20) + rng = np.random.RandomState(20) cone = {"ell1": [3, 5]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_ell1_mixed_with_standard_cones(self, solver_opts): - np.random.seed(30) + rng = np.random.RandomState(30) cone = dict(z=1, l=2, q=[3], ell1=[4]) - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False) _assert_solved(sol) def test_ell1_norm_bound(self): """Verify s in ell1 cone satisfies s[0] >= ||s[1:]||_1.""" - np.random.seed(35) + rng = np.random.RandomState(35) cone = {"ell1": [5]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, verbose=False, eps_abs=1e-9, eps_rel=1e-9) if sol["info"]["status_val"] == 1: s = sol["s"] @@ -196,25 +198,25 @@ class TestNuclearNormCone: @pytest.mark.parametrize("solver_opts", _solver_configs) def test_nuclear_simple(self, solver_opts): - np.random.seed(40) + rng = np.random.RandomState(40) cone = {"nuc_m": [3], "nuc_n": [2]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_nuclear_multiple(self, solver_opts): - np.random.seed(50) + rng = np.random.RandomState(50) cone = {"nuc_m": [3, 4], "nuc_n": [2, 3]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_nuclear_mixed(self, solver_opts): - np.random.seed(60) + rng = np.random.RandomState(60) cone = dict(l=3, nuc_m=[3], nuc_n=[2]) - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False) _assert_solved(sol) @@ -227,10 +229,10 @@ def test_nuclear_mismatched_lengths_raises(self): def test_nuclear_norm_bound(self): """Verify s in nuclear norm cone satisfies ||X||_* <= t.""" - np.random.seed(65) + rng = np.random.RandomState(65) rows, cols = 4, 3 cone = {"nuc_m": [rows], "nuc_n": [cols]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, verbose=False, eps_abs=1e-9, eps_rel=1e-9) if sol["info"]["status_val"] == 1: s = sol["s"] @@ -251,25 +253,25 @@ class TestLogdetCone: @pytest.mark.parametrize("solver_opts", _solver_configs) def test_logdet_simple(self, solver_opts): - np.random.seed(70) + rng = np.random.RandomState(70) cone = {"d": [3]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False, max_iters=10000) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_logdet_multiple(self, solver_opts): - np.random.seed(80) + rng = np.random.RandomState(80) cone = {"d": [2, 3]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False, max_iters=10000) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_logdet_mixed(self, solver_opts): - np.random.seed(90) + rng = np.random.RandomState(90) cone = dict(l=2, d=[2]) - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False, max_iters=10000) _assert_solved(sol) @@ -284,25 +286,25 @@ class TestSumLargestCone: @pytest.mark.parametrize("solver_opts", _solver_configs) def test_sum_largest_simple(self, solver_opts): - np.random.seed(100) + rng = np.random.RandomState(100) cone = {"sl_n": [4], "sl_k": [2]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False, max_iters=10000) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_sum_largest_multiple(self, solver_opts): - np.random.seed(110) + rng = np.random.RandomState(110) cone = {"sl_n": [3, 4], "sl_k": [1, 2]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False, max_iters=10000) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_sum_largest_mixed(self, solver_opts): - np.random.seed(120) + rng = np.random.RandomState(120) cone = dict(z=1, l=2, sl_n=[3], sl_k=[1]) - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False, max_iters=10000) _assert_solved(sol) @@ -325,7 +327,7 @@ class TestAllConesCombined: @pytest.mark.parametrize("solver_opts", _solver_configs) def test_kitchen_sink(self, solver_opts): """Problem with every cone type present.""" - np.random.seed(200) + rng = np.random.RandomState(200) cone = dict( z=1, l=2, @@ -342,15 +344,15 @@ def test_kitchen_sink(self, solver_opts): sl_n=[3], sl_k=[1], ) - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False, max_iters=10000) _assert_solved(sol) @pytest.mark.parametrize("solver_opts", _solver_configs) def test_spectral_and_complex_psd(self, solver_opts): - np.random.seed(210) + rng = np.random.RandomState(210) cone = dict(cs=[3], ell1=[5], nuc_m=[4], nuc_n=[3]) - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, **solver_opts, verbose=False, max_iters=5000) _assert_solved(sol) @@ -364,37 +366,37 @@ def test_spectral_and_complex_psd(self, solver_opts): class TestEdgeCases: def test_ell1_dim_one(self): - np.random.seed(300) + rng = np.random.RandomState(300) cone = {"ell1": [1]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, verbose=False) _assert_solved(sol) def test_nuclear_square(self): - np.random.seed(310) + rng = np.random.RandomState(310) cone = {"nuc_m": [3], "nuc_n": [3]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, verbose=False) _assert_solved(sol) def test_logdet_dim_one(self): - np.random.seed(320) + rng = np.random.RandomState(320) cone = {"d": [1]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, verbose=False, max_iters=10000) _assert_solved(sol) def test_sum_largest_k_one(self): - np.random.seed(330) + rng = np.random.RandomState(330) cone = {"sl_n": [3], "sl_k": [1]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) sol = scs.solve(data, cone, verbose=False, max_iters=10000) _assert_solved(sol) def test_warm_start_with_spectral(self): - np.random.seed(340) + rng = np.random.RandomState(340) cone = {"ell1": [4]} - data = _gen_feasible_qp(cone) + data = _gen_feasible_qp(cone, rng=rng) solver = scs.SCS(data, cone, verbose=False, max_iters=500) sol1 = solver.solve() sol2 = solver.solve(warm_start=True) diff --git a/test/test_thread_safety.py b/test/test_thread_safety.py index 3f5c3f12..dfbc3966 100644 --- a/test/test_thread_safety.py +++ b/test/test_thread_safety.py @@ -8,6 +8,7 @@ from concurrent.futures import ThreadPoolExecutor import numpy as np +import pytest import scipy.sparse as sp from numpy.testing import assert_almost_equal @@ -27,6 +28,7 @@ def _make_simple_lp(): return data, cone, 1.0 # expected x[0] +@pytest.mark.thread_unsafe(reason="creates its own threads internally") class TestThreadSafety: """Multiple threads call solve() on the same SCS object. diff --git a/tsan-suppressions.txt b/tsan-suppressions.txt new file mode 100644 index 00000000..9c6ad019 --- /dev/null +++ b/tsan-suppressions.txt @@ -0,0 +1,25 @@ +# Thread Sanitizer suppressions for scs-python +# +# These suppress known CPython-internal races that are not caused by scs. +# Based on: https://github.com/python/cpython/blob/3.14/Tools/tsan/suppressions_free_threading.txt + +# CPython internal races (free-threaded build) +race_top:assign_version_tag +race_top:_Py_slot_tp_getattr_hook +race_top:dump_traceback +race_top:fatal_error +race_top:_PyFrame_GetCode +race_top:_PyFrame_Initialize +race_top:_PyObject_TryGetInstanceAttribute +race_top:PyUnstable_InterpreterFrame_GetLine +race_top:type_modified_unlocked +race_top:write_thread_id +race_top:rangeiter_next +race_top:mi_block_set_nextx +race_top:update_one_slot +race_top:set_tp_bases +race_top:type_set_bases_unlocked +thread:pthread_create +race:list_ass_slice_lock_held +race:list_inplace_repeat_lock_held +race:PyObject_Realloc From 29880b03d325cdd513548d1ff46df571c296a558 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Tue, 7 Apr 2026 20:51:40 +0100 Subject: [PATCH 07/18] Fix thread-safe signal handling and link pthreads Update scs_source submodule to thread-safe-ctrlc branch which adds mutex-protected reference counting for signal handler registration, fixing TSan-detected data races when multiple threads solve concurrently. Add pthreads dependency to meson.build for the ctrlc mutex. Co-Authored-By: Claude Opus 4.6 --- meson.build | 1 + 1 file changed, 1 insertion(+) diff --git a/meson.build b/meson.build index c08f6d09..ae80eb57 100644 --- a/meson.build +++ b/meson.build @@ -132,6 +132,7 @@ if get_option('native_arch') endif _deps = [blas_deps] +_deps += dependency('threads') if get_option('use_openmp') _deps += dependency('openmp') endif From ead672e6c12cc1df65f547daceac27ac88ec9f2b Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Tue, 7 Apr 2026 23:48:46 +0100 Subject: [PATCH 08/18] Set OPENBLAS_NUM_THREADS=1 for TSan CI OpenBLAS spawns internal threads that are not instrumented with TSan annotations, causing false positive data race reports (e.g. in dsyrk worker threads racing with the caller after the BLAS call returns). Forcing single-threaded BLAS under TSan is standard practice. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index 59853eaf..537bc975 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -102,6 +102,7 @@ jobs: python -m pytest test/ -v -s env: TSAN_OPTIONS: "halt_on_error=1 allocator_may_return_null=1 suppressions=${{ github.workspace }}/tsan-suppressions.txt" + OPENBLAS_NUM_THREADS: "1" - name: Run threading stress tests under TSan run: | @@ -109,3 +110,4 @@ jobs: python -m pytest test/test_free_threading.py test/test_thread_safety.py -v -s env: TSAN_OPTIONS: "halt_on_error=1 allocator_may_return_null=1 suppressions=${{ github.workspace }}/tsan-suppressions.txt" + OPENBLAS_NUM_THREADS: "1" From 26e53ce8722988938d90eb72ed69579fad21cac5 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Wed, 8 Apr 2026 00:07:37 +0100 Subject: [PATCH 09/18] Add ASan CI job via sanitizer matrix Refactor the TSan CI job into a matrix over {tsan, asan}. Both build CPython 3.14t from source with the corresponding sanitizer flag. Runtime options (TSAN_OPTIONS, ASAN_OPTIONS, OPENBLAS_NUM_THREADS) are set via GITHUB_ENV in a shared setup step. ASan uses detect_leaks=0 to avoid false positives from CPython's internal memory allocator. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 56 ++++++++++++++--------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index 537bc975..16280e43 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -51,8 +51,19 @@ jobs: source .venv/bin/activate python -m pytest test/ -v --parallel-threads=4 --iterations=3 -p no:faulthandler -o faulthandler_timeout=600 - test_tsan: + test_sanitizers: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - sanitizer: tsan + cpython_configure_flag: "--with-thread-sanitizer" + cflags: "-fsanitize=thread -g" + - sanitizer: asan + cpython_configure_flag: "--with-address-sanitizer" + cflags: "-fsanitize=address -g" + steps: - uses: actions/checkout@v6 with: @@ -65,49 +76,52 @@ jobs: libbz2-dev libreadline-dev libsqlite3-dev libncurses5-dev \ libncursesw5-dev xz-utils libffi-dev liblzma-dev - - name: Cache CPython TSan build + - name: Cache CPython ${{ matrix.sanitizer }} build id: cache-cpython uses: actions/cache@v4 with: - path: cpython-tsan - key: cpython-tsan-3.14-${{ runner.os }}-v1 + path: cpython-${{ matrix.sanitizer }} + key: cpython-${{ matrix.sanitizer }}-3.14-${{ runner.os }}-v1 - - name: Build CPython 3.14t with Thread Sanitizer + - name: Build CPython 3.14t with ${{ matrix.sanitizer }} if: steps.cache-cpython.outputs.cache-hit != 'true' run: | git clone --depth 1 https://github.com/python/cpython.git -b 3.14 cpython-src cd cpython-src - CC=clang CXX=clang++ ./configure --disable-gil --with-thread-sanitizer \ - --prefix $GITHUB_WORKSPACE/cpython-tsan + CC=clang CXX=clang++ ./configure --disable-gil ${{ matrix.cpython_configure_flag }} \ + --prefix $GITHUB_WORKSPACE/cpython-${{ matrix.sanitizer }} make -j$(nproc) make install - name: Create venv and install dependencies run: | - $GITHUB_WORKSPACE/cpython-tsan/bin/python3.14t -m venv .tsan-venv - source .tsan-venv/bin/activate + $GITHUB_WORKSPACE/cpython-${{ matrix.sanitizer }}/bin/python3.14t -m venv .san-venv + source .san-venv/bin/activate pip install numpy scipy pytest meson-python meson ninja - name: Build and install scs run: | - source .tsan-venv/bin/activate + source .san-venv/bin/activate pip install -v . --no-build-isolation env: CC: clang - CFLAGS: "-fsanitize=thread -g" + CFLAGS: ${{ matrix.cflags }} - - name: Run tests under TSan + - name: Set sanitizer runtime options run: | - source .tsan-venv/bin/activate + if [ "${{ matrix.sanitizer }}" = "tsan" ]; then + echo "TSAN_OPTIONS=halt_on_error=1 allocator_may_return_null=1 suppressions=${{ github.workspace }}/tsan-suppressions.txt" >> $GITHUB_ENV + echo "OPENBLAS_NUM_THREADS=1" >> $GITHUB_ENV + elif [ "${{ matrix.sanitizer }}" = "asan" ]; then + echo "ASAN_OPTIONS=halt_on_error=1 detect_leaks=0 allocator_may_return_null=1" >> $GITHUB_ENV + fi + + - name: Run tests under ${{ matrix.sanitizer }} + run: | + source .san-venv/bin/activate python -m pytest test/ -v -s - env: - TSAN_OPTIONS: "halt_on_error=1 allocator_may_return_null=1 suppressions=${{ github.workspace }}/tsan-suppressions.txt" - OPENBLAS_NUM_THREADS: "1" - - name: Run threading stress tests under TSan + - name: Run threading stress tests under ${{ matrix.sanitizer }} run: | - source .tsan-venv/bin/activate + source .san-venv/bin/activate python -m pytest test/test_free_threading.py test/test_thread_safety.py -v -s - env: - TSAN_OPTIONS: "halt_on_error=1 allocator_may_return_null=1 suppressions=${{ github.workspace }}/tsan-suppressions.txt" - OPENBLAS_NUM_THREADS: "1" From 3e110c99a1ac097ef470fd58eff43b3bde92d51f Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Wed, 8 Apr 2026 00:25:15 +0100 Subject: [PATCH 10/18] Set sanitizer env vars before build step ASan's LeakSanitizer was killing the build because ASAN_OPTIONS was only set after the build. Move the env setup step before pip install so detect_leaks=0 applies during compilation too. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index 16280e43..a89f9670 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -99,14 +99,6 @@ jobs: source .san-venv/bin/activate pip install numpy scipy pytest meson-python meson ninja - - name: Build and install scs - run: | - source .san-venv/bin/activate - pip install -v . --no-build-isolation - env: - CC: clang - CFLAGS: ${{ matrix.cflags }} - - name: Set sanitizer runtime options run: | if [ "${{ matrix.sanitizer }}" = "tsan" ]; then @@ -116,6 +108,14 @@ jobs: echo "ASAN_OPTIONS=halt_on_error=1 detect_leaks=0 allocator_may_return_null=1" >> $GITHUB_ENV fi + - name: Build and install scs + run: | + source .san-venv/bin/activate + pip install -v . --no-build-isolation + env: + CC: clang + CFLAGS: ${{ matrix.cflags }} + - name: Run tests under ${{ matrix.sanitizer }} run: | source .san-venv/bin/activate From 2e4b3798984d5a2011f2e87276c82f581d6b10e5 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Wed, 8 Apr 2026 09:08:54 +0100 Subject: [PATCH 11/18] Move sanitizer suppressions to test/, add LSan suppressions Move tsan-suppressions.txt to test/ and add lsan-suppressions.txt for CPython-internal leaks. Use LSan suppressions instead of detect_leaks=0 so that real SCS leaks are still caught. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 5 +-- test/lsan-suppressions.txt | 35 +++++++++++++++++++ .../tsan-suppressions.txt | 0 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 test/lsan-suppressions.txt rename tsan-suppressions.txt => test/tsan-suppressions.txt (100%) diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index a89f9670..54040086 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -102,10 +102,11 @@ jobs: - name: Set sanitizer runtime options run: | if [ "${{ matrix.sanitizer }}" = "tsan" ]; then - echo "TSAN_OPTIONS=halt_on_error=1 allocator_may_return_null=1 suppressions=${{ github.workspace }}/tsan-suppressions.txt" >> $GITHUB_ENV + echo "TSAN_OPTIONS=halt_on_error=1 allocator_may_return_null=1 suppressions=${{ github.workspace }}/test/tsan-suppressions.txt" >> $GITHUB_ENV echo "OPENBLAS_NUM_THREADS=1" >> $GITHUB_ENV elif [ "${{ matrix.sanitizer }}" = "asan" ]; then - echo "ASAN_OPTIONS=halt_on_error=1 detect_leaks=0 allocator_may_return_null=1" >> $GITHUB_ENV + echo "ASAN_OPTIONS=halt_on_error=1 allocator_may_return_null=1" >> $GITHUB_ENV + echo "LSAN_OPTIONS=suppressions=${{ github.workspace }}/test/lsan-suppressions.txt" >> $GITHUB_ENV fi - name: Build and install scs diff --git a/test/lsan-suppressions.txt b/test/lsan-suppressions.txt new file mode 100644 index 00000000..c25eebed --- /dev/null +++ b/test/lsan-suppressions.txt @@ -0,0 +1,35 @@ +# Leak Sanitizer suppressions for scs-python +# +# CPython does not free all memory at exit (interned strings, singletons, +# pymalloc arenas, etc.). These are not real leaks. + +# CPython memory allocator internals +leak:_PyMem +leak:_PyObject +leak:PyMem +leak:PyObject_Malloc +leak:PyObject_Realloc +leak:PyObject_Calloc + +# CPython interpreter/module init +leak:_Py_InitializeMain +leak:Py_InitializeFromConfig +leak:PyModule +leak:PyImport +leak:_PyImport +leak:PyType_Ready +leak:type_new +leak:_PyType + +# CPython interned strings and caches +leak:PyUnicode +leak:_PyUnicode +leak:intern_string_constants +leak:PyCode_New + +# NumPy/SciPy internal allocations +leak:numpy +leak:scipy + +# Meson build probes (run during pip install) +leak:meson diff --git a/tsan-suppressions.txt b/test/tsan-suppressions.txt similarity index 100% rename from tsan-suppressions.txt rename to test/tsan-suppressions.txt From ddf02d5b97667c55922cc2cd9c8242806a4b53ef Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Fri, 10 Apr 2026 10:31:59 +0100 Subject: [PATCH 12/18] Stabilize Accelerate backend tests --- test/test_solve_random_cone_prob_accelerate.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/test_solve_random_cone_prob_accelerate.py b/test/test_solve_random_cone_prob_accelerate.py index f6faf800..6c10ccbd 100644 --- a/test/test_solve_random_cone_prob_accelerate.py +++ b/test/test_solve_random_cone_prob_accelerate.py @@ -17,8 +17,6 @@ from scs import _scs_accelerate # noqa: E402 -np.random.seed(1) - # cone: K = { "z": 10, @@ -34,7 +32,8 @@ def test_solve_feasible(): - data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1) + rng = np.random.RandomState(3000) + data, p_star = tools.gen_feasible(K, n=m // 3, density=0.1, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.ACCELERATE, **params) sol = solver.solve() x = sol["x"] @@ -53,17 +52,19 @@ def test_solve_feasible(): def test_solve_infeasible(): - data = tools.gen_infeasible(K, n=m // 2) + rng = np.random.RandomState(3001) + data = tools.gen_infeasible(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.ACCELERATE, **params) sol = solver.solve() y = sol["y"] np.testing.assert_array_less(np.linalg.norm(data["A"].T @ y), 1e-3) np.testing.assert_array_less(data["b"].T @ y, -0.1) - np.testing.assert_almost_equal(y, tools.proj_dual_cone(y, K), decimal=4) + np.testing.assert_almost_equal(y, tools.proj_dual_cone(y, K), decimal=3) def test_solve_unbounded(): - data = tools.gen_unbounded(K, n=m // 2) + rng = np.random.RandomState(3002) + data = tools.gen_unbounded(K, n=m // 2, rng=rng) solver = scs.SCS(data, K, linear_solver=scs.LinearSolver.ACCELERATE, **params) sol = solver.solve() x = sol["x"] From 796382ea397184a77e8371bf5037495fd51eb617 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Fri, 10 Apr 2026 10:34:19 +0100 Subject: [PATCH 13/18] Keep Accelerate random-problem tests deterministic --- test/test_solve_random_cone_prob_accelerate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_solve_random_cone_prob_accelerate.py b/test/test_solve_random_cone_prob_accelerate.py index 6c10ccbd..1c27a58b 100644 --- a/test/test_solve_random_cone_prob_accelerate.py +++ b/test/test_solve_random_cone_prob_accelerate.py @@ -59,7 +59,7 @@ def test_solve_infeasible(): y = sol["y"] np.testing.assert_array_less(np.linalg.norm(data["A"].T @ y), 1e-3) np.testing.assert_array_less(data["b"].T @ y, -0.1) - np.testing.assert_almost_equal(y, tools.proj_dual_cone(y, K), decimal=3) + np.testing.assert_almost_equal(y, tools.proj_dual_cone(y, K), decimal=4) def test_solve_unbounded(): From 027c3e0b4fcb83c056d37486b6ac36dafa31bf63 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Fri, 10 Apr 2026 18:53:38 +0100 Subject: [PATCH 14/18] Add faulthandler_exit_on_timeout and simplify CI pytest flags Set faulthandler_exit_on_timeout = true in pyproject.toml so hanging tests actually terminate in CI instead of just dumping tracebacks. Remove redundant -p no:faulthandler -o faulthandler_timeout=600 flags from the free-threading CI since the config is now in pyproject.toml. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/freethreading_tests.yml | 4 ++-- pyproject.toml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/freethreading_tests.yml b/.github/workflows/freethreading_tests.yml index 54040086..8d3126e7 100644 --- a/.github/workflows/freethreading_tests.yml +++ b/.github/workflows/freethreading_tests.yml @@ -44,12 +44,12 @@ jobs: - name: Run tests run: | source .venv/bin/activate - python -m pytest test/ -v -p no:faulthandler -o faulthandler_timeout=600 + python -m pytest test/ -v - name: Run tests in parallel threads (race detection) run: | source .venv/bin/activate - python -m pytest test/ -v --parallel-threads=4 --iterations=3 -p no:faulthandler -o faulthandler_timeout=600 + python -m pytest test/ -v --parallel-threads=4 --iterations=3 test_sanitizers: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index a2140c42..53460c51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ testpaths = [ "test", ] faulthandler_timeout = 600 +faulthandler_exit_on_timeout = true markers = [ "thread_unsafe: mark test as unsafe to run in parallel threads (pytest-run-parallel)", ] From 845986e589b435dc1ce06aacff7cd2162797c85f Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Fri, 17 Apr 2026 17:10:22 +0100 Subject: [PATCH 15/18] Address review: update FT classifier, drop cibuildwheel enable, cleanup - Fix Free Threading trove classifier: drop extraneous "Implementation ::" prefix, bump "1 - Unstable" to "3 - Stable" per PEP 779 and reviewer feedback (free-threading is no longer experimental in 3.14). - Drop `enable = ["cpython-freethreading"]`: deprecated in cibuildwheel 3.4.1, 3.14t wheels build without it. Side effect: we no longer ship 3.13t wheels, which aligns with upstream's migration push to 3.14t. - Drop `thread_unsafe_fixtures = ["capsys", "capfd"]`: pytest-run-parallel marks these automatically. Co-Authored-By: Claude Opus 4.7 --- pyproject.toml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 53460c51..dd719a0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: Implementation :: Free Threading :: 1 - Unstable', + 'Programming Language :: Python :: Free Threading :: 3 - Stable', 'Programming Language :: Python :: Implementation :: CPython', 'Operating System :: Microsoft :: Windows', 'Operating System :: POSIX', @@ -39,9 +39,6 @@ dependencies = [ ] [tool.cibuildwheel] -enable = [ - "cpython-freethreading", # Build free-threaded (3.13t+) wheels -] skip = [ "*-win32", # fails on locating Python headers, probably meson.build is misconfigured "*musllinux*aarch64*" # no Scipy wheels for this @@ -105,6 +102,6 @@ faulthandler_exit_on_timeout = true markers = [ "thread_unsafe: mark test as unsafe to run in parallel threads (pytest-run-parallel)", ] -# pytest-run-parallel: fixtures and functions that are not thread-safe -thread_unsafe_fixtures = ["capsys", "capfd"] +# pytest-run-parallel: functions that are not thread-safe +# (capsys/capfd are detected automatically by pytest-run-parallel) thread_unsafe_functions = ["numpy.random.seed"] From dc30748c03ef160f9b86f74e27c719a04e3fd7e8 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Fri, 17 Apr 2026 17:16:55 +0100 Subject: [PATCH 16/18] Drop re-init concurrency test, document construction as thread-local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review discussion: the `SCS_init` guard against concurrent re-init of a live instance is not standard practice for CPython extensions — NumPy, SciPy, and similar libraries assume object construction is thread-local and do not lock `__init__`. The `test_reinit_while_solving` test also swallowed exceptions broadly, which hid rather than detected any real race on the `self->work` field. Remove the test and add a docstring note to `SCS.__init__` making the thread-local-construction assumption explicit. Co-Authored-By: Claude Opus 4.7 --- scs/py/__init__.py | 5 +++ test/test_free_threading.py | 64 ------------------------------------- 2 files changed, 5 insertions(+), 64 deletions(-) diff --git a/scs/py/__init__.py b/scs/py/__init__.py index 98cb47e6..d5387640 100644 --- a/scs/py/__init__.py +++ b/scs/py/__init__.py @@ -95,6 +95,11 @@ def __init__(self, data, cone, **settings): @param data Dictionary containing keys `P`, `A`, `b`, `c`. @param cone Dictionary containing cone information. @param settings Settings as kwargs, see docs. + + Thread safety: construction is assumed to be thread-local. Calling + `__init__` on a live SCS instance from another thread (i.e. while + `solve` or `update` may be running on it) is undefined behavior. + Use a fresh `SCS(...)` instance instead. """ self._settings = settings if not data or not cone: diff --git a/test/test_free_threading.py b/test/test_free_threading.py index 4872dbca..9c0f0feb 100644 --- a/test/test_free_threading.py +++ b/test/test_free_threading.py @@ -873,70 +873,6 @@ def worker(): assert len(errors) == 0, f"Errors: {errors}" -@pytest.mark.thread_unsafe(reason="creates its own threads internally") -class TestReInitRace: - """Test calling __init__ on an object while another thread is solving. - - This is unusual (re-initializing an already-initialized object), but - it is possible in Python and exercises the SCS_init guard that checks - if self->work is already set, as well as the TOCTOU fix on dealloc. - """ - - def test_reinit_while_solving(self): - """One thread solves while another re-inits the same object. - - Neither thread should crash. The solve may fail (workspace torn - down under it) or succeed (lock serialized the operations). We - only assert no segfaults/corruption. - """ - data, cone, _ = _make_simple_lp() - solver = scs.SCS(data, cone, verbose=False) - errors = [] - lock = threading.Lock() - barrier = threading.Barrier(2) - - def solve_loop(): - try: - barrier.wait(timeout=10) - for _ in range(20): - try: - sol = solver.solve() - # Might succeed or fail -- either is fine - assert sol["info"]["status_val"] in (1, 2, -1, -2, -7) - except ValueError: - # "Workspace not initialized!" is expected if - # re-init tore down the workspace - pass - except Exception as e: - with lock: - errors.append(f"solve: {e}") - - def reinit_loop(): - try: - barrier.wait(timeout=10) - for _ in range(20): - try: - # Re-initialize with same data -- SCS_init checks - # if self->work is set and errors out - solver.__init__(data, cone, verbose=False) - except (ValueError, TypeError): - pass - except Exception as e: - with lock: - errors.append(f"reinit: {e}") - - t1 = threading.Thread(target=solve_loop) - t2 = threading.Thread(target=reinit_loop) - t1.start() - t2.start() - t1.join(timeout=30) - t2.join(timeout=30) - assert not t1.is_alive() - assert not t2.is_alive() - - assert len(errors) == 0, f"Errors: {errors}" - - @pytest.mark.thread_unsafe(reason="creates its own threads internally") class TestErrorPathContention: """Tests that lock is properly released on error paths. From e53abfac327232312e11478a64ebbb163b555630 Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Fri, 17 Apr 2026 17:39:55 +0100 Subject: [PATCH 17/18] Use LinearSolver enum in test_concurrent_direct_and_indirect Missed during the rebase onto master: this test still passed the old `use_indirect=` boolean kwarg, which was removed in #189 in favor of the `linear_solver=scs.LinearSolver.*` enum. This broke the full CI matrix (wheel builds, accelerate builds, free-threading tests, sanitizers) on a single shared failure. Co-Authored-By: Claude Opus 4.7 --- test/test_free_threading.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/test_free_threading.py b/test/test_free_threading.py index 9c0f0feb..95fd849c 100644 --- a/test/test_free_threading.py +++ b/test/test_free_threading.py @@ -165,18 +165,19 @@ def test_concurrent_direct_and_indirect(self): """Threads use different solver backends (direct vs indirect).""" data, cone, expected = _make_simple_lp() - def worker(use_indirect): + def worker(linear_solver): solver = scs.SCS( - data, cone, use_indirect=use_indirect, verbose=False + data, cone, linear_solver=linear_solver, verbose=False ) sol = solver.solve() assert sol["info"]["status_val"] == 1 assert_almost_equal(sol["x"][0], expected, decimal=2) return sol["x"][0] + backends = [scs.LinearSolver.QDLDL, scs.LinearSolver.INDIRECT] with ThreadPoolExecutor(max_workers=NUM_THREADS) as pool: futures = [ - pool.submit(worker, i % 2 == 0) for i in range(NUM_THREADS) + pool.submit(worker, backends[i % 2]) for i in range(NUM_THREADS) ] results = [f.result(timeout=30) for f in futures] From 0061bfc4698fd0743bf65da6fa97ab2ce649e8bf Mon Sep 17 00:00:00 2001 From: Brendan O'Donoghue Date: Fri, 17 Apr 2026 17:50:20 +0100 Subject: [PATCH 18/18] Mark test_resolve_auto_* as thread_unsafe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These tests patch scs.sys and scs._load_module — module-level state that leaks to other tests running in parallel threads under pytest-run-parallel, causing MagicMock to be substituted for the real scs.SCS class in unrelated tests. Co-Authored-By: Claude Opus 4.7 --- test/test_scs_coverage.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_scs_coverage.py b/test/test_scs_coverage.py index 27d905b4..1b724a52 100644 --- a/test/test_scs_coverage.py +++ b/test/test_scs_coverage.py @@ -2897,6 +2897,7 @@ def test_linear_solver_auto_string(): # =========================================================================== +@pytest.mark.thread_unsafe(reason="patches module-level scs._load_module / scs.sys") def test_resolve_auto_falls_back_to_qdldl(): """AUTO should fall back to QDLDL when platform-preferred module is missing.""" from unittest.mock import patch @@ -2910,6 +2911,7 @@ def fail_import(name): assert module is _scs_direct +@pytest.mark.thread_unsafe(reason="patches module-level scs._load_module / scs.sys") def test_resolve_auto_darwin_tries_accelerate(): """On macOS, AUTO should try _scs_accelerate first.""" from unittest.mock import patch, MagicMock @@ -2929,6 +2931,7 @@ def mock_load(name): assert module is fake_accel +@pytest.mark.thread_unsafe(reason="patches module-level scs._load_module / scs.sys") def test_resolve_auto_linux_tries_mkl(): """On Linux, AUTO should try _scs_mkl first.""" from unittest.mock import patch, MagicMock @@ -2948,6 +2951,7 @@ def mock_load(name): assert module is fake_mkl +@pytest.mark.thread_unsafe(reason="patches module-level scs._load_module / scs.sys") def test_resolve_auto_windows_tries_mkl(): """On Windows, AUTO should try _scs_mkl first.""" from unittest.mock import patch, MagicMock @@ -2967,6 +2971,7 @@ def mock_load(name): assert module is fake_mkl +@pytest.mark.thread_unsafe(reason="patches module-level scs._load_module / scs.sys") def test_resolve_auto_darwin_fallback_when_no_accelerate(): """On macOS, AUTO should fall back to QDLDL when Accelerate is missing.""" from unittest.mock import patch @@ -2982,6 +2987,7 @@ def fail_import(name): assert module is _scs_direct +@pytest.mark.thread_unsafe(reason="patches module-level scs._load_module / scs.sys") def test_resolve_auto_linux_fallback_when_no_mkl(): """On Linux, AUTO should fall back to QDLDL when MKL is missing.""" from unittest.mock import patch