From 0c4632ecb53bf09f16b673b41f84612c68c4cd9c Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 18 Aug 2026 16:16:15 +0200 Subject: [PATCH 1/2] 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 2/2] 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