Skip to content

Fix file-descriptor leak in ProcessManager.restart_process - #70185

Open
dwoz wants to merge 1 commit into
saltstack:3008.xfrom
dwoz:dwoz/fix/master-fileserver-update-fd-leak
Open

Fix file-descriptor leak in ProcessManager.restart_process#70185
dwoz wants to merge 1 commit into
saltstack:3008.xfrom
dwoz:dwoz/fix/master-fileserver-update-fd-leak

Conversation

@dwoz

@dwoz dwoz commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Motivation

Prometheus scraping of the local salt-master container showed steady growth in
salt_master_process_fds{process="FileserverUpdate"} over two independent
multi-hour windows:

  • Window 1: 92 -> 132 (+40) in 12.75 h, ~+3.1 FD/hr
  • Window 2 (post-restart segment): 52 -> 76 (+24) in 10 h, ~+2.4 FD/hr

The same +4 FD-per-hour step pattern appeared on the parent-process series
salt_master_process_fds{process="master-main"} (95 -> 131 across the same
window) confirming the leak lives in the supervising parent, not in
FileserverUpdate itself. The step cadence matched
fileserver_interval=3600s exactly -- one step per subprocess-restart
cycle.

Root cause

salt.utils.process.ProcessManager.restart_process (salt/utils/process.py:563)
drops the dead child's Process reference without calling
Process.close(). The dead multiprocessing.popen_fork.Popen object owns
the two parent-side pipe fds Popen._launch allocates via os.pipe()
(parent_r and parent_w), and those fds are only released when the
Popen is finalized.

SignalHandlingProcess.__new__ registers _setup_signals via
register_after_fork_method(instance), which appends
(_setup_signals, (instance,), {}) to self._after_fork_methods. That list
holds a strong reference back to the instance -- a reference cycle that
defeats deterministic finalization when the last ProcessManager reference
is dropped. Result: every subprocess restart leaks 2 pipe fds in the parent,
and every subsequent forked child inherits the accumulated set.

Fix

Call Process.close() on the dead child in restart_process() after
starting its replacement, before removing the entry from _process_map.
This releases the Popen sentinel pipe deterministically regardless of the
reference cycle. Guarded with a defensive try/except (ValueError, AttributeError) per the Process.close() contract.

The same leak affected every long-lived master subprocess the
ProcessManager restarts (Maintenance, EventReturn, MWorker, ...); the
fix is in the shared supervisor code path, so all of them benefit.

Test

tests/pytests/functional/utils/test_process_restart_fd_leak.py::test_restart_process_does_not_leak_pipe_fds

Drives ProcessManager.restart_process() directly against a short-lived
SignalHandlingProcess and asserts the parent's /proc/<pid>/fd count
stays flat across 20 restarts.

  • Without the fix: AssertionError: ProcessManager.restart_process leaked 40 fds across 20 restarts (baseline=26, after=66) -- exactly +2 * iterations.
  • With the fix: PASSED (delta = 0).

Testing done

  • pytest tests/pytests/functional/utils/test_process_restart_fd_leak.py -- fails on baseline, passes with fix
  • pytest tests/pytests/functional/utils/test_process.py -- all 7 pass (including the pre-existing test_subprocess_list_fds and test_process_manager_60749)
  • pytest tests/unit/utils/test_process.py -- all 21 pass (6 skipped)
  • pytest tests/pytests/unit/test_master.py -- all 43 pass (25 skipped)
  • pre-commit run --files salt/utils/process.py tests/pytests/functional/utils/test_process_restart_fd_leak.py changelog/* -- clean

@dwoz
dwoz requested a review from a team as a code owner August 29, 2026 02:54
@dwoz
dwoz force-pushed the dwoz/fix/master-fileserver-update-fd-leak branch from 480ade0 to 093222f Compare August 29, 2026 02:55
@dwoz dwoz added this to the Potassium v3009.0 milestone Aug 29, 2026
@dwoz dwoz added the test:full Run the full test suite label Aug 29, 2026
@dwoz

dwoz commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Confirming this fix covers the Maintenance FD leak as well.

Independently reproduced the same drift on a stock master container: Prometheus salt_master_process_fds{process="Maintenance"} grew +4 FDs/hr for 12h straight, transitions aligned exactly with maintenance_interval=3600 (10:07 → 74, 11:07 → 78, 12:07 → 82, ...). MainProcess and FileserverUpdate showed the identical +4/hr pattern at the same timestamps — the tell that this is a single supervisor-level bug affecting every child ProcessManager restarts.

Root cause matches yours exactly: ProcessManager.restart_process() never calls .close() on the reaped Process, so the parent-side Popen.sentinel+parent_w pipe pair (2 fds) leaks per restart. Applied your branch head locally, verified my Maintenance-focused regression test flips FAIL→PASS:

No separate PR needed — closing my local branch as duplicate. Your test file (SignalHandlingProcess-based) already covers the exact class Maintenance uses, so no coverage gap.

Every time a salt-master supervised subprocess exits and is restarted
(FileserverUpdate on the fileserver_interval cycle, Maintenance on the
maintenance_interval cycle, etc.), ProcessManager.restart_process was
dropping the dead child's Process reference without calling
Process.close().  The dead multiprocessing.popen_fork.Popen object owns
the two parent-side pipe fds opened in Popen._launch (parent_r,
parent_w), and those fds are only released when the Popen is
finalized.  Because SignalHandlingProcess.__new__ registers
_setup_signals via register_after_fork_method(instance), the resulting
_after_fork_methods list holds a strong reference back to the instance
-- a reference cycle that defeats deterministic finalization of the
Popen when the last ProcessManager reference is dropped.

Net effect on a stock master with fileserver_interval=3600: the parent
leaks ~+2 pipe fds per subprocess restart per cycle, and every new
forked child inherits the accumulated set.  Measured on a running
local salt-master container over two independent multi-hour windows in
Prometheus (salt_master_process_fds{process="FileserverUpdate"}): 92
-> 132 in 12.75h and 52 -> 76 in 10h, i.e. +4 fds per hour aligned
with the FileserverUpdate restart cycle.  Same pattern on the
master-main process series (95 -> 131 across the same window),
confirming the leak is in the supervising parent, not the child.

Fix: call Process.close() on the dead child in restart_process()
after starting its replacement but before removing the entry from
_process_map, releasing the Popen sentinel pipe.

Regression test drives restart_process() directly against a
QuickSignalProc SignalHandlingProcess and asserts the parent's fd
count stays flat across 20 restarts.  Without this fix the test fails
with delta=40 fds leaked; with it, delta=0.

Refs: salt/utils/process.py:563 (restart_process),
      salt/master.py:692 (FileserverUpdate).
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