From 65bbe5117a5f7897bd7398e6b39832ad93052648 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 11 Jul 2026 15:36:41 -0400 Subject: [PATCH 1/4] Isolate the active-HighState stack per execution context (#63056) Concurrent state/orchestration renders shared a single class-level HighState.stack list. When the reactor renders orchestrations in parallel worker threads (it runs them inline, without forking), one render's push_active was visible to another, so HighState.get_active could return the wrong HighState in the middle of a render. This surfaced as an IndexError popping an empty pydsl render stack, or KeyError: '__env__' from a spuriously detected conflicting ID. Store the stack in a contextvars.ContextVar instead, mirroring salt.loader's loader_ctxvar, so each execution context (and therefore each reactor worker thread) gets its own stack. The pydsl top-file matches, previously cached in a module-level SLS_MATCHES global with the same sharing problem, are now cached per HighState instance. SSHHighState.push_active was a redundant override reaching for the removed class attribute, so it now inherits the context-backed base method. The conflicting-ID error formatter also uses .get() so a genuine conflict reports cleanly instead of raising KeyError. --- changelog/63056.fixed.md | 1 + salt/client/ssh/state.py | 4 +- salt/state.py | 54 +++++++++--- salt/utils/pydsl.py | 15 ++-- .../unit/state/test_active_highstate_stack.py | 85 +++++++++++++++++++ 5 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 changelog/63056.fixed.md create mode 100644 tests/pytests/unit/state/test_active_highstate_stack.py diff --git a/changelog/63056.fixed.md b/changelog/63056.fixed.md new file mode 100644 index 000000000000..c6d60db7a87d --- /dev/null +++ b/changelog/63056.fixed.md @@ -0,0 +1 @@ +Fixed a race in concurrent state/orchestration renders where the active-HighState stack was shared on the class, so parallel reactor renders corrupted one another and failed with ``IndexError`` (empty pydsl render stack) or ``KeyError: '__env__'`` (spurious conflicting-ID). The stack and the cached pydsl top-file matches are now isolated per execution context. diff --git a/salt/client/ssh/state.py b/salt/client/ssh/state.py index e52f41cbc647..8785e3582560 100644 --- a/salt/client/ssh/state.py +++ b/salt/client/ssh/state.py @@ -140,9 +140,7 @@ def __init__( self._pydsl_all_decls = {} self._pydsl_render_stack = [] - - def push_active(self): - salt.state.HighState.stack.append(self) + self._pydsl_sls_matches = None def load_dynamic(self, matches): """ diff --git a/salt/state.py b/salt/state.py index 5a2829d2c054..b0daae00540c 100644 --- a/salt/state.py +++ b/salt/state.py @@ -11,6 +11,7 @@ } """ +import contextvars import copy import datetime import fnmatch @@ -4863,10 +4864,10 @@ def merge_included_states(self, highstate, state, errors): " conflicting ID is '{}' and is found in SLS" " '{}:{}' and SLS '{}:{}'".format( id_, - highstate[id_]["__env__"], - highstate[id_]["__sls__"], - state[id_]["__env__"], - state[id_]["__sls__"], + highstate[id_].get("__env__"), + highstate[id_].get("__sls__"), + state[id_].get("__env__"), + state[id_].get("__sls__"), ) ) try: @@ -5083,6 +5084,14 @@ def __exit__(self, *_): self.destroy() +# The stack of active HighState objects during a state run is kept per +# execution context rather than on the class, so concurrent runs -- e.g. +# reactor orchestrations rendered in parallel reactor worker threads -- each +# get their own stack instead of corrupting a shared class-level list +# (#63056). This mirrors salt.loader's loader_ctxvar. +_active_highstates = contextvars.ContextVar("salt_active_highstates") + + class HighState(BaseHighState): """ Generate and execute the salt "High State". The High State is the @@ -5090,8 +5099,9 @@ class HighState(BaseHighState): salt master or in the local cache. """ - # a stack of active HighState objects during a state.highstate run - stack = [] + # The stack of active HighState objects during a state run is stored per + # execution context in the module-level ``_active_highstates`` ContextVar; + # see ``_active_stack`` and the push/pop/get/clear accessors below. def __init__( self, @@ -5151,26 +5161,44 @@ def __init__( # a stack of current rendering Sls objects, maintained and used by the pydsl renderer. self._pydsl_render_stack = [] + # cached top-file matches for pydsl includes, computed once per run. + # Held on the instance (not a module global) so concurrent runs do not + # share one another's matches (#63056). + self._pydsl_sls_matches = None + + @classmethod + def _active_stack(cls): + # The active-HighState stack for the current execution context, created + # lazily on first use. A default= on the ContextVar would share one + # list object across every context, defeating the isolation, so the + # per-context list is set explicitly here instead. + try: + return _active_highstates.get() + except LookupError: + stack = [] + _active_highstates.set(stack) + return stack + def push_active(self): - self.stack.append(self) + self._active_stack().append(self) @classmethod def clear_active(cls): # Nuclear option # - # Blow away the entire stack. Used primarily by the test runner but also - # useful in custom wrappers of the HighState class, to reset the stack - # to a fresh state. - cls.stack = [] + # Blow away the active-HighState stack for the current execution + # context. Used primarily by the test runner but also useful in custom + # wrappers of the HighState class, to reset the stack to a fresh state. + _active_highstates.set([]) @classmethod def pop_active(cls): - cls.stack.pop() + cls._active_stack().pop() @classmethod def get_active(cls): try: - return cls.stack[-1] + return cls._active_stack()[-1] except IndexError: return None diff --git a/salt/utils/pydsl.py b/salt/utils/pydsl.py index 516a4305b7ee..94ce3aae0a30 100644 --- a/salt/utils/pydsl.py +++ b/salt/utils/pydsl.py @@ -103,9 +103,6 @@ def __getattr__(self, name): return self.get(name) -SLS_MATCHES = None - - class Sls: def __init__(self, sls, saltenv, rendered_sls): self.name = sls @@ -146,9 +143,13 @@ def include(self, *sls_names, **kws): HIGHSTATE = HighState.get_active() - global SLS_MATCHES - if SLS_MATCHES is None: - SLS_MATCHES = HIGHSTATE.top_matches(HIGHSTATE.get_top()) + # Cache the top-file matches on the active HighState instead of a module + # global so concurrent runs don't share each other's matches (#63056). + sls_matches = HIGHSTATE._pydsl_sls_matches + if sls_matches is None: + sls_matches = HIGHSTATE._pydsl_sls_matches = HIGHSTATE.top_matches( + HIGHSTATE.get_top() + ) highstate = self.included_highstate slsmods = [] # a list of pydsl sls modules rendered. @@ -159,7 +160,7 @@ def include(self, *sls_names, **kws): sls ) # needed in case the starting sls uses the pydsl renderer. histates, errors = HIGHSTATE.render_state( - sls, saltenv, self.rendered_sls, SLS_MATCHES + sls, saltenv, self.rendered_sls, sls_matches ) HIGHSTATE.merge_included_states(highstate, histates, errors) if errors: diff --git a/tests/pytests/unit/state/test_active_highstate_stack.py b/tests/pytests/unit/state/test_active_highstate_stack.py new file mode 100644 index 000000000000..fd8845eaf738 --- /dev/null +++ b/tests/pytests/unit/state/test_active_highstate_stack.py @@ -0,0 +1,85 @@ +""" +Tests for the per-execution-context active-HighState stack (#63056). + +Concurrent state runs -- e.g. reactor orchestrations rendered in parallel +reactor worker threads -- previously shared a single class-level +``HighState.stack`` list. One run's ``push_active`` was therefore visible to +another, so ``HighState.get_active`` could return the wrong HighState in the +middle of a render (surfacing downstream as ``IndexError`` popping an empty +pydsl render stack, or ``KeyError: '__env__'`` from a spuriously detected +conflicting ID). The stack is now isolated per execution context via a +ContextVar. +""" + +import threading + +import salt.state + + +class _Marker(salt.state.HighState): + # A cheap stand-in that skips HighState's heavy __init__ but inherits the + # real push/pop/get/clear accessors under test. + def __init__(self, tag): # pylint: disable=super-init-not-called + self.tag = tag + + +def test_active_stack_push_pop_get_clear(): + HighState = salt.state.HighState + HighState.clear_active() + assert HighState.get_active() is None + + a = _Marker("a") + b = _Marker("b") + + a.push_active() + assert HighState.get_active() is a + b.push_active() + assert HighState.get_active() is b + b.pop_active() + assert HighState.get_active() is a + a.pop_active() + assert HighState.get_active() is None + + # clear_active() resets the stack for the current context. + a.push_active() + HighState.clear_active() + assert HighState.get_active() is None + + +def test_active_stack_isolated_across_threads(): + HighState = salt.state.HighState + HighState.clear_active() + + results = {} + # Two barriers make the failure deterministic on a *shared* stack: every + # thread pushes before any reads (so the shared top holds both markers), + # and every thread reads before any pops (so a fast pop can't restore the + # reader's own marker by luck). With a shared stack both threads then read + # whichever marker was pushed last -- two identical tags, never {A, B}. + both_pushed = threading.Barrier(2) + both_read = threading.Barrier(2) + + def worker(tag): + marker = _Marker(tag) + marker.push_active() + both_pushed.wait(timeout=10) + try: + active = HighState.get_active() + results[tag] = None if active is None else active.tag + both_read.wait(timeout=10) + finally: + marker.pop_active() + + threads = [ + threading.Thread(target=worker, args=("A",)), + threading.Thread(target=worker, args=("B",)), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=15) + + # Each thread sees only the HighState it pushed, despite the concurrent + # push from the other thread. On the old shared stack both threads would + # read whichever marker was pushed last. + assert results == {"A": "A", "B": "B"} From 2d16e12f9c834c47b0784385fbe877e30b6b7c65 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 13 Jul 2026 06:51:09 -0400 Subject: [PATCH 2/4] Move active-stack accessors to BaseHighState so SSHHighState keeps push_active The #63056 refactor put the active-HighState stack accessors (push_active/pop_active/get_active/clear_active) on HighState. But SSHHighState subclasses BaseHighState, not HighState, and the salt-ssh state wrapper calls st_.push_active() on every run -- so every salt-ssh state execution raised "'SSHHighState' object has no attribute 'push_active'". Move the accessors to BaseHighState (their shared ancestor) so both HighState and SSHHighState resolve them and share the one context-isolated stack, restoring the original shared-stack semantics without an SSH-specific override. Add a regression test that exercises SSHHighState directly -- the original test only covered HighState, which is why the break reached CI. --- salt/state.py | 72 +++++++++---------- .../unit/state/test_active_highstate_stack.py | 39 ++++++++++ 2 files changed, 75 insertions(+), 36 deletions(-) diff --git a/salt/state.py b/salt/state.py index b0daae00540c..984dba8ba443 100644 --- a/salt/state.py +++ b/salt/state.py @@ -3911,6 +3911,42 @@ def __init__(self, opts): self.avail = self.__gather_avail() self.building_highstate = HashableOrderedDict() + @classmethod + def _active_stack(cls): + # The active-HighState stack for the current execution context, created + # lazily on first use. A default= on the ContextVar would share one + # list object across every context, defeating the isolation, so the + # per-context list is set explicitly here instead. + try: + return _active_highstates.get() + except LookupError: + stack = [] + _active_highstates.set(stack) + return stack + + def push_active(self): + self._active_stack().append(self) + + @classmethod + def clear_active(cls): + # Nuclear option + # + # Blow away the active-HighState stack for the current execution + # context. Used primarily by the test runner but also useful in custom + # wrappers of the HighState class, to reset the stack to a fresh state. + _active_highstates.set([]) + + @classmethod + def pop_active(cls): + cls._active_stack().pop() + + @classmethod + def get_active(cls): + try: + return cls._active_stack()[-1] + except IndexError: + return None + def __gather_avail(self): """ Lazily gather the lists of available sls data from the master @@ -5166,42 +5202,6 @@ def __init__( # share one another's matches (#63056). self._pydsl_sls_matches = None - @classmethod - def _active_stack(cls): - # The active-HighState stack for the current execution context, created - # lazily on first use. A default= on the ContextVar would share one - # list object across every context, defeating the isolation, so the - # per-context list is set explicitly here instead. - try: - return _active_highstates.get() - except LookupError: - stack = [] - _active_highstates.set(stack) - return stack - - def push_active(self): - self._active_stack().append(self) - - @classmethod - def clear_active(cls): - # Nuclear option - # - # Blow away the active-HighState stack for the current execution - # context. Used primarily by the test runner but also useful in custom - # wrappers of the HighState class, to reset the stack to a fresh state. - _active_highstates.set([]) - - @classmethod - def pop_active(cls): - cls._active_stack().pop() - - @classmethod - def get_active(cls): - try: - return cls._active_stack()[-1] - except IndexError: - return None - def destroy(self): if not self.preserve_client: self.client.destroy() diff --git a/tests/pytests/unit/state/test_active_highstate_stack.py b/tests/pytests/unit/state/test_active_highstate_stack.py index fd8845eaf738..e06a066592b1 100644 --- a/tests/pytests/unit/state/test_active_highstate_stack.py +++ b/tests/pytests/unit/state/test_active_highstate_stack.py @@ -83,3 +83,42 @@ def worker(tag): # push from the other thread. On the old shared stack both threads would # read whichever marker was pushed last. assert results == {"A": "A", "B": "B"} + + +def test_sshhighstate_shares_active_stack_with_highstate(): + """ + salt-ssh's ``SSHHighState`` subclasses ``BaseHighState`` (not ``HighState``) + and its state wrapper calls ``st_.push_active()`` on every run. The + active-stack accessors therefore have to live on ``BaseHighState``: when + they lived on ``HighState``, ``SSHHighState`` had no ``push_active`` and + every salt-ssh state execution raised + ``'SSHHighState' object has no attribute 'push_active'``. + + Also pins the cross-subclass contract the pydsl renderer depends on: a + non-``HighState`` ``BaseHighState`` subclass that pushes itself is visible + to ``HighState.get_active()``. + """ + from salt.client.ssh.state import SSHHighState + + assert issubclass(SSHHighState, salt.state.BaseHighState) + assert not issubclass(SSHHighState, salt.state.HighState) + for name in ("push_active", "pop_active", "get_active", "clear_active"): + assert hasattr(SSHHighState, name), f"SSHHighState lost {name}" + # Inherited from the shared base, not redefined per subclass. + assert SSHHighState.push_active is salt.state.BaseHighState.push_active + + class _SSHMarker(SSHHighState): + # Skip SSHHighState's heavy __init__; only the inherited accessors are + # under test. + def __init__(self): # pylint: disable=super-init-not-called + pass + + salt.state.BaseHighState.clear_active() + try: + marker = _SSHMarker() + marker.push_active() + assert salt.state.HighState.get_active() is marker + marker.pop_active() + assert salt.state.HighState.get_active() is None + finally: + salt.state.BaseHighState.clear_active() From cf523c46d33b25bd013ee990d6f2b88af56bc0a8 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 15 Jul 2026 13:34:42 -0400 Subject: [PATCH 3/4] Bundle typing_extensions in the salt-ssh thin for py3.6 targets This PR adds `import contextvars` to salt/state.py, which is imported at every salt-call startup (salt.minion -> salt.utils.state -> salt.state). A Python 3.6 target has no stdlib contextvars, so the thin ships a backport; that backport imports immutables, which imports typing_extensions -- not previously bundled -- so salt-call died with `ModuleNotFoundError: No module named 'typing_extensions'`. Bundle typing_extensions alongside the immutables the thin already ships for py3.6, mirroring the has_immutables pattern, and extend the get_tops tests to expect it. This is what integration/ssh/test_log.py (a python 3.6 container) was tripping on. --- salt/utils/thin.py | 13 +++++++++++++ tests/pytests/unit/utils/test_thin.py | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/salt/utils/thin.py b/salt/utils/thin.py index 045a51cfa089..c1659825534e 100644 --- a/salt/utils/thin.py +++ b/salt/utils/thin.py @@ -41,6 +41,17 @@ except ImportError: pass +# ``immutables`` (bundled above for the Python 3.6 contextvars backport) imports +# ``typing_extensions``; without it in the thin, a py3.6 target crashes on +# ``import contextvars`` with ModuleNotFoundError. Bundle it alongside. +has_typing_extensions = False +try: + import typing_extensions + + has_typing_extensions = True +except ImportError: + pass + try: import zlib @@ -452,6 +463,8 @@ def get_tops(extra_mods="", so_mods=""): mods.append(contextvars) if has_immutables: mods.append(immutables) + if has_typing_extensions: + mods.append(typing_extensions) for mod in mods: if mod: log.debug('Adding module to the tops: "%s"', mod.__name__) diff --git a/tests/pytests/unit/utils/test_thin.py b/tests/pytests/unit/utils/test_thin.py index d505ce00548e..0ec92c28f84f 100644 --- a/tests/pytests/unit/utils/test_thin.py +++ b/tests/pytests/unit/utils/test_thin.py @@ -485,6 +485,11 @@ def test_get_ext_namespaces_failure(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) +@patch_if( + salt.utils.thin.has_typing_extensions, + "salt.utils.thin.typing_extensions", + type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), +) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops(thin_ctx): """ @@ -512,6 +517,8 @@ def test_get_tops(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) + if salt.utils.thin.has_typing_extensions: + base_tops.extend(["typing_extensions"]) tops = [] for top in thin.get_tops(extra_mods="foo,bar"): if top.find("/") != -1: @@ -596,6 +603,11 @@ def test_get_tops(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) +@patch_if( + salt.utils.thin.has_typing_extensions, + "salt.utils.thin.typing_extensions", + type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), +) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops_extra_mods(thin_ctx): """ @@ -625,6 +637,8 @@ def test_get_tops_extra_mods(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) + if salt.utils.thin.has_typing_extensions: + base_tops.extend(["typing_extensions"]) libs = salt.utils.thin.find_site_modules("contextvars") foo = {"__file__": os.sep + os.path.join("custom", "foo", "__init__.py")} bar = {"__file__": os.sep + os.path.join("custom", "bar")} @@ -717,6 +731,11 @@ def test_get_tops_extra_mods(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) +@patch_if( + salt.utils.thin.has_typing_extensions, + "salt.utils.thin.typing_extensions", + type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), +) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops_so_mods(thin_ctx): """ @@ -746,6 +765,8 @@ def test_get_tops_so_mods(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) + if salt.utils.thin.has_typing_extensions: + base_tops.extend(["typing_extensions"]) libs = salt.utils.thin.find_site_modules("contextvars") with patch("salt.utils.thin.find_site_modules", MagicMock(side_effect=[libs])): with patch( From fe8ca91f2dbd9addcd2075f1686616124ed26f91 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 15 Jul 2026 18:59:27 -0400 Subject: [PATCH 4/4] Guard the contextvars import for py3.6 salt-ssh; revert thin typing_extensions bundle The previous commit bundled typing_extensions into the salt-ssh thin, but the thin ships the build host's (modern) typing_extensions, whose py3.8+ syntax is a SyntaxError on a Python 3.6 target -- and it shadows the target's own compatible typing_extensions, so importlib_metadata (imported at salt-call startup via salt._compat) crashed before reaching any of this PR's code. That broke salt-ssh on every py3.6 target (Rocky Linux 8, Amazon Linux 2). Reverted. Instead, guard "import contextvars" in salt/state.py. On targets where it can only be resolved through the thin's backport -- which drags in immutables/typing_extensions that may be missing (a py3.9 Debian target) or incompatible (py3.6) -- catch ImportError/SyntaxError and fall back to a shared class-level active-HighState stack. salt-ssh runs one execution per target, so the per-context isolation (#63056) is not needed there; on py3.7+ minions and the onedir master/minion, stdlib contextvars is used and the isolation is unchanged. Validated on live Python 3.6.8 (AlmaLinux 8): a modern typing_extensions is a SyntaxError on py3.6, and the guarded import catches both that and a missing typing_extensions, engaging the fallback without crashing. Adds a test for the fallback path. --- salt/state.py | 28 ++++++++++++++-- salt/utils/thin.py | 13 -------- .../unit/state/test_active_highstate_stack.py | 33 +++++++++++++++++++ tests/pytests/unit/utils/test_thin.py | 21 ------------ 4 files changed, 58 insertions(+), 37 deletions(-) diff --git a/salt/state.py b/salt/state.py index 984dba8ba443..da34efa01e60 100644 --- a/salt/state.py +++ b/salt/state.py @@ -11,7 +11,16 @@ } """ -import contextvars +try: + import contextvars +except (ImportError, SyntaxError): + # Some salt-ssh targets (notably Python 3.6, which lacks a stdlib + # contextvars) resolve ``import contextvars`` to the thin's bundled backport, + # which pulls in immutables/typing_extensions that can be missing or + # syntactically incompatible on the target. Degrade to a shared stack there + # -- salt-ssh runs one execution per target, so the per-context isolation + # contextvars provides (#63056) is not needed. + contextvars = None import copy import datetime import fnmatch @@ -3911,8 +3920,15 @@ def __init__(self, opts): self.avail = self.__gather_avail() self.building_highstate = HashableOrderedDict() + # Fallback shared stack, used only when contextvars is unavailable (see the + # guarded import at the top of the module). salt-ssh runs one execution per + # target, so a shared stack there is safe. + _shared_active_stack = [] + @classmethod def _active_stack(cls): + if _active_highstates is None: + return BaseHighState._shared_active_stack # The active-HighState stack for the current execution context, created # lazily on first use. A default= on the ContextVar would share one # list object across every context, defeating the isolation, so the @@ -3934,7 +3950,10 @@ def clear_active(cls): # Blow away the active-HighState stack for the current execution # context. Used primarily by the test runner but also useful in custom # wrappers of the HighState class, to reset the stack to a fresh state. - _active_highstates.set([]) + if _active_highstates is None: + BaseHighState._shared_active_stack.clear() + else: + _active_highstates.set([]) @classmethod def pop_active(cls): @@ -5125,7 +5144,10 @@ def __exit__(self, *_): # reactor orchestrations rendered in parallel reactor worker threads -- each # get their own stack instead of corrupting a shared class-level list # (#63056). This mirrors salt.loader's loader_ctxvar. -_active_highstates = contextvars.ContextVar("salt_active_highstates") +if contextvars is not None: + _active_highstates = contextvars.ContextVar("salt_active_highstates") +else: + _active_highstates = None class HighState(BaseHighState): diff --git a/salt/utils/thin.py b/salt/utils/thin.py index c1659825534e..045a51cfa089 100644 --- a/salt/utils/thin.py +++ b/salt/utils/thin.py @@ -41,17 +41,6 @@ except ImportError: pass -# ``immutables`` (bundled above for the Python 3.6 contextvars backport) imports -# ``typing_extensions``; without it in the thin, a py3.6 target crashes on -# ``import contextvars`` with ModuleNotFoundError. Bundle it alongside. -has_typing_extensions = False -try: - import typing_extensions - - has_typing_extensions = True -except ImportError: - pass - try: import zlib @@ -463,8 +452,6 @@ def get_tops(extra_mods="", so_mods=""): mods.append(contextvars) if has_immutables: mods.append(immutables) - if has_typing_extensions: - mods.append(typing_extensions) for mod in mods: if mod: log.debug('Adding module to the tops: "%s"', mod.__name__) diff --git a/tests/pytests/unit/state/test_active_highstate_stack.py b/tests/pytests/unit/state/test_active_highstate_stack.py index e06a066592b1..d631e057f306 100644 --- a/tests/pytests/unit/state/test_active_highstate_stack.py +++ b/tests/pytests/unit/state/test_active_highstate_stack.py @@ -14,6 +14,7 @@ import threading import salt.state +from tests.support.mock import patch class _Marker(salt.state.HighState): @@ -122,3 +123,35 @@ def __init__(self): # pylint: disable=super-init-not-called assert salt.state.HighState.get_active() is None finally: salt.state.BaseHighState.clear_active() + + +def test_active_stack_falls_back_when_contextvars_unavailable(): + """ + On a salt-ssh target without a usable ``contextvars`` (e.g. Python 3.6, + where the module is only available through the thin's backport and can pull + in an incompatible ``typing_extensions``), ``import contextvars`` is guarded + and ``_active_highstates`` is ``None``. The active-stack accessors must then + degrade to a shared class-level list instead of raising -- salt-ssh runs a + single execution per target, so a shared stack is safe there. + """ + HighState = salt.state.HighState + with patch.object(salt.state, "_active_highstates", None): + salt.state.BaseHighState._shared_active_stack.clear() + HighState.clear_active() + assert HighState.get_active() is None + + a = _Marker("a") + b = _Marker("b") + a.push_active() + assert HighState.get_active() is a + b.push_active() + assert HighState.get_active() is b + b.pop_active() + assert HighState.get_active() is a + a.pop_active() + assert HighState.get_active() is None + + a.push_active() + HighState.clear_active() + assert HighState.get_active() is None + salt.state.BaseHighState._shared_active_stack.clear() diff --git a/tests/pytests/unit/utils/test_thin.py b/tests/pytests/unit/utils/test_thin.py index 0ec92c28f84f..d505ce00548e 100644 --- a/tests/pytests/unit/utils/test_thin.py +++ b/tests/pytests/unit/utils/test_thin.py @@ -485,11 +485,6 @@ def test_get_ext_namespaces_failure(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) -@patch_if( - salt.utils.thin.has_typing_extensions, - "salt.utils.thin.typing_extensions", - type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), -) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops(thin_ctx): """ @@ -517,8 +512,6 @@ def test_get_tops(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) - if salt.utils.thin.has_typing_extensions: - base_tops.extend(["typing_extensions"]) tops = [] for top in thin.get_tops(extra_mods="foo,bar"): if top.find("/") != -1: @@ -603,11 +596,6 @@ def test_get_tops(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) -@patch_if( - salt.utils.thin.has_typing_extensions, - "salt.utils.thin.typing_extensions", - type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), -) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops_extra_mods(thin_ctx): """ @@ -637,8 +625,6 @@ def test_get_tops_extra_mods(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) - if salt.utils.thin.has_typing_extensions: - base_tops.extend(["typing_extensions"]) libs = salt.utils.thin.find_site_modules("contextvars") foo = {"__file__": os.sep + os.path.join("custom", "foo", "__init__.py")} bar = {"__file__": os.sep + os.path.join("custom", "bar")} @@ -731,11 +717,6 @@ def test_get_tops_extra_mods(thin_ctx): "salt.utils.thin.immutables", type("immutables", (), {"__file__": "/site-packages/immutables"}), ) -@patch_if( - salt.utils.thin.has_typing_extensions, - "salt.utils.thin.typing_extensions", - type("typing_extensions", (), {"__file__": "/site-packages/typing_extensions"}), -) @patch("salt.utils.thin.log", MagicMock()) def test_get_tops_so_mods(thin_ctx): """ @@ -765,8 +746,6 @@ def test_get_tops_so_mods(thin_ctx): ] if salt.utils.thin.has_immutables: base_tops.extend(["immutables"]) - if salt.utils.thin.has_typing_extensions: - base_tops.extend(["typing_extensions"]) libs = salt.utils.thin.find_site_modules("contextvars") with patch("salt.utils.thin.find_site_modules", MagicMock(side_effect=[libs])): with patch(