287 lines
12 KiB
Python
287 lines
12 KiB
Python
"""Tray supervisor with authenticated local control and owned process cleanup."""
|
||
import argparse
|
||
import json
|
||
import logging
|
||
import multiprocessing as mp
|
||
import os
|
||
import secrets
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import webbrowser
|
||
from monitor_runtime.paths import data, resource, atomic_json
|
||
|
||
def worker(address, auth):
|
||
from multiprocessing.connection import Client
|
||
connection = Client(address, family='AF_PIPE', authkey=bytes.fromhex(auth))
|
||
try:
|
||
if connection.recv() != 'run':
|
||
return 1
|
||
from monitor_runtime.service import run
|
||
return run(connection)
|
||
finally:
|
||
connection.close()
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument('--worker', nargs=2, metavar=('PIPE', 'AUTH'))
|
||
parser.add_argument('--stop', action='store_true')
|
||
parser.add_argument('--self-test', action='store_true')
|
||
parser.add_argument('--trust-model')
|
||
parser.add_argument('--yes', action='store_true')
|
||
args = parser.parse_args()
|
||
if os.name != 'nt':
|
||
raise RuntimeError('Monitor desktop requires Windows')
|
||
data('.runtime').mkdir(parents=True, exist_ok=True)
|
||
data('log').mkdir(parents=True, exist_ok=True)
|
||
logging.basicConfig(filename=str(data('log/desktop.log')), level=logging.INFO,
|
||
format='%(asctime)s %(levelname)s %(message)s')
|
||
if args.self_test:
|
||
from monitor_runtime.diagnostics import run
|
||
return run()
|
||
if args.worker:
|
||
return worker(*args.worker)
|
||
if args.trust_model:
|
||
return trust_model(args.trust_model, args.yes)
|
||
from app.services.lifecycle import ServiceLeaderLock
|
||
# Separate from the service lock; held for the entire supervisor lifetime.
|
||
lock = ServiceLeaderLock(data('.runtime/desktop.lock'))
|
||
if not lock.acquire():
|
||
return control_existing('stop' if args.stop else 'open')
|
||
if args.stop:
|
||
lock.release()
|
||
return 0
|
||
try:
|
||
return Supervisor().run()
|
||
finally:
|
||
lock.release()
|
||
|
||
def control_existing(command):
|
||
from multiprocessing.connection import Client
|
||
deadline = time.monotonic() + 10
|
||
while True:
|
||
try:
|
||
state = json.loads(data('.runtime/desktop.json').read_text(encoding='utf-8'))
|
||
with Client(state['pipe'], family='AF_PIPE', authkey=bytes.fromhex(state['auth'])) as connection:
|
||
connection.send(command)
|
||
response = connection.recv()
|
||
break
|
||
except (OSError, EOFError, json.JSONDecodeError):
|
||
if time.monotonic() >= deadline:
|
||
raise RuntimeError('已有实例的控制接口不可用,请查看日志')
|
||
time.sleep(.2)
|
||
if command == 'stop':
|
||
deadline = time.monotonic() + 40
|
||
from app.services.lifecycle import ServiceLeaderLock
|
||
while time.monotonic() < deadline:
|
||
lock = ServiceLeaderLock(data('.runtime/desktop.lock'))
|
||
if lock.acquire():
|
||
lock.release()
|
||
return 0
|
||
time.sleep(.3)
|
||
raise RuntimeError('Monitor 未能在 40 秒内退出,请查看日志')
|
||
return 0
|
||
|
||
def trust_model(filename, confirmed):
|
||
from pathlib import Path
|
||
from app.utils.ModelTrust import sha256_file
|
||
from monitor_runtime.windows import message
|
||
model = Path(filename).resolve()
|
||
if model.suffix.lower() != '.pt' or not model.is_file():
|
||
raise ValueError('请选择存在的 .pt 模型文件')
|
||
digest = sha256_file(model)
|
||
if not confirmed:
|
||
message('模型 SHA-256: ' + digest + '\n确认来源可信后使用 --yes 重新执行。')
|
||
return 2
|
||
path = data('.trusted-models.json')
|
||
doc = json.loads(path.read_text(encoding='utf-8')) if path.exists() else {'trusted_sha256': []}
|
||
doc['trusted_sha256'] = sorted(set(doc['trusted_sha256']) | {digest})
|
||
atomic_json(path, doc)
|
||
message('已登记可信模型 SHA-256:' + digest)
|
||
return 0
|
||
|
||
class Supervisor:
|
||
def __init__(self):
|
||
self.stop = threading.Event()
|
||
self.restart = threading.Event()
|
||
self.url = ''
|
||
self.status = '正在启动'
|
||
self.icon = None
|
||
self.child = None
|
||
self.pipe = None
|
||
self.job = None
|
||
|
||
def open_web(self, *_):
|
||
if self.url:
|
||
webbrowser.open(self.url)
|
||
|
||
def exit(self, *_):
|
||
self.stop.set()
|
||
|
||
def run(self):
|
||
import pystray
|
||
from PIL import Image
|
||
from multiprocessing.connection import Listener
|
||
auth = secrets.token_bytes(32)
|
||
address = r'\\.\pipe\MonitorControl-' + secrets.token_hex(16)
|
||
self.control = Listener(address, family='AF_PIPE', authkey=auth)
|
||
atomic_json(data('.runtime/desktop.json'), {'pipe': address, 'auth': auth.hex()})
|
||
threading.Thread(target=self.control_loop, daemon=True).start()
|
||
menu = pystray.Menu(
|
||
pystray.MenuItem('打开管理网页', self.open_web, default=True),
|
||
pystray.MenuItem(lambda _: self.status, lambda *_: None, enabled=False),
|
||
pystray.MenuItem('查看状态', lambda *_: self.show_status()),
|
||
pystray.MenuItem('打开日志目录', lambda *_: os.startfile(str(data('log')))),
|
||
pystray.MenuItem('重启服务', lambda *_: self.restart.set()),
|
||
pystray.MenuItem('退出', self.exit))
|
||
self.icon = pystray.Icon('Monitor', Image.open(resource('static/images/logo.png')), 'Monitor', menu)
|
||
threading.Thread(target=self.supervise, daemon=True).start()
|
||
try:
|
||
self.icon.run()
|
||
finally:
|
||
self.stop.set()
|
||
self.control.close()
|
||
return 0
|
||
|
||
def show_status(self):
|
||
from monitor_runtime.windows import message
|
||
message(self.status + '\n' + self.url.split('#')[0])
|
||
|
||
def control_loop(self):
|
||
while not self.stop.is_set():
|
||
try:
|
||
with self.control.accept() as conn:
|
||
command = conn.recv()
|
||
if command == 'stop':
|
||
self.stop.set()
|
||
elif command == 'open':
|
||
self.open_web()
|
||
conn.send('ok')
|
||
except (OSError, EOFError):
|
||
return
|
||
|
||
def start_child(self):
|
||
from multiprocessing.connection import Listener
|
||
from monitor_runtime.windows import Job
|
||
auth = secrets.token_bytes(32)
|
||
address = r'\\.\pipe\MonitorWorker-' + secrets.token_hex(16)
|
||
listener = Listener(address, family='AF_PIPE', authkey=auth)
|
||
args = [sys.executable]
|
||
if not getattr(sys, 'frozen', False):
|
||
args.append(str(resource('monitor_entry.py')))
|
||
args += ['--worker', address, auth.hex()]
|
||
env = os.environ.copy()
|
||
env['MONITOR_DESKTOP'] = '1'
|
||
env['MONITOR_SERVICE_MODE'] = 'disabled'
|
||
env['MONITOR_BOOTSTRAP_SERVICES'] = 'false'
|
||
env['MONITOR_DEBUG'] = 'false'
|
||
env['MONITOR_DATA_DIR'] = str(data(''))
|
||
self.job = Job()
|
||
log = open(data('log/service.log'), 'ab', buffering=0)
|
||
try:
|
||
self.child = subprocess.Popen(args, cwd=str(data('')), env=env, stdin=subprocess.DEVNULL,
|
||
stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW)
|
||
self.job.assign(self.child)
|
||
finally:
|
||
log.close()
|
||
# Child waits for run; Job assignment therefore precedes any child spawn.
|
||
accepted = []
|
||
def accept():
|
||
try:
|
||
accepted.append(listener.accept())
|
||
except OSError:
|
||
pass
|
||
thread = threading.Thread(target=accept, daemon=True)
|
||
thread.start()
|
||
deadline = time.monotonic() + 30
|
||
while not accepted:
|
||
if self.child.poll() is not None or time.monotonic() > deadline or self.stop.wait(.1):
|
||
listener.close()
|
||
raise RuntimeError('服务进程未能建立控制连接,请查看 service.log')
|
||
self.pipe = accepted[0]
|
||
listener.close()
|
||
self.pipe.send('run')
|
||
|
||
def stop_child(self):
|
||
if self.child and self.child.poll() is None:
|
||
try:
|
||
self.pipe.send('stop')
|
||
except (AttributeError, EOFError, OSError):
|
||
pass
|
||
try:
|
||
self.child.wait(timeout=25)
|
||
except subprocess.TimeoutExpired:
|
||
logging.error('Graceful shutdown timed out; closing owned job')
|
||
if self.pipe:
|
||
self.pipe.close()
|
||
self.pipe = None
|
||
if self.job:
|
||
self.job.close()
|
||
self.job = None
|
||
self.child = None
|
||
|
||
def supervise(self):
|
||
try:
|
||
while not self.stop.is_set():
|
||
self.restart.clear()
|
||
self.status = '正在启动'
|
||
self.start_child()
|
||
deadline = time.monotonic() + 180
|
||
last_heartbeat = time.monotonic()
|
||
opened = False
|
||
auto_restart = False
|
||
try:
|
||
while not self.stop.is_set() and not self.restart.is_set():
|
||
if self.pipe.poll(.3):
|
||
message = self.pipe.recv()
|
||
last_heartbeat = time.monotonic()
|
||
phase = message.get('phase')
|
||
if phase == 'failed':
|
||
raise RuntimeError(message.get('reason', '服务启动失败'))
|
||
self.status = {'ready': '服务运行中', 'setup': '等待首次初始化',
|
||
'license': '等待有效授权'}.get(phase, phase)
|
||
if message.get('url'):
|
||
self.url = message['url']
|
||
if not opened:
|
||
self.open_web()
|
||
opened = True
|
||
self.icon.update_menu()
|
||
code = self.child.poll()
|
||
if code is not None:
|
||
if code == 20:
|
||
auto_restart = True
|
||
break
|
||
raise RuntimeError('服务进程退出,代码 %s' % code)
|
||
if not opened and time.monotonic() > deadline:
|
||
raise RuntimeError('启动超过 180 秒,请查看日志')
|
||
if opened and time.monotonic() - last_heartbeat > 35:
|
||
raise RuntimeError('服务健康状态超时')
|
||
except EOFError:
|
||
code = self.child.wait(timeout=30)
|
||
if code == 20:
|
||
auto_restart = True
|
||
else:
|
||
raise RuntimeError('服务控制连接断开,代码 %s' % code)
|
||
finally:
|
||
self.stop_child()
|
||
if not auto_restart and not self.restart.is_set():
|
||
break
|
||
except Exception as exc:
|
||
logging.exception('Supervisor failure')
|
||
self.status = '启动失败:' + str(exc)
|
||
self.url = ''
|
||
self.icon.update_menu()
|
||
from monitor_runtime.windows import message
|
||
message(self.status + '\n日志:' + str(data('log')), True)
|
||
self.stop_child()
|
||
# Keep tray available; retries are explicit, never an infinite crash loop.
|
||
while not self.stop.wait(.3):
|
||
if self.restart.is_set():
|
||
self.supervise()
|
||
return
|
||
finally:
|
||
self.stop_child()
|
||
if self.stop.is_set():
|
||
self.icon.stop()
|