|
|
@@ -95,6 +95,17 @@ def job_running() -> tuple[bool, dict]:
|
|
|
if not j or j.get('status') != 'running':
|
|
|
return False, j
|
|
|
pid = j.get('pid')
|
|
|
+ # 心跳优先: 执行器每 15 s 更新 job 文件; 超过 STALE_S 没动静 = 被强杀(pid 可能被复用, 只看 pid 会误判)
|
|
|
+ STALE_S = 150
|
|
|
+ try:
|
|
|
+ age = time.time() - JOB.stat().st_mtime
|
|
|
+ except OSError:
|
|
|
+ age = 0
|
|
|
+ if age > STALE_S:
|
|
|
+ j.update(status='done', rc=None, finished=time.strftime('%Y-%m-%d %H:%M:%S'),
|
|
|
+ note=(j.get('note') or '') + f' (心跳停了 {int(age)} s —— 任务多半被强杀, 未拿到退出码)')
|
|
|
+ JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
|
|
|
+ return False, j
|
|
|
alive = False
|
|
|
if pid:
|
|
|
try:
|
|
|
@@ -128,19 +139,24 @@ def _log_tail(path, n=40) -> list:
|
|
|
|
|
|
|
|
|
def spawn(args: list, tag: str, detached=False) -> dict:
|
|
|
- """起一个动作: 交给 scripts/_ops_run.py 执行 (它会写回真实退出码), 日志落 logs/ops_<tag>_<时间>.log。"""
|
|
|
+ """起一个动作: 经 `_ops_launch.py` 二次启动 `_ops_run.py`。
|
|
|
+
|
|
|
+ 两层是**必须的**(见 _ops_launch.py 注释): 直接 Popen 的话, 任务是网关的子进程, 而
|
|
|
+ `guanlan.py stop` 用 `taskkill /T` —— 停网关时会连带杀掉正在执行的任务(实测把自己杀了)。
|
|
|
+ 执行器负责写回真实退出码; 日志落 logs/ops_<tag>_<时间>.log。
|
|
|
+ """
|
|
|
RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True)
|
|
|
log = LOGS / f'ops_{tag}_{time.strftime("%Y%m%d_%H%M%S")}.log'
|
|
|
- # PYTHONUNBUFFERED: 输出重定向到文件时 Python 默认块缓冲(8 KB), 页面上的"日志尾巴"会长时间不动 ——
|
|
|
- # 加了它才能边跑边看到每一行(实测: 重算跑到第二分钟, 日志尾还停在第 3 行)。
|
|
|
env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
|
|
|
- flags = 0
|
|
|
- if os.name == 'nt':
|
|
|
- flags = subprocess.CREATE_NEW_PROCESS_GROUP | (subprocess.DETACHED_PROCESS if detached else 0)
|
|
|
- runner = [PY, 'scripts/_ops_run.py', '--tag', tag, '--'] + list(args)
|
|
|
- f = open(log, 'ab')
|
|
|
- p = subprocess.Popen(runner, cwd=str(ROOT), env=env, stdout=f, stderr=subprocess.STDOUT, creationflags=flags)
|
|
|
- j = dict(kind=tag, cmd=' '.join(args), pid=p.pid, log=str(log), status='running', rc=None,
|
|
|
+ r = subprocess.run([PY, 'scripts/_ops_launch.py', '--tag', tag, '--log', str(log), '--'] + list(args),
|
|
|
+ cwd=str(ROOT), env=env, capture_output=True, text=True, errors='replace', timeout=60)
|
|
|
+ pid = None
|
|
|
+ for line in reversed((r.stdout or '').strip().splitlines()):
|
|
|
+ if line.strip().isdigit():
|
|
|
+ pid = int(line.strip()); break
|
|
|
+ if pid is None:
|
|
|
+ print(' 启动器输出异常:', r.stdout, r.stderr)
|
|
|
+ j = dict(kind=tag, cmd=' '.join(args), pid=pid, log=str(log), status='running', rc=None,
|
|
|
started=time.strftime('%Y-%m-%d %H:%M:%S'), note='')
|
|
|
JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
|
|
|
return j
|
|
|
@@ -251,11 +267,13 @@ def act_stop(body: dict) -> tuple[int, dict]:
|
|
|
# 按钮在前端就该是灰的, 但后端也必须拒 —— 否则直接打 API/重复提交就能做出无意义动作
|
|
|
return 409, dict(err='组件服务都已停止, 没有可停的 (按钮在页面上是禁用的)。')
|
|
|
if include_gw:
|
|
|
- # 连网关一起停: 必须分离进程"先停 → 睡一会 → 再起", 否则控制台自己没了就再也起不回来
|
|
|
+ # 连网关一起停: 必须分离进程"先停 → 睡一会 → 再起", 否则控制台自己没了就再也起不回来。
|
|
|
+ # 现在经 _ops_launch 二次启动 → 这个任务不是网关的子孙, stop 里的 taskkill /T 杀不到它,
|
|
|
+ # 所以"起回来"那半一定执行得到(此前会让整个栈停在半路)。
|
|
|
j = spawn(['-c', 'import subprocess,sys,time;'
|
|
|
f'subprocess.run([r"{PY}", "guanlan.py", "stop"]);'
|
|
|
'time.sleep(2);'
|
|
|
- f'subprocess.run([r"{PY}", "guanlan.py", "serve"])'], 'restart_all', detached=True)
|
|
|
+ f'subprocess.run([r"{PY}", "guanlan.py", "serve"])'], 'restart_all')
|
|
|
j['note'] = '含网关的完整重启: 页面会断开约 15 秒, 之后自动重连'
|
|
|
JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
|
|
|
return 200, dict(ok=True, job=j, note=j['note'])
|