commit:     35a737cc9c93f4c38c65a57a9f70948ddd416714
Author:     Zac Medico <zmedico <AT> gentoo <DOT> org>
AuthorDate: Mon Nov 10 02:31:20 2025 +0000
Commit:     Zac Medico <zmedico <AT> gentoo <DOT> org>
CommitDate: Mon Nov 10 02:31:20 2025 +0000
URL:        https://gitweb.gentoo.org/proj/portage.git/commit/?id=35a737cc

depgraph: avoid RecursionError for virtual cycle

The included test cases report these errors:

!!! virtual cycle detected:

  virtual/gzip-1::test_repo

!!! virtual cycle detected:

  virtual/A-1::test_repo
  virtual/B-1::test_repo
  virtual/C-1::test_repo

Bug: https://bugs.gentoo.org/965570
Signed-off-by: Zac Medico <zmedico <AT> gentoo.org>

 lib/_emerge/depgraph.py                          | 34 ++++++++++++-
 lib/portage/tests/resolver/ResolverPlayground.py |  5 ++
 lib/portage/tests/resolver/meson.build           |  1 +
 lib/portage/tests/resolver/test_virtual_cycle.py | 63 ++++++++++++++++++++++++
 4 files changed, 102 insertions(+), 1 deletion(-)

diff --git a/lib/_emerge/depgraph.py b/lib/_emerge/depgraph.py
index 87d090ae3b..d3d97b59bc 100644
--- a/lib/_emerge/depgraph.py
+++ b/lib/_emerge/depgraph.py
@@ -1,4 +1,4 @@
-# Copyright 1999-2024 Gentoo Authors
+# Copyright 1999-2025 Gentoo Authors
 # Distributed under the terms of the GNU General Public License v2
 
 import errno
@@ -694,6 +694,9 @@ class depgraph:
             maxsize=1000
         )(self._slot_operator_check_reverse_dependencies)
 
+        self._virt_deps_visible_recursion = set()
+        self._virtual_cycle = None
+
     def _index_binpkgs(self):
         for root in self._frozen_config.trees:
             bindb = self._frozen_config.trees[root]["bintree"].dbapi
@@ -4828,6 +4831,18 @@ class depgraph:
             if spinner is not None and spinner.update is not 
spinner.update_quiet:
                 spinner_cb.handle = self._event_loop.call_soon(spinner_cb)
             return self._select_files(args)
+        except self._virtual_cycle_error as e:
+            self._virtual_cycle = e.value
+
+            msg = ["\n\n!!! virtual cycle detected:\n\n"]
+            for pkg in sorted(self._virtual_cycle):
+                msg.append(f"  {pkg.cpv}::{pkg.repo}\n")
+            msg.append("\n")
+
+            for chunk in msg:
+                writemsg(chunk, noiselevel=-1)
+            self._dynamic_config._skip_restart = True
+            return 0, []
         finally:
             if spinner_cb.handle is not None:
                 spinner_cb.handle.cancel()
@@ -5998,6 +6013,17 @@ class depgraph:
         useful for checking if it will be necessary to expand virtual slots,
         for cases like bug #382557.
         """
+        if pkg in self._virt_deps_visible_recursion:
+            raise 
self._virtual_cycle_error(list(self._virt_deps_visible_recursion))
+
+        self._virt_deps_visible_recursion.add(pkg)
+        try:
+            return self._virt_deps_visible_imp(pkg, ignore_use)
+        finally:
+            self._virt_deps_visible_recursion.remove(pkg)
+
+    def _virt_deps_visible_imp(self, pkg, ignore_use):
+
         try:
             rdepend = self._select_atoms(
                 pkg.root,
@@ -11334,6 +11360,12 @@ class depgraph:
         been disqualified due to autounmask changes.
         """
 
+    class _virtual_cycle_error(_internal_exception):
+        """
+        This is raised by _virt_deps_visible when a virtual cycle is
+        detected.
+        """
+
     def need_restart(self):
         return (
             self._dynamic_config._need_restart

diff --git a/lib/portage/tests/resolver/ResolverPlayground.py 
b/lib/portage/tests/resolver/ResolverPlayground.py
index 47d93274f8..3e2cc6ec70 100644
--- a/lib/portage/tests/resolver/ResolverPlayground.py
+++ b/lib/portage/tests/resolver/ResolverPlayground.py
@@ -1034,6 +1034,7 @@ class ResolverPlaygroundResult:
         "forced_rebuilds",
         "required_use_unsatisfied",
         "graph_order",
+        "virtual_cycle",
     )
     optional_checks = (
         "forced_rebuilds",
@@ -1057,6 +1058,7 @@ class ResolverPlaygroundResult:
         self.unsatisfied_deps = frozenset()
         self.forced_rebuilds = None
         self.required_use_unsatisfied = None
+        self.virtual_cycle = None
 
         self.graph_order = [
             _mergelist_str(node, self.depgraph)
@@ -1135,6 +1137,9 @@ class ResolverPlaygroundResult:
         if required_use_unsatisfied:
             self.required_use_unsatisfied = set(required_use_unsatisfied)
 
+        if self.depgraph._virtual_cycle:
+            self.virtual_cycle = {pkg.cpv for pkg in 
self.depgraph._virtual_cycle}
+
 
 class ResolverPlaygroundDepcleanResult:
     checks = (

diff --git a/lib/portage/tests/resolver/meson.build 
b/lib/portage/tests/resolver/meson.build
index 7569af8cd0..653b0536e1 100644
--- a/lib/portage/tests/resolver/meson.build
+++ b/lib/portage/tests/resolver/meson.build
@@ -90,6 +90,7 @@ py.install_sources(
         'test_use_dep_defaults.py',
         'test_virtual_minimize_children.py',
         'test_virtual_slot.py',
+        'test_virtual_cycle.py',
         'test_with_test_deps.py',
         '__init__.py',
         '__test__.py',

diff --git a/lib/portage/tests/resolver/test_virtual_cycle.py 
b/lib/portage/tests/resolver/test_virtual_cycle.py
new file mode 100644
index 0000000000..720f3c461a
--- /dev/null
+++ b/lib/portage/tests/resolver/test_virtual_cycle.py
@@ -0,0 +1,63 @@
+# Copyright 2025 Gentoo Authors
+# Distributed under the terms of the GNU General Public License v2
+
+from portage.tests import TestCase
+from portage.tests.resolver.ResolverPlayground import (
+    ResolverPlayground,
+    ResolverPlaygroundTestCase,
+)
+
+
+class VirtualCycleTestCase(TestCase):
+    def testVirtualCycle(self):
+        ebuilds = {
+            "app-misc/foo-1": {
+                "EAPI": "8",
+                "RDEPEND": "virtual/A",
+            },
+            "virtual/A-1": {
+                "EAPI": "8",
+                "RDEPEND": "virtual/B",
+            },
+            "virtual/B-1": {
+                "EAPI": "8",
+                "RDEPEND": "virtual/C",
+            },
+            "virtual/C-1": {
+                "EAPI": "8",
+                "RDEPEND": "virtual/A",
+            },
+            "app-misc/bar-1": {
+                "EAPI": "8",
+                "RDEPEND": "virtual/gzip",
+            },
+            "virtual/gzip-1": {
+                "EAPI": "8",
+                "RDEPEND": "virtual/gzip",
+            },
+        }
+
+        test_cases = (
+            # Test direct virtual cycle for bug 965570.
+            ResolverPlaygroundTestCase(
+                ["app-misc/bar"],
+                success=False,
+                virtual_cycle={"virtual/gzip-1"},
+            ),
+            # Test indirect virtual cycle for bug 965570.
+            ResolverPlaygroundTestCase(
+                ["app-misc/foo"],
+                success=False,
+                virtual_cycle={"virtual/A-1", "virtual/B-1", "virtual/C-1"},
+            ),
+        )
+
+        playground = ResolverPlayground(debug=False, ebuilds=ebuilds)
+
+        try:
+            for test_case in test_cases:
+                playground.run_TestCase(test_case)
+                self.assertEqual(test_case.test_success, True, 
test_case.fail_msg)
+        finally:
+            playground.debug = False
+            playground.cleanup()

Reply via email to