From 0c4632ecb53bf09f16b673b41f84612c68c4cd9c Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 18 Aug 2026 16:16:15 +0200 Subject: [PATCH 01/11] fix(solvers): dispose the solver model before its env on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solver.close() closed the env ExitStack before dropping solver_model, so the native model was collected against freed memory — a Fatal Python error or Windows access violation from an unrelated GC pass. COPT also closed its env in a finally while returning the model built in it; the env now lives on the solver's ExitStack. --- doc/release_notes.rst | 5 ++ linopy/solvers.py | 105 +++++++++++++++++++++--------------------- test/test_solvers.py | 9 ++++ 3 files changed, 66 insertions(+), 53 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index ada70ec6..a4011d23 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,11 @@ Release Notes Upcoming Version ---------------- +**Bug fixes** + +* ``Solver.close()`` now drops the native solver model before closing the environment that owns it. The reverse order left the model pointing at freed memory, so collecting it later crashed the interpreter — a ``Fatal Python error: Aborted`` or a Windows access violation, usually from a garbage collection pass in unrelated code. Solvers registering their model on the environment's ``ExitStack`` (Gurobi, Xpress, Mosek) were already safe; HiGHS, SCIP, COPT and MindOpt were not. +* The COPT interface no longer closes its environment while returning the solver model built in it. The environment now lives on the solver's ``ExitStack`` and is released by ``Solver.close()``, so ``model.solver_model`` stays usable after ``model.solve("copt")``. + Version 0.9.1 ------------- diff --git a/linopy/solvers.py b/linopy/solvers.py index 3b8f2fed..59b82270 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1124,10 +1124,10 @@ def close(self) -> None: (``solver_model``, ``compute_infeasibilities()``) and persistent re-solves are no longer available. """ + self.solver_model = None if self._env_stack is not None: self._env_stack.close() self.env = None - self.solver_model = None self._env_stack = None def __del__(self) -> None: @@ -3933,74 +3933,73 @@ def _run_file( io_api = read_io_api_from_problem_file(problem_fn) sense = read_sense_from_problem_file(problem_fn) - if env is None: - env_ = coptpy.Envr() + self.close() + self._env_stack = contextlib.ExitStack() + env_ = coptpy.Envr() + self._env_stack.callback(env_.close) - try: - m = env_.createModel() + m = env_.createModel() - m.read(path_to_string(problem_fn)) + m.read(path_to_string(problem_fn)) - if log_fn is not None: - m.setLogFile(path_to_string(log_fn)) + if log_fn is not None: + m.setLogFile(path_to_string(log_fn)) - for k, v in self.solver_options.items(): - m.setParam(k, v) + for k, v in self.solver_options.items(): + m.setParam(k, v) - if warmstart_fn is not None: - m.readBasis(path_to_string(warmstart_fn)) + if warmstart_fn is not None: + m.readBasis(path_to_string(warmstart_fn)) - m.solve() + m.solve() - if basis_fn and m.HasBasis: - try: - m.write(path_to_string(basis_fn)) - except coptpy.CoptError as err: - logger.warning("No model basis stored. Raised error: %s", err) + if basis_fn and m.HasBasis: + try: + m.write(path_to_string(basis_fn)) + except coptpy.CoptError as err: + logger.warning("No model basis stored. Raised error: %s", err) - if solution_fn: - try: - m.write(path_to_string(solution_fn)) - except coptpy.CoptError as err: - logger.warning("No model solution stored. Raised error: %s", err) + if solution_fn: + try: + m.write(path_to_string(solution_fn)) + except coptpy.CoptError as err: + logger.warning("No model solution stored. Raised error: %s", err) + + # TODO: check if this suffices + condition = m.MipStatus if m.ismip else m.LpStatus + termination_condition = CONDITION_MAP.get(condition, str(condition)) + status = Status.from_termination_condition(termination_condition) + status.legacy_status = str(condition) + def get_solver_solution() -> Solution: # TODO: check if this suffices - condition = m.MipStatus if m.ismip else m.LpStatus - termination_condition = CONDITION_MAP.get(condition, str(condition)) - status = Status.from_termination_condition(termination_condition) - status.legacy_status = str(condition) + objective = m.BestObj if m.ismip else m.LpObjVal - def get_solver_solution() -> Solution: - # TODO: check if this suffices - objective = m.BestObj if m.ismip else m.LpObjVal + vars_ = m.getVars() + sol = _solution_from_names( + np.array([v.x for v in vars_], dtype=float), + [v.name for v in vars_], + self._n_vars, + ) - vars_ = m.getVars() - sol = _solution_from_names( - np.array([v.x for v in vars_], dtype=float), - [v.name for v in vars_], - self._n_vars, + try: + cons = m.getConstrs() + dual = _solution_from_names( + np.array([c.pi for c in cons], dtype=float), + [c.name for c in cons], + self._n_cons, ) + except (coptpy.CoptError, AttributeError): + logger.warning("Dual values of MILP couldn't be parsed") + dual = np.array([], dtype=float) - try: - cons = m.getConstrs() - dual = _solution_from_names( - np.array([c.pi for c in cons], dtype=float), - [c.name for c in cons], - self._n_cons, - ) - except (coptpy.CoptError, AttributeError): - logger.warning("Dual values of MILP couldn't be parsed") - dual = np.array([], dtype=float) - - return Solution(sol, dual, objective) + return Solution(sol, dual, objective) - solution = self.safe_get_solution(status=status, func=get_solver_solution) - solution = maybe_adjust_objective_sign(solution, io_api, sense) + solution = self.safe_get_solution(status=status, func=get_solver_solution) + solution = maybe_adjust_objective_sign(solution, io_api, sense) - self.io_api = io_api - return self._make_result(status, solution, solver_model=m) - finally: - env_.close() + self.io_api = io_api + return self._make_result(status, solution, solver_model=m) class MindOpt(Solver[None]): diff --git a/test/test_solvers.py b/test/test_solvers.py index 3522d8be..eb8c5ffe 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -204,6 +204,15 @@ def test_gurobi_env_persists_after_solve(simple_model: Model) -> None: assert isinstance(simple_model.solver_model.NumVars, int) +@pytest.mark.skipif( + "copt" not in set(solvers.licensed_solvers), reason="COPT is not installed" +) +def test_copt_env_persists_after_solve(simple_model: Model) -> None: + simple_model.solve("copt") + assert simple_model.solver is not None + assert isinstance(simple_model.solver_model.getVars(), list) + + @pytest.mark.parametrize("solver", sorted(set(solvers.licensed_solvers))) def test_solver_close_releases_state(simple_model: Model, solver: str) -> None: simple_model.solve(solver) From d57a46283a7d50557bb18405e8202e300aecc540 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 18 Aug 2026 16:22:41 +0200 Subject: [PATCH 02/11] docs: condense the release note for the solver close fix --- doc/release_notes.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index a4011d23..a8aa451e 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -6,8 +6,7 @@ Upcoming Version **Bug fixes** -* ``Solver.close()`` now drops the native solver model before closing the environment that owns it. The reverse order left the model pointing at freed memory, so collecting it later crashed the interpreter — a ``Fatal Python error: Aborted`` or a Windows access violation, usually from a garbage collection pass in unrelated code. Solvers registering their model on the environment's ``ExitStack`` (Gurobi, Xpress, Mosek) were already safe; HiGHS, SCIP, COPT and MindOpt were not. -* The COPT interface no longer closes its environment while returning the solver model built in it. The environment now lives on the solver's ``ExitStack`` and is released by ``Solver.close()``, so ``model.solver_model`` stays usable after ``model.solve("copt")``. +* ``Solver.close()`` now drops the native solver model before closing the environment that owns it. The reverse order left the model pointing at freed memory, so collecting it later crashed the interpreter, typically during an unrelated garbage collection pass. This affected HiGHS, SCIP, COPT and MindOpt; solvers registering their model on the environment's ``ExitStack`` (Gurobi, Xpress, Mosek) were already safe. COPT additionally kept its environment alive, so ``model.solver_model`` stays usable after ``model.solve("copt")``. (`#899 `__) Version 0.9.1 From a8d1253fd86a64c911475843310c6966ea5e80c0 Mon Sep 17 00:00:00 2001 From: FBumann Date: Tue, 25 Aug 2026 10:51:28 +0200 Subject: [PATCH 03/11] test(solvers): assert the COPT model is readable, not that getVars() is a list coptpy returns a coptcore.VarArray, so the isinstance check could never pass where COPT is installed. Co-Authored-By: Claude Opus 5 (1M context) --- test/test_solvers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_solvers.py b/test/test_solvers.py index eb8c5ffe..b92ad9e1 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -210,7 +210,7 @@ def test_gurobi_env_persists_after_solve(simple_model: Model) -> None: def test_copt_env_persists_after_solve(simple_model: Model) -> None: simple_model.solve("copt") assert simple_model.solver is not None - assert isinstance(simple_model.solver_model.getVars(), list) + assert len(simple_model.solver_model.getVars()) == 2 @pytest.mark.parametrize("solver", sorted(set(solvers.licensed_solvers))) From 3856863ef2e9da5950ea90befdf8f3cc1f19eab3 Mon Sep 17 00:00:00 2001 From: FBumann Date: Tue, 25 Aug 2026 11:07:28 +0200 Subject: [PATCH 04/11] fix(solvers): stop disposing native solver handles from a finalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Solver is only ever reachable in a Model/Solver reference cycle, so it is reclaimed by the cyclic collector and never by refcount. Its __del__ therefore ran mid-collection, tearing down native solver handles at an arbitrary point in an unrelated call stack — the interpreter aborted in coptpy.Envr(), cplex.Cplex(), GLPK's result parsing and inside xarray alignment, in a different CI job on every run. close() is unchanged and is still called when a new solve replaces the solver and when model.solver is reassigned. Solvers that are never closed are left to the vendor wrappers, which dispose in their own finalizers. Co-Authored-By: Claude Opus 5 (1M context) --- doc/release_notes.rst | 3 ++- linopy/solvers.py | 18 ++++++++++-------- test/test_solvers.py | 8 ++++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index a74ec6eb..68e2f386 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -53,7 +53,8 @@ Upcoming Version **Bug fixes** * A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``. -* ``Solver.close()`` now drops the native solver model before closing the environment that owns it. The reverse order left the model pointing at freed memory, so collecting it later crashed the interpreter, typically during an unrelated garbage collection pass. This affected HiGHS, SCIP, COPT and MindOpt; solvers registering their model on the environment's ``ExitStack`` (Gurobi, Xpress, Mosek) were already safe. COPT additionally kept its environment alive, so ``model.solver_model`` stays usable after ``model.solve("copt")``. (`#899 `__) +* ``model.solver_model`` stays usable after ``model.solve("copt")``. COPT closed its environment as soon as the solve returned, leaving the returned model pointing at freed memory. The environment is now owned by the solver and released on ``Solver.close()``, which drops the native model before the environment that owns it. (`#899 `__) +* ``Solver`` no longer disposes native solver handles from a finalizer. A solver is only ever reachable in a ``Model``/``Solver`` reference cycle, so its finalizer ran mid-collection and could abort the interpreter at an arbitrary point in an unrelated call stack. ``close()`` is unchanged and is still called when a new solve replaces the solver or ``model.solver`` is reassigned. (`#899 `__) Version 0.9.1 ------------- diff --git a/linopy/solvers.py b/linopy/solvers.py index 59b82270..25acd042 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1119,10 +1119,16 @@ def close(self) -> None: user-supplied environment is left untouched. Idempotent, and called automatically when a new ``solve()`` replaces - this solver, when ``model.solver`` is reassigned (e.g. to ``None``), - and on garbage collection. After closing, post-solve introspection - (``solver_model``, ``compute_infeasibilities()``) and persistent - re-solves are no longer available. + this solver and when ``model.solver`` is reassigned (e.g. to + ``None``). It is deliberately not called from a finalizer: a solver is + only ever reachable in a ``Model``/``Solver`` reference cycle, so a + finalizer would run mid-collection and tear down native handles at an + arbitrary point in an unrelated call stack. Uncollected solvers are + left to the vendor wrappers, which dispose in their own finalizers. + + After closing, post-solve introspection (``solver_model``, + ``compute_infeasibilities()``) and persistent re-solves are no longer + available. """ self.solver_model = None if self._env_stack is not None: @@ -1130,10 +1136,6 @@ def close(self) -> None: self.env = None self._env_stack = None - def __del__(self) -> None: - with contextlib.suppress(Exception): - self.close() - def __getstate__(self) -> dict[str, Any]: drop = {"solver_model", "env", "_env_stack", "snapshot", "_lock"} return {k: v for k, v in self.__dict__.items() if k not in drop} diff --git a/test/test_solvers.py b/test/test_solvers.py index b92ad9e1..51bcd7a0 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -213,6 +213,14 @@ def test_copt_env_persists_after_solve(simple_model: Model) -> None: assert len(simple_model.solver_model.getVars()) == 2 +def test_solver_defines_no_finalizer() -> None: + """ + A solver is only reachable in a Model/Solver reference cycle, so a + finalizer would tear down native handles mid-collection. + """ + assert not hasattr(solvers.Solver, "__del__") + + @pytest.mark.parametrize("solver", sorted(set(solvers.licensed_solvers))) def test_solver_close_releases_state(simple_model: Model, solver: str) -> None: simple_model.solve(solver) From c42069658236a4478a1ba257bfd1257920c354dc Mon Sep 17 00:00:00 2001 From: FBumann Date: Tue, 25 Aug 2026 11:52:47 +0200 Subject: [PATCH 05/11] fix(cplex): own the Cplex handle and end it on close The file interface built a cplex.Cplex() per solve, handed it back as solver_model and never ended it, so every solve left a live CPLEX environment for the garbage collector. Collecting one re-entered the CPLEX library while another environment was being constructed, which aborted the interpreter. Co-Authored-By: Claude Opus 5 (1M context) --- doc/release_notes.rst | 1 + linopy/solvers.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 68e2f386..5d06f1df 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -54,6 +54,7 @@ Upcoming Version * A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``. * ``model.solver_model`` stays usable after ``model.solve("copt")``. COPT closed its environment as soon as the solve returned, leaving the returned model pointing at freed memory. The environment is now owned by the solver and released on ``Solver.close()``, which drops the native model before the environment that owns it. (`#899 `__) +* The CPLEX file interface now owns the ``cplex.Cplex`` handle it hands back as ``model.solver_model`` and ends it on ``Solver.close()``. Every solve used to leave a live CPLEX environment behind for the garbage collector, which could re-enter the CPLEX library while another environment was being constructed and abort the interpreter. (`#899 `__) * ``Solver`` no longer disposes native solver handles from a finalizer. A solver is only ever reachable in a ``Model``/``Solver`` reference cycle, so its finalizer ran mid-collection and could abort the interpreter at an arbitrary point in an unrelated call stack. ``close()`` is unchanged and is still called when a new solve replaces the solver or ``model.solver`` is reassigned. (`#899 `__) Version 0.9.1 diff --git a/linopy/solvers.py b/linopy/solvers.py index 25acd042..b529f261 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2406,7 +2406,10 @@ def _run_file( io_api = read_io_api_from_problem_file(problem_fn) sense = read_sense_from_problem_file(problem_fn) + self.close() + self._env_stack = contextlib.ExitStack() m = cplex.Cplex() + self._env_stack.callback(m.end) if log_fn is not None: log_f = open(path_to_string(log_fn), "w") From 34f03dbe986ff70cca9c95f480412972628303ea Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:13:40 +0200 Subject: [PATCH 06/11] Revert "fix(cplex): own the Cplex handle and end it on close" This reverts commit c42069658236a4478a1ba257bfd1257920c354dc. --- doc/release_notes.rst | 1 - linopy/solvers.py | 3 --- 2 files changed, 4 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 04e3783e..7400a743 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -54,7 +54,6 @@ Upcoming Version * A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``. * ``model.solver_model`` stays usable after ``model.solve("copt")``. COPT closed its environment as soon as the solve returned, leaving the returned model pointing at freed memory. The environment is now owned by the solver and released on ``Solver.close()``, which drops the native model before the environment that owns it. (`#899 `__) -* The CPLEX file interface now owns the ``cplex.Cplex`` handle it hands back as ``model.solver_model`` and ends it on ``Solver.close()``. Every solve used to leave a live CPLEX environment behind for the garbage collector, which could re-enter the CPLEX library while another environment was being constructed and abort the interpreter. (`#899 `__) * ``Solver`` no longer disposes native solver handles from a finalizer. A solver is only ever reachable in a ``Model``/``Solver`` reference cycle, so its finalizer ran mid-collection and could abort the interpreter at an arbitrary point in an unrelated call stack. ``close()`` is unchanged and is still called when a new solve replaces the solver or ``model.solver`` is reassigned. (`#899 `__) Version 0.9.1 diff --git a/linopy/solvers.py b/linopy/solvers.py index b529f261..25acd042 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2406,10 +2406,7 @@ def _run_file( io_api = read_io_api_from_problem_file(problem_fn) sense = read_sense_from_problem_file(problem_fn) - self.close() - self._env_stack = contextlib.ExitStack() m = cplex.Cplex() - self._env_stack.callback(m.end) if log_fn is not None: log_f = open(path_to_string(log_fn), "w") From 20adb750c88ab6109cc01dc05281f0770dc2181c Mon Sep 17 00:00:00 2001 From: FBumann Date: Tue, 25 Aug 2026 12:16:54 +0200 Subject: [PATCH 07/11] fix(copt): close the environment per solve instead of holding it open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeping the environment on the solver's ExitStack leaked it: with no finalizer, a solver that is merely dropped — every solve in the test suite builds a fresh Model and drops it — never runs close(), so the callback never fires. Measured on the file-based path: 20 solves created 20 COPT environments and closed none, against 20/20 on master. Close per solve as before, but do not hand back a solver model whose environment has just been closed; model.solver_model is None after a file-based COPT solve rather than a handle into freed memory. Co-Authored-By: Claude Opus 5 (1M context) --- doc/release_notes.rst | 2 +- linopy/solvers.py | 103 +++++++++++++++++++++--------------------- test/test_solvers.py | 25 +++++++++- 3 files changed, 76 insertions(+), 54 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 7400a743..1e01b093 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -53,7 +53,7 @@ Upcoming Version **Bug fixes** * A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``. -* ``model.solver_model`` stays usable after ``model.solve("copt")``. COPT closed its environment as soon as the solve returned, leaving the returned model pointing at freed memory. The environment is now owned by the solver and released on ``Solver.close()``, which drops the native model before the environment that owns it. (`#899 `__) +* ``Solver.close()`` now drops the native solver model before closing the environment that owns it, and the COPT file interface no longer returns a solver model whose environment it has already closed — ``model.solver_model`` is ``None`` after a file-based COPT solve instead of a handle into freed memory. (`#899 `__) * ``Solver`` no longer disposes native solver handles from a finalizer. A solver is only ever reachable in a ``Model``/``Solver`` reference cycle, so its finalizer ran mid-collection and could abort the interpreter at an arbitrary point in an unrelated call stack. ``close()`` is unchanged and is still called when a new solve replaces the solver or ``model.solver`` is reassigned. (`#899 `__) Version 0.9.1 diff --git a/linopy/solvers.py b/linopy/solvers.py index 25acd042..873e1116 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -3935,73 +3935,74 @@ def _run_file( io_api = read_io_api_from_problem_file(problem_fn) sense = read_sense_from_problem_file(problem_fn) - self.close() - self._env_stack = contextlib.ExitStack() - env_ = coptpy.Envr() - self._env_stack.callback(env_.close) - - m = env_.createModel() + if env is None: + env_ = coptpy.Envr() - m.read(path_to_string(problem_fn)) + try: + m = env_.createModel() - if log_fn is not None: - m.setLogFile(path_to_string(log_fn)) + m.read(path_to_string(problem_fn)) - for k, v in self.solver_options.items(): - m.setParam(k, v) + if log_fn is not None: + m.setLogFile(path_to_string(log_fn)) - if warmstart_fn is not None: - m.readBasis(path_to_string(warmstart_fn)) + for k, v in self.solver_options.items(): + m.setParam(k, v) - m.solve() + if warmstart_fn is not None: + m.readBasis(path_to_string(warmstart_fn)) - if basis_fn and m.HasBasis: - try: - m.write(path_to_string(basis_fn)) - except coptpy.CoptError as err: - logger.warning("No model basis stored. Raised error: %s", err) + m.solve() - if solution_fn: - try: - m.write(path_to_string(solution_fn)) - except coptpy.CoptError as err: - logger.warning("No model solution stored. Raised error: %s", err) + if basis_fn and m.HasBasis: + try: + m.write(path_to_string(basis_fn)) + except coptpy.CoptError as err: + logger.warning("No model basis stored. Raised error: %s", err) - # TODO: check if this suffices - condition = m.MipStatus if m.ismip else m.LpStatus - termination_condition = CONDITION_MAP.get(condition, str(condition)) - status = Status.from_termination_condition(termination_condition) - status.legacy_status = str(condition) + if solution_fn: + try: + m.write(path_to_string(solution_fn)) + except coptpy.CoptError as err: + logger.warning("No model solution stored. Raised error: %s", err) - def get_solver_solution() -> Solution: # TODO: check if this suffices - objective = m.BestObj if m.ismip else m.LpObjVal + condition = m.MipStatus if m.ismip else m.LpStatus + termination_condition = CONDITION_MAP.get(condition, str(condition)) + status = Status.from_termination_condition(termination_condition) + status.legacy_status = str(condition) - vars_ = m.getVars() - sol = _solution_from_names( - np.array([v.x for v in vars_], dtype=float), - [v.name for v in vars_], - self._n_vars, - ) + def get_solver_solution() -> Solution: + # TODO: check if this suffices + objective = m.BestObj if m.ismip else m.LpObjVal - try: - cons = m.getConstrs() - dual = _solution_from_names( - np.array([c.pi for c in cons], dtype=float), - [c.name for c in cons], - self._n_cons, + vars_ = m.getVars() + sol = _solution_from_names( + np.array([v.x for v in vars_], dtype=float), + [v.name for v in vars_], + self._n_vars, ) - except (coptpy.CoptError, AttributeError): - logger.warning("Dual values of MILP couldn't be parsed") - dual = np.array([], dtype=float) - return Solution(sol, dual, objective) + try: + cons = m.getConstrs() + dual = _solution_from_names( + np.array([c.pi for c in cons], dtype=float), + [c.name for c in cons], + self._n_cons, + ) + except (coptpy.CoptError, AttributeError): + logger.warning("Dual values of MILP couldn't be parsed") + dual = np.array([], dtype=float) - solution = self.safe_get_solution(status=status, func=get_solver_solution) - solution = maybe_adjust_objective_sign(solution, io_api, sense) + return Solution(sol, dual, objective) - self.io_api = io_api - return self._make_result(status, solution, solver_model=m) + solution = self.safe_get_solution(status=status, func=get_solver_solution) + solution = maybe_adjust_objective_sign(solution, io_api, sense) + + self.io_api = io_api + return self._make_result(status, solution) + finally: + env_.close() class MindOpt(Solver[None]): diff --git a/test/test_solvers.py b/test/test_solvers.py index 51bcd7a0..ebf62c52 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -6,6 +6,7 @@ """ from pathlib import Path +from typing import Any from unittest.mock import MagicMock import numpy as np @@ -207,10 +208,30 @@ def test_gurobi_env_persists_after_solve(simple_model: Model) -> None: @pytest.mark.skipif( "copt" not in set(solvers.licensed_solvers), reason="COPT is not installed" ) -def test_copt_env_persists_after_solve(simple_model: Model) -> None: +def test_copt_closes_env_per_solve( + simple_model: Model, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + The file interface closes its environment before returning, so it must not + hand back a solver model that outlives it. Keeping the environment open + instead would leak it: nothing closes a solver that is merely dropped. + """ + import coptpy + + closes: list[int] = [] + original = coptpy.Envr.close + + def close(self: Any, *args: Any, **kwargs: Any) -> Any: + closes.append(1) + return original(self, *args, **kwargs) + + monkeypatch.setattr(coptpy.Envr, "close", close) + simple_model.solve("copt") + assert simple_model.solver is not None - assert len(simple_model.solver_model.getVars()) == 2 + assert simple_model.solver_model is None + assert closes def test_solver_defines_no_finalizer() -> None: From e0bcf58d475045f7c60016652865232e40024977 Mon Sep 17 00:00:00 2001 From: FBumann Date: Tue, 25 Aug 2026 12:35:58 +0200 Subject: [PATCH 08/11] fix(mindopt): do not return a solver model that was just disposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MindOpt disposes both the model and the environment before returning, so the handle it handed back as solver_model pointed at freed memory — the same defect as COPT. Knitro already does this correctly: it extracts what it needs into a plain dataclass before freeing the native context. Co-Authored-By: Claude Opus 5 (1M context) --- doc/release_notes.rst | 2 +- linopy/solvers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 1e01b093..ae2b2576 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -53,7 +53,7 @@ Upcoming Version **Bug fixes** * A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``. -* ``Solver.close()`` now drops the native solver model before closing the environment that owns it, and the COPT file interface no longer returns a solver model whose environment it has already closed — ``model.solver_model`` is ``None`` after a file-based COPT solve instead of a handle into freed memory. (`#899 `__) +* ``Solver.close()`` now drops the native solver model before closing the environment that owns it, and the COPT and MindOpt file interfaces no longer return a solver model they have already disposed — ``model.solver_model`` is ``None`` after a file-based COPT or MindOpt solve instead of a handle into freed memory. (`#899 `__) * ``Solver`` no longer disposes native solver handles from a finalizer. A solver is only ever reachable in a ``Model``/``Solver`` reference cycle, so its finalizer ran mid-collection and could abort the interpreter at an arbitrary point in an unrelated call stack. ``close()`` is unchanged and is still called when a new solve replaces the solver or ``model.solver`` is reassigned. (`#899 `__) Version 0.9.1 diff --git a/linopy/solvers.py b/linopy/solvers.py index 873e1116..eb56a0b0 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -4138,7 +4138,7 @@ def get_solver_solution() -> Solution: solution = maybe_adjust_objective_sign(solution, io_api, sense) self.io_api = io_api - return self._make_result(status, solution, solver_model=m) + return self._make_result(status, solution) finally: if m is not None: m.dispose() From a7be805628694aedde9ba07156d16bca888a8875 Mon Sep 17 00:00:00 2001 From: FBumann Date: Tue, 25 Aug 2026 12:39:26 +0200 Subject: [PATCH 09/11] chore: trim the diff to the fix Restore an unrelated blank line, shorten the close() docstring and drop the typing import the spy no longer needs. Co-Authored-By: Claude Opus 5 (1M context) --- doc/release_notes.rst | 1 + linopy/solvers.py | 16 ++++++---------- test/test_solvers.py | 12 +++++------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index ae2b2576..bc2a7590 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,7 @@ Release Notes Upcoming Version ---------------- + *Strict "v1" arithmetic semantics (opt-in)* * A new, stricter convention for how linopy arithmetic aligns coordinates and treats missing data is available behind ``linopy.options["semantics"] = "v1"``. Legacy behaviour remains the **default** in this release; v1 is opt-in. In short, under v1: diff --git a/linopy/solvers.py b/linopy/solvers.py index eb56a0b0..1efecce9 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1119,16 +1119,12 @@ def close(self) -> None: user-supplied environment is left untouched. Idempotent, and called automatically when a new ``solve()`` replaces - this solver and when ``model.solver`` is reassigned (e.g. to - ``None``). It is deliberately not called from a finalizer: a solver is - only ever reachable in a ``Model``/``Solver`` reference cycle, so a - finalizer would run mid-collection and tear down native handles at an - arbitrary point in an unrelated call stack. Uncollected solvers are - left to the vendor wrappers, which dispose in their own finalizers. - - After closing, post-solve introspection (``solver_model``, - ``compute_infeasibilities()``) and persistent re-solves are no longer - available. + this solver and when ``model.solver`` is reassigned (e.g. to ``None``). + Deliberately not called from a finalizer: a solver is only reachable in + a ``Model``/``Solver`` reference cycle, so that would tear down native + handles mid-collection. After closing, post-solve introspection + (``solver_model``, ``compute_infeasibilities()``) and persistent + re-solves are no longer available. """ self.solver_model = None if self._env_stack is not None: diff --git a/test/test_solvers.py b/test/test_solvers.py index ebf62c52..5589307e 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -6,7 +6,6 @@ """ from pathlib import Path -from typing import Any from unittest.mock import MagicMock import numpy as np @@ -213,23 +212,22 @@ def test_copt_closes_env_per_solve( ) -> None: """ The file interface closes its environment before returning, so it must not - hand back a solver model that outlives it. Keeping the environment open + hand back a solver model that outlives it. Holding the environment open instead would leak it: nothing closes a solver that is merely dropped. """ import coptpy - closes: list[int] = [] + closes: list[object] = [] original = coptpy.Envr.close - def close(self: Any, *args: Any, **kwargs: Any) -> Any: - closes.append(1) - return original(self, *args, **kwargs) + def close(self: object) -> None: + closes.append(self) + original(self) monkeypatch.setattr(coptpy.Envr, "close", close) simple_model.solve("copt") - assert simple_model.solver is not None assert simple_model.solver_model is None assert closes From 09b637174da9da6ae725819b7ac13dad012a7894 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:46:35 +0200 Subject: [PATCH 10/11] docs: correct the solver-release lifecycle Removing Solver.__del__ withdrew a guarantee announced in 0.8.0, so say so where it was promised: Model.solve still claimed the solver is released on garbage collection, and the note sat under bug fixes rather than breaking changes. Co-Authored-By: Claude Opus 5 (1M context) --- doc/release_notes.rst | 5 ++++- linopy/model.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index bc2a7590..50d03c6b 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -55,7 +55,10 @@ Upcoming Version * A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``. * ``Solver.close()`` now drops the native solver model before closing the environment that owns it, and the COPT and MindOpt file interfaces no longer return a solver model they have already disposed — ``model.solver_model`` is ``None`` after a file-based COPT or MindOpt solve instead of a handle into freed memory. (`#899 `__) -* ``Solver`` no longer disposes native solver handles from a finalizer. A solver is only ever reachable in a ``Model``/``Solver`` reference cycle, so its finalizer ran mid-collection and could abort the interpreter at an arbitrary point in an unrelated call stack. ``close()`` is unchanged and is still called when a new solve replaces the solver or ``model.solver`` is reassigned. (`#899 `__) + +**Breaking Changes** + +* A solver is no longer released when the model is garbage-collected, withdrawing a guarantee announced in 0.8.0. ``Solver`` disposed its native handles from a finalizer, but a solver is only ever reachable in a ``Model``/``Solver`` reference cycle, so that finalizer ran mid-collection and tore down native state at an arbitrary point in an unrelated call stack — enough to abort the interpreter inside code that never touched linopy. A ``Model`` that merely goes out of scope now leaves its solver, and any license it holds, open. Release it explicitly with ``model.solver.close()``, ``model.solver = None``, or ``contextlib.closing(model.solver)``. ``close()`` itself is unchanged and is still called when a new ``solve()`` replaces the solver, so a loop re-solving the same model is unaffected. (`#899 `__) Version 0.9.1 ------------- diff --git a/linopy/model.py b/linopy/model.py index 7e4b16cf..61c3f0b7 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -2011,7 +2011,8 @@ def solve( means the license remains acquired until the solver is released: call ``model.solver.close()`` (or assign ``model.solver = None``) to free it explicitly. It is also released on the next ``solve()`` - call and when the model is garbage-collected. + call, but not by garbage collection: a model that merely goes out of + scope leaves its solver open. """ if mock_solve: return self._mock_solve( From ee509562fb9fdbc1fbf9b47a2e98c9a9ff968495 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 27 Aug 2026 14:16:43 +0200 Subject: [PATCH 11/11] docs: tighten the solver-release release notes --- doc/release_notes.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 50d03c6b..d79eb96c 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -54,11 +54,11 @@ Upcoming Version **Bug fixes** * A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``. -* ``Solver.close()`` now drops the native solver model before closing the environment that owns it, and the COPT and MindOpt file interfaces no longer return a solver model they have already disposed — ``model.solver_model`` is ``None`` after a file-based COPT or MindOpt solve instead of a handle into freed memory. (`#899 `__) +* ``Solver.close()`` no longer leaves dangling native handles behind. The solver model is now dropped before the environment that owns it, instead of after. And the COPT and MindOpt file interfaces no longer hand back a model they already disposed: after a file-based COPT or MindOpt solve, ``model.solver_model`` is ``None`` rather than a handle into freed memory. (`#899 `__) **Breaking Changes** -* A solver is no longer released when the model is garbage-collected, withdrawing a guarantee announced in 0.8.0. ``Solver`` disposed its native handles from a finalizer, but a solver is only ever reachable in a ``Model``/``Solver`` reference cycle, so that finalizer ran mid-collection and tore down native state at an arbitrary point in an unrelated call stack — enough to abort the interpreter inside code that never touched linopy. A ``Model`` that merely goes out of scope now leaves its solver, and any license it holds, open. Release it explicitly with ``model.solver.close()``, ``model.solver = None``, or ``contextlib.closing(model.solver)``. ``close()`` itself is unchanged and is still called when a new ``solve()`` replaces the solver, so a loop re-solving the same model is unaffected. (`#899 `__) +* A solver is no longer released automatically when the model is garbage-collected, withdrawing a guarantee announced in 0.8.0. The finalizer that did so ran mid-collection and could tear down native state inside an unrelated call stack, aborting the interpreter. Release the solver explicitly instead, with ``model.solver.close()``, ``model.solver = None``, or ``contextlib.closing(model.solver)``. A new ``solve()`` still closes the solver it replaces, so a re-solve loop needs no change. (`#899 `__) Version 0.9.1 -------------