Fix shutdown timeouts (#695)

Co-authored-by: AAGaming <aagaming@riseup.net>
This commit is contained in:
Sims
2024-09-01 20:15:49 +02:00
committed by GitHub
parent 4842a599e0
commit 016ed6e998
9 changed files with 70 additions and 44 deletions
+6 -1
View File
@@ -104,10 +104,15 @@ class Loader:
async def enable_reload_wait(self): async def enable_reload_wait(self):
if self.live_reload: if self.live_reload:
await sleep(10) await sleep(10)
if self.watcher: if self.watcher and self.live_reload:
self.logger.info("Hot reload enabled") self.logger.info("Hot reload enabled")
self.watcher.disabled = False self.watcher.disabled = False
async def disable_reload(self):
if self.watcher:
self.watcher.disabled = True
self.live_reload = False
async def handle_frontend_assets(self, request: web.Request): async def handle_frontend_assets(self, request: web.Request):
file = Path(__file__).parent.joinpath("static").joinpath(request.match_info["path"]) file = Path(__file__).parent.joinpath("static").joinpath(request.match_info["path"])
return web.FileResponse(file, headers={"Cache-Control": "no-cache"}) return web.FileResponse(file, headers={"Cache-Control": "no-cache"})
@@ -7,22 +7,24 @@ from .localplatform import ON_WINDOWS
BUFFER_LIMIT = 2 ** 20 # 1 MiB BUFFER_LIMIT = 2 ** 20 # 1 MiB
class UnixSocket: class UnixSocket:
def __init__(self, on_new_message: Callable[[str], Coroutine[Any, Any, Any]]): def __init__(self):
''' '''
on_new_message takes 1 string argument. on_new_message takes 1 string argument.
It's return value gets used, if not None, to write data to the socket. It's return value gets used, if not None, to write data to the socket.
Method should be async Method should be async
''' '''
self.socket_addr = f"/tmp/plugin_socket_{time.time()}" self.socket_addr = f"/tmp/plugin_socket_{time.time()}"
self.on_new_message = on_new_message self.on_new_message = None
self.socket = None self.socket = None
self.reader = None self.reader = None
self.writer = None self.writer = None
self.server_writer = None self.server_writer = None
self.open_lock = asyncio.Lock() self.open_lock = asyncio.Lock()
self.active = True
async def setup_server(self): async def setup_server(self, on_new_message: Callable[[str], Coroutine[Any, Any, Any]]):
try: try:
self.on_new_message = on_new_message
self.socket = await asyncio.start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT) self.socket = await asyncio.start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
except asyncio.CancelledError: except asyncio.CancelledError:
await self.close_socket_connection() await self.close_socket_connection()
@@ -58,6 +60,8 @@ class UnixSocket:
if self.socket: if self.socket:
self.socket.close() self.socket.close()
await self.socket.wait_closed() await self.socket.wait_closed()
self.active = False
async def read_single_line(self) -> str|None: async def read_single_line(self) -> str|None:
reader, _ = await self.get_socket_connection() reader, _ = await self.get_socket_connection()
@@ -81,7 +85,7 @@ class UnixSocket:
async def _read_single_line(self, reader: asyncio.StreamReader) -> str: async def _read_single_line(self, reader: asyncio.StreamReader) -> str:
line = bytearray() line = bytearray()
while True: while self.active:
try: try:
line.extend(await reader.readuntil()) line.extend(await reader.readuntil())
except asyncio.LimitOverrunError: except asyncio.LimitOverrunError:
@@ -91,7 +95,7 @@ class UnixSocket:
line.extend(err.partial) line.extend(err.partial)
break break
except asyncio.CancelledError: except asyncio.CancelledError:
break raise
else: else:
break break
@@ -111,7 +115,7 @@ class UnixSocket:
async def _listen_for_method_call(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): async def _listen_for_method_call(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
self.server_writer = writer self.server_writer = writer
while True: while self.active and self.on_new_message:
def _(task: asyncio.Task[str|None]): def _(task: asyncio.Task[str|None]):
res = task.result() res = task.result()
@@ -122,18 +126,19 @@ class UnixSocket:
asyncio.create_task(self.on_new_message(line)).add_done_callback(_) asyncio.create_task(self.on_new_message(line)).add_done_callback(_)
class PortSocket (UnixSocket): class PortSocket (UnixSocket):
def __init__(self, on_new_message: Callable[[str], Coroutine[Any, Any, Any]]): def __init__(self):
''' '''
on_new_message takes 1 string argument. on_new_message takes 1 string argument.
It's return value gets used, if not None, to write data to the socket. It's return value gets used, if not None, to write data to the socket.
Method should be async Method should be async
''' '''
super().__init__(on_new_message) super().__init__()
self.host = "127.0.0.1" self.host = "127.0.0.1"
self.port = random.sample(range(40000, 60000), 1)[0] self.port = random.sample(range(40000, 60000), 1)[0]
async def setup_server(self): async def setup_server(self, on_new_message: Callable[[str], Coroutine[Any, Any, Any]]):
try: try:
self.on_new_message = on_new_message
self.socket = await asyncio.start_server(self._listen_for_method_call, host=self.host, port=self.port, limit=BUFFER_LIMIT) self.socket = await asyncio.start_server(self._listen_for_method_call, host=self.host, port=self.port, limit=BUFFER_LIMIT)
except asyncio.CancelledError: except asyncio.CancelledError:
await self.close_socket_connection() await self.close_socket_connection()
+7 -1
View File
@@ -101,6 +101,8 @@ class PluginManager:
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))]) self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
async def handle_crash(self): async def handle_crash(self):
if not self.reinject:
return
new_time = time() new_time = time()
if (new_time - self.last_webhelper_exit < 60): if (new_time - self.last_webhelper_exit < 60):
self.webhelper_crash_count += 1 self.webhelper_crash_count += 1
@@ -118,9 +120,13 @@ class PluginManager:
async def shutdown(self, _: Application): async def shutdown(self, _: Application):
try: try:
logger.info(f"Shutting down...") logger.info(f"Shutting down...")
logger.info("Disabling reload...")
await self.plugin_loader.disable_reload()
logger.info("Killing plugins...")
await self.plugin_loader.shutdown_plugins() await self.plugin_loader.shutdown_plugins()
await self.ws.disconnect() logger.info("Disconnecting from WS...")
self.reinject = False self.reinject = False
await self.ws.disconnect()
if self.js_ctx_tab: if self.js_ctx_tab:
await self.js_ctx_tab.close_websocket() await self.js_ctx_tab.close_websocket()
self.js_ctx_tab = None self.js_ctx_tab = None
+34 -22
View File
@@ -1,8 +1,10 @@
from asyncio import CancelledError, Task, create_task, sleep from asyncio import CancelledError, Task, create_task, sleep, get_event_loop, wait
from json import dumps, load, loads from json import dumps, load, loads
from logging import getLogger from logging import getLogger
from os import path from os import path
from multiprocessing import Process from multiprocessing import Process
from time import time
from traceback import format_exc
from .sandboxed_plugin import SandboxedPlugin from .sandboxed_plugin import SandboxedPlugin
from .messages import MethodCallRequest, SocketMessageType from .messages import MethodCallRequest, SocketMessageType
@@ -42,8 +44,7 @@ class PluginWrapper:
self.sandboxed_plugin = SandboxedPlugin(self.name, self.passive, self.flags, self.file, self.plugin_directory, self.plugin_path, self.version, self.author, self.api_version) self.sandboxed_plugin = SandboxedPlugin(self.name, self.passive, self.flags, self.file, self.plugin_directory, self.plugin_path, self.version, self.author, self.api_version)
self.proc: Process | None = None self.proc: Process | None = None
# TODO: Maybe make LocalSocket not require on_new_message to make this cleaner self._socket = LocalSocket()
self._socket = LocalSocket(self.sandboxed_plugin.on_new_message)
self._listener_task: Task[Any] self._listener_task: Task[Any]
self._method_call_requests: Dict[str, MethodCallRequest] = {} self._method_call_requests: Dict[str, MethodCallRequest] = {}
@@ -65,7 +66,7 @@ class PluginWrapper:
return self.name return self.name
async def _response_listener(self): async def _response_listener(self):
while True: while self._socket.active:
try: try:
line = await self._socket.read_single_line() line = await self._socket.read_single_line()
if line != None: if line != None:
@@ -115,29 +116,40 @@ class PluginWrapper:
return self return self
async def stop(self, uninstall: bool = False): async def stop(self, uninstall: bool = False):
self.log.info(f"Stopping plugin {self.name}") try:
if self.passive: start_time = time()
return if self.passive:
if hasattr(self, "_socket"): return
await self._socket.write_single_line(dumps({ "stop": True, "uninstall": uninstall }, ensure_ascii=False))
await self._socket.close_socket_connection() _, pending = await wait([
if self.proc: create_task(self._socket.write_single_line(dumps({ "stop": True, "uninstall": uninstall }, ensure_ascii=False)))
self.proc.join() ], timeout=1)
await self.kill_if_still_running()
if hasattr(self, "_listener_task"): if hasattr(self, "_listener_task"):
self._listener_task.cancel() self._listener_task.cancel()
await self.kill_if_still_running()
for pending_task in pending:
pending_task.cancel()
self.log.info(f"Plugin {self.name} has been stopped in {time() - start_time:.1f}s")
except Exception as e:
self.log.error(f"Error during shutdown for plugin {self.name}: {str(e)}\n{format_exc()}")
async def kill_if_still_running(self): async def kill_if_still_running(self):
time = 0 start_time = time()
sigtermed = False
while self.proc and self.proc.is_alive(): while self.proc and self.proc.is_alive():
await sleep(0.1) elapsed_time = time() - start_time
time += 1 if elapsed_time >= 5 and not sigtermed:
if time == 100: sigtermed = True
self.log.warn(f"Plugin {self.name} still alive 10 seconds after stop request! Sending SIGTERM!") self.log.warn(f"Plugin {self.name} still alive 5 seconds after stop request! Sending SIGTERM!")
self.terminate() self.terminate()
elif time == 200: elif elapsed_time >= 10:
self.log.warn(f"Plugin {self.name} still alive 20 seconds after stop request! Sending SIGKILL!") self.log.warn(f"Plugin {self.name} still alive 10 seconds after stop request! Sending SIGKILL!")
self.terminate(True) self.terminate(True)
await sleep(0.1)
def terminate(self, kill: bool = False): def terminate(self, kill: bool = False):
if self.proc and self.proc.is_alive(): if self.proc and self.proc.is_alive():
@@ -1,6 +1,5 @@
import sys import sys
from os import path, environ from os import path, environ
from signal import SIG_IGN, SIGINT, SIGTERM, getsignal, signal
from importlib.util import module_from_spec, spec_from_file_location from importlib.util import module_from_spec, spec_from_file_location
from json import dumps, loads from json import dumps, loads
from logging import getLogger from logging import getLogger
@@ -19,8 +18,6 @@ from typing import List, TypeVar, Any
DataType = TypeVar("DataType") DataType = TypeVar("DataType")
original_term_handler = getsignal(SIGTERM)
class SandboxedPlugin: class SandboxedPlugin:
def __init__(self, def __init__(self,
name: str, name: str,
@@ -48,11 +45,6 @@ class SandboxedPlugin:
self._socket = socket self._socket = socket
try: try:
# Ignore signals meant for parent Process
# TODO SURELY there's a better way to do this.
signal(SIGINT, SIG_IGN)
signal(SIGTERM, SIG_IGN)
setproctitle(f"{self.name} ({self.file})") setproctitle(f"{self.name} ({self.file})")
setthreadtitle(self.name) setthreadtitle(self.name)
@@ -120,7 +112,7 @@ class SandboxedPlugin:
get_event_loop().create_task(self.Plugin._main()) get_event_loop().create_task(self.Plugin._main())
else: else:
get_event_loop().create_task(self.Plugin._main(self.Plugin)) get_event_loop().create_task(self.Plugin._main(self.Plugin))
get_event_loop().create_task(socket.setup_server()) get_event_loop().create_task(socket.setup_server(self.on_new_message))
except: except:
self.log.error("Failed to start " + self.name + "!\n" + format_exc()) self.log.error("Failed to start " + self.name + "!\n" + format_exc())
sys.exit(0) sys.exit(0)
@@ -167,8 +159,6 @@ class SandboxedPlugin:
data = loads(message) data = loads(message)
if "stop" in data: if "stop" in data:
# Incase the loader needs to terminate our process soon
signal(SIGTERM, original_term_handler)
self.log.info(f"Calling Loader unload function for {self.name}.") self.log.info(f"Calling Loader unload function for {self.name}.")
await self._unload() await self._unload()
+3
View File
@@ -34,10 +34,13 @@ curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/di
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
[Unit] [Unit]
Description=SteamDeck Plugin Loader Description=SteamDeck Plugin Loader
After=network.target
[Service] [Service]
Type=simple Type=simple
User=root User=root
Restart=always Restart=always
KillMode=process
TimeoutStopSec=45
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER} Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER}
+3
View File
@@ -34,10 +34,13 @@ curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/di
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
[Unit] [Unit]
Description=SteamDeck Plugin Loader Description=SteamDeck Plugin Loader
After=network.target
[Service] [Service]
Type=simple Type=simple
User=root User=root
Restart=always Restart=always
KillMode=process
TimeoutStopSec=45
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER} Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER}
+1
View File
@@ -5,6 +5,7 @@ After=network.target
Type=simple Type=simple
User=root User=root
Restart=always Restart=always
KillMode=process
TimeoutStopSec=45 TimeoutStopSec=45
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services WorkingDirectory=${HOMEBREW_FOLDER}/services
+1
View File
@@ -5,6 +5,7 @@ After=network.target
Type=simple Type=simple
User=root User=root
Restart=always Restart=always
KillMode=process
TimeoutStopSec=45 TimeoutStopSec=45
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services WorkingDirectory=${HOMEBREW_FOLDER}/services