Skip to content

Commit 24a4cfa

Browse files
committed
Fix beacon delete not closing beacon modules
When beacons.delete is called, the beacon is removed from opts but the beacon module's close() function is never called. This leaves resources like inotify file descriptors and notifier threads active, causing CPU spin that never recovers. Add _close_beacon() and close_beacons() methods to the Beacon class. Call _close_beacon() from delete_beacon() before removing the beacon from opts, and call close_beacons() from Minion.beacons_refresh() before replacing the Beacon instance. Fixes #66449
1 parent 180a3ba commit 24a4cfa

4 files changed

Lines changed: 208 additions & 0 deletions

File tree

changelog/66449.fixed.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fixed beacon delete not calling the beacon's close function, causing resource
2+
leaks (e.g. inotify file descriptors) and CPU spin after deleting beacons at
3+
runtime via ``beacons.delete``. Also fixed inotify file descriptor leak during
4+
beacon refresh when the Beacon instance is replaced.

salt/beacons/__init__.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,55 @@ def __init__(self, opts, functions, interval_map=None):
2626
self.beacons = salt.loader.beacons(opts, functions)
2727
self.interval_map = interval_map or dict()
2828

29+
def _close_beacon(self, name):
30+
"""
31+
Close a single beacon module if it has a close function.
32+
This releases resources like inotify file descriptors.
33+
"""
34+
beacon_config = self.opts["beacons"].get(name)
35+
if beacon_config is None:
36+
return
37+
38+
current_beacon_config = None
39+
if isinstance(beacon_config, list):
40+
current_beacon_config = {}
41+
list(map(current_beacon_config.update, beacon_config))
42+
elif isinstance(beacon_config, dict):
43+
current_beacon_config = beacon_config
44+
45+
if current_beacon_config is None:
46+
return
47+
48+
beacon_name = name
49+
if self._determine_beacon_config(current_beacon_config, "beacon_module"):
50+
beacon_name = current_beacon_config["beacon_module"]
51+
52+
close_str = f"{beacon_name}.close"
53+
if close_str in self.beacons:
54+
try:
55+
config = copy.deepcopy(beacon_config)
56+
if isinstance(config, list):
57+
config.append({"_beacon_name": name})
58+
log.debug("Closing beacon %s", name)
59+
self.beacons[close_str](config)
60+
except Exception: # pylint: disable=broad-except
61+
log.debug("Failed to close beacon %s", name, exc_info=True)
62+
63+
def close_beacons(self):
64+
"""
65+
Close all beacon modules that have a close function.
66+
This ensures resources like inotify file descriptors are properly
67+
released when beacons are refreshed or the Beacon instance is replaced.
68+
69+
See: https://github.com/saltstack/salt/issues/66449
70+
See: https://github.com/saltstack/salt/issues/58907
71+
"""
72+
beacons = self._get_beacons()
73+
for mod in beacons:
74+
if mod == "enabled":
75+
continue
76+
self._close_beacon(mod)
77+
2978
def process(self, config, grains):
3079
"""
3180
Process the configured beacons
@@ -405,6 +454,9 @@ def delete_beacon(self, name):
405454
complete = False
406455
else:
407456
if name in self.opts["beacons"]:
457+
# Close the beacon module to release resources (e.g. inotify fds)
458+
# before removing it from the configuration.
459+
self._close_beacon(name)
408460
del self.opts["beacons"][name]
409461
comment = f"Deleting beacon item: {name}"
410462
else:

salt/minion.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3201,6 +3201,10 @@ def beacons_refresh(self):
32013201
prev_interval_map = {}
32023202
if hasattr(self, "beacons") and hasattr(self.beacons, "interval_map"):
32033203
prev_interval_map = self.beacons.interval_map
3204+
# Close existing beacon modules to release resources (e.g. inotify fds)
3205+
# before replacing the Beacon instance.
3206+
if hasattr(self, "beacons"):
3207+
self.beacons.close_beacons()
32043208
self.beacons = salt.beacons.Beacon(
32053209
self.opts, self.functions, interval_map=prev_interval_map
32063210
)

tests/pytests/unit/test_beacons.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,151 @@ def test_beacon_module(minion_opts):
121121
with patch.object(beacon, "beacons", mocked) as patched:
122122
beacon.process(minion_opts["beacons"], minion_opts["grains"])
123123
patched[name].assert_has_calls(calls)
124+
125+
126+
def test_close_beacons_calls_close_on_modules(minion_opts):
127+
"""
128+
Test that close_beacons() calls the close function on each beacon
129+
module that provides one, releasing resources like inotify fds.
130+
131+
See: https://github.com/saltstack/salt/issues/66449
132+
"""
133+
minion_opts["id"] = "minion"
134+
minion_opts["__role"] = "minion"
135+
minion_opts["beacons"] = {
136+
"inotify": [
137+
{"files": {"/etc/fstab": {}}},
138+
],
139+
}
140+
141+
beacon = salt.beacons.Beacon(minion_opts, [])
142+
143+
close_mock = MagicMock()
144+
beacon.beacons["inotify.close"] = close_mock
145+
146+
beacon.close_beacons()
147+
148+
close_mock.assert_called_once()
149+
call_args = close_mock.call_args[0][0]
150+
assert isinstance(call_args, list)
151+
assert {"_beacon_name": "inotify"} in call_args
152+
153+
154+
def test_close_beacons_with_beacon_module_override(minion_opts):
155+
"""
156+
Test that close_beacons() respects beacon_module and calls close
157+
on the correct underlying module name.
158+
"""
159+
minion_opts["id"] = "minion"
160+
minion_opts["__role"] = "minion"
161+
minion_opts["beacons"] = {
162+
"watch_apache": [
163+
{"processes": {"apache2": "stopped"}},
164+
{"beacon_module": "ps"},
165+
],
166+
}
167+
168+
beacon = salt.beacons.Beacon(minion_opts, [])
169+
170+
close_mock = MagicMock()
171+
beacon.beacons["ps.close"] = close_mock
172+
173+
beacon.close_beacons()
174+
175+
close_mock.assert_called_once()
176+
call_args = close_mock.call_args[0][0]
177+
assert {"_beacon_name": "watch_apache"} in call_args
178+
179+
180+
def test_close_beacons_skips_modules_without_close(minion_opts):
181+
"""
182+
Test that close_beacons() gracefully skips beacons that don't
183+
have a close function.
184+
"""
185+
minion_opts["id"] = "minion"
186+
minion_opts["__role"] = "minion"
187+
minion_opts["beacons"] = {
188+
"status": [
189+
{"time": ["all"]},
190+
],
191+
}
192+
193+
beacon = salt.beacons.Beacon(minion_opts, [])
194+
195+
assert "status.close" not in beacon.beacons
196+
beacon.close_beacons()
197+
198+
199+
def test_delete_beacon_calls_close(minion_opts):
200+
"""
201+
Test that delete_beacon() calls the beacon's close function before
202+
removing it, so resources like inotify file descriptors are released.
203+
"""
204+
minion_opts["id"] = "minion"
205+
minion_opts["__role"] = "minion"
206+
minion_opts["beacons"] = {
207+
"inotify": [
208+
{"files": {"/etc/fstab": {}}},
209+
],
210+
}
211+
212+
beacon = salt.beacons.Beacon(minion_opts, [])
213+
close_mock = MagicMock()
214+
beacon.beacons["inotify.close"] = close_mock
215+
216+
with patch("salt.utils.event.get_event"):
217+
beacon.delete_beacon("inotify")
218+
219+
close_mock.assert_called_once()
220+
call_args = close_mock.call_args[0][0]
221+
assert isinstance(call_args, list)
222+
assert {"_beacon_name": "inotify"} in call_args
223+
assert "inotify" not in minion_opts["beacons"]
224+
225+
226+
def test_delete_beacon_calls_close_with_beacon_module(minion_opts):
227+
"""
228+
Test that delete_beacon() respects beacon_module and calls close
229+
on the correct underlying module.
230+
"""
231+
minion_opts["id"] = "minion"
232+
minion_opts["__role"] = "minion"
233+
minion_opts["beacons"] = {
234+
"watch_apache": [
235+
{"processes": {"apache2": "stopped"}},
236+
{"beacon_module": "ps"},
237+
],
238+
}
239+
240+
beacon = salt.beacons.Beacon(minion_opts, [])
241+
close_mock = MagicMock()
242+
beacon.beacons["ps.close"] = close_mock
243+
244+
with patch("salt.utils.event.get_event"):
245+
beacon.delete_beacon("watch_apache")
246+
247+
close_mock.assert_called_once()
248+
call_args = close_mock.call_args[0][0]
249+
assert {"_beacon_name": "watch_apache"} in call_args
250+
assert "watch_apache" not in minion_opts["beacons"]
251+
252+
253+
def test_delete_beacon_without_close(minion_opts):
254+
"""
255+
Test that delete_beacon() works when the beacon module has no close function.
256+
"""
257+
minion_opts["id"] = "minion"
258+
minion_opts["__role"] = "minion"
259+
minion_opts["beacons"] = {
260+
"status": [
261+
{"time": ["all"]},
262+
],
263+
}
264+
265+
beacon = salt.beacons.Beacon(minion_opts, [])
266+
assert "status.close" not in beacon.beacons
267+
268+
with patch("salt.utils.event.get_event"):
269+
beacon.delete_beacon("status")
270+
271+
assert "status" not in minion_opts["beacons"]

0 commit comments

Comments
 (0)