mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-11 20:01:57 +00:00
Fix shutdown timeouts (#695)
Co-authored-by: AAGaming <aagaming@riseup.net>
This commit is contained in:
@@ -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()
|
||||||
@@ -59,6 +61,8 @@ class UnixSocket:
|
|||||||
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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
@@ -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}
|
||||||
|
|||||||
Vendored
+3
@@ -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
@@ -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
|
||||||
|
|||||||
Vendored
+1
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user