# -*- 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/.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