Skip to content

Fix wrong retcodes from concurrent jobs on a threaded minion#69755

Open
ggiesen wants to merge 1 commit into
saltstack:3006.xfrom
ggiesen:fix-61830-retcode-race
Open

Fix wrong retcodes from concurrent jobs on a threaded minion#69755
ggiesen wants to merge 1 commit into
saltstack:3006.xfrom
ggiesen:fix-61830-retcode-race

Conversation

@ggiesen

@ggiesen ggiesen commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Makes a threaded minion (multiprocessing: False) report the correct retcode for concurrent jobs. Each job now reads its retcode from the loader generation it actually ran under, instead of from a shared attribute that a sibling job can swap out mid-job.

What issues does this PR fix or reference?

Fixes #61830
Fixes #63117

Previous Behavior

With multiprocessing: False, each job runs as a thread against the one shared Minion instance. Minion._thread_return calls minion_instance.gen_modules() at the top of every job, which rebuilds and rebinds the shared minion_instance.functions (the per-job rebuild added in 9f1fe42b3c to reduce os.fork races). A module writes its retcode into the loader generation it ran under (__context__["retcode"]), but _thread_return reads the retcode back from minion_instance.functions at completion. If a concurrent job's gen_modules() rebinds minion_instance.functions between the write and the read, the read lands on a fresh, empty context and defaults to EX_OK -- so a failed job is reported as succeeded (and, symmetrically, a passing job can inherit a sibling's failure). multiprocessing: True avoids it because each job is a separate process. This is #61830, and described independently in #63117.

New Behavior

gen_modules() returns the execution-module loader it built, and _thread_return / _thread_multi_return capture that returned loader and thread it through _execute_job_function, so the func lookup, the retcode reset, the module's write, and the read all resolve to the one loader that job owns. A concurrent gen_modules() rebinding minion_instance.functions can no longer steal the read. The same capture is applied to the metaproxy and deltaproxy copies of these methods.

Because gen_modules is also bound as the sys.reload_modules execution function and a loader is not serializable over the wire, sys.reload_modules is rebound to a thin _reload_modules wrapper that reloads and returns None, preserving the published wire contract.

This is a targeted correctness fix; it deliberately keeps the per-job gen_modules() rebuild. Removing that rebuild is a real but separate performance improvement (measured at ~21 ms/job, so it matters most for high-frequency light jobs) that requires isolating __context__ on contextvars first. Tracked separately in #69756 with the before/after measurements.

Merge requirements satisfied?

  • Docs - not applicable
  • Changelog - changelog/61830.fixed.md
  • Tests written/updated - tests/pytests/unit/test_minion.py: a direct-altitude test drives the real _thread_return with a forced sibling gen_modules rebind in the write->read window and asserts a failed job (retcode 42) is delivered failed while the rebound shared loader is empty, plus the inverse (a passing job stays success); and a test that sys.reload_modules returns None and is wire-serializable (while gen_modules() itself returns a non-serializable loader -- why the wrapper exists). Both pin-proofed against baseline minion.py.

Commits signed with GPG?

No

@ggiesen ggiesen requested a review from a team as a code owner July 10, 2026 15:34
@charzl charzl self-assigned this Jul 10, 2026
@dwoz dwoz added the test:full Run the full test suite label Jul 10, 2026
With multiprocessing:False every job runs against one shared
minion_instance. _thread_return calls gen_modules() at the top of every
job, which rebuilds all loaders and rebinds minion_instance.functions to
a brand-new LazyLoader whose __context__ is a fresh, empty dict. A module
writes its retcode into the __context__ of the loader generation it ran
under (e.g. cmdmod via NamedLoaderContext), but _thread_return read the
retcode back from minion_instance.functions at completion. When a sibling
job's gen_modules() rebound functions between a job's write and its read,
the read landed on the new empty context and returned EX_OK, so a failed
command was delivered to the master as a success.

Make each job own the loader it wrote its retcode into:

- gen_modules() now returns the loader generation it built.
- _thread_return / _thread_multi_return capture that return value and use
  it for the function lookup, the retcode reset, and the retcode read,
  instead of re-reading the shared minion_instance.functions attribute.
- _execute_job_function takes the captured loader (defaulting to
  self.functions) and uses it for the lookup and the retcode reset.
- sys.reload_modules is bound to a new _reload_modules wrapper that
  reloads and returns None, so the reload job stays serializable on the
  wire (a LazyLoader is not).
- The proxy and delta-proxy thread_return / thread_multi_return copies
  capture minion_instance.functions once per job and use it consistently,
  closing the same write/read desync there.

Adds a direct-altitude regression test that drives the real
Minion._thread_return with the poison sibling reload forced into the
write->read window and asserts a failed job is delivered as failed (and a
passing job as passing), plus a test that sys.reload_modules still returns
a serializable None.

Fixes saltstack#61830
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.

3 participants