Skip to content

Isolate the active-HighState stack per execution context (#63056)#69782

Open
ggiesen wants to merge 2 commits into
saltstack:3006.xfrom
ggiesen:fix-63056-pydsl-render-race
Open

Isolate the active-HighState stack per execution context (#63056)#69782
ggiesen wants to merge 2 commits into
saltstack:3006.xfrom
ggiesen:fix-63056-pydsl-render-race

Conversation

@ggiesen

@ggiesen ggiesen commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Isolates the stack of active HighState objects per execution context so
concurrent state/orchestration renders don't corrupt one another.

The stack lived on the class (HighState.stack = []), shared by every thread.
The reactor renders orchestrations in a ThreadPool (reactor_worker_threads),
running the runner inline in worker threads rather than forking. When two
reactor events fire close together, their orchestrations render concurrently in
different worker threads and each push_active/pop_active mutates the same
shared list. HighState.get_active() then returns the wrong HighState in the
middle of a render, which shows up as:

  • IndexError: list index out of range popping an empty pydsl render stack
    (renderers/pydsl.py reads get_render_stack()[-1] off the wrong HighState), and
  • KeyError: '__env__' when a cross-contaminated building_highstate produces a
    spurious conflicting-ID and the error formatter indexes a chunk that lacks
    __env__.

The fix stores the stack in a contextvars.ContextVar, mirroring
salt.loader's loader_ctxvar (which the failing traceback already runs
through), so each execution context -- and therefore each reactor worker thread
-- gets its own stack. Three follow-on pieces make the isolation complete:

  • The pydsl top-file matches were cached in a module-level SLS_MATCHES global
    with the same cross-run sharing problem; they're now cached per HighState
    instance.
  • SSHHighState.push_active was a redundant override that reached for the
    removed class attribute; it now inherits the context-backed base method (so
    salt-ssh pydsl states keep working).
  • The conflicting-ID error formatter uses .get() for __env__/__sls__, so a
    genuine conflict reports cleanly instead of raising KeyError.

Single-threaded behavior is unchanged: within one context the lazy per-context
list behaves exactly like the old shared list, and the canonical
push_active() ... try/finally: pop_active() pattern keeps the stack balanced.

What issues does this PR fix or reference?

Fixes #63056

Previous Behavior

Concurrent reactor orchestrations (or any concurrent renders that touch the
active-HighState stack) intermittently failed with Rendering SLS ... failed, render error: '__env__' followed by IndexError: list index out of range.
Events that arrived too close together dropped their orchestration.

New Behavior

Each render gets its own active-HighState stack and its own pydsl top-file
match cache, so concurrent renders no longer corrupt one another.

Merge requirements satisfied?

  • Docs
  • Changelog
  • Tests written/updated

A deterministic two-thread regression test asserts each thread sees only the
HighState it pushed. It fails against the pre-fix code
({'A': 'B', 'B': 'B'}) and passes with the fix.

Commits signed with GPG?

No

…3056)

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.
@ggiesen ggiesen requested a review from a team as a code owner July 11, 2026 19:37
@dwoz dwoz added the test:full Run the full test suite label Jul 12, 2026
…sh_active

The saltstack#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:full Run the full test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants