From 345eb0bcad14c76d16b509c6b4d632b0c1255f0d Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 25 Aug 2026 17:59:40 -0700 Subject: [PATCH 1/2] Fix 2^n slowdown in wrap_sysconfig by making it idempotent (#321) bootstrap() is re-entered every time the relenv .pth file is re-executed, and importlib.reload(site) does exactly that. Salt calls importlib.reload(site) from state.module_refresh(), so on a long-lived minion each state refresh stacks another wrapper around sysconfig.get_config_var, get_config_vars, and get_paths. Because get_config_vars_wrapper calls its wrapped function twice per invocation, N stacked layers cost 2**N calls to the innermost stdlib function. Reported measurement: 46 layers on a wedged minion, at which depth a single sysconfig.get_config_var() call takes on the order of years to return and the process spins at 100% CPU forever. Highstates never finish. Guard wrap_sysconfig with an early-return when the wrapper is already installed. functools.wraps sets __wrapped__ on our wrapper, so its presence is a reliable 'already wrapped' marker. Regression test walks the __wrapped__ chain to recover the raw stdlib functions (previous tests in the module may have already wrapped), calls wrap_sysconfig three times, and asserts the module attributes are still the same wrapper objects that the first call installed -- no stacking. --- relenv/runtime.py | 11 ++++++++ tests/test_runtime.py | 61 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/relenv/runtime.py b/relenv/runtime.py index 46c2dd1a..bed13592 100644 --- a/relenv/runtime.py +++ b/relenv/runtime.py @@ -582,6 +582,17 @@ def wrap_sysconfig(name: str) -> ModuleType: """ module: ModuleType = importlib.import_module("sysconfig") mod = cast("Any", module) + # Idempotence guard. bootstrap() is re-entered whenever the relenv .pth + # file is re-executed, and importlib.reload(site) does exactly that. + # Salt calls importlib.reload(site) from state.module_refresh() on every + # module refresh; on a long-lived minion the wrappers stack. Because + # get_config_vars_wrapper calls its wrapped function twice per + # invocation, N stacked layers cost 2**N calls to the innermost + # implementation and a single sysconfig.get_config_var() eventually + # never returns (issue #321). functools.wraps sets ``__wrapped__`` + # on our wrappers, so its presence marks "already wrapped." + if getattr(mod.get_config_var, "__wrapped__", None) is not None: + return module mod.get_config_var = get_config_var_wrapper(mod.get_config_var) mod.get_config_vars = get_config_vars_wrapper(mod.get_config_vars, mod) mod._PIP_USE_SYSCONFIG = True diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 10fd6f8c..66dc6831 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -2070,3 +2070,64 @@ def fake_wrap_sysconfig(name: str) -> ModuleType: finally: monkeypatch.setattr(sys, "meta_path", original_meta_path) + + +def test_wrap_sysconfig_is_idempotent() -> None: + """ + Regression test for issue #321. + + wrap_sysconfig() must be idempotent. bootstrap() re-runs whenever the + relenv .pth file is re-executed (importlib.reload(site) does this; + Salt's state.module_refresh() does exactly that). Without a guard, + every call stacks another wrapper layer around sysconfig.get_config_var, + get_config_vars, and get_paths. Because get_config_vars_wrapper + invokes its wrapped function twice per call, N layers cost 2**N + calls to the innermost function, and a long-lived Salt minion + eventually spins forever inside a single get_config_var() call. + """ + import sysconfig + + # Previous tests in this module may have already wrapped sysconfig. + # Walk the ``__wrapped__`` chain (installed by functools.wraps) to + # recover the original stdlib callable and reinstall it, so we can + # assert cleanly on wrapping / non-wrapping identity. + pre_test_get_config_var = sysconfig.get_config_var + pre_test_get_config_vars = sysconfig.get_config_vars + pre_test_get_paths = sysconfig.get_paths + + def _unwrap(func: Any) -> Any: + while getattr(func, "__wrapped__", None) is not None: + func = func.__wrapped__ + return func + + raw_get_config_var = _unwrap(pre_test_get_config_var) + raw_get_config_vars = _unwrap(pre_test_get_config_vars) + raw_get_paths = _unwrap(pre_test_get_paths) + sysconfig.get_config_var = raw_get_config_var + sysconfig.get_config_vars = raw_get_config_vars + sysconfig.get_paths = raw_get_paths + try: + # First call installs wrappers. + relenv.runtime.wrap_sysconfig("sysconfig") + wrapped_get_config_var = sysconfig.get_config_var + wrapped_get_config_vars = sysconfig.get_config_vars + wrapped_get_paths = sysconfig.get_paths + assert wrapped_get_config_var is not raw_get_config_var + assert getattr(wrapped_get_config_var, "__wrapped__", None) is raw_get_config_var + + # Second and third calls must be no-ops. The wrapper attributes must + # still be the same objects installed by the first call — no stacking. + relenv.runtime.wrap_sysconfig("sysconfig") + relenv.runtime.wrap_sysconfig("sysconfig") + assert sysconfig.get_config_var is wrapped_get_config_var + assert sysconfig.get_config_vars is wrapped_get_config_vars + assert sysconfig.get_paths is wrapped_get_paths + # ``__wrapped__`` must still point at the raw stdlib function, not + # another layer of wrapping. + assert getattr(sysconfig.get_config_var, "__wrapped__", None) is raw_get_config_var + finally: + # Restore whatever state the previous tests left in place so we do + # not leak wrappers into subsequent tests. + sysconfig.get_config_var = pre_test_get_config_var + sysconfig.get_config_vars = pre_test_get_config_vars + sysconfig.get_paths = pre_test_get_paths From d9bd39ae552702839548123ce6799f3312928843 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Tue, 25 Aug 2026 22:57:22 -0700 Subject: [PATCH 2/2] Use object typing for _unwrap to satisfy ruff F821 The previous version referenced Any without importing it, which ruff caught as F821. object is sufficient here since _unwrap only uses getattr and does not need any specific typing.Any semantics. --- tests/test_runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 66dc6831..6cf70cec 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -2095,7 +2095,7 @@ def test_wrap_sysconfig_is_idempotent() -> None: pre_test_get_config_vars = sysconfig.get_config_vars pre_test_get_paths = sysconfig.get_paths - def _unwrap(func: Any) -> Any: + def _unwrap(func: object) -> object: while getattr(func, "__wrapped__", None) is not None: func = func.__wrapped__ return func