Skip to content

Commit 8a3652f

Browse files
committed
Core: Add graceful shutdown of pluguns (fixes #54)
1 parent ab4053f commit 8a3652f

File tree

5 files changed

+117
-14
lines changed

5 files changed

+117
-14
lines changed

bot.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818

1919

2020
class PluginInterface:
21+
"""
22+
Interface for a plugin. This class runs in the main bot process and
23+
communicates with the plugin process over IPC.
24+
"""
25+
2126
def __init__(self, name, bot, pid):
2227

2328
logging.info("PluginInterface.__init__ %s", "ipc://ipc_plugin_" + name)
@@ -109,7 +114,7 @@ async def run(self):
109114
self._recieve(await self._socket_plugin.recv_json())
110115

111116
def __getattr__(self, name):
112-
if name in ["started", "update"]:
117+
if name in ["started", "update", "shutdown"]:
113118

114119
def call(*args, **kwarg):
115120
self._call(name, *args)
@@ -118,6 +123,23 @@ def call(*args, **kwarg):
118123
else:
119124
raise AttributeError(self, name)
120125

126+
async def shutdown_plugin(self):
127+
self.shutdown()
128+
for _ in range(5):
129+
await asyncio.sleep(0.1)
130+
try:
131+
pid, status = os.waitpid(self.pid, os.WNOHANG)
132+
if pid != 0:
133+
return
134+
except ChildProcessError:
135+
return
136+
137+
logging.error("Plugin %s did not shut down cleanly, sending SIGTERM", self.name)
138+
print(
139+
f"Error: Plugin {self.name} did not shut down cleanly, sending SIGTERM", file=sys.stderr
140+
)
141+
os.kill(self.pid, signal.SIGTERM)
142+
121143

122144
class Bot:
123145
def __init__(self, temp_folder):
@@ -135,6 +157,14 @@ def __init__(self, temp_folder):
135157
self.servers = dict()
136158
self.temp_folder = temp_folder
137159

160+
async def unload_plugin(self, name):
161+
plugin = next((p for p in self.plugins if p.name == name), None)
162+
if plugin:
163+
await plugin.shutdown_plugin()
164+
self.plugins.remove(plugin)
165+
return True
166+
return False
167+
138168
async def reconnect(self, connection):
139169
while not connection.is_connected():
140170
logging.error("Waiting 30 seconds to reconnect")
@@ -261,8 +291,11 @@ async def load_plugins():
261291
logging.exception("Bot.run aborted")
262292

263293
# Request termination of plugins
264-
for plugin in self.plugins:
265-
os.kill(plugin.pid, signal.SIGTERM)
294+
async def unload_all_plugins():
295+
tasks = [self.unload_plugin(plugin.name) for plugin in list(self.plugins)]
296+
await asyncio.gather(*tasks, return_exceptions=True)
297+
298+
self.loop.run_until_complete(unload_all_plugins())
266299

267300
self.loop.close()
268301
command_line.wait_until_done()

command_line.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,17 @@ def do_reload_plugin(self, arg):
131131
def do_unload_plugin(self, arg):
132132
"""Unload a plugin. Usage: unload_plugin <plugin_name>"""
133133
name = arg.strip()
134-
plugin = next((p for p in self.bot.plugins if p.name == name), None)
135-
if not plugin:
136-
print(f"Plugin '{name}' not found.")
137-
return
134+
135+
async def _unload():
136+
if await self.bot.unload_plugin(name):
137+
print(f"Plugin '{name}' unloaded.")
138+
else:
139+
print(f"Plugin '{name}' not found.")
140+
141+
# Schedule _unload on the bot's main asyncio loop
142+
future = asyncio.run_coroutine_threadsafe(_unload(), self.bot.loop)
138143
try:
139-
os.kill(plugin.pid, signal.SIGTERM)
140-
self.bot.plugins.remove(plugin)
141-
print(f"Plugin '{name}' unloaded.")
144+
future.result()
142145
except Exception as e:
143146
print(f"Error unloading plugin '{name}': {e}")
144147

plugin.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424

2525

2626
class Plugin:
27+
"""
28+
Base class for plugins. This class runs in its own separate process,
29+
spawned by the main bot process.
30+
"""
31+
2732
def __init__(self, name):
2833

2934
locale.setlocale(locale.LC_ALL, "")
@@ -59,7 +64,7 @@ def __init__(self, name):
5964

6065
def _recieve(self, data):
6166
func_name = data["function"]
62-
if func_name.startswith("on_") or func_name in ["started", "update"]:
67+
if func_name.startswith("on_") or func_name in ["started", "update", "shutdown"]:
6368
try:
6469
func = getattr(self, func_name)
6570
except AttributeError as e:
@@ -70,6 +75,10 @@ def _recieve(self, data):
7075
else:
7176
logging.warning("Unsupported call to plugin function with name " + func_name)
7277

78+
def shutdown(self):
79+
logging.info("Plugin.shutdown")
80+
asyncio.get_event_loop().stop()
81+
7382
def _call(self, function, *args):
7483
logging.info("Plugin.call %s", self.threading_data.__dict__)
7584
socket = self.threading_data.call_socket

test/test_bot.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import irc.client_aio
33
import signal
4+
import os
45

56
import unittest
67
import tempfile
@@ -94,14 +95,12 @@ def test_reconnect_loop(self, mock_settings, mock_sleep):
9495
@patch("bot.PluginInterface")
9596
@patch("irc.client_aio.AioReactor")
9697
@patch("os.spawnvpe", return_value=1234)
97-
@patch("os.kill") # temporary until we have graceful shutdowns of plugins
9898
@patch("bot.irc.connection.AioFactory")
9999
@patch("bot.CommandLine")
100100
def test_run(
101101
self,
102102
mock_command_line,
103103
mock_factory,
104-
mock_kill,
105104
mock_spawnvpe,
106105
mock_reactor,
107106
mock_plugin_interface,
@@ -116,6 +115,8 @@ def test_run(
116115

117116
plugin_interface_instance = MagicMock()
118117
plugin_interface_instance.pid = 666
118+
plugin_interface_instance.name = "test_plugin"
119+
plugin_interface_instance.shutdown_plugin = AsyncMock()
119120
mock_plugin_interface.return_value = plugin_interface_instance
120121

121122
bot = self.create_bot(mock_settings)
@@ -124,7 +125,7 @@ def test_run(
124125
bot.run()
125126

126127
mock_plugin_interface.assert_called_once() # Make sure we loaded one pluign
127-
mock_kill.assert_called_with(666, signal.SIGTERM) # Make sure the plugin was destroyed
128+
plugin_interface_instance.shutdown_plugin.assert_called_once() # Make sure the plugin was gracefully shut down
128129
reactor_instance.server.assert_called_once() # Make sure we create the server
129130
server_mock.connect.assert_called_once() # Make sure we try to connect to the server
130131
reactor_instance.process_forever.assert_called_once() # Make sure the reactor runs forever
@@ -144,3 +145,52 @@ def test_plugin_init(self, mock_settings, mock_context):
144145
plugin = PluginInterface("testplugin", bot, 123)
145146
mock_socket.bind.assert_called()
146147
bot.plugin_started.assert_called_with(plugin)
148+
149+
@patch("bot.zmq.asyncio.Context")
150+
@patch("bot.settings")
151+
@patch("bot.os.waitpid")
152+
@patch("bot.os.kill")
153+
@patch("bot.asyncio.sleep", new_callable=AsyncMock)
154+
async def test_shutdown_plugin_clean(
155+
self, mock_sleep, mock_kill, mock_waitpid, mock_settings, mock_context
156+
):
157+
bot = MagicMock()
158+
mock_socket = MagicMock()
159+
mock_context.return_value.socket.return_value = mock_socket
160+
161+
plugin = PluginInterface("testplugin", bot, 123)
162+
# Mocking the _call method to avoid sending real zmq messages
163+
plugin._call = MagicMock()
164+
165+
# Simulate process exiting cleanly
166+
mock_waitpid.return_value = (123, 0)
167+
168+
await plugin.shutdown_plugin()
169+
170+
plugin._call.assert_called_with("shutdown")
171+
mock_waitpid.assert_called_with(123, os.WNOHANG)
172+
mock_kill.assert_not_called()
173+
174+
@patch("bot.zmq.asyncio.Context")
175+
@patch("bot.settings")
176+
@patch("bot.os.waitpid")
177+
@patch("bot.os.kill")
178+
@patch("bot.asyncio.sleep", new_callable=AsyncMock)
179+
async def test_shutdown_plugin_timeout(
180+
self, mock_sleep, mock_kill, mock_waitpid, mock_settings, mock_context
181+
):
182+
bot = MagicMock()
183+
mock_socket = MagicMock()
184+
mock_context.return_value.socket.return_value = mock_socket
185+
186+
plugin = PluginInterface("testplugin", bot, 123)
187+
plugin._call = MagicMock()
188+
189+
# Simulate process not exiting
190+
mock_waitpid.return_value = (0, 0)
191+
192+
await plugin.shutdown_plugin()
193+
194+
plugin._call.assert_called_with("shutdown")
195+
self.assertEqual(mock_waitpid.call_count, 5)
196+
mock_kill.assert_called_with(123, signal.SIGTERM)

test/test_plugin.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,11 @@ async def run_once():
8383

8484
asyncio.run(run_once())
8585
self.plugin.on_message.assert_called_once_with("test_param")
86+
87+
def test_shutdown_stops_loop(self):
88+
with patch("asyncio.get_event_loop") as mock_get_loop:
89+
mock_loop = MagicMock()
90+
mock_get_loop.return_value = mock_loop
91+
self.plugin.shutdown = Plugin.shutdown.__get__(self.plugin, DummyPlugin)
92+
self.plugin.shutdown()
93+
mock_loop.stop.assert_called_once()

0 commit comments

Comments
 (0)