dsh-engineering-ui 0.0.0-stage → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +772 -3
- package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
- package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
- package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
- package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
- package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
- package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
- package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
- package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
- package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
- package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
- package/engineering/skills/cad-workflow/SKILL.md +132 -0
- package/engineering/skills/mode-selection/SKILL.md +660 -0
- package/engineering/skills/physics-in-loop/SKILL.md +238 -0
- package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
- package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
- package/engineering/skills/sw-design/SKILL.md +112 -0
- package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
- package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
- package/engineering/skills/sw-to-cad/SKILL.md +121 -0
- package/engineering/skills/webots-simulation/SKILL.md +58 -0
- package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
- package/engineering/tools/_plugin_paths.py +93 -0
- package/engineering/tools/_root.py +330 -0
- package/engineering/tools/_store.py +446 -0
- package/engineering/tools/ac_bridge.py +459 -0
- package/engineering/tools/ac_rules.json +21 -0
- package/engineering/tools/ac_validate.py +699 -0
- package/engineering/tools/ask_user.py +887 -0
- package/engineering/tools/build_arm1.py +46 -0
- package/engineering/tools/build_assembly.py +94 -0
- package/engineering/tools/build_base.py +57 -0
- package/engineering/tools/build_housing.py +70 -0
- package/engineering/tools/build_structural.py +43 -0
- package/engineering/tools/build_transmission.py +69 -0
- package/engineering/tools/cadx.lsp +128 -0
- package/engineering/tools/choice_contract.py +61 -0
- package/engineering/tools/conn_state.py +1671 -0
- package/engineering/tools/defense_gate.py +3374 -0
- package/engineering/tools/drawing_input.py +314 -0
- package/engineering/tools/env_check.py +467 -0
- package/engineering/tools/examples/build_axis.py +142 -0
- package/engineering/tools/examples/build_cup.py +56 -0
- package/engineering/tools/examples/build_plate.py +58 -0
- package/engineering/tools/examples/build_sphere.py +30 -0
- package/engineering/tools/examples/build_transition_duct.py +381 -0
- package/engineering/tools/gate_integrity.py +401 -0
- package/engineering/tools/mode2_flow.py +3589 -0
- package/engineering/tools/mode_checker.py +77 -0
- package/engineering/tools/mode_gate.py +8013 -0
- package/engineering/tools/part_registry.py +4266 -0
- package/engineering/tools/physics/__init__.py +50 -0
- package/engineering/tools/physics/_compat.py +12 -0
- package/engineering/tools/physics/design_state.py +142 -0
- package/engineering/tools/physics/domain_validator.py +751 -0
- package/engineering/tools/physics/fatigue.py +489 -0
- package/engineering/tools/physics/fea_solver.py +1163 -0
- package/engineering/tools/physics/feapy_solver.py +314 -0
- package/engineering/tools/physics/gb_materials.json +1228 -0
- package/engineering/tools/physics/geometry_gate.py +337 -0
- package/engineering/tools/physics/load_case.py +951 -0
- package/engineering/tools/physics/material_db.py +874 -0
- package/engineering/tools/physics/mesh_adapter.py +321 -0
- package/engineering/tools/physics/refine_rules.py +272 -0
- package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
- package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
- package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
- package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
- package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
- package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
- package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
- package/engineering/tools/physics/rules/housing_rules.json +48 -0
- package/engineering/tools/physics/rules/manifest.json +54 -0
- package/engineering/tools/physics/rules/mold_rules.json +48 -0
- package/engineering/tools/physics/rules/structural_rules.json +56 -0
- package/engineering/tools/physics/rules/transmission_rules.json +48 -0
- package/engineering/tools/physics/simulation_report.py +829 -0
- package/engineering/tools/physics_bridge.py +2685 -0
- package/engineering/tools/profile-manifest.ps1 +72 -0
- package/engineering/tools/run_regression.py +109 -0
- package/engineering/tools/settings.py +419 -0
- package/engineering/tools/solidworks-modeling.md +519 -0
- package/engineering/tools/sw_bridge.py +8215 -0
- package/engineering/tools/sw_guard.py +1066 -0
- package/engineering/tools/sw_surface.py +87 -0
- package/engineering/tools/swapi.py +14766 -0
- package/engineering/tools/verify_sync.py +218 -0
- package/engineering/tools/visual_check.py +2589 -0
- package/engineering/tools/webots_env.py +121 -0
- package/engineering/tools/webots_mcp/__init__.py +3 -0
- package/engineering/tools/webots_mcp/__main__.py +3 -0
- package/engineering/tools/webots_mcp/assembly.py +391 -0
- package/engineering/tools/webots_mcp/bootstrap.py +55 -0
- package/engineering/tools/webots_mcp/controller_worker.py +124 -0
- package/engineering/tools/webots_mcp/evidence.py +288 -0
- package/engineering/tools/webots_mcp/fake_worker.py +69 -0
- package/engineering/tools/webots_mcp/fault_resources.py +121 -0
- package/engineering/tools/webots_mcp/host.py +45 -0
- package/engineering/tools/webots_mcp/ledger.py +73 -0
- package/engineering/tools/webots_mcp/owned_job.py +70 -0
- package/engineering/tools/webots_mcp/physical_gate.py +187 -0
- package/engineering/tools/webots_mcp/physics.py +152 -0
- package/engineering/tools/webots_mcp/pipeline.py +108 -0
- package/engineering/tools/webots_mcp/recipes.py +129 -0
- package/engineering/tools/webots_mcp/repair.py +132 -0
- package/engineering/tools/webots_mcp/repair_trial.py +375 -0
- package/engineering/tools/webots_mcp/runtime.py +311 -0
- package/engineering/tools/webots_mcp/server.py +217 -0
- package/engineering/tools/webots_mcp/sw_export.py +509 -0
- package/engineering/tools/webots_mcp/transport.py +156 -0
- package/engineering/tools/workflow_gate.py +6134 -0
- package/package.json +64 -4
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
r"""sw_guard.py — SolidWorks 建模期间的轻量级系统健康监控守护脚本。
|
|
3
|
+
|
|
4
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
5
|
+
【为什么要写这个脚本】
|
|
6
|
+
SolidWorks 做大型装配 / 复杂特征建模时会吃满 CPU 与内存,一旦把机器拖到
|
|
7
|
+
换页抖动(thrashing),整机可能长时间无响应,用户连任务管理器都点不开。
|
|
8
|
+
所以需要一个**后台轻量级监控**:CPU 或内存超过阈值就告警,并且**立即关闭
|
|
9
|
+
SolidWorks**,把机器从"卡死"救回来。
|
|
10
|
+
|
|
11
|
+
【硬性约束:绝对不能用 psutil】
|
|
12
|
+
本机系统 Python(3.13)与随包 Python 都**没有安装 psutil**,所以本脚本
|
|
13
|
+
**只依赖标准库**,直接通过 `ctypes` 调 Windows API 取 CPU / 内存:
|
|
14
|
+
|
|
15
|
+
内存 → kernel32.GlobalMemoryStatusEx(MEMORYSTATUSEX 结构体)
|
|
16
|
+
CPU → kernel32.GetSystemTimes(idle / kernel / user 三个 FILETIME,
|
|
17
|
+
用两次采样的增量算占用率)
|
|
18
|
+
|
|
19
|
+
【ctypes 的坑(都已在代码里处理)】
|
|
20
|
+
1. `MEMORYSTATUSEX.dwLength` **必须先赋值** 为 `ctypes.sizeof(结构体)`,
|
|
21
|
+
否则 GlobalMemoryStatusEx 直接返回失败(结构体版本校验不通过)。
|
|
22
|
+
2. `FILETIME` 是 64 位时间,拆成 dwLowDateTime / dwHighDateTime 两个
|
|
23
|
+
32 位字段,**必须自己拼**:`high << 32 | low`。只读低 32 位会得到一个
|
|
24
|
+
每约 7 分钟回绕一次的假数值。
|
|
25
|
+
3. GetSystemTimes 返回的 kernel 时间**已经包含 idle 时间**,所以
|
|
26
|
+
总时间 = kernel + user(不能 idle+kernel+user),忙时间 = 总 - idle。
|
|
27
|
+
4. CPU 占用率是**增量**量,单次调用出不来结果:必须先存一份基线,
|
|
28
|
+
隔一段时间再采一次做差。脚本用 SystemMonitor 持有这个基线。
|
|
29
|
+
5. 调用失败**必须优雅降级**:返回 `{"ok": False, "error": "..."}`,
|
|
30
|
+
绝不抛异常崩溃、也绝不假装读到 0%(0% 会被误判成"机器很闲",
|
|
31
|
+
是比崩溃更危险的错误)。
|
|
32
|
+
|
|
33
|
+
【taskkill 的坑】
|
|
34
|
+
进程本来就不存在时 taskkill 返回**非 0**(实测中文/英文系统均为 128),
|
|
35
|
+
这**不算失败**。必须结合返回码与输出文本区分:
|
|
36
|
+
· 有"没有找到 / not found / No tasks"字样,或返回码 128 → 没进程,视为成功;
|
|
37
|
+
· 其它非 0(如拒绝访问) → 才是真的杀失败。
|
|
38
|
+
|
|
39
|
+
【编码的坑】
|
|
40
|
+
Windows 下 Python 的 stdout 默认按系统 ANSI 代码页编码(简中 = cp936/GBK),
|
|
41
|
+
而调用方(PowerShell / DSH / 管道)按 UTF-8 解码,中文必然乱码。
|
|
42
|
+
所以本脚本在导入时就把 stdout/stderr 切到 UTF-8(见 _setup_utf8),
|
|
43
|
+
与工程内 `_utf8.py` 的做法保持一致,但**不依赖它**,保证本文件可独立运行。
|
|
44
|
+
|
|
45
|
+
【CLI 用法】
|
|
46
|
+
python sw_guard.py run [--interval 10] [--cpu 85] [--mem 85]
|
|
47
|
+
[--log <path>] [--max-ticks N] [--no-kill]
|
|
48
|
+
python sw_guard.py check [--json] # 单次体检,只读不杀
|
|
49
|
+
python sw_guard.py state [--json] # 读状态文件
|
|
50
|
+
python sw_guard.py --once # 等价于 check
|
|
51
|
+
|
|
52
|
+
【状态文件】
|
|
53
|
+
每次采样都把最新状态**原子写入**(先写临时文件再 os.replace)到
|
|
54
|
+
`engineering/tools/load_cases/sw_guard_state.json`,避免外部读到"半个 JSON"。
|
|
55
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
"""
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import argparse
|
|
60
|
+
import ctypes
|
|
61
|
+
import json
|
|
62
|
+
import os
|
|
63
|
+
import subprocess
|
|
64
|
+
import sys
|
|
65
|
+
import time
|
|
66
|
+
from datetime import datetime
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"sample_memory",
|
|
72
|
+
"sample_cpu",
|
|
73
|
+
"SystemMonitor",
|
|
74
|
+
"check_thresholds",
|
|
75
|
+
"kill_solidworks",
|
|
76
|
+
"run",
|
|
77
|
+
"read_state",
|
|
78
|
+
"main",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
# ── 版本与常量 ──────────────────────────────────────────────────────────────
|
|
82
|
+
__version__ = "1.0.0"
|
|
83
|
+
|
|
84
|
+
# 本文件所在目录(engineering/tools),状态文件与默认日志都落在这里
|
|
85
|
+
TOOLS_DIR = Path(__file__).resolve().parent
|
|
86
|
+
LOAD_CASES_DIR = TOOLS_DIR / "load_cases"
|
|
87
|
+
# 状态文件路径:load_cases 若不存在,写入时会自动创建(见 _write_state_atomic)
|
|
88
|
+
STATE_PATH = LOAD_CASES_DIR / "sw_guard_state.json"
|
|
89
|
+
# 默认日志路径:run() 未显式传 log_path 时用它,保证"告警一定留痕"
|
|
90
|
+
DEFAULT_LOG_PATH = LOAD_CASES_DIR / "sw_guard.log"
|
|
91
|
+
|
|
92
|
+
# SolidWorks 主程序映像名;taskkill 用 /IM 按映像名杀,不依赖 PID
|
|
93
|
+
SW_IMAGE_NAME = "SLDWORKS.exe"
|
|
94
|
+
|
|
95
|
+
# alerts 数组上限:状态文件要长期被反复覆写,无上限增长迟早撑爆磁盘 / 拖慢读写
|
|
96
|
+
MAX_ALERTS = 50
|
|
97
|
+
|
|
98
|
+
# taskkill 判定"进程不存在"的关键字(中英双系统都要覆盖)
|
|
99
|
+
_NOT_FOUND_MARKERS = (
|
|
100
|
+
"not found", # 英文:ERROR: The process "X" not found.
|
|
101
|
+
"no tasks", # 英文:INFO: No tasks are running which match ...
|
|
102
|
+
"no running instance",
|
|
103
|
+
"没有找到", # 中文:错误: 没有找到进程 "X"
|
|
104
|
+
"找不到",
|
|
105
|
+
"无法找到",
|
|
106
|
+
"没有运行",
|
|
107
|
+
)
|
|
108
|
+
# 实测得出:taskkill 在"找不到进程"时统一返回 128(中英文系统一致)
|
|
109
|
+
_TASKKILL_NOT_FOUND_RC = 128
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
# 一、控制台编码修复
|
|
114
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
115
|
+
_utf8_ready = False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _setup_utf8(force: bool = False) -> bool:
|
|
119
|
+
"""把 stdout / stderr 切到 UTF-8,保证中文告警不乱码。
|
|
120
|
+
|
|
121
|
+
为什么必须做:Windows 下 Python 的 stdout 默认用系统 ANSI 代码页
|
|
122
|
+
(简体中文机器 = cp936/GBK),而调用方(PowerShell、DSH 工具链、管道)
|
|
123
|
+
按 UTF-8 解码子进程输出。两边编解码不一致 → 中文必然变成乱码
|
|
124
|
+
(典型的 `����ģʽ`)。在 **Python 进程内部** reconfigure 是唯一解,
|
|
125
|
+
外部设置 `[Console]::OutputEncoding` 管不到子进程内部。
|
|
126
|
+
|
|
127
|
+
本函数**幂等且静默失败**:编码修复只是"锦上添花",
|
|
128
|
+
绝不能因为它失败而影响监控主流程。
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
force: True 时即使已修复过也重做一次(供测试断言用)。
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
bool: 当前 stdout 是否处于 UTF-8 状态。
|
|
135
|
+
"""
|
|
136
|
+
global _utf8_ready
|
|
137
|
+
if _utf8_ready and not force:
|
|
138
|
+
return True
|
|
139
|
+
ok = False
|
|
140
|
+
for stream_name in ("stdout", "stderr"):
|
|
141
|
+
stream = getattr(sys, stream_name, None)
|
|
142
|
+
if stream is None:
|
|
143
|
+
continue
|
|
144
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
145
|
+
if reconfigure is not None:
|
|
146
|
+
try:
|
|
147
|
+
# errors="replace":极端情况下用 '?' 顶替无法编码的字符,绝不抛异常
|
|
148
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
149
|
+
ok = True
|
|
150
|
+
continue
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
# 兜底:老版本或被重定向成不支持 reconfigure 的对象,包一层 UTF-8 wrapper
|
|
154
|
+
try:
|
|
155
|
+
buf = getattr(stream, "buffer", None)
|
|
156
|
+
if buf is not None:
|
|
157
|
+
import io
|
|
158
|
+
|
|
159
|
+
setattr(
|
|
160
|
+
sys, stream_name,
|
|
161
|
+
io.TextIOWrapper(buf, encoding="utf-8", errors="replace",
|
|
162
|
+
line_buffering=True),
|
|
163
|
+
)
|
|
164
|
+
ok = True
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
_utf8_ready = ok
|
|
168
|
+
return ok
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _now_str() -> str:
|
|
172
|
+
"""返回本地时间的 `YYYY-MM-DD HH:MM:SS` 字符串(告警行与状态文件统一用它)。"""
|
|
173
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _utf8_out(text: str) -> None:
|
|
177
|
+
"""安全打印:即使 stdout 不是 UTF-8(reconfigure 失败),也绝不因编码崩溃。
|
|
178
|
+
|
|
179
|
+
ctypes / 监控脚本存在的意义是"救机器",绝不能自己先崩掉。
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
print(text, flush=True)
|
|
183
|
+
except UnicodeEncodeError:
|
|
184
|
+
# 极端兜底:退化成 ASCII,只丢中文字形,不丢信息结构
|
|
185
|
+
enc = getattr(sys.stdout, "encoding", None) or "ascii"
|
|
186
|
+
sys.stdout.write(text.encode(enc, "replace").decode(enc, "replace") + "\n")
|
|
187
|
+
sys.stdout.flush()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
191
|
+
# 二、ctypes 结构体与 kernel32 绑定
|
|
192
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
193
|
+
class _FILETIME(ctypes.Structure):
|
|
194
|
+
"""Windows FILETIME 结构:64 位时间被拆成高低两个 32 位 DWORD。
|
|
195
|
+
|
|
196
|
+
坑:ctypes 不会自动帮你合成 64 位,必须手工 `high << 32 | low`。
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
_fields_ = [
|
|
200
|
+
("dwLowDateTime", ctypes.c_uint32),
|
|
201
|
+
("dwHighDateTime", ctypes.c_uint32),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class _MEMORYSTATUSEX(ctypes.Structure):
|
|
206
|
+
"""Windows MEMORYSTATUSEX 结构,供 GlobalMemoryStatusEx 使用。
|
|
207
|
+
|
|
208
|
+
坑:`dwLength` **必须先设为结构体字节大小**,否则 API 认为是未知版本,
|
|
209
|
+
直接返回 0(失败)且不改动任何字段。这是最常见的"读到全 0"原因。
|
|
210
|
+
字段顺序/类型必须与 Windows SDK 完全一致,否则内存布局错位。
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
_fields_ = [
|
|
214
|
+
("dwLength", ctypes.c_uint32),
|
|
215
|
+
("dwMemoryLoad", ctypes.c_uint32),
|
|
216
|
+
("ullTotalPhys", ctypes.c_uint64),
|
|
217
|
+
("ullAvailPhys", ctypes.c_uint64),
|
|
218
|
+
("ullTotalPageFile", ctypes.c_uint64),
|
|
219
|
+
("ullAvailPageFile", ctypes.c_uint64),
|
|
220
|
+
("ullTotalVirtual", ctypes.c_uint64),
|
|
221
|
+
("ullAvailVirtual", ctypes.c_uint64),
|
|
222
|
+
("ullAvailExtendedVirtual", ctypes.c_uint64),
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _load_kernel32() -> Any:
|
|
227
|
+
"""加载 kernel32 并声明函数签名;非 Windows 或加载失败时抛 OSError。
|
|
228
|
+
|
|
229
|
+
为什么要声明 argtypes/restype:
|
|
230
|
+
· 不声明时 ctypes 默认按 c_int 处理返回值,64 位句柄 / BOOL 会出错;
|
|
231
|
+
· 传 byref 指针时没有签名约束容易传错类型,导致 API 静默失败。
|
|
232
|
+
use_last_error=True 才能用 ctypes.get_last_error() 取回 GetLastError,
|
|
233
|
+
用于把失败原因写进 error 字段(而不是只丢一句"失败了")。
|
|
234
|
+
"""
|
|
235
|
+
if sys.platform != "win32":
|
|
236
|
+
raise OSError("当前平台不是 Windows(sys.platform=%s),无法调用 kernel32" % sys.platform)
|
|
237
|
+
last_err = None
|
|
238
|
+
for factory in (lambda: ctypes.WinDLL("kernel32", use_last_error=True),
|
|
239
|
+
lambda: ctypes.windll.kernel32):
|
|
240
|
+
try:
|
|
241
|
+
k32 = factory()
|
|
242
|
+
except Exception as exc: # pragma: no cover - 仅在异常环境触发
|
|
243
|
+
last_err = exc
|
|
244
|
+
continue
|
|
245
|
+
try:
|
|
246
|
+
k32.GlobalMemoryStatusEx.argtypes = [ctypes.POINTER(_MEMORYSTATUSEX)]
|
|
247
|
+
k32.GlobalMemoryStatusEx.restype = ctypes.c_int
|
|
248
|
+
k32.GetSystemTimes.argtypes = [
|
|
249
|
+
ctypes.POINTER(_FILETIME),
|
|
250
|
+
ctypes.POINTER(_FILETIME),
|
|
251
|
+
ctypes.POINTER(_FILETIME),
|
|
252
|
+
]
|
|
253
|
+
k32.GetSystemTimes.restype = ctypes.c_int
|
|
254
|
+
except Exception as exc: # pragma: no cover
|
|
255
|
+
last_err = exc
|
|
256
|
+
continue
|
|
257
|
+
return k32
|
|
258
|
+
raise OSError("无法加载 kernel32:%r" % (last_err,))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _win_error_text(prefix: str) -> str:
|
|
262
|
+
"""把 GetLastError 翻译成可读文本,供 error 字段使用。"""
|
|
263
|
+
try:
|
|
264
|
+
code = ctypes.get_last_error()
|
|
265
|
+
except Exception:
|
|
266
|
+
code = None
|
|
267
|
+
if code:
|
|
268
|
+
try:
|
|
269
|
+
return "%s(GetLastError=%d)" % (prefix, code)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
return prefix
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
276
|
+
# 三、内存采样
|
|
277
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
278
|
+
def sample_memory() -> Dict[str, Any]:
|
|
279
|
+
"""用 GlobalMemoryStatusEx 采一次物理内存使用情况。
|
|
280
|
+
|
|
281
|
+
为什么不用 psutil:环境里根本没装(系统 Python 3.13 与随包 Python 都没有),
|
|
282
|
+
所以直接走 kernel32。
|
|
283
|
+
|
|
284
|
+
为什么 percent 自己算而不是直接用 dwMemoryLoad:
|
|
285
|
+
dwMemoryLoad 是 API 给的**取整整数**,精度只到 1%,用于阈值判定会丢细节;
|
|
286
|
+
这里用 `used / total * 100` 得到浮点值,同时把 dwMemoryLoad 原样放在
|
|
287
|
+
`os_load_pct` 字段里,方便交叉核对两者是否一致。
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
dict: 成功时
|
|
291
|
+
{"ok": True, "total_mb": float, "available_mb": float,
|
|
292
|
+
"used_mb": float, "percent": float, "os_load_pct": float}
|
|
293
|
+
失败时 `{"ok": False, "error": "..."}`(**绝不**返回 0% 冒充成功)。
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
k32 = _load_kernel32()
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
return {"ok": False, "error": "加载 kernel32 失败:%s" % exc}
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
status = _MEMORYSTATUSEX()
|
|
302
|
+
# ★ 关键:先告诉 API 结构体大小,否则调用必定失败
|
|
303
|
+
status.dwLength = ctypes.sizeof(_MEMORYSTATUSEX)
|
|
304
|
+
ok = k32.GlobalMemoryStatusEx(ctypes.byref(status))
|
|
305
|
+
if not ok:
|
|
306
|
+
return {"ok": False, "error": _win_error_text("GlobalMemoryStatusEx 调用失败")}
|
|
307
|
+
|
|
308
|
+
total_bytes = int(status.ullTotalPhys)
|
|
309
|
+
avail_bytes = int(status.ullAvailPhys)
|
|
310
|
+
if total_bytes <= 0:
|
|
311
|
+
# 物理内存总量不可能为 0;出现即说明结构体没被正确填充
|
|
312
|
+
return {"ok": False,
|
|
313
|
+
"error": "GlobalMemoryStatusEx 返回的总内存为 0,结构体可能未正确填充"}
|
|
314
|
+
|
|
315
|
+
used_bytes = max(0, total_bytes - avail_bytes)
|
|
316
|
+
mb = 1024.0 * 1024.0
|
|
317
|
+
percent = used_bytes / total_bytes * 100.0
|
|
318
|
+
return {
|
|
319
|
+
"ok": True,
|
|
320
|
+
"total_mb": round(total_bytes / mb, 2),
|
|
321
|
+
"available_mb": round(avail_bytes / mb, 2),
|
|
322
|
+
"used_mb": round(used_bytes / mb, 2),
|
|
323
|
+
"percent": round(percent, 2),
|
|
324
|
+
"os_load_pct": float(status.dwMemoryLoad),
|
|
325
|
+
}
|
|
326
|
+
except Exception as exc: # 任何 ctypes 层异常都降级,不让监控脚本自杀
|
|
327
|
+
return {"ok": False, "error": "采样内存时发生异常:%r" % (exc,)}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
331
|
+
# 四、CPU 采样(增量法)
|
|
332
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
333
|
+
def _get_system_times() -> Dict[str, Any]:
|
|
334
|
+
"""调一次 GetSystemTimes,返回 idle / kernel / user 三个 64 位计数。
|
|
335
|
+
|
|
336
|
+
★ FILETIME 拼接是这里最大的坑:高低 32 位必须自己合成 64 位,
|
|
337
|
+
只读 dwLowDateTime 会得到一个约 7 分钟回绕一次的毫无意义的数。
|
|
338
|
+
|
|
339
|
+
★ 另一个坑:Windows 的 kernel time **已经包含了 idle time**,
|
|
340
|
+
所以总时间 = kernel + user(不是 idle + kernel + user,那样会重复计入
|
|
341
|
+
idle 导致占用率被系统性低估)。忙时间 = 总时间 - idle。
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
dict: 成功 {"ok": True, "idle": int, "kernel": int, "user": int, "total": int}
|
|
345
|
+
失败 {"ok": False, "error": "..."}
|
|
346
|
+
"""
|
|
347
|
+
try:
|
|
348
|
+
k32 = _load_kernel32()
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
return {"ok": False, "error": "加载 kernel32 失败:%s" % exc}
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
idle, kernel, user = _FILETIME(), _FILETIME(), _FILETIME()
|
|
354
|
+
ok = k32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user))
|
|
355
|
+
if not ok:
|
|
356
|
+
return {"ok": False, "error": _win_error_text("GetSystemTimes 调用失败")}
|
|
357
|
+
|
|
358
|
+
# 64 位拼接:高 32 位左移 32 位后与低 32 位按位或
|
|
359
|
+
idle_v = (int(idle.dwHighDateTime) << 32) | int(idle.dwLowDateTime)
|
|
360
|
+
kernel_v = (int(kernel.dwHighDateTime) << 32) | int(kernel.dwLowDateTime)
|
|
361
|
+
user_v = (int(user.dwHighDateTime) << 32) | int(user.dwLowDateTime)
|
|
362
|
+
# kernel 含 idle,故总时间 = kernel + user
|
|
363
|
+
total_v = kernel_v + user_v
|
|
364
|
+
return {"ok": True, "idle": idle_v, "kernel": kernel_v,
|
|
365
|
+
"user": user_v, "total": total_v}
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
return {"ok": False, "error": "读取系统时间时发生异常:%r" % (exc,)}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class SystemMonitor:
|
|
371
|
+
"""持有 CPU 采样基线的监控器。
|
|
372
|
+
|
|
373
|
+
为什么需要这个类:CPU 占用率是**两个时间点之间的增量**,单次调用
|
|
374
|
+
GetSystemTimes 拿不到任何占比信息。必须有人记住"上一次的三个计数",
|
|
375
|
+
下次采样做差才能算出占用率 —— 这个状态就放在这里。
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def __init__(self, min_window: float = 0.2) -> None:
|
|
379
|
+
"""
|
|
380
|
+
Args:
|
|
381
|
+
min_window: 两次采样之间的**最小**间隔(秒)。间隔太短时
|
|
382
|
+
FILETIME 增量很小,除法的量化误差会被放大(比如 0.001s 内
|
|
383
|
+
增量只有几百个 tick,算出 0% 或 100% 的假值)。
|
|
384
|
+
低于这个窗口时本类会主动 sleep 补足。
|
|
385
|
+
"""
|
|
386
|
+
self.min_window = max(0.05, float(min_window))
|
|
387
|
+
self._last: Optional[Dict[str, int]] = None
|
|
388
|
+
|
|
389
|
+
def reset(self) -> None:
|
|
390
|
+
"""丢弃基线,让下一次 sample_cpu 重新走"首采 + 校准"路径。"""
|
|
391
|
+
self._last = None
|
|
392
|
+
|
|
393
|
+
def sample_cpu(self, min_window: Optional[float] = None) -> Dict[str, Any]:
|
|
394
|
+
"""采样一次 CPU 总占用率(0~100)。
|
|
395
|
+
|
|
396
|
+
★ 首次调用没有基线,无法直接做差。这里**不返回 None / 0**,而是:
|
|
397
|
+
取基线 → 短暂 sleep 一个采样窗口 → 再取一次 → 立刻算出真实占用率,
|
|
398
|
+
并把第二次结果留给下一次调用当基线。
|
|
399
|
+
这样 `--once` / `check` 这种"只调一次"的场景也能拿到**真实数值**,
|
|
400
|
+
而不是一个"尚未初始化"的空壳(那会让体检结果毫无意义)。
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
min_window: 覆盖实例级最小采样窗口(秒);None 表示用实例默认值。
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
dict: 成功 {"ok": True, "percent": float, "window_sec": float}
|
|
407
|
+
失败 {"ok": False, "error": "..."}(阈值判定会跳过该维度)
|
|
408
|
+
"""
|
|
409
|
+
window = self.min_window if min_window is None else max(0.05, float(min_window))
|
|
410
|
+
first = _get_system_times()
|
|
411
|
+
if not first.get("ok"):
|
|
412
|
+
# 采样失败时**保留旧基线**:下次成功即可正常做差,避免反复重新校准
|
|
413
|
+
return {"ok": False, "error": first.get("error", "GetSystemTimes 失败")}
|
|
414
|
+
|
|
415
|
+
if self._last is None:
|
|
416
|
+
# 首采:先建立基线,再等一个窗口,然后立刻做差 —— 保证首次就有真值
|
|
417
|
+
self._last = first
|
|
418
|
+
time.sleep(window)
|
|
419
|
+
second = _get_system_times()
|
|
420
|
+
if not second.get("ok"):
|
|
421
|
+
return {"ok": False, "error": second.get("error", "GetSystemTimes 失败")}
|
|
422
|
+
return self._diff(first, second, window)
|
|
423
|
+
|
|
424
|
+
elapsed = time.monotonic() - getattr(self, "_last_mono", time.monotonic())
|
|
425
|
+
if elapsed < window:
|
|
426
|
+
# 间隔过短会让增量过小、量化误差放大 → 补足到最小窗口再采
|
|
427
|
+
time.sleep(window - elapsed)
|
|
428
|
+
second = _get_system_times()
|
|
429
|
+
if not second.get("ok"):
|
|
430
|
+
return {"ok": False, "error": second.get("error", "GetSystemTimes 失败")}
|
|
431
|
+
return self._diff(self._last, second, window)
|
|
432
|
+
|
|
433
|
+
def _diff(self, prev: Dict[str, int], cur: Dict[str, int],
|
|
434
|
+
window: float) -> Dict[str, Any]:
|
|
435
|
+
"""用两次采样的增量计算 CPU 占用率,并把 cur 存为新基线。
|
|
436
|
+
|
|
437
|
+
公式:占用率 = (总增量 - 空闲增量) / 总增量 × 100%
|
|
438
|
+
"""
|
|
439
|
+
d_total = int(cur["total"]) - int(prev["total"])
|
|
440
|
+
d_idle = int(cur["idle"]) - int(prev["idle"])
|
|
441
|
+
# 更新基线(连同单调时钟,供下次判断窗口是否足够)
|
|
442
|
+
self._last = cur
|
|
443
|
+
self._last_mono = time.monotonic()
|
|
444
|
+
|
|
445
|
+
if d_total <= 0:
|
|
446
|
+
# 计数器没前进:采样过密、时钟回拨、或计数器异常。
|
|
447
|
+
# 绝不能拿它去做除法(ZeroDivisionError / 负占用率),直接报错降级。
|
|
448
|
+
return {"ok": False,
|
|
449
|
+
"error": "两次采样之间系统时间增量非正(d_total=%d),可能采样过密" % d_total}
|
|
450
|
+
|
|
451
|
+
busy = d_total - d_idle
|
|
452
|
+
percent = busy / d_total * 100.0
|
|
453
|
+
# 夹到 [0, 100]:极端竞态下可能略微越界,夹紧避免出现 "-0.3%" 这种怪值
|
|
454
|
+
percent = min(100.0, max(0.0, percent))
|
|
455
|
+
return {"ok": True, "percent": round(percent, 2),
|
|
456
|
+
"window_sec": round(window, 3)}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def sample_cpu(monitor: Optional[SystemMonitor] = None,
|
|
460
|
+
min_window: float = 0.5) -> Dict[str, Any]:
|
|
461
|
+
"""模块级便利函数:采一次 CPU 占用率。
|
|
462
|
+
|
|
463
|
+
有传 monitor 就复用它的基线(连续监控场景,开销最小);
|
|
464
|
+
没传就临时建一个(单次体检场景,会自动做一次校准窗口)。
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
monitor: 可复用的 SystemMonitor;None 时内部临时创建。
|
|
468
|
+
min_window: 最小采样窗口(秒);单次体检默认 0.5s,比 0.2s 更稳。
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
dict: {"ok": bool, "percent": float} 或 {"ok": False, "error": "..."}
|
|
472
|
+
"""
|
|
473
|
+
mon = monitor if monitor is not None else SystemMonitor(min_window=min_window)
|
|
474
|
+
return mon.sample_cpu(min_window if monitor is None else None)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
478
|
+
# 五、阈值判定
|
|
479
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
480
|
+
def check_thresholds(cpu_pct: Optional[float], mem_pct: Optional[float],
|
|
481
|
+
cpu_limit: float = 85.0, mem_limit: float = 85.0) -> Dict[str, Any]:
|
|
482
|
+
"""判断 CPU / 内存是否越过阈值,并生成**中文**告警原因。
|
|
483
|
+
|
|
484
|
+
设计要点:
|
|
485
|
+
· 用**严格大于**(`>`)而不是 `>=`:阈值 85.0 表示"超过 85 才告警",
|
|
486
|
+
恰好 85.0 时不该误杀 SolidWorks。
|
|
487
|
+
· 某维度为 None(该次采样失败)时**不判定为超标**,并在 `errors` 里
|
|
488
|
+
留痕。因为"读不到数据"和"机器很闲"是两回事:宁可漏报一次,
|
|
489
|
+
也不能因为 API 失败就误杀用户的建模成果。
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
cpu_pct: CPU 占用率(%);None 表示该维度采样失败。
|
|
493
|
+
mem_pct: 内存占用率(%);None 表示该维度采样失败。
|
|
494
|
+
cpu_limit: CPU 阈值(%)。
|
|
495
|
+
mem_limit: 内存阈值(%)。
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
dict: {"breach": bool, "reasons": [中文原因...], "cpu_pct": ...,
|
|
499
|
+
"mem_pct": ..., "cpu_limit": ..., "mem_limit": ..., "errors": [...]}
|
|
500
|
+
"""
|
|
501
|
+
reasons: List[str] = []
|
|
502
|
+
errors: List[str] = []
|
|
503
|
+
|
|
504
|
+
if cpu_pct is None:
|
|
505
|
+
errors.append("CPU 采样失败,本次跳过 CPU 阈值判定")
|
|
506
|
+
elif float(cpu_pct) > float(cpu_limit):
|
|
507
|
+
# 文案与需求一致:CPU 占用 91.2% 超过阈值 85.0%
|
|
508
|
+
reasons.append("CPU 占用 %.1f%% 超过阈值 %.1f%%" % (float(cpu_pct), float(cpu_limit)))
|
|
509
|
+
|
|
510
|
+
if mem_pct is None:
|
|
511
|
+
errors.append("内存采样失败,本次跳过内存阈值判定")
|
|
512
|
+
elif float(mem_pct) > float(mem_limit):
|
|
513
|
+
reasons.append("内存占用 %.1f%% 超过阈值 %.1f%%" % (float(mem_pct), float(mem_limit)))
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
"breach": len(reasons) > 0,
|
|
517
|
+
"reasons": reasons,
|
|
518
|
+
"cpu_pct": None if cpu_pct is None else round(float(cpu_pct), 2),
|
|
519
|
+
"mem_pct": None if mem_pct is None else round(float(mem_pct), 2),
|
|
520
|
+
"cpu_limit": float(cpu_limit),
|
|
521
|
+
"mem_limit": float(mem_limit),
|
|
522
|
+
"errors": errors,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
527
|
+
# 六、关闭 SolidWorks
|
|
528
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
529
|
+
def kill_solidworks(image_name: str = SW_IMAGE_NAME, timeout: float = 15.0) -> Dict[str, Any]:
|
|
530
|
+
"""用 taskkill 强制结束 SolidWorks 进程。
|
|
531
|
+
|
|
532
|
+
★ 最大的坑:进程本来就不存在时 taskkill **返回非 0**(实测 = 128),
|
|
533
|
+
这**不是错误**。如果按"非 0 即失败"处理,监控会天天误报"关闭失败",
|
|
534
|
+
用户就会开始忽略告警 —— 那这个守护脚本等于废了。
|
|
535
|
+
所以这里严格区分三种结果:
|
|
536
|
+
1. 返回码 0 → 真的杀掉了,method="taskkill"
|
|
537
|
+
2. 输出含"没有找到/not found" → 本来就没运行,method="not_running",
|
|
538
|
+
依然算 **ok=True**(因为期望状态"SW 已关闭"已经满足)
|
|
539
|
+
3. 其它非 0(如拒绝访问/权限不足)→ 才是真的失败,ok=False
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
image_name: 映像名,默认 SLDWORKS.exe(按映像名杀,不依赖 PID)。
|
|
543
|
+
timeout: 子进程超时(秒)。卡死时不能让守护脚本跟着一起卡死,
|
|
544
|
+
否则机器真被拖死时它反而帮不上忙。
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
dict: 成功 {"ok": True, "method": "taskkill"|"not_running", ...}
|
|
548
|
+
失败 {"ok": False, "method": "taskkill", "error": "...", ...}
|
|
549
|
+
"""
|
|
550
|
+
cmd = ["taskkill", "/F", "/IM", image_name]
|
|
551
|
+
try:
|
|
552
|
+
# errors="replace":taskkill 的输出按系统 ANSI 代码页解码,
|
|
553
|
+
# 万一遇到不可解码字节会抛 UnicodeDecodeError 把守护脚本打崩,
|
|
554
|
+
# 这里用 replace 兜底(结果只是个别字符变成 '?',不影响关键字匹配)。
|
|
555
|
+
proc = subprocess.run(cmd, capture_output=True, text=True,
|
|
556
|
+
timeout=timeout, errors="replace")
|
|
557
|
+
except FileNotFoundError:
|
|
558
|
+
return {"ok": False, "method": "taskkill",
|
|
559
|
+
"error": "找不到 taskkill 命令(系统环境异常)", "output": ""}
|
|
560
|
+
except subprocess.TimeoutExpired:
|
|
561
|
+
return {"ok": False, "method": "taskkill",
|
|
562
|
+
"error": "taskkill 执行超时(%.0f 秒),可能系统已严重卡顿" % timeout,
|
|
563
|
+
"output": ""}
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
return {"ok": False, "method": "taskkill",
|
|
566
|
+
"error": "调用 taskkill 时发生异常:%r" % (exc,), "output": ""}
|
|
567
|
+
|
|
568
|
+
rc = proc.returncode
|
|
569
|
+
# stdout 与 stderr 都看:不同 Windows 版本把提示写在不同的流里
|
|
570
|
+
output = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
|
571
|
+
low = output.lower()
|
|
572
|
+
|
|
573
|
+
if rc == 0:
|
|
574
|
+
# note 里用真实的 image_name 而不是写死 "SolidWorks":
|
|
575
|
+
# 本函数的 image_name 是可覆盖的(测试/复用其它进程时),写死会撒谎
|
|
576
|
+
return {"ok": True, "method": "taskkill", "returncode": rc,
|
|
577
|
+
"output": output, "note": "已强制结束进程 %s" % image_name}
|
|
578
|
+
|
|
579
|
+
# 非 0:先判断是不是"本来就没在运行"
|
|
580
|
+
matched = [m for m in _NOT_FOUND_MARKERS if (m in low if m.isascii() else m in output)]
|
|
581
|
+
if matched or rc == _TASKKILL_NOT_FOUND_RC:
|
|
582
|
+
# rc==128 是 taskkill 的"未找到进程"专用返回码,即便文案被本地化到
|
|
583
|
+
# 认不出来,也能靠返回码兜住(matched 为空时在 note 里说明是码判定)
|
|
584
|
+
how = "输出关键字:%s" % "/".join(matched) if matched else "返回码 %d" % rc
|
|
585
|
+
return {"ok": True, "method": "not_running", "returncode": rc, "output": output,
|
|
586
|
+
"note": "SolidWorks 未在运行(%s)" % how}
|
|
587
|
+
|
|
588
|
+
return {"ok": False, "method": "taskkill", "returncode": rc, "output": output,
|
|
589
|
+
"error": "taskkill 关闭 SolidWorks 失败(返回码 %d):%s" % (rc, output or "无输出")}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
593
|
+
# 七、状态文件与日志(原子写)
|
|
594
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
595
|
+
def _write_state_atomic(state: Dict[str, Any], path: Path = STATE_PATH) -> Dict[str, Any]:
|
|
596
|
+
"""把状态**原子地**写入 JSON 文件。
|
|
597
|
+
|
|
598
|
+
为什么要原子写:外部脚本(或用户)随时可能读这个文件。如果直接
|
|
599
|
+
`open(path,"w")` 覆盖,读到"写了一半"的 JSON 就会解析失败。
|
|
600
|
+
做法是:写同目录临时文件 → flush + fsync 落盘 → `os.replace` 原子替换。
|
|
601
|
+
`os.replace` 在同一卷上是原子操作,读方要么看到旧内容、要么看到新内容,
|
|
602
|
+
绝不会看到半个文件。
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
dict: {"ok": bool, "path": str} 或 {"ok": False, "error": "..."}
|
|
606
|
+
"""
|
|
607
|
+
try:
|
|
608
|
+
path = Path(path)
|
|
609
|
+
path.parent.mkdir(parents=True, exist_ok=True) # load_cases 不存在就建
|
|
610
|
+
# 临时文件必须与目标**同目录**,否则 os.replace 可能跨卷而失去原子性
|
|
611
|
+
tmp = path.with_name(path.name + ".tmp%d" % os.getpid())
|
|
612
|
+
try:
|
|
613
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
614
|
+
json.dump(state, fh, ensure_ascii=False, indent=2)
|
|
615
|
+
fh.flush()
|
|
616
|
+
os.fsync(fh.fileno()) # 强制落盘,避免掉电/崩溃后留下空文件
|
|
617
|
+
os.replace(tmp, path) # ★ 原子替换
|
|
618
|
+
finally:
|
|
619
|
+
# 替换成功后 tmp 已不存在;失败时把残留临时文件清掉,别留垃圾
|
|
620
|
+
if tmp.exists():
|
|
621
|
+
try:
|
|
622
|
+
tmp.unlink()
|
|
623
|
+
except OSError:
|
|
624
|
+
pass
|
|
625
|
+
return {"ok": True, "path": str(path)}
|
|
626
|
+
except Exception as exc:
|
|
627
|
+
return {"ok": False, "error": "写状态文件失败:%r" % (exc,), "path": str(path)}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def read_state(path: Path = STATE_PATH, retries: int = 3,
|
|
631
|
+
retry_delay: float = 0.05) -> Dict[str, Any]:
|
|
632
|
+
"""读取状态文件。
|
|
633
|
+
|
|
634
|
+
★ Windows 特有的坑(并发压力测试实测到):写方执行 `os.replace` 的那一瞬间,
|
|
635
|
+
读方 open() 可能撞上**共享冲突**并抛 `PermissionError`。
|
|
636
|
+
注意这**不是**原子写失效 —— 实测 29612 次并发读里 JSON 解析失败 0 次
|
|
637
|
+
(永远不会读到半个文件),只有极少数撞上这个共享冲突。
|
|
638
|
+
实际使用中状态文件约 10 秒才写一次,概率极低,但这里仍做几次短重试,
|
|
639
|
+
让偶发的瞬时冲突自愈,而不是把一次可恢复的抖动报成"状态文件读不了"。
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
path: 状态文件路径。
|
|
643
|
+
retries: 最大尝试次数(含首次)。
|
|
644
|
+
retry_delay: 每次重试前的等待秒数。
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
dict: 成功时是状态内容本身(额外补一个 "_path" 便于排查);
|
|
648
|
+
失败时 {"ok": False, "error": "..."}。
|
|
649
|
+
"""
|
|
650
|
+
path = Path(path)
|
|
651
|
+
if not path.exists():
|
|
652
|
+
return {"ok": False, "error": "状态文件不存在:%s" % path, "path": str(path)}
|
|
653
|
+
|
|
654
|
+
last_exc: Optional[BaseException] = None
|
|
655
|
+
for attempt in range(max(1, int(retries))):
|
|
656
|
+
try:
|
|
657
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
658
|
+
data = json.load(fh)
|
|
659
|
+
if not isinstance(data, dict):
|
|
660
|
+
return {"ok": False, "error": "状态文件内容不是 JSON 对象", "path": str(path)}
|
|
661
|
+
data.setdefault("ok", True)
|
|
662
|
+
data["_path"] = str(path)
|
|
663
|
+
return data
|
|
664
|
+
except PermissionError as exc:
|
|
665
|
+
# 只对共享冲突重试;JSON 损坏等其它错误重试也没用,直接跳出
|
|
666
|
+
last_exc = exc
|
|
667
|
+
if attempt + 1 < max(1, int(retries)):
|
|
668
|
+
time.sleep(retry_delay)
|
|
669
|
+
continue
|
|
670
|
+
break
|
|
671
|
+
except Exception as exc:
|
|
672
|
+
last_exc = exc
|
|
673
|
+
break
|
|
674
|
+
|
|
675
|
+
return {"ok": False, "error": "读取状态文件失败:%r" % (last_exc,), "path": str(path)}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _append_log(log_path: Optional[Path], record: Dict[str, Any]) -> None:
|
|
679
|
+
"""把一条告警以 JSONL 形式追加进日志文件(一行一条,便于机器解析)。
|
|
680
|
+
|
|
681
|
+
日志写入失败**绝不影响主流程**:告警的首要任务是关掉 SolidWorks,
|
|
682
|
+
磁盘满/权限不足不该成为放弃救援的理由。
|
|
683
|
+
"""
|
|
684
|
+
if not log_path:
|
|
685
|
+
return
|
|
686
|
+
try:
|
|
687
|
+
log_path = Path(log_path)
|
|
688
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
689
|
+
with open(log_path, "a", encoding="utf-8") as fh:
|
|
690
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
691
|
+
except Exception:
|
|
692
|
+
pass
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _trim_alerts(alerts: List[Dict[str, Any]], limit: int = MAX_ALERTS) -> List[Dict[str, Any]]:
|
|
696
|
+
"""只保留最近 limit 条告警。
|
|
697
|
+
|
|
698
|
+
为什么必须裁剪:状态文件每 10 秒重写一次,如果 alerts 无限追加,
|
|
699
|
+
这个文件会无限膨胀(一天就是 8640 条),既拖慢每次写入,
|
|
700
|
+
又可能把磁盘吃光 —— 一个"防拖死机器"的工具反而拖死磁盘就很讽刺。
|
|
701
|
+
保留 50 条足够复盘最近的异常。
|
|
702
|
+
"""
|
|
703
|
+
if len(alerts) <= limit:
|
|
704
|
+
return alerts
|
|
705
|
+
return alerts[-limit:]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
709
|
+
# 八、主循环
|
|
710
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
711
|
+
def run(interval: float = 10.0, cpu_limit: float = 85.0, mem_limit: float = 85.0,
|
|
712
|
+
log_path: Optional[Any] = None, on_breach: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
|
713
|
+
max_ticks: Optional[int] = None, stop_after_kill: bool = True,
|
|
714
|
+
state_path: Optional[Any] = None, kill_enabled: bool = True,
|
|
715
|
+
startup_delay: float = 0.0) -> Dict[str, Any]:
|
|
716
|
+
"""后台健康监控主循环:每 interval 秒采样一次,超阈值就告警并关闭 SolidWorks。
|
|
717
|
+
|
|
718
|
+
流程(每个 tick):
|
|
719
|
+
采样 CPU / 内存 → check_thresholds 判定
|
|
720
|
+
├─ 未超阈值:静默(只更新状态文件)
|
|
721
|
+
└─ 超阈值 :打印中文告警 → 写日志 → 记入 alerts →
|
|
722
|
+
立即 kill_solidworks() → 默认**退出循环**
|
|
723
|
+
→ 原子写状态文件 → sleep(interval)
|
|
724
|
+
|
|
725
|
+
★ 为什么杀完默认退出:SolidWorks 已经被关掉了,继续监控下去没有意义
|
|
726
|
+
(不会有新的 SW 负载),反而让一个后台进程白占资源。
|
|
727
|
+
需要继续观察(比如用户会立刻重启 SW)时传 `stop_after_kill=False`。
|
|
728
|
+
★ 状态文件**每个 tick 都写**(不只是告警时写),这样外部随时能通过
|
|
729
|
+
updated_at 判断"守护脚本还活着吗",而不是等出事才发现它早挂了。
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
interval: 采样间隔(秒),默认 10。
|
|
733
|
+
cpu_limit: CPU 阈值(%),默认 85。
|
|
734
|
+
mem_limit: 内存阈值(%),默认 85。
|
|
735
|
+
log_path: 日志文件路径;None 时用默认 `load_cases/sw_guard.log`。
|
|
736
|
+
on_breach: 告警回调,签名 `on_breach(alert_dict) -> Any`;
|
|
737
|
+
alert_dict 含 time / reasons / cpu_pct / mem_pct / kill 结果。
|
|
738
|
+
回调内部异常会被吞掉,绝不让用户代码打断救援流程。
|
|
739
|
+
max_ticks: 最多跑多少个 tick(测试用);None 表示无限跑。
|
|
740
|
+
stop_after_kill: 杀死 SolidWorks 后是否退出主循环,默认 True。
|
|
741
|
+
state_path: 状态文件路径;None 时用默认 load_cases/sw_guard_state.json。
|
|
742
|
+
kill_enabled: False 时只告警不杀(演练/测试模式,对应 CLI 的 --no-kill)。
|
|
743
|
+
startup_delay: 循环开始前的等待秒数(0 表示立即开始)。
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
dict: {"ok": bool, "ticks": int, "alerts": [...], "killed": bool,
|
|
747
|
+
"last": {...}, "state_path": ..., "log_path": ...,
|
|
748
|
+
"started_at": ..., "ended_at": ..., "stopped_reason": ...}
|
|
749
|
+
"""
|
|
750
|
+
monitor = SystemMonitor()
|
|
751
|
+
state_file = Path(state_path) if state_path else STATE_PATH
|
|
752
|
+
# 未显式给日志路径时用默认路径:需求要求"超阈值必须写日志文件",
|
|
753
|
+
# 默认落空会让告警无迹可查,所以这里给一个合理的默认值。
|
|
754
|
+
log_file = Path(log_path) if log_path else DEFAULT_LOG_PATH
|
|
755
|
+
|
|
756
|
+
started_at = _now_str()
|
|
757
|
+
alerts: List[Dict[str, Any]] = []
|
|
758
|
+
killed = False
|
|
759
|
+
last: Dict[str, Any] = {}
|
|
760
|
+
tick = 0
|
|
761
|
+
stopped_reason = "completed"
|
|
762
|
+
fatal_error: Optional[str] = None
|
|
763
|
+
|
|
764
|
+
# 初始状态先落一次盘:让"守护已启动"这件事立刻可观测
|
|
765
|
+
state: Dict[str, Any] = {
|
|
766
|
+
"ok": True,
|
|
767
|
+
"pid": os.getpid(),
|
|
768
|
+
"started_at": started_at,
|
|
769
|
+
"updated_at": started_at,
|
|
770
|
+
"ticks": 0,
|
|
771
|
+
"interval": interval,
|
|
772
|
+
"cpu_limit": cpu_limit,
|
|
773
|
+
"mem_limit": mem_limit,
|
|
774
|
+
"kill_enabled": kill_enabled,
|
|
775
|
+
"stop_after_kill": stop_after_kill,
|
|
776
|
+
"last": {},
|
|
777
|
+
"alerts": [],
|
|
778
|
+
"killed": False,
|
|
779
|
+
"version": __version__,
|
|
780
|
+
}
|
|
781
|
+
_write_state_atomic(state, state_file)
|
|
782
|
+
|
|
783
|
+
if startup_delay and startup_delay > 0:
|
|
784
|
+
time.sleep(startup_delay)
|
|
785
|
+
|
|
786
|
+
try:
|
|
787
|
+
while True:
|
|
788
|
+
if max_ticks is not None and tick >= int(max_ticks):
|
|
789
|
+
stopped_reason = "max_ticks"
|
|
790
|
+
break
|
|
791
|
+
tick += 1
|
|
792
|
+
|
|
793
|
+
cpu = monitor.sample_cpu()
|
|
794
|
+
mem = sample_memory()
|
|
795
|
+
cpu_pct = cpu.get("percent") if cpu.get("ok") else None
|
|
796
|
+
mem_pct = mem.get("percent") if mem.get("ok") else None
|
|
797
|
+
verdict = check_thresholds(cpu_pct, mem_pct, cpu_limit, mem_limit)
|
|
798
|
+
|
|
799
|
+
last = {
|
|
800
|
+
"tick": tick,
|
|
801
|
+
"time": _now_str(),
|
|
802
|
+
"cpu_pct": verdict["cpu_pct"],
|
|
803
|
+
"mem_pct": verdict["mem_pct"],
|
|
804
|
+
"cpu_ok": bool(cpu.get("ok")),
|
|
805
|
+
"mem_ok": bool(mem.get("ok")),
|
|
806
|
+
"cpu_error": cpu.get("error"),
|
|
807
|
+
"mem_error": mem.get("error"),
|
|
808
|
+
"total_mb": mem.get("total_mb"),
|
|
809
|
+
"available_mb": mem.get("available_mb"),
|
|
810
|
+
"used_mb": mem.get("used_mb"),
|
|
811
|
+
"breach": verdict["breach"],
|
|
812
|
+
"reasons": verdict["reasons"],
|
|
813
|
+
"errors": verdict["errors"],
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
kill_result: Optional[Dict[str, Any]] = None
|
|
817
|
+
if verdict["breach"]:
|
|
818
|
+
# ── 告警行格式与需求一致:
|
|
819
|
+
# [sw-guard][ALERT] 2026-10-12 22:30:00 CPU 占用 91.2% 超过阈值 85.0% → 立即关闭 SolidWorks
|
|
820
|
+
reason_text = ";".join(verdict["reasons"])
|
|
821
|
+
tail = "立即关闭 SolidWorks" if kill_enabled else "演练模式(--no-kill)不执行关闭"
|
|
822
|
+
message = "[sw-guard][ALERT] %s %s → %s" % (last["time"], reason_text, tail)
|
|
823
|
+
_utf8_out(message)
|
|
824
|
+
|
|
825
|
+
if kill_enabled:
|
|
826
|
+
# 超阈值后**立即**关闭:这里不加任何 sleep,
|
|
827
|
+
# 机器已经在换页抖动,每一秒的延迟都可能让 GUI 彻底失去响应。
|
|
828
|
+
kill_result = kill_solidworks()
|
|
829
|
+
if kill_result.get("ok") and kill_result.get("method") == "taskkill":
|
|
830
|
+
killed = True # 只有真的杀掉了才算 killed
|
|
831
|
+
else:
|
|
832
|
+
kill_result = {"ok": True, "method": "skipped",
|
|
833
|
+
"note": "演练模式:未执行 taskkill"}
|
|
834
|
+
alert = {
|
|
835
|
+
"time": last["time"],
|
|
836
|
+
"tick": tick,
|
|
837
|
+
"reasons": list(verdict["reasons"]),
|
|
838
|
+
"cpu_pct": verdict["cpu_pct"],
|
|
839
|
+
"mem_pct": verdict["mem_pct"],
|
|
840
|
+
"message": message,
|
|
841
|
+
"kill": kill_result,
|
|
842
|
+
}
|
|
843
|
+
alerts.append(alert)
|
|
844
|
+
alerts = _trim_alerts(alerts) # ★ 防止 alerts 无限增长
|
|
845
|
+
last["kill"] = kill_result
|
|
846
|
+
|
|
847
|
+
# 日志落盘:JSONL,含完整关停结果,便于事后复盘
|
|
848
|
+
_append_log(log_file, {"time": last["time"], "level": "ALERT",
|
|
849
|
+
"pid": os.getpid(), "tick": tick,
|
|
850
|
+
"cpu_pct": verdict["cpu_pct"],
|
|
851
|
+
"mem_pct": verdict["mem_pct"],
|
|
852
|
+
"reasons": verdict["reasons"],
|
|
853
|
+
"kill": kill_result})
|
|
854
|
+
|
|
855
|
+
# 用户回调:独立 try/except,回调写错不能连累救援
|
|
856
|
+
if on_breach is not None:
|
|
857
|
+
try:
|
|
858
|
+
on_breach(alert)
|
|
859
|
+
except Exception as exc:
|
|
860
|
+
_utf8_out("[sw-guard][WARN] on_breach 回调异常(已忽略):%r" % (exc,))
|
|
861
|
+
|
|
862
|
+
state.update({"ticks": tick, "last": last, "alerts": alerts,
|
|
863
|
+
"killed": killed, "updated_at": last["time"]})
|
|
864
|
+
_write_state_atomic(state, state_file)
|
|
865
|
+
|
|
866
|
+
if stop_after_kill and kill_enabled:
|
|
867
|
+
# ── 【真实缺陷修复】只有【真的杀掉了 SW】才允许退出循环 ──────
|
|
868
|
+
# 现场实测:内存出现一次瞬时尖峰(95.77%),但当时 SolidWorks
|
|
869
|
+
# **根本没在运行**(method="not_running"),看护却照样退出。
|
|
870
|
+
# 后果:等到用户真的开始建模时,**已经没有任何守护进程**了 ——
|
|
871
|
+
# 保护在最需要它的时刻恰好缺席。
|
|
872
|
+
# 现在只有 method == "taskkill"(确实执行了击杀)才退出;
|
|
873
|
+
# SW 不在运行 / 击杀失败 / 未找到进程 → 继续守护,
|
|
874
|
+
# 等 SW 真的起来时仍然有人看着。
|
|
875
|
+
if killed:
|
|
876
|
+
stopped_reason = "killed"
|
|
877
|
+
break
|
|
878
|
+
if tick >= 3:
|
|
879
|
+
# 连续多次只在"没 SW 可杀"的情况下超阈值 → 说明是别的东西
|
|
880
|
+
# 在吃资源(编译/浏览器等),继续空转没有意义,但必须
|
|
881
|
+
# 明确标注原因,避免看起来像"守护正常结束"。
|
|
882
|
+
stopped_reason = "breach_but_sw_not_running"
|
|
883
|
+
break
|
|
884
|
+
stopped_reason = "breach_sw_not_running_continue"
|
|
885
|
+
|
|
886
|
+
# 每个 tick 都更新状态文件(含未告警的 tick),保证"存活可观测"
|
|
887
|
+
state.update({"ticks": tick, "last": last, "alerts": alerts,
|
|
888
|
+
"killed": killed, "updated_at": last["time"],
|
|
889
|
+
"ok": True})
|
|
890
|
+
_write_state_atomic(state, state_file)
|
|
891
|
+
|
|
892
|
+
if max_ticks is not None and tick >= int(max_ticks):
|
|
893
|
+
stopped_reason = "max_ticks"
|
|
894
|
+
break
|
|
895
|
+
# 放在循环末尾 sleep:跑满 max_ticks 时不会多等一个 interval
|
|
896
|
+
time.sleep(max(0.0, float(interval)))
|
|
897
|
+
except KeyboardInterrupt:
|
|
898
|
+
# Ctrl+C 属于正常的人工停止,不是错误
|
|
899
|
+
stopped_reason = "interrupted"
|
|
900
|
+
_utf8_out("[sw-guard] 收到中断信号(Ctrl+C),已停止监控。")
|
|
901
|
+
except Exception as exc: # 兜底:任何未预期异常都转成结构化结果,不裸抛
|
|
902
|
+
stopped_reason = "error"
|
|
903
|
+
fatal_error = repr(exc)
|
|
904
|
+
_utf8_out("[sw-guard][ERROR] 主循环异常终止:%r" % (exc,))
|
|
905
|
+
|
|
906
|
+
ended_at = _now_str()
|
|
907
|
+
state.update({"ticks": tick, "last": last, "alerts": alerts,
|
|
908
|
+
"killed": killed, "updated_at": ended_at,
|
|
909
|
+
"stopped_reason": stopped_reason, "ok": fatal_error is None})
|
|
910
|
+
_write_state_atomic(state, state_file)
|
|
911
|
+
|
|
912
|
+
summary: Dict[str, Any] = {
|
|
913
|
+
"ok": fatal_error is None,
|
|
914
|
+
"ticks": tick,
|
|
915
|
+
"alerts": alerts,
|
|
916
|
+
"killed": killed,
|
|
917
|
+
"last": last,
|
|
918
|
+
"stopped_reason": stopped_reason,
|
|
919
|
+
"started_at": started_at,
|
|
920
|
+
"ended_at": ended_at,
|
|
921
|
+
"interval": interval,
|
|
922
|
+
"cpu_limit": cpu_limit,
|
|
923
|
+
"mem_limit": mem_limit,
|
|
924
|
+
"kill_enabled": kill_enabled,
|
|
925
|
+
"stop_after_kill": stop_after_kill,
|
|
926
|
+
"state_path": str(state_file),
|
|
927
|
+
"log_path": str(log_file) if log_file else None,
|
|
928
|
+
}
|
|
929
|
+
if fatal_error:
|
|
930
|
+
summary["error"] = fatal_error
|
|
931
|
+
return summary
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
935
|
+
# 九、CLI
|
|
936
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
937
|
+
def _print_json(obj: Any) -> None:
|
|
938
|
+
"""统一 JSON 输出:ensure_ascii=False + indent=2,保证中文直接可读。"""
|
|
939
|
+
_utf8_out(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _health_check(state_path: Path = STATE_PATH,
|
|
943
|
+
window: float = 0.5) -> Dict[str, Any]:
|
|
944
|
+
"""做一次即时体检:采 CPU + 内存,给出阈值判定,**不杀任何进程**。"""
|
|
945
|
+
monitor = SystemMonitor(min_window=window)
|
|
946
|
+
cpu = monitor.sample_cpu(window)
|
|
947
|
+
mem = sample_memory()
|
|
948
|
+
cpu_pct = cpu.get("percent") if cpu.get("ok") else None
|
|
949
|
+
mem_pct = mem.get("percent") if mem.get("ok") else None
|
|
950
|
+
verdict = check_thresholds(cpu_pct, mem_pct)
|
|
951
|
+
return {
|
|
952
|
+
"ok": bool(cpu.get("ok") and mem.get("ok")),
|
|
953
|
+
"time": _now_str(),
|
|
954
|
+
"pid": os.getpid(),
|
|
955
|
+
"cpu": cpu,
|
|
956
|
+
"memory": mem,
|
|
957
|
+
"verdict": verdict,
|
|
958
|
+
"state_file": str(state_path),
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
963
|
+
"""处理 `run` 子命令:启动监控循环。"""
|
|
964
|
+
max_ticks = 1 if getattr(args, "once", False) else args.max_ticks
|
|
965
|
+
# --no-kill = 演练模式:只告警、不执行 taskkill。
|
|
966
|
+
# 名字直译就是"不杀",所以它关掉的是"杀"这个动作本身,
|
|
967
|
+
# 而不是"杀完之后是否退出循环"(后者由 --continue-after-kill 控制)。
|
|
968
|
+
kill_enabled = not getattr(args, "no_kill", False)
|
|
969
|
+
|
|
970
|
+
summary = run(
|
|
971
|
+
interval=args.interval,
|
|
972
|
+
cpu_limit=args.cpu,
|
|
973
|
+
mem_limit=args.mem,
|
|
974
|
+
log_path=args.log,
|
|
975
|
+
max_ticks=max_ticks,
|
|
976
|
+
stop_after_kill=not getattr(args, "continue_after_kill", False),
|
|
977
|
+
kill_enabled=kill_enabled,
|
|
978
|
+
)
|
|
979
|
+
_print_json(summary)
|
|
980
|
+
return 0 if summary.get("ok") else 1
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
984
|
+
"""处理 `check` 子命令:单次体检,只读不杀。
|
|
985
|
+
|
|
986
|
+
无论是否带 --json 都输出 JSON(需求要求所有输出走 json.dumps 且中文可读);
|
|
987
|
+
--json 开关保留是为了让调用方显式声明"我要机器可解析的输出"。
|
|
988
|
+
"""
|
|
989
|
+
result = _health_check()
|
|
990
|
+
_print_json(result)
|
|
991
|
+
return 0 if result.get("ok") else 1
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _cmd_state(args: argparse.Namespace) -> int:
|
|
995
|
+
"""处理 `state` 子命令:读并打印状态文件。"""
|
|
996
|
+
data = read_state()
|
|
997
|
+
_print_json(data)
|
|
998
|
+
if data.get("ok"):
|
|
999
|
+
return 0
|
|
1000
|
+
return 2 # 状态文件不存在 / 损坏,用独立退出码区分于"采样失败"
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1004
|
+
"""构建 argparse 解析器(三个子命令:run / check / state)。"""
|
|
1005
|
+
parser = argparse.ArgumentParser(
|
|
1006
|
+
prog="sw_guard.py",
|
|
1007
|
+
description="SolidWorks 建模健康监控:CPU/内存超阈值即告警并关闭 SolidWorks。",
|
|
1008
|
+
)
|
|
1009
|
+
parser.add_argument("--version", action="version", version="sw_guard %s" % __version__)
|
|
1010
|
+
# 顶层 --once:等价于 check,便于脚本一句命令做即时体检
|
|
1011
|
+
parser.add_argument("--once", action="store_true",
|
|
1012
|
+
help="单次采样模式(等价于 check),便于脚本调用做即时体检")
|
|
1013
|
+
|
|
1014
|
+
sub = parser.add_subparsers(dest="command")
|
|
1015
|
+
|
|
1016
|
+
p_run = sub.add_parser("run", help="启动监控主循环(每 interval 秒采样一次)")
|
|
1017
|
+
p_run.add_argument("--interval", type=float, default=10.0, help="采样间隔秒数,默认 10")
|
|
1018
|
+
p_run.add_argument("--cpu", type=float, default=85.0, help="CPU 阈值%%,默认 85")
|
|
1019
|
+
p_run.add_argument("--mem", type=float, default=85.0, help="内存阈值%%,默认 85")
|
|
1020
|
+
p_run.add_argument("--log", type=str, default=None,
|
|
1021
|
+
help="日志文件路径,默认 engineering/tools/load_cases/sw_guard.log")
|
|
1022
|
+
p_run.add_argument("--max-ticks", type=int, default=None, help="最多采样次数(测试用)")
|
|
1023
|
+
p_run.add_argument("--no-kill", action="store_true",
|
|
1024
|
+
help="演练模式:只告警不执行 taskkill(不会关闭 SolidWorks)")
|
|
1025
|
+
p_run.add_argument("--continue-after-kill", action="store_true",
|
|
1026
|
+
help="关闭 SolidWorks 后继续监控(默认杀完即退出循环)")
|
|
1027
|
+
p_run.add_argument("--once", action="store_true", help="只跑一个 tick 就退出")
|
|
1028
|
+
p_run.set_defaults(func=_cmd_run)
|
|
1029
|
+
|
|
1030
|
+
p_check = sub.add_parser("check", help="单次体检:打印当前 CPU/内存,不杀进程")
|
|
1031
|
+
p_check.add_argument("--json", action="store_true", help="以 JSON 输出(默认即 JSON)")
|
|
1032
|
+
p_check.set_defaults(func=_cmd_check)
|
|
1033
|
+
|
|
1034
|
+
p_state = sub.add_parser("state", help="读取并打印状态文件")
|
|
1035
|
+
p_state.add_argument("--json", action="store_true", help="以 JSON 输出(默认即 JSON)")
|
|
1036
|
+
p_state.set_defaults(func=_cmd_state)
|
|
1037
|
+
|
|
1038
|
+
return parser
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
1042
|
+
"""CLI 入口,返回进程退出码。
|
|
1043
|
+
|
|
1044
|
+
退出码约定:
|
|
1045
|
+
0 = 成功
|
|
1046
|
+
1 = 采样失败 / 运行期异常
|
|
1047
|
+
2 = 用法错误 或 状态文件不存在
|
|
1048
|
+
"""
|
|
1049
|
+
parser = build_parser()
|
|
1050
|
+
args = parser.parse_args(argv)
|
|
1051
|
+
|
|
1052
|
+
if getattr(args, "command", None) is None:
|
|
1053
|
+
# 没给子命令:带 --once 就做一次体检;否则打印帮助并以用法错误退出
|
|
1054
|
+
if getattr(args, "once", False):
|
|
1055
|
+
return _cmd_check(args)
|
|
1056
|
+
parser.print_help(sys.stderr)
|
|
1057
|
+
return 2
|
|
1058
|
+
|
|
1059
|
+
return int(args.func(args))
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
# 导入即修复控制台编码,保证任何使用方式下中文都不乱码(幂等且静默失败)
|
|
1063
|
+
_setup_utf8()
|
|
1064
|
+
|
|
1065
|
+
if __name__ == "__main__":
|
|
1066
|
+
sys.exit(main())
|