| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- # -*- coding: utf-8 -*-
- """子进程创建的统一口径 —— **不弹命令窗口** (2026-09-16 用户令: 启动/操作观澜时不弹命令窗口)。
- ## 为什么要收敛到一处
- Windows 上"**无控制台的父进程** + 裸 spawn 一个控制台程序" = 系统给子进程**新建一个可见控制台窗口**。
- 本项目里这类父进程很多:
- · 网关 `guanlan_gateway.py` 自己是被 `DETACHED_PROCESS` 起来的 (无控制台) → 它调的 `tasklist` / `git` 会闪窗;
- · 运维动作进程 (`scripts/_ops_launch.py` → `_ops_run.py`) 同样无控制台 → 从页面点"重算"时,
- 动作全过程都在一个可见窗口里跑 (最长 20 分钟), 页面每 2 s 轮询 `tasklist` 还会**反复闪窗**;
- · 组件服务原先有的地方用 `DETACHED_PROCESS` (子进程干脆没有控制台), 有的地方什么都不加 (于是弹窗),
- 两种写法混用 —— 实测现场会攒下多个标题为 `.venv\\Scripts\\python.exe` 的黑窗。
- 统一到本模块后: 只认 `NO_WINDOW` (`CREATE_NO_WINDOW`) 一种写法。
- ★ `CREATE_NO_WINDOW` 与 `DETACHED_PROCESS` **互斥**, 不要叠加 —— 前者是"给一个没有窗口的控制台",
- 后者是"不给控制台"; 叠在一起行为依赖 Windows 版本。需要"子进程活过父进程"时用
- `NEW_GROUP` (`CREATE_NEW_PROCESS_GROUP`) + 不共享控制台即可, 不需要 DETACHED。
- ## 日志去哪了 (hide 窗口不等于看不见)
- 窗口藏起来后, 子进程的 stdout/stderr 一律重定向到 `logs/<name>.log` (`spawn(log=…)`),
- `/ops` 页面也会显示任务日志尾巴 —— 排障路径不变, 只是不再靠一个黑窗。
- ## 用法
- from src.proc import spawn, run, NO_WINDOW
- pid = spawn([py, 'scripts/x.py'], log=P.LOGS / 'x.log', env=e, cwd=ROOT) # 后台, 不弹窗
- r = run(['tasklist', '/FI', f'PID eq {pid}'], capture_output=True, text=True) # 等待, 不弹窗
- """
- from __future__ import annotations
- try:
- from ._root import install_root as _install_root
- except ImportError: # 直接当脚本跑(python <本文件>)时没有包上下文
- from _root import install_root as _install_root
- import os
- import pathlib
- import subprocess
- import sys
- WIN = os.name == 'nt'
- # CREATE_NO_WINDOW = 0x08000000: 给子进程一个**没有窗口**的控制台 (stdio 仍可重定向)
- NO_WINDOW = 0x08000000 if WIN else 0
- # CREATE_NEW_PROCESS_GROUP = 0x00000200: 子进程不受父进程 Ctrl-C 影响, 也不共享父的控制台事件
- NEW_GROUP = 0x00000200 if WIN else 0
- ROOT = _install_root(__file__)
- def flags(*, new_group: bool = True) -> int:
- """本模块的统一 creationflags (非 Windows 返回 0, 调用方无需分支)。"""
- f = NO_WINDOW
- if new_group:
- f |= NEW_GROUP
- return f
- def _kw(kw: dict, *, new_group: bool = True) -> dict:
- if WIN:
- kw.setdefault('creationflags', flags(new_group=new_group))
- else:
- kw.setdefault('start_new_session', True)
- return kw
- def _inherit_stdio(kw: dict) -> dict:
- """把父进程**当前**的标准句柄显式交给子进程 (STARTF_USESTDHANDLES)。
- 为什么必须显式 (2026-09-16 实逮, 是本模块第一版引入的坑): Windows 上 `CREATE_NO_WINDOW` 会给
- 子进程新建一个"没有窗口的控制台", 而**新建控制台会把该进程的标准句柄重指到新控制台的缓冲区** ——
- 于是"父进程 stdout 已被重定向到日志文件"这件事, 在**孙子辈**就丢了。
- 现场表现: 从页面点"执行重算" → `_ops_launch`(显式 stdout=日志) → `_ops_run`(输出进了日志 ✔)
- → `rebuild_all.py`(没显式句柄 → 输出掉进那个隐形控制台, 页面只剩"运行中"、日志里一个字都没有)。
- 显式传 `sys.stdout/stderr` 即可让整条链写进同一个日志; `sys.stdout is None` (pythonw) 时跳过。
- """
- if kw.get('capture_output'):
- # ★ `capture_output=True` 与显式 `stdout=` **互斥**, 同给会抛 ValueError。
- # 2026-09-16 实逮: 忘了这一步 → guanlan_ops.job_running() 里的 `tasklist` 每次都抛,
- # 异常被 except 吞成 alive=False ⇒ **任何在跑的重算都被立刻改写成"被强杀"**,
- # 页面显示完成、重算按钮重新可点(可能并发起两个重算)。
- # 这里什么都不加就对了; 若调用方自己又传了 stdout, 让 Python 照旧抛错 (不替它吞)。
- return kw
- if 'stdout' not in kw:
- s = sys.stdout
- if s is not None and hasattr(s, 'fileno'):
- try:
- s.fileno()
- kw['stdout'] = s
- except Exception:
- pass
- if 'stderr' not in kw:
- s = sys.stderr
- if s is not None and hasattr(s, 'fileno'):
- try:
- s.fileno()
- kw['stderr'] = s # 不合并到 stdout: 让调用方自己决定 (spawn(log=) 才合并)
- except Exception:
- pass
- return kw
- def spawn(cmd, log=None, env=None, cwd=None, *, new_group: bool = True, stdin_devnull: bool = True, **popen_kw):
- """后台起一个**无窗口**子进程。`log` 给路径时把 stdout/stderr 追加进该文件。
- `**popen_kw` 透传给 `Popen` —— 调用方要自己给 `stdout=`/`stderr=` 句柄时 (例如"日志由子进程
- 自己写、父进程只留 fd") 用得上; 给了就**不覆盖**它。没给则继承父进程当前的标准句柄 (见 _inherit_stdio)。
- → Popen 对象 (拿 `.pid`)。
- """
- kw = dict(cwd=str(cwd or ROOT), env=env)
- if stdin_devnull and 'stdin' not in popen_kw:
- kw['stdin'] = subprocess.DEVNULL
- fh = None
- if log is not None and 'stdout' not in popen_kw:
- log = pathlib.Path(log)
- log.parent.mkdir(parents=True, exist_ok=True)
- fh = open(log, 'ab')
- kw['stdout'] = fh
- kw['stderr'] = subprocess.STDOUT
- kw.update(popen_kw)
- _inherit_stdio(kw)
- _kw(kw, new_group=new_group)
- p = subprocess.Popen([str(c) for c in cmd], **kw)
- if fh is not None:
- # 父进程不一定等子进程结束; 句柄由子进程持有, 这里不要 close 掉它 —— 交给 GC/进程退出即可。
- p._dsh_log = fh # 仅作引用保存, 避免过早回收
- return p
- def run(cmd, *, new_group: bool = True, **kw):
- """前台等待的 `subprocess.run`, 但**不弹窗** (tasklist / git / 短命令都该走这里)。
- ★ 文本解码请显式带 `errors='replace'`: `tasklist` 的输出是**控制台代码页**(中文 Windows = GBK),
- 而本进程可能是 PYTHONUTF8=1 起的 (默认文本编码 UTF-8) → 解码失败会让 `r.stdout` 变成 None,
- 调用方再 `str(pid) in r.stdout` 就 TypeError (2026-09-12 实逮过)。
- ★ 未显式给 stdout/stderr 时继承父进程当前句柄 (见 _inherit_stdio) —— 否则 `CREATE_NO_WINDOW`
- 新建的控制台会把子进程输出"吸走", 日志里什么都看不到。
- """
- _inherit_stdio(kw)
- _kw(kw, new_group=new_group)
- return subprocess.run([str(c) for c in cmd], **kw)
- def run_text(cmd, **kw):
- """`run` + text=True + errors='replace' (控制台输出来源的默认姿势)。"""
- kw.setdefault('capture_output', True)
- kw.setdefault('text', True)
- kw.setdefault('errors', 'replace')
- return run(cmd, **kw)
- def python_exe() -> str:
- """跑脚本用的解释器 (venv 优先)。放这里是为了让调用方一处取值, 别再各写一遍。"""
- try:
- from src import paths as P
- v = P.venv_python()
- if v:
- return str(v)
- except Exception:
- pass
- return sys.executable
|