implement a shutdown routine instead of just waiting for all plugins to stop on their own

This commit is contained in:
AAGaming
2024-07-03 20:08:30 -04:00
parent b32fa7948f
commit 2c4edeb810
8 changed files with 121 additions and 22 deletions
+4 -1
View File
@@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from asyncio import AbstractEventLoop, Queue, sleep from asyncio import AbstractEventLoop, Queue, gather, sleep
from logging import getLogger from logging import getLogger
from os import listdir, path from os import listdir, path
from pathlib import Path from pathlib import Path
@@ -98,6 +98,9 @@ class Loader:
server_instance.ws.add_route("loader/call_plugin_method", self.handle_plugin_method_call) server_instance.ws.add_route("loader/call_plugin_method", self.handle_plugin_method_call)
server_instance.ws.add_route("loader/call_legacy_plugin_method", self.handle_plugin_method_call_legacy) server_instance.ws.add_route("loader/call_legacy_plugin_method", self.handle_plugin_method_call_legacy)
async def shutdown_plugins(self):
await gather(*[self.plugins[plugin_name].stop() for plugin_name in self.plugins])
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)
@@ -21,8 +21,12 @@ class UnixSocket:
self.server_writer = None self.server_writer = None
async def setup_server(self): async def setup_server(self):
self.socket = await asyncio.start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT) try:
self.socket = await asyncio.start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
except asyncio.CancelledError:
await self.close_socket_connection()
raise
async def _open_socket_if_not_exists(self): async def _open_socket_if_not_exists(self):
if not self.reader: if not self.reader:
retries = 0 retries = 0
@@ -49,6 +53,10 @@ class UnixSocket:
self.reader = None self.reader = None
if self.socket:
self.socket.close()
await self.socket.wait_closed()
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()
@@ -121,8 +129,12 @@ class PortSocket (UnixSocket):
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):
self.socket = await asyncio.start_server(self._listen_for_method_call, host=self.host, port=self.port, limit=BUFFER_LIMIT) try:
self.socket = await asyncio.start_server(self._listen_for_method_call, host=self.host, port=self.port, limit=BUFFER_LIMIT)
except asyncio.CancelledError:
await self.close_socket_connection()
raise
async def _open_socket_if_not_exists(self): async def _open_socket_if_not_exists(self):
if not self.reader: if not self.reader:
retries = 0 retries = 0
+52 -4
View File
@@ -1,6 +1,6 @@
# Change PyInstaller files permissions # Change PyInstaller files permissions
import sys import sys
from typing import Dict from typing import Any, Dict
from .localplatform.localplatform import (chmod, chown, service_stop, service_start, from .localplatform.localplatform import (chmod, chown, service_stop, service_start,
ON_WINDOWS, ON_LINUX, get_log_level, get_live_reload, ON_WINDOWS, ON_LINUX, get_log_level, get_live_reload,
get_server_port, get_server_host, get_chown_plugin_path, get_server_port, get_server_host, get_chown_plugin_path,
@@ -8,7 +8,7 @@ from .localplatform.localplatform import (chmod, chown, service_stop, service_st
if hasattr(sys, '_MEIPASS'): if hasattr(sys, '_MEIPASS'):
chmod(sys._MEIPASS, 755) # type: ignore chmod(sys._MEIPASS, 755) # type: ignore
# Full imports # Full imports
from asyncio import AbstractEventLoop, new_event_loop, set_event_loop, sleep from asyncio import AbstractEventLoop, CancelledError, Task, all_tasks, current_task, gather, new_event_loop, set_event_loop, sleep
from logging import basicConfig, getLogger from logging import basicConfig, getLogger
from os import path from os import path
from traceback import format_exc from traceback import format_exc
@@ -55,6 +55,8 @@ if get_chown_plugin_path() == True:
class PluginManager: class PluginManager:
def __init__(self, loop: AbstractEventLoop) -> None: def __init__(self, loop: AbstractEventLoop) -> None:
self.loop = loop self.loop = loop
self.reinject: bool = True
self.js_ctx_tab: Tab | None = None
self.web_app = Application() self.web_app = Application()
self.web_app.middlewares.append(csrf_middleware) self.web_app.middlewares.append(csrf_middleware)
self.cors = aiohttp_cors.setup(self.web_app, defaults={ self.cors = aiohttp_cors.setup(self.web_app, defaults={
@@ -82,6 +84,7 @@ class PluginManager:
self.loop.create_task(self.load_plugins()) self.loop.create_task(self.load_plugins())
self.web_app.on_startup.append(startup) self.web_app.on_startup.append(startup)
self.web_app.on_shutdown.append(self.shutdown)
self.loop.set_exception_handler(self.exception_handler) self.loop.set_exception_handler(self.exception_handler)
self.web_app.add_routes([get("/auth/token", self.get_auth_token)]) self.web_app.add_routes([get("/auth/token", self.get_auth_token)])
@@ -90,6 +93,40 @@ class PluginManager:
self.cors.add(route) # pyright: ignore [reportUnknownMemberType] self.cors.add(route) # pyright: ignore [reportUnknownMemberType]
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 shutdown(self, _: Application):
try:
logger.info(f"Shutting down...")
await self.plugin_loader.shutdown_plugins()
await self.ws.disconnect()
self.reinject = False
if self.js_ctx_tab:
await self.js_ctx_tab.close_websocket()
self.js_ctx_tab = None
except:
logger.info("Error during shutdown:\n" + format_exc())
pass
finally:
logger.info("Cancelling tasks...")
tasks = all_tasks()
current = current_task()
async def cancel_task(task: Task[Any]):
logger.debug(f"Cancelling task {task}")
try:
task.cancel()
try:
await task
except CancelledError:
pass
logger.debug(f"Task {task} finished")
except:
logger.warn(f"Failed to cancel task {task}:\n" + format_exc())
pass
if current:
tasks.remove(current)
await gather(*[cancel_task(task) for task in tasks])
logger.info("Shutdown finished.")
def exception_handler(self, loop: AbstractEventLoop, context: Dict[str, str]): def exception_handler(self, loop: AbstractEventLoop, context: Dict[str, str]):
if context["message"] == "Unclosed connection": if context["message"] == "Unclosed connection":
return return
@@ -107,11 +144,13 @@ class PluginManager:
logger.debug("Did not find pluginOrder setting, set it to default") logger.debug("Did not find pluginOrder setting, set it to default")
async def loader_reinjector(self): async def loader_reinjector(self):
while True: while self.reinject:
tab = None tab = None
nf = False nf = False
dc = False dc = False
while not tab: while not tab:
if not self.reinject:
return
try: try:
tab = await get_gamepadui_tab() tab = await get_gamepadui_tab()
except (client_exceptions.ClientConnectorError, client_exceptions.ServerDisconnectedError): except (client_exceptions.ClientConnectorError, client_exceptions.ServerDisconnectedError):
@@ -127,6 +166,7 @@ class PluginManager:
if not tab: if not tab:
await sleep(5) await sleep(5)
await tab.open_websocket() await tab.open_websocket()
self.js_ctx_tab = tab
await tab.enable() await tab.enable()
await self.inject_javascript(tab, True) await self.inject_javascript(tab, True)
try: try:
@@ -135,16 +175,22 @@ class PluginManager:
if not await tab.has_global_var("deckyHasLoaded", False): if not await tab.has_global_var("deckyHasLoaded", False):
await self.inject_javascript(tab) await self.inject_javascript(tab)
elif msg.get("method", None) == "Inspector.detached": elif msg.get("method", None) == "Inspector.detached":
if not self.reinject:
return
logger.info("CEF has requested that we detach.") logger.info("CEF has requested that we detach.")
await tab.close_websocket() await tab.close_websocket()
self.js_ctx_tab = None
break break
# If this is a forceful disconnect the loop will just stop without any failure message. In this case, injector.py will handle this for us so we don't need to close the socket. # If this is a forceful disconnect the loop will just stop without any failure message. In this case, injector.py will handle this for us so we don't need to close the socket.
# This is because of https://github.com/aio-libs/aiohttp/blob/3ee7091b40a1bc58a8d7846e7878a77640e96996/aiohttp/client_ws.py#L321 # This is because of https://github.com/aio-libs/aiohttp/blob/3ee7091b40a1bc58a8d7846e7878a77640e96996/aiohttp/client_ws.py#L321
logger.info("CEF has disconnected...") logger.info("CEF has disconnected...")
# At this point the loop starts again and we connect to the freshly started Steam client once it is ready. # At this point the loop starts again and we connect to the freshly started Steam client once it is ready.
except Exception: except Exception:
if not self.reinject:
return
logger.error("Exception while reading page events " + format_exc()) logger.error("Exception while reading page events " + format_exc())
await tab.close_websocket() await tab.close_websocket()
self.js_ctx_tab = None
pass pass
# while True: # while True:
# await sleep(5) # await sleep(5)
@@ -157,6 +203,8 @@ class PluginManager:
try: try:
# if first: # if first:
if ON_LINUX and await tab.has_global_var("deckyHasLoaded", False): if ON_LINUX and await tab.has_global_var("deckyHasLoaded", False):
await tab.close_websocket()
self.js_ctx_tab = None
await restart_webhelper() await restart_webhelper()
return # We'll catch the next tab in the main loop return # We'll catch the next tab in the main loop
await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.Browser.RestartJSContext(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{await import('http://localhost:1337/frontend/index.js?v=%s')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}" % (get_loader_version(), ), False, False, False) await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.Browser.RestartJSContext(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{await import('http://localhost:1337/frontend/index.js?v=%s')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}" % (get_loader_version(), ), False, False, False)
@@ -165,7 +213,7 @@ class PluginManager:
pass pass
def run(self): def run(self):
return run_app(self.web_app, host=get_server_host(), port=get_server_port(), loop=self.loop, access_log=None) run_app(self.web_app, host=get_server_host(), port=get_server_port(), loop=self.loop, access_log=None, handle_signals=False, shutdown_timeout=15)
def main(): def main():
if ON_WINDOWS: if ON_WINDOWS:
+32 -3
View File
@@ -1,4 +1,4 @@
from asyncio import Task, create_task from asyncio import CancelledError, Task, create_task, sleep
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
@@ -41,6 +41,7 @@ class PluginWrapper:
self.log = getLogger("plugin") self.log = getLogger("plugin")
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
# TODO: Maybe make LocalSocket not require on_new_message to make this cleaner # TODO: Maybe make LocalSocket not require on_new_message to make this cleaner
self._socket = LocalSocket(self.sandboxed_plugin.on_new_message) self._socket = LocalSocket(self.sandboxed_plugin.on_new_message)
self._listener_task: Task[Any] self._listener_task: Task[Any]
@@ -73,6 +74,10 @@ class PluginWrapper:
create_task(self.emitted_event_callback(res["event"], res["args"])) create_task(self.emitted_event_callback(res["event"], res["args"]))
elif res["type"] == SocketMessageType.RESPONSE.value: elif res["type"] == SocketMessageType.RESPONSE.value:
self._method_call_requests.pop(res["id"]).set_result(res) self._method_call_requests.pop(res["id"]).set_result(res)
except CancelledError:
self.log.info(f"Stopping response listener for {self.name}")
await self._socket.close_socket_connection()
raise
except: except:
pass pass
@@ -104,13 +109,37 @@ class PluginWrapper:
def start(self): def start(self):
if self.passive: if self.passive:
return self return self
Process(target=self.sandboxed_plugin.initialize, args=[self._socket]).start() self.proc = Process(target=self.sandboxed_plugin.initialize, args=[self._socket], daemon=True)
self.proc.start()
self._listener_task = create_task(self._response_listener()) self._listener_task = create_task(self._response_listener())
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}")
if self.passive:
return
if hasattr(self, "_socket"): if hasattr(self, "_socket"):
await self._socket.write_single_line(dumps({ "stop": True, "uninstall": uninstall }, ensure_ascii=False)) await self._socket.write_single_line(dumps({ "stop": True, "uninstall": uninstall }, ensure_ascii=False))
await self._socket.close_socket_connection() await self._socket.close_socket_connection()
if hasattr(self, "_listener_task"): if hasattr(self, "_listener_task"):
self._listener_task.cancel() self._listener_task.cancel()
await self.kill_if_still_running()
async def kill_if_still_running(self):
time = 0
while self.proc and self.proc.is_alive():
await sleep(0.1)
time += 1
if time == 100:
self.log.warn(f"Plugin {self.name} still alive 10 seconds after stop request! Sending SIGTERM!")
self.terminate()
elif time == 200:
self.log.warn(f"Plugin {self.name} still alive 20 seconds after stop request! Sending SIGKILL!")
self.terminate(True)
def terminate(self, kill: bool = False):
if self.proc and self.proc.is_alive():
if kill:
self.proc.kill()
else:
self.proc.terminate()
@@ -1,5 +1,5 @@
from os import path, environ from os import path, environ
from signal import SIGINT, signal from signal import SIG_IGN, SIGINT, SIGTERM, 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
@@ -39,13 +39,14 @@ class SandboxedPlugin:
self.author = author self.author = author
self.api_version = api_version self.api_version = api_version
self.log = getLogger("plugin") self.log = getLogger("sandboxed_plugin")
def initialize(self, socket: LocalSocket): def initialize(self, socket: LocalSocket):
self._socket = socket self._socket = socket
try: try:
signal(SIGINT, lambda s, f: exit(0)) signal(SIGINT, SIG_IGN)
signal(SIGTERM, SIG_IGN)
set_event_loop(new_event_loop()) set_event_loop(new_event_loop())
if self.passive: if self.passive:
@@ -112,10 +113,10 @@ class SandboxedPlugin:
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())
get_event_loop().run_forever()
except: except:
self.log.error("Failed to start " + self.name + "!\n" + format_exc()) self.log.error("Failed to start " + self.name + "!\n" + format_exc())
exit(0) exit(0)
get_event_loop().run_forever()
async def _unload(self): async def _unload(self):
try: try:
@@ -130,7 +131,7 @@ class SandboxedPlugin:
self.log.info("Could not find \"_unload\" in " + self.name + "'s main.py" + "\n") self.log.info("Could not find \"_unload\" in " + self.name + "'s main.py" + "\n")
except: except:
self.log.error("Failed to unload " + self.name + "!\n" + format_exc()) self.log.error("Failed to unload " + self.name + "!\n" + format_exc())
exit(0) pass
async def _uninstall(self): async def _uninstall(self):
try: try:
@@ -145,13 +146,13 @@ class SandboxedPlugin:
self.log.info("Could not find \"_uninstall\" in " + self.name + "'s main.py" + "\n") self.log.info("Could not find \"_uninstall\" in " + self.name + "'s main.py" + "\n")
except: except:
self.log.error("Failed to uninstall " + self.name + "!\n" + format_exc()) self.log.error("Failed to uninstall " + self.name + "!\n" + format_exc())
exit(0) pass
async def on_new_message(self, message : str) -> str|None: async def on_new_message(self, message : str) -> str|None:
data = loads(message) data = loads(message)
if "stop" in data: if "stop" in data:
self.log.info("Calling Loader unload function.") self.log.info(f"Calling Loader unload function for {self.name}.")
await self._unload() await self._unload()
if data.get('uninstall'): if data.get('uninstall'):
@@ -160,9 +161,9 @@ class SandboxedPlugin:
get_event_loop().stop() get_event_loop().stop()
while get_event_loop().is_running(): while get_event_loop().is_running():
await sleep(0) await sleep(0.1)
get_event_loop().close() get_event_loop().close()
raise Exception("Closing message listener") exit(0)
d: SocketResponseDict = {"type": SocketMessageType.RESPONSE, "res": None, "success": True, "id": data["id"]} d: SocketResponseDict = {"type": SocketMessageType.RESPONSE, "res": None, "success": True, "id": data["id"]}
try: try:
+5 -1
View File
@@ -2,7 +2,7 @@ from logging import getLogger
from asyncio import AbstractEventLoop, create_task from asyncio import AbstractEventLoop, create_task
from aiohttp import WSMsgType, WSMessage from aiohttp import WSCloseCode, WSMsgType, WSMessage
from aiohttp.web import Application, WebSocketResponse, Request, Response, get from aiohttp.web import Application, WebSocketResponse, Request, Response, get
from enum import IntEnum from enum import IntEnum
@@ -133,3 +133,7 @@ class WSRouter:
async def emit(self, event: str, *args: Any): async def emit(self, event: str, *args: Any):
self.logger.debug(f'Firing frontend event {event} with args {args}') self.logger.debug(f'Firing frontend event {event} with args {args}')
await self.write({ "type": MessageType.EVENT.value, "event": event, "args": args }) await self.write({ "type": MessageType.EVENT.value, "event": event, "args": args })
async def disconnect(self):
if self.ws:
await self.ws.close(code=WSCloseCode.GOING_AWAY, message=b"Loader is shutting down")
+1
View File
@@ -4,6 +4,7 @@ Description=SteamDeck Plugin Loader
Type=simple Type=simple
User=root User=root
Restart=always Restart=always
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
@@ -4,6 +4,7 @@ Description=SteamDeck Plugin Loader
Type=simple Type=simple
User=root User=root
Restart=always Restart=always
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}