proc.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. # -*- coding: utf-8 -*-
  2. """子进程创建的统一口径 —— **不弹命令窗口** (2026-09-16 用户令: 启动/操作观澜时不弹命令窗口)。
  3. ## 为什么要收敛到一处
  4. Windows 上"**无控制台的父进程** + 裸 spawn 一个控制台程序" = 系统给子进程**新建一个可见控制台窗口**。
  5. 本项目里这类父进程很多:
  6. · 网关 `guanlan_gateway.py` 自己是被 `DETACHED_PROCESS` 起来的 (无控制台) → 它调的 `tasklist` / `git` 会闪窗;
  7. · 运维动作进程 (`scripts/_ops_launch.py` → `_ops_run.py`) 同样无控制台 → 从页面点"重算"时,
  8. 动作全过程都在一个可见窗口里跑 (最长 20 分钟), 页面每 2 s 轮询 `tasklist` 还会**反复闪窗**;
  9. · 组件服务原先有的地方用 `DETACHED_PROCESS` (子进程干脆没有控制台), 有的地方什么都不加 (于是弹窗),
  10. 两种写法混用 —— 实测现场会攒下多个标题为 `.venv\\Scripts\\python.exe` 的黑窗。
  11. 统一到本模块后: 只认 `NO_WINDOW` (`CREATE_NO_WINDOW`) 一种写法。
  12. ★ `CREATE_NO_WINDOW` 与 `DETACHED_PROCESS` **互斥**, 不要叠加 —— 前者是"给一个没有窗口的控制台",
  13. 后者是"不给控制台"; 叠在一起行为依赖 Windows 版本。需要"子进程活过父进程"时用
  14. `NEW_GROUP` (`CREATE_NEW_PROCESS_GROUP`) + 不共享控制台即可, 不需要 DETACHED。
  15. ## 日志去哪了 (hide 窗口不等于看不见)
  16. 窗口藏起来后, 子进程的 stdout/stderr 一律重定向到 `logs/<name>.log` (`spawn(log=…)`),
  17. `/ops` 页面也会显示任务日志尾巴 —— 排障路径不变, 只是不再靠一个黑窗。
  18. ## 用法
  19. from src.proc import spawn, run, NO_WINDOW
  20. pid = spawn([py, 'scripts/x.py'], log=P.LOGS / 'x.log', env=e, cwd=ROOT) # 后台, 不弹窗
  21. r = run(['tasklist', '/FI', f'PID eq {pid}'], capture_output=True, text=True) # 等待, 不弹窗
  22. """
  23. from __future__ import annotations
  24. try:
  25. from ._root import install_root as _install_root
  26. except ImportError: # 直接当脚本跑(python <本文件>)时没有包上下文
  27. from _root import install_root as _install_root
  28. import os
  29. import pathlib
  30. import subprocess
  31. import sys
  32. WIN = os.name == 'nt'
  33. # CREATE_NO_WINDOW = 0x08000000: 给子进程一个**没有窗口**的控制台 (stdio 仍可重定向)
  34. NO_WINDOW = 0x08000000 if WIN else 0
  35. # CREATE_NEW_PROCESS_GROUP = 0x00000200: 子进程不受父进程 Ctrl-C 影响, 也不共享父的控制台事件
  36. NEW_GROUP = 0x00000200 if WIN else 0
  37. ROOT = _install_root(__file__)
  38. def flags(*, new_group: bool = True) -> int:
  39. """本模块的统一 creationflags (非 Windows 返回 0, 调用方无需分支)。"""
  40. f = NO_WINDOW
  41. if new_group:
  42. f |= NEW_GROUP
  43. return f
  44. def _kw(kw: dict, *, new_group: bool = True) -> dict:
  45. if WIN:
  46. kw.setdefault('creationflags', flags(new_group=new_group))
  47. else:
  48. kw.setdefault('start_new_session', True)
  49. return kw
  50. def _inherit_stdio(kw: dict) -> dict:
  51. """把父进程**当前**的标准句柄显式交给子进程 (STARTF_USESTDHANDLES)。
  52. 为什么必须显式 (2026-09-16 实逮, 是本模块第一版引入的坑): Windows 上 `CREATE_NO_WINDOW` 会给
  53. 子进程新建一个"没有窗口的控制台", 而**新建控制台会把该进程的标准句柄重指到新控制台的缓冲区** ——
  54. 于是"父进程 stdout 已被重定向到日志文件"这件事, 在**孙子辈**就丢了。
  55. 现场表现: 从页面点"执行重算" → `_ops_launch`(显式 stdout=日志) → `_ops_run`(输出进了日志 ✔)
  56. → `rebuild_all.py`(没显式句柄 → 输出掉进那个隐形控制台, 页面只剩"运行中"、日志里一个字都没有)。
  57. 显式传 `sys.stdout/stderr` 即可让整条链写进同一个日志; `sys.stdout is None` (pythonw) 时跳过。
  58. """
  59. if kw.get('capture_output'):
  60. # ★ `capture_output=True` 与显式 `stdout=` **互斥**, 同给会抛 ValueError。
  61. # 2026-09-16 实逮: 忘了这一步 → guanlan_ops.job_running() 里的 `tasklist` 每次都抛,
  62. # 异常被 except 吞成 alive=False ⇒ **任何在跑的重算都被立刻改写成"被强杀"**,
  63. # 页面显示完成、重算按钮重新可点(可能并发起两个重算)。
  64. # 这里什么都不加就对了; 若调用方自己又传了 stdout, 让 Python 照旧抛错 (不替它吞)。
  65. return kw
  66. if 'stdout' not in kw:
  67. s = sys.stdout
  68. if s is not None and hasattr(s, 'fileno'):
  69. try:
  70. s.fileno()
  71. kw['stdout'] = s
  72. except Exception:
  73. pass
  74. if 'stderr' not in kw:
  75. s = sys.stderr
  76. if s is not None and hasattr(s, 'fileno'):
  77. try:
  78. s.fileno()
  79. kw['stderr'] = s # 不合并到 stdout: 让调用方自己决定 (spawn(log=) 才合并)
  80. except Exception:
  81. pass
  82. return kw
  83. def spawn(cmd, log=None, env=None, cwd=None, *, new_group: bool = True, stdin_devnull: bool = True, **popen_kw):
  84. """后台起一个**无窗口**子进程。`log` 给路径时把 stdout/stderr 追加进该文件。
  85. `**popen_kw` 透传给 `Popen` —— 调用方要自己给 `stdout=`/`stderr=` 句柄时 (例如"日志由子进程
  86. 自己写、父进程只留 fd") 用得上; 给了就**不覆盖**它。没给则继承父进程当前的标准句柄 (见 _inherit_stdio)。
  87. → Popen 对象 (拿 `.pid`)。
  88. """
  89. kw = dict(cwd=str(cwd or ROOT), env=env)
  90. if stdin_devnull and 'stdin' not in popen_kw:
  91. kw['stdin'] = subprocess.DEVNULL
  92. fh = None
  93. if log is not None and 'stdout' not in popen_kw:
  94. log = pathlib.Path(log)
  95. log.parent.mkdir(parents=True, exist_ok=True)
  96. fh = open(log, 'ab')
  97. kw['stdout'] = fh
  98. kw['stderr'] = subprocess.STDOUT
  99. kw.update(popen_kw)
  100. _inherit_stdio(kw)
  101. _kw(kw, new_group=new_group)
  102. p = subprocess.Popen([str(c) for c in cmd], **kw)
  103. if fh is not None:
  104. # 父进程不一定等子进程结束; 句柄由子进程持有, 这里不要 close 掉它 —— 交给 GC/进程退出即可。
  105. p._dsh_log = fh # 仅作引用保存, 避免过早回收
  106. return p
  107. def run(cmd, *, new_group: bool = True, **kw):
  108. """前台等待的 `subprocess.run`, 但**不弹窗** (tasklist / git / 短命令都该走这里)。
  109. ★ 文本解码请显式带 `errors='replace'`: `tasklist` 的输出是**控制台代码页**(中文 Windows = GBK),
  110. 而本进程可能是 PYTHONUTF8=1 起的 (默认文本编码 UTF-8) → 解码失败会让 `r.stdout` 变成 None,
  111. 调用方再 `str(pid) in r.stdout` 就 TypeError (2026-09-12 实逮过)。
  112. ★ 未显式给 stdout/stderr 时继承父进程当前句柄 (见 _inherit_stdio) —— 否则 `CREATE_NO_WINDOW`
  113. 新建的控制台会把子进程输出"吸走", 日志里什么都看不到。
  114. """
  115. _inherit_stdio(kw)
  116. _kw(kw, new_group=new_group)
  117. return subprocess.run([str(c) for c in cmd], **kw)
  118. def run_text(cmd, **kw):
  119. """`run` + text=True + errors='replace' (控制台输出来源的默认姿势)。"""
  120. kw.setdefault('capture_output', True)
  121. kw.setdefault('text', True)
  122. kw.setdefault('errors', 'replace')
  123. return run(cmd, **kw)
  124. def python_exe() -> str:
  125. """跑脚本用的解释器 (venv 优先)。放这里是为了让调用方一处取值, 别再各写一遍。"""
  126. try:
  127. from src import paths as P
  128. v = P.venv_python()
  129. if v:
  130. return str(v)
  131. except Exception:
  132. pass
  133. return sys.executable