proc.py 7.3 KB

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