Files
Viper_VET/App/lib/VGuard.vp
T
TermiNexus af05cd0b53 chore(syntax): VET R12 下标 while -> for range(20 处)
i = 0 + while i < N: + 尾自增 -> or i in range(N):(8 文件 / 20 处;含 <= -> range(EXPR + 1)、带注解初始化、尾 i = i + 1)。余 19 处经守卫判定不可安全改写(复合条件 6、尾部非自增 7、体内重绑定/continue 2、循环后仍被读取 3、无整数初始化 1)。
2026-10-05 21:22:45 +08:00

1276 lines
80 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import t, c
from stdint import *
import viperlib
import sys
import memhub
import lib.VLog as VLog
import lib.VCli as VCli
import lib.VCheck as VCheck
import lib.VPe as VPe
import w32.win32file as wf
import w32.win32base as wb
import w32.win32sync as wsync
import w32.win32process as wp
import w32.win32console as wc
# ============================================================
# VGuard - 心跳确认看门狗(--need-confirm <秒>;仅 scan 模式)
#
# 设计(本轮重构:与扫盘彻底解耦):
# * 父进程(VET.exe scan --need-confirm x --need-confirm-timeout t)在 App/main.vp 中
# 用 CreateProcessA 拉起"自身"作为**独立看门狗子进程**:
# VET.exe confirm-watch --interval x --timeout t --parent-pid <父 pid>
# 父进程不阻塞等待看门狗;看门狗只做:
# sleep(interval) → stderr 提示 → 在 timeout 内等 stdin 一行 {"ok": 1}
# → 成功续期继续循环;失败(期限到点仍未收到合法 {"ok": 1} / 形状合法但值不对 /
# 读失败 / stdin 无效)则
# 在 stdout 逐字打印 {"exit":1, "msg": "timeout/ok!=1"}\n
# → TerminateProcess(父进程) → 自身退出码 1。
# 其中"期限到点"是唯一的超时失败口:形状可疑的行被忽略并继续等待(见下方"快修"小节)。
# * 扫盘侧完全不参与确认(VScan 已移除 GuardTick 挂钩);stdout 的"只放 JSON"约定不变。
#
# 本轮改动:确认输入由"阻塞式读一行"改为"非阻塞取数 + deadline 驱动"。
# * 旧实现的死结:WaitForMultipleObjects 只回答"stdin 有输入可读",但控制台处于
# 行输入模式时(setconsolemode 文档:ENABLE_LINE_INPUT = ReadFile/ReadConsole
# "读到回车才返回"),随后的 ReadFile 会一直等到回车;于是"半行 + 不回车"把看门狗
# 挂在读里,timeout 内是否收到合法 {"ok": 1} 无从判定。
# * 新实现按输入形态分两路,两路共用同一 deadline、同一未消费输入 FIFO、同一行累积、
# 同一分类(GuardClassifyLine)与同一取值口径(ParseOkOne 未改):
# ① 控制台(GetFileType=FILE_TYPE_CHAR 且 GetConsoleMode 成功):
# 进入前把 ENABLE_LINE_INPUT|ENABLE_ECHO_INPUT 清掉(掩码 GUARD_RAW_CLEAR_MASK;
# 取舍见下方"控制台模式取舍")→ 取记录(需要新数据才取,见"快修"),分两路:
# * 主路径:kernel32!ReadConsoleInputExA + CONSOLE_READ_NOWAIT(动态解析,
# 见下方"加固 1")——输入缓冲为空时立即返回,取数不再依赖"先查数量";
# * 降级路径(旧系统无该导出):有界等"可读"(上限 min(剩余, GUARD_LEGACY_WAIT_MS))
# → GetNumberOfConsoleInputEvents → ReadConsoleInputA(残余竞态见"加固 1")。
# 两路都只认 KEY_EVENT(0x0001) 且 bKeyDown 的字符,过滤后**整批压入 FIFO**,再由
# FIFO 逐字节喂行缓冲,累积到 Enter('\r' 归一成 '\n')为止 → 恢复进入前的控制台模式。
# ② 管道 / 重定向 / 其它(非控制台):管道路径先用 PeekNamedPipe 取"当前真正可用的
# 字节数",只读 min(可用, 缓冲余量);非管道(磁盘文件重定向等)读一块有界字节
# (<= GUARD_CHUNK_CAP),读到 0 字节即 EOF;该批同样**整批压入 FIFO** 后再逐字节喂
# 行缓冲;无可读数据时按 min(剩余, GUARD_POLL_MS) 等句柄推进。
# * 兜底:任何一次等待/读取都以"剩余毫秒"(GuardRemainMs)为上限,到点即判超时;
# 读取只发生在"已确认有数据"之后,不存在"等到下一次按键 / 下一次回车才算完"的无界阻塞点。
#
# 控制台模式取舍(为什么清 ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT):
# * 清 ENABLE_LINE_INPUT 是"能判定"的前提:文档(setconsolemode)明确 ENABLE_PROCESSED_INPUT
# 只在"ENABLE_LINE_INPUT 也启用"时才会把回车等字符交给系统处理、不进输入缓冲;
# 清掉行输入后,回车以 KEY_EVENT 记录形式到达本进程,我们才能自己判定"一整行"。
# * 清 ENABLE_ECHO_INPUT 是"消除失效标志":该位只作用于 ReadFile/ReadConsole 的读回显,
# 且文档注明"仅在 ENABLE_LINE_INPUT 同时启用时可用";本模块根本不调用 ReadFile/ReadConsole
# 读控制台,位留着不产生任何效果。代价:按键不回显(用户看不到自己敲的字符),
# 提示行已写明应输入的内容,判定不受影响。
# * 其余位一律不动,尤其保留 ENABLE_PROCESSED_INPUT:Ctrl+C 语义与旧实现完全一致
# (文档:该位决定 CTRL+C 是否进入输入缓冲,还是交给处理函数)。
# * 恢复策略:进入前用 GetConsoleMode 存原模式,成功切换过才置 Guard_RawActive;
# 正常(本行确认成功)/ 失败(期限到点、值不对、读写失败、EOF)/ 父进程退出
# 三条路径都调 GuardLeaveRaw 写回原模式。Ctrl+C / Ctrl+Break 这条外部终止路径由
# SetConsoleCtrlHandler 注册的处理例程在进程终止前写回("加固 2");
# 只剩"被 TerminateProcess 强杀"(父进程 StopWatchdog 回收、心跳失败杀父)不执行
# 任何用户态代码,仍无法恢复(该控制台输入模式可能残留"已清行输入")。
#
# 加固 1:控制台取数改用 ReadConsoleInputExA + CONSOLE_READ_NOWAIT(消除竞态)
# * 旧组合的问题:GetNumberOfConsoleInputEvents 报出 N>0 之后再 ReadConsoleInputA,
# 若另一读者在这两步之间取走记录,ReadConsoleInputA 会**等至少一条记录**
# (该函数无超时参数)⇒ deadline 被突破。
# * 新组合:ReadConsoleInputExA(hIn, buf, n, &read, CONSOLE_READ_NOWAIT) —— 文档明写
# CONSOLE_READ_NOWAIT "输入缓冲为空也立即返回",取数与"此刻是否有数据"解耦,
# 不存在"查询 → 阻塞读"的两步组合。
# * 该导出**没有导入库**(Windows 文档 Requirements:Library none),文档要求用
# LoadLibrary/GetProcAddress 动态链接,DLL 内只有 ReadConsoleInputExA / ReadConsoleInputExW
# 两个名字。故本模块走**动态解析**(方案 a),而非静态 extern def(方案 b):
# - 静态 extern def 的名字必须是 ReadConsoleInputExA(无 ReadConsoleInputEx 这个导出;
# 本机 D:/msys64/mingw64/lib/libkernel32.a 里只有 ExA/ExW 两个导入符号,已用只读扫描确认),
# 且旧系统(缺该导出)会在**加载期**直接失败,整个 exe 起不来——为一次非阻塞取数
# 付"VET 全面不可用"的代价不可接受;
# - 动态解析只依赖本仓既有声明 wb.GetModuleHandleA / wb.GetProcAddress
# (includes/w32/win32base.vp:66/65)+ 语言既有的函数指针变量间接调用
# (t.Callable 注解 → i8** 槽;分派 CallGenMixin.vp:959-990、签名构造与间接调用
# CallGenLeafKit.vp:741-812),**不新增任何链接期符号**,任何系统上都能加载。
# - 解析失败(老系统无该导出)⇒ 回退到降级路径:有界等"可读" →
# GetNumberOfConsoleInputEvents → ReadConsoleInputA。该路径的残余竞态无法根除
# (被取走记录时 ReadConsoleInputA 会等下一条记录),故把这一步之前的等待收紧为
# min(剩余毫秒, GUARD_LEGACY_WAIT_MS),并在剩余为 0 时**不做**这次读。
# * 只做一次解析(结果记在 Guard_ReadExState),不在每次取数里反复 GetProcAddress。
#
# 加固 2:SetConsoleCtrlHandler 兜住控制台模式残留(清除行输入后 Ctrl+C 的写回)
# * 现状:Ctrl+C / Ctrl+Break / 关窗时进程由默认处理终止,来不及执行写回 ⇒ 该终端
# 后续输入行为异常(行输入被清)。注册处理例程后,进程终止前会先回调本模块的
# GuardCtrlHandler,把进入前的模式写回(幂等)。
# * 注册时机:GuardEnterRaw 中"确实改过模式"时(Guard_RawActive=1)注册;摘除时机:
# GuardLeaveRaw 在写回模式后立刻 SetConsoleCtrlHandler(handler, FALSE)。注册与
# "已切换模式"同生命周期,故 Guard_RawActive==0 ⇔ 未注册,不存在泄漏(见"静态论证")。
# * handler 返回值一律 FALSE(交还默认处理):语义与加固前逐字一致——Ctrl+C/关窗仍由
# 默认处理终止本进程。若返回 TRUE("已处理")则必须自行 ExitProcess,否则看门狗会继续
# 跑到下一个 interval、再按心跳失败 TerminateProcess 父进程,把"用户主动 Ctrl+C"
# 变成"杀父";返回 FALSE 既保住原语义,也让 handler 只需一次 SetConsoleMode。
# * handler 由系统在**新线程**上调用,只读模块级标量 + 一次 SetConsoleMode,不触碰
# _mbuddy 池 / VLog 缓冲 / 其它模块级缓冲(无并发写,见"静态论证")。
#
# check 请求通道(本轮新增;执行侧见 VCheck.vp):
# * 父进程建一对管道(GuardIpcCreate):读端留父进程(清继承位),写端可继承并把**数值
# 句柄**经 `--ipc-write <数值>` 写进看门狗命令行;看门狗用 t.CPtr(数值) 还原句柄。
# * 看门狗在 stdin 上收到一行后先**分类**(VCheck.ParseCheckLine,用仓库 json 库解析):
# 含 "check"(字符串) 与 "id"(整数) ⇒ check 请求:逐字节原样转发(补 '\n')到 IPC 写端,
# 该行**不计入心跳、也不判失败**(期限继续走;写失败只记 stderr);
# 其余 ⇒ 交给 GuardClassifyLine 三态判定(见下方"快修:批量输入与可疑行")。
# * 父进程在 VScan 的轮询点非阻塞读该管道(PollIpc:PeekNamedPipe 取可用量 → 只读可用量
# → 按 '\n' 切行 → 交 VCheck.HandleCheckLine 同步执行并回执)。
#
# 快修:批量输入与可疑行(两处缺陷的修复;语义变化见本节末尾)
# * 缺陷 1「同批剩余字节被丢弃」:旧 GuardPumpStream 读到一块后逐字节喂行缓冲,**一行凑满
# 就 return 1**,同批里"第一行之后"的字节(已被 ReadFile 从管道取走)永久丢失 ⇒ stdin
# 一次写入两行以上即出错;GuardPumpConsole 同病(一批 KEY_EVENT 记录里第一行之后的字符
# 被丢)。修法:新增模块级**未消费输入 FIFO**(Guard_Pend/Guard_PendLen/Guard_PendPos,
# 容量 GUARD_PEND_CAP=128),两路取数共用,每轮三步:
# ① 先 GuardFeedPending():FIFO 里可能已有上一批留下的整行 ⇒ 凑满即返回 1(不发起读取);
# ② 需要新数据时才取一批(读入量同时受 GUARD_CHUNK_CAP / GUARD_REC_MAX 与 GUARD_PEND_CAP
# 约束,保证放得进 FIFO);
# ③ 把该批**全部**字节(控制台为"过滤后的字符")压入 FIFO,再 GuardFeedPending() 并返回
# 其结果:该批可能自成一行,剩余字节留在 FIFO 由下一次调用继续喂。
# 返回码语义不变:1=已凑满一行 / 0=无可读或尚未成行 / -1=读失败 /
# GUARD_PUMP_EOF=不可能再有输入。容量推导见 GUARD_PEND_CAP 声明处
# (= max(GUARD_CHUNK_CAP=64, GUARD_REC_MAX=16) × 2 余量)。
# * 缺陷 2「判定过严」:旧 GuardWaitConfirm 把"非 check、又非合法 {"ok": 1}"一律
# GuardWatchFail,于是"管道分片/被撕裂的残行"与"用户真的输错"后果相同(都打
# {"exit":1,…} + TerminateProcess(父))。修法:新增 GuardClassifyLine(Buf, Len) 三态分类:
# GUARD_LINE_SUSPECT(0) 形状可疑 ⇒ **忽略该行并继续在剩余期限内等待**(stderr 回显,
# 截断到 GUARD_SUSPECT_ECHO_MAX=120 字符;deadline 不变、不重置期限);
# GUARD_LINE_OK(1) ParseOkOne 通过 ⇒ 本行确认成功;
# GUARD_LINE_BADOK(2) 形状通过但 ParseOkOne 不过 ⇒ 致命失败(保留既有 stderr 文案
# "[confirm] 收到非 {"ok": 1} 的输入: …" 与 GuardWatchFail(hParent, "ok != 1") 路径)。
# "形状可疑"的判据(三选一即可疑,字节视角):① 去掉前导空格/制表符后首字符不是 '{';
# ② 整行不含 '}';③ 行长 > GUARD_OK_LINE_MAX(256)(合法 {"ok": 1} 正常不超过 16 字节)。
# check 行由 ParseCheckLine **先**判(见上),故第 ③ 项不会误伤长 check 行。
# * 于是"唯一的超时失败口"是"期限到点仍未收到合法 {"ok": 1}";读失败 / EOF / stdin 无效 /
# 父进程退出等路径一律不变;既有 check 例外不变(优先分类转发、不计心跳、不判失败)。
# * 被忽略的行**不会绕过期限**:两条忽略路径(check 转发 / 可疑行)都落到循环里的
# "剩余为 0 ⇒ 超时失败"检查上(不 reset deadline)⇒ 持续涌入的可疑行或 check 行
# 也会在期限到点收尾,"期限到点才判失败"对"无输入"与"只有被忽略的行"一致成立。
# * 两处取舍(写在这里便于推翻):
# ① "形状合法但值不对"({"ok": 0} / {"ok": 2})**仍判致命失败**——"用户真的输错"这条
# 保持原语义不放宽;
# ② **空行**由旧语义的"立即失败(stdin 输入为空行)"改为"按可疑行忽略"(首字符不是 '{'),
# 最终由期限到点失败 ⇒ stderr 原因由"stdin 输入为空行"变为"超时无输入",失败时机也
# 从"收到空行"推迟到"期限到点"。这是本轮**可接受的语义变化**。
#
# 为什么用子进程而不是线程(取舍):
# * 本仓有 includes/vthreading.vp,但线程与主流程共享同一地址空间:
# - VLog.Log_Buffer(VET/App/lib/VLog.vp:26)、VScan 的模块级进度/静态缓冲
# (Scr_*/Prog_*,VScan.vp:60-88)都是进程级单例,线程并发写会互相踩踏,
# 需要为"日志/格式化"再加锁并让出 MemBuddy 分配路径;
# - MemBuddy(memhub.MemBuddy)池非线程安全,看门狗的分配会与扫盘分配竞争;
# - TerminateProcess 无法用于"只杀主线程",线程方案下"失败即终止整个程序"
# 要靠 ExitProcess/abort,且要保证不写缓存、不再打一轮 JSON,反而更绕。
# * 子进程天然内存隔离:句柄表、MemBuddy 池、模块级缓冲互不干扰,失败即
# TerminateProcess(父进程不做任何收尾 ⇒ 不写缓存、不再输出本轮数组 JSON),
# 与旧 sys.exit(1) 的"立即终止"语义一致且更强。
# * 代价:多一个进程与一次 CreateProcessA;可被"父进程句柄"精确回收(见下)。
#
# 生命周期与"无孤儿"保证:
# * 父正常结束(含 --once)/参数解析后提前返回:main.vp 调 StopWatchdog() →
# TerminateProcess(看门狗) + CloseHandle。
# * 父异常退出(Ctrl+C / 被 TerminateProcess):看门狗持有父进程句柄(OpenProcess),
# 父进程对象一终止即置信号 ⇒ WaitForSingleObject / WaitForMultipleObjects 立即返回,
# 看门狗自行退出(不需要父侧配合)。
# * 看门狗自身异常/崩溃:父进程的 Watch_Process 句柄仍可被 StopWatchdog 关闭回收。
#
# 依赖本仓既有声明(未改 includes/):
# wf.GetStdHandle(includes/w32/win32file.vp:120)、wf.ReadFile(:90)、wf.GetFileType(:98)、
# wf.SetHandleInformation(:140)、wf.HANDLE_FLAG_INHERIT(:136)、
# wf.CreatePipe(:139)(check 通道管道;与 VProc.vp:121 同一用法)、
# wf.GetModuleFileNameA(:133)、wf.STD_INPUT_HANDLE(:44)、wf.STD_OUTPUT_HANDLE(:45)、
# wf.STD_ERROR_HANDLE(:46)、
# wsync.WaitForSingleObject(includes/w32/win32sync.vp:37)、
# wsync.WaitForMultipleObjects(includes/w32/win32sync.vp:39)、
# wp.CreateProcessA(includes/w32/win32process.vp:88)、wp.TerminateProcess(:90)、
# wp.GetExitCodeProcess(:91)、wp.OpenProcess(:93)、wp.GetCurrentProcessId(:95)、
# wp.STARTF_USESTDHANDLES(:27)、
# wp.STARTUPINFOA(:42)、wp.PROCESS_INFORMATION(:82)、
# wb.WAIT_OBJECT_0(:15)、wb.WAIT_TIMEOUT(:16)、wb.WAIT_FAILED(:14)、
# wb.INVALID_HANDLE_VALUE(:9)、wb.CloseHandle(:64)、wb.GetLastError(:62)、
# wb.GetTickCount64(:80)、wb.Sleep(:77)、
# wc.GetConsoleMode(includes/w32/win32console.vp:98)、wc.SetConsoleMode(:99)、
# wc.GetNumberOfConsoleInputEvents(:102)、wc.ReadConsoleInputA(:100)(仅降级路径)、
# wc.ENABLE_LINE_INPUT(:6)、wc.ENABLE_ECHO_INPUT(:7)、wc.KEY_EVENT(:29)、
# wb.GetModuleHandleA(includes/w32/win32base.vp:66)、wb.GetProcAddress(:65)
# (加固 1 的动态解析:只取函数地址,不新增链接期符号)、
# VPe.ReadU16(VET/App/lib/VPe.vp:82)、VPe.ReadU32(:88)(PE 小端读取,按字节偏移复用)、
# sys.exit(includes/sys.vp:26)、memhub.MemBuddy.alloc(includes/memhub.vp:725)、
# VCheck.ParseCheckLine / VCheck.HandleCheckLine(VET/App/lib/VCheck.vp:请求行分类与执行)、
# VLog 原语(VET/App/lib/VLog.vp)、VCli.ParseUint/ParseUint64/ParseWatchArgs(VET/App/lib/VCli.vp)。
#
# 本模块自声明(本仓 includes 未声明;prototype + DLL 见"API 证据表"):
# SYNCHRONIZE: winnt.h 0x00100000(kernel32!OpenProcess 的访问权限位)。
# FILE_TYPE_DISK/CHAR/PIPE: fileapi.h 0x0001/0x0002/0x0003(kernel32!GetFileType 返回值)。
# GUARD_RAW_CLEAR_MASK: ~(ENABLE_LINE_INPUT|ENABLE_ECHO_INPUT) = ~0x0006 = 0xFFFFFFF9(wincon.h)。
# CONSOLE_READ_NOWAIT: 0x0002(ReadConsoleInputEx 的 wFlags;文档明确"输入缓冲为空也立即返回";
# 同族的 CONSOLE_READ_NOREMOVE = 0x0001 本轮不用)。
# CTRL_C_EVENT/CTRL_BREAK_EVENT/CTRL_CLOSE_EVENT/CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT:
# 0/1/2/5/6(consoleapi.h;处理例程的入参取值。本模块的例程对所有取值一律写回模式后返回
# FALSE,故只在此记录常量值,不在代码里分支)。
# kernel32!PeekNamedPipe(原型见下方 extern def;本仓 includes 无此声明)。
# kernel32!SetConsoleCtrlHandler(原型见下方 extern def;本仓 includes 无此声明)。
# kernel32!ReadConsoleInputExA:**无导入库、无头文件**,只能动态解析(本模块不声明 extern,
# 见"加固 1"与 GuardResolveReadEx)。
# ============================================================
define SYNCHRONIZE = 0x00100000 # winnt.h
# OpenProcess 需要的访问权限:PROCESS_TERMINATE(0x0001) | SYNCHRONIZE(0x00100000)
define WATCH_PARENT_ACCESS = 0x00100001
define CONFIRM_WATCH_CMD = "confirm-watch" # 内部隐藏子命令名
# stdin 行缓冲:既要放 {"ok": 1},也要放 check 请求行({"check":"<exe 绝对路径>","id":<uint64>},
# 路径可达 MAX_PATH 量级)⇒ 取 4096(与 VCheck.CK_LINE_MAX 同值)。行超此上限按"一行结束"
# 处理(与旧实现"缓冲写满即截断成一行"一致;超长的 check 行会因路径被截断而判 NOT_FOUND)。
define GUARD_LINE_CAP = 4096
define GUARD_STAMP_CAP = 64 # 时间戳文本缓冲
define SELF_PATH_CAP = 1024 # GetModuleFileNameA 输出缓冲
# 命令行额外量(子命令 + interval/timeout/parent-pid/ipc-write + --verbose + NUL;句柄按
# 最多 20 位十进制字符计。留足余量以免 snprintf 截断出"看似正常实则错参"的命令行)
define WATCH_CMD_EXTRA_CAP = 256
define WATCH_HANDLE_COUNT = 2 # WaitForMultipleObjects 句柄个数
# 拉起后"存活校验"的等待窗口(毫秒)。代价:每次 scan 启动多等这么一会儿(README 已注明);
# 取 400 是"够看门狗跑完参数解析/OpenProcess/首次等待"与"不显著拖慢启动"之间的折中。
define WATCH_ALIVE_PROBE_MS = 400
# ---- 非阻塞确认输入(控制台 / 管道 / 重定向)----
# GetFileType 返回值(fileapi.h;本仓 include 未声明这几个常量)
define FILE_TYPE_DISK = 0x0001 # 磁盘文件(含 `< file` 重定向)
define FILE_TYPE_CHAR = 0x0002 # 字符设备(控制台/LPT 等)
define FILE_TYPE_PIPE = 0x0003 # 管道/套接字(含 `|` 重定向)
# 输入形态(GuardInputKind 返回值)
define GUARD_IN_CONSOLE = 1 # 控制台(CHAR + GetConsoleMode 成功)
define GUARD_IN_PIPE = 2 # 管道(可用 PeekNamedPipe 预询可读量)
define GUARD_IN_STREAM = 3 # 其它流(磁盘文件等:只能有界读)
# 清位掩码:ENABLE_LINE_INPUT(0x0002)|ENABLE_ECHO_INPUT(0x0004) 的补(wincon.h)
define GUARD_RAW_CLEAR_MASK = 0xFFFFFFF9
# INPUT_RECORD 内 KEY_EVENT_RECORD 字段偏移(x64 布局推导见 VET/README.md「控制台记录的读取口径」)
define GUARD_KEY_DOWN_OFF = 4 # KeyEvent.bKeyDown (BOOL)
define GUARD_KEY_CHAR_OFF = 14 # KeyEvent.uChar (WCHAR/CHAR union 头 2 字节)
define GUARD_REC_SIZE = 20 # sizeof(INPUT_RECORD)=EventType(2)+pad(2)+union(16)
define GUARD_REC_MAX = 16 # 单次 ReadConsoleInputA 最多读的记录数
define GUARD_REC_CAP = 320 # 记录缓冲字节数(20*16)
define GUARD_CHUNK_CAP = 64 # 管道/文件单次 ReadFile 上限(字节)
define GUARD_POLL_MS = 20 # 无可读数据时的推进节拍(同 VScan.PACE_SLEEP_MS)
# 取数结果码(两路共用;-1 为"读取失败")
define GUARD_PUMP_EOF = -2 # 不可能再出现输入(ReadFile 读到 0 字节)
# ---- 快修:未消费输入 FIFO(两路取数共用;见模块头「快修:批量输入与可疑行」)----
# 容量的推导(常量实际值取自本文件上方声明):
# * 管道/文件路径单批入队量 = min(管道可用量, GUARD_CHUNK_CAP=64, GUARD_PEND_CAP) <= 64;
# * 控制台路径单批入队量 = 单次取的记录条数 <= GUARD_REC_MAX=16,且每条记录至多产出 1 个字节;
# * 故"单批入队量"的全局上界 = max(64, 16) = 64;再取 2 倍余量 => 128。
# 为什么 2 倍余量就够(不必按 GUARD_LINE_CAP=4096 取):GuardPump* 只在"需要新数据"时才读,
# 而"需要新数据"的唯一条件是 GuardFeedPending() 返回 0(该返回只可能出现在 FIFO 被耗尽之后),
# 即"入队时 FIFO 必为空";此时 FIFO 只需容纳一个整批。余量用于吸收"未来把批上限调大"的改动。
# 不变量(改动任一常量时须保持):GUARD_PEND_CAP >= GUARD_CHUNK_CAP,且 >= GUARD_REC_MAX
# (否则单批读入量放不进 FIFO;GuardPumpStream/GuardPumpConsole 里的两处夹取是该不变量的
# 显式执行,GuardPendPut 的边界守卫是最后一道防线)。
define GUARD_PEND_CAP = 128 # 未消费输入 FIFO 容量(字节)
# 可疑行回显的截断长度(stderr 只回显前缀,避免把超长输入整行打出去)
define GUARD_SUSPECT_ECHO_MAX = 120
# 行分类结果码(GuardClassifyLine 返回值,见模块头「快修:批量输入与可疑行」)
define GUARD_LINE_SUSPECT = 0 # 形状可疑 ⇒ 忽略该行并继续等(期限不变)
define GUARD_LINE_OK = 1 # 合法 {"ok": 1} ⇒ 本行确认成功
define GUARD_LINE_BADOK = 2 # 形状合法但值不对 ⇒ 致命失败(口径不放宽)
# 形状检查的长度上界:合法 {"ok": 1} 正常不超过 16 字节(去掉空格/制表符后更短),
# 取 256 留足余量(可容忍 "{ \"ok\" : 1 }" 之类的空白展开),超过即按"被撕裂的残行/噪声"处理。
define GUARD_OK_LINE_MAX = 256
# ---- check 请求通道(看门狗 → 父进程的 IPC 管道;依附 --need-confirm)----
# 行缓冲上限与看门狗侧 GUARD_LINE_CAP 同值(请求行由看门狗逐字节转发,长度不会超过它)
define IPC_LINE_CAP = 4096
define IPC_READ_CHUNK = 1024 # 单次 ReadFile 上限(只读"已确认可用"的部分)
define IPC_PIPE_SIZE = 65536 # CreatePipe 的 nSize 提示(默认过小,给足以免看门狗写阻塞)
# ---- 加固 1:ReadConsoleInputExA 动态解析(无导入库,只能 GetProcAddress)----
define CONSOLE_READ_NOWAIT = 0x0002 # 输入缓冲为空也立即返回(文档 wFlags)
# ReadConsoleInputExA 的解析状态(Guard_ReadExState;0 = 未尝试解析)
define GUARD_EX_OK = 1 # 已解析到函数地址(走主路径)
define GUARD_EX_MISSING = 2 # 该导出不存在(走降级路径,不再重试)
# 降级路径"等可读"的单次上限(毫秒):把这一步的等待收紧,避免一次等待吃掉整个剩余预算;
# 取 5 是"够等一次输入到达"与"不显著拖慢判定"之间的折中(其后还有 GUARD_POLL_MS 节拍)。
define GUARD_LEGACY_WAIT_MS = 5
# SetConsoleCtrlHandler 的 Add 参数(consoleapi.h 的 TRUE/FALSE 同名,此处用字面量以免混淆)
define GUARD_CTRL_ADD = 1
define GUARD_CTRL_REMOVE = 0
# Win32 自声明(本仓 includes/w32 无 PeekNamedPipe):
# kernel32!PeekNamedPipe: BOOL PeekNamedPipe(HANDLE hNamedPipe, LPVOID lpBuffer,
# DWORD nBufferSize, LPDWORD lpBytesRead, LPDWORD lpTotalBytesAvail,
# LPDWORD lpBytesLeftThisMessage);
# 原型出处:namedpipeapi.h / Kernel32.lib / kernel32.dll(project.vpj 已链接 kernel32)。
# 语义:句柄必须是管道读端;不取走数据、立即返回;lpTotalBytesAvail = 当前可读字节数。
extern def PeekNamedPipe(hNamedPipe: wb.HANDLE, lpBuffer: t.CPtr, nBufferSize: ULONG,
lpBytesRead: t.CPtr, lpTotalBytesAvail: ULONG*,
lpBytesLeftThisMessage: t.CPtr) -> BOOL
# Win32 自声明(本仓 includes/w32 无 SetConsoleCtrlHandler):
# kernel32!SetConsoleCtrlHandler: BOOL SetConsoleCtrlHandler(PHANDLER_ROUTINE HandlerRoutine,
# BOOL Add);
# typedef BOOL (WINAPI *PHANDLER_ROUTINE)(DWORD dwCtrlType); // consoleapi.h:114-115
# 原型出处:consoleapi.h / Kernel32.lib / kernel32.dll(本机 libkernel32.a 内含该导入符号)。
# 第一个形参按本仓"函数指针/void* 用 t.CPtr 传"的写法声明;实参直接写处理例程的函数名,
# 由 TPV 把函数引用 bitcast 成函数指针(先例:includes/vthreading.vp:22-27 把函数名传给
# CreateThread 的 VOIDPTR 形参;IR 现场 Test/BuddyTest/.tpv_build/temp/8e/
# 8efc91cbab0452f0.ll:2672-2676 的 `bitcast i32 (i8*)* @... to i8*` + `call @CreateThread`)。
# 返回 0 = 失败(GetLastError 取原因);Add=FALSE 时摘除。
extern def SetConsoleCtrlHandler(HandlerRoutine: t.CPtr, Add: BOOL) -> BOOL
# 由 App/main.vp 注入(与其它模块同一 MemBuddy 实例)
_mbuddy: memhub.MemBuddy*
# 看门狗句柄(父进程侧;StopWatchdog 负责关闭)
Watch_Process: wb.HANDLE = None # CreateProcessA → PROCESS_INFORMATION.hProcess
Watch_Thread: wb.HANDLE = None # PROCESS_INFORMATION.hThread
# 看门狗侧缓冲(confirm-watch 内一次性分配)
Guard_Line: bytes = None # stdin 行缓冲
Guard_Stamp: bytes = None # "当前时间"文本缓冲
Guard_Dl: bytes = None # "本轮期限"文本缓冲
Guard_Recs: bytes = None # 控制台 INPUT_RECORD 数组缓冲(原生字节,按偏移读)
Guard_Chunk: bytes = None # 管道/文件读取块缓冲
# 未消费输入 FIFO(快修;两路取数共用):读取批次先整批压入这里,再逐字节喂 Guard_Line。
# 语义:[Guard_PendPos, Guard_PendLen) 为"尚未喂给行缓冲"的字节区间;Pos==Len 表示空。
Guard_Pend: bytes = None # 未消费输入 FIFO(容量 GUARD_PEND_CAP)
Guard_PendLen: int = 0 # FIFO 已写入的字节数(写入端游标)
Guard_PendPos: int = 0 # FIFO 已喂出的字节数(消费端游标)
Guard_Fill: int = 0 # 本轮已累积的行长度(Guard_Line 内,不含 NUL)
Guard_SavedMode: ULONG = 0 # 进入"原始按键"模式前的控制台模式
Guard_RawActive: int = 0 # 1=当前处于已切换模式(退出前必须写回)
# 看门狗侧:check 通道的管道写端(由父进程经 `--ipc-write <数值>` 传入;None=无通道)。
# 只用于"原样转发 check 请求行",写失败仅记 stderr(不改变心跳语义)。
Guard_IpcWrite: wb.HANDLE = None
# ---- check 请求通道(父进程侧;非阻塞轮询)----
# 管道由父进程(本模块)建立:读端留在父进程(清掉继承位),写端设为可继承并把**数值句柄**
# 经命令行 `--ipc-write <数值>` 交给看门狗(看门狗用 t.CPtr(数值) 得到句柄直接 WriteFile)。
Ipc_Read: wb.HANDLE = None # 管道读端(父进程持有;None=未建立/已关闭)
Ipc_Line: bytes = None # 行累积缓冲(IPC_LINE_CAP)
Ipc_Chunk: bytes = None # 单次读取块(IPC_READ_CHUNK)
Ipc_Fill: int = 0 # 已累积的字节数(不含 NUL)
# 加固 1:ReadConsoleInputExA 的函数指针槽(t.Callable 注解 → i8** 槽,调用即间接 call;
# 形参表按 ABI 记:HANDLE/PINPUT_RECORD/LPDWORD 都是 i8*,DWORD=i32,wFlags=USHORT=i16)。
# 值由 GuardResolveReadEx 一次性填入;Guard_ReadExState 取值为 0/GUARD_EX_OK/GUARD_EX_MISSING。
Guard_ReadEx: t.Callable[[t.CPtr, t.CPtr, ULONG, t.CPtr, USHORT], BOOL] = None
Guard_ReadExState: int = 0
# 加固 2:控制事件处理例程的注册状态与"已切换模式"的控制台句柄
# (两者都在 GuardCtrlHandler 所在的系统线程里被读取,故只在主线程注册前写入一次)
Guard_ConHandle: wb.HANDLE = None # GuardEnterRaw 改过模式的那个控制台句柄
Guard_CtrlHandlerOn: int = 0 # 1=已注册(GuardLeaveRaw 负责摘除)
# WaitForMultipleObjects 需要一个"句柄数组"(HANDLE 即 VOIDPTR,x64 下 8 字节)。
# 用两个 HANDLE 字段的类型化结构体,按声明顺序连续存放 = 16 字节,恰为 Win32 期望。
class WATCH_HANDLES():
H0: wb.HANDLE
H1: wb.HANDLE
# ============================================================
# check 请求通道:IPC 管道(父进程侧:建立 / 关闭 / 非阻塞轮询)
#
# 为什么用"管道 + 数值句柄经命令行传递":
# * 父子之间已有 stdin/stdout/stderr 三个共享句柄,但它们都被"用户输入 / 结果 JSON /
# 日志"占用(stdout 的契约是"只放单行 JSON",不能被请求流污染),故另开一对管道;
# * 管道由父进程 CreatePipe 建立:读端留在父进程(SetHandleInformation 清掉继承位),
# 写端保持可继承(bInheritHandle=1)并随 CreateProcessA(bInheritHandles=1) 进入看门狗;
# 看门狗侧无需知道管道任何信息,只需 "句柄数值" ⇒ 经命令行 `--ipc-write <数值>` 传入;
# * 传"数值"而不是"复制/命名管道":句柄值本身就是 pid 一样的小整数,子进程用
# t.CPtr(数值)(即 ptrtoint/inttoptr 的逆运算)即可还原为可用的 HANDLE。
# 句柄值按 u64 位模式传递(ParseUint64 严格解析),因此内核句柄那种"高位为 1 的
# 符号扩展值"也能原样还原,不做 32 位截断。
# * 父进程在 CreateProcessA 成功后**立即关掉自己的写端副本**:此后本进程再拉起
# `--version` 子进程时不会把这个写端一起继承出去(读端本就不可继承)。
#
# 父进程侧读取**绝不阻塞**:
# * 先 PeekNamedPipe 取"当前真正可用的字节数";可用为 0 就直接返回(不调用 ReadFile);
# * 只读 min(可用, IPC_READ_CHUNK) 字节——ReadFile 在"确有数据可读"时立即返回;
# * 每次轮询的数据量上界 = 进入时管道中已有的字节数(排空后即返回),无任何"等下一次到来",
# 故轮询点可以放在扫盘的高频路径上(PaceDir/PaceFile)而不会拖慢/挂住扫盘。
#
# 行切分:与看门狗侧同一口径——'\n' 结束一行(看门狗把行原样转发时补的那个 '\n');
# 行内容不含 '\n'(看门狗侧 GuardFeedByte 已把 '\n' 排除在缓冲之外)。
# 超长行(≥ IPC_LINE_CAP-1 仍未见到 '\n')按"丢弃该行"处理(Ipc_Fill 归零),
# 既不越界也不把两行粘成一行。
# ============================================================
# 建立 IPC 管道:成功返回**写端句柄**(由调用方交给看门狗并在用完后 CloseHandle),
# 失败返回 None(调用方按"看门狗拉起失败=致命"处理)。读端存入 Ipc_Read。
def GuardIpcCreate() -> wb.HANDLE:
global Ipc_Read, Ipc_Line, Ipc_Chunk, Ipc_Fill
GuardIpcClose() # 幂等:如已有旧管道先关掉
if Ipc_Line is None:
Ipc_Line = _mbuddy.alloc(IPC_LINE_CAP)
if Ipc_Chunk is None:
Ipc_Chunk = _mbuddy.alloc(IPC_READ_CHUNK)
if Ipc_Line is None or Ipc_Chunk is None:
return None
Sa: wb.SECURITY_ATTRIBUTES = wb.SECURITY_ATTRIBUTES()
Sa.nLength = uint32(wb.SECURITY_ATTRIBUTES.__sizeof__())
Sa.lpSecurityDescriptor = t.CPtr(0)
Sa.bInheritHandle = 1 # 两端先都建为可继承,随后单方面清读端
hRead: wb.HANDLE = None
hWrite: wb.HANDLE = None
if wf.CreatePipe(&hRead, &hWrite,
&Sa, ULONG(IPC_PIPE_SIZE)) == 0:
VLog.LogFmt1("[confirm] 警告: check 通道 CreatePipe 失败 (GetLastError=%u)\n",
sizet(wb.GetLastError()))
return None
# 读端只在父进程用:清掉继承位,避免被任何子进程(看门狗、--version 探测)继承
wf.SetHandleInformation(hRead, wf.HANDLE_FLAG_INHERIT, 0)
wf.SetHandleInformation(hWrite, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT)
Ipc_Read = hRead
Ipc_Fill = 0
return hWrite
# 关闭 IPC 读端(幂等;StopWatchdog 与 SpawnWatchdog 的失败路径都会调)。
def GuardIpcClose():
global Ipc_Read, Ipc_Fill
if Ipc_Read is not None:
wb.CloseHandle(Ipc_Read)
Ipc_Read = None
Ipc_Fill = 0
# 非阻塞轮询 IPC 读端:把"当前已可用"的字节全部取走并按 '\n' 切行,逐行交给 VCheck 处理
# (处理是同步的:check 的"读 .PRVERSN → --version → 回执"整段在本次调用内完成)。
# 返回本次已处理的 check 请求行数(0 = 无数据/非 check 行/通道未建立)。
def PollIpc() -> int:
global Ipc_Fill
if Ipc_Read is None or Ipc_Read == wb.INVALID_HANDLE_VALUE:
return 0
Done: int = 0
while True:
Avail: ULONG = 0
if PeekNamedPipe(Ipc_Read, t.CPtr(0), 0, t.CPtr(0), &Avail, t.CPtr(0)) == 0:
return Done # 管道异常:本轮不再读(不阻塞、不重试)
if Avail == 0:
return Done # 无数据 ⇒ 立即返回(轮询点的高频路径)
N: ULONG = ULONG(IPC_READ_CHUNK)
if Avail < N:
N = Avail # 只读"确已有数据"的部分 ⇒ ReadFile 不会阻塞
Br: ULONG = 0
if wf.ReadFile(Ipc_Read, Ipc_Chunk, N, &Br, None) == 0:
return Done
if Br == 0:
return Done
ub: BYTEPTR = BYTEPTR(Ipc_Chunk)
i: int = 0
while i < int(Br):
Ch: int = int(ub[i])
i += 1
if Ch == 10: # '\n':一行结束
Ipc_Line[Ipc_Fill] = 0
if Ipc_Fill > 0:
if VCheck.HandleCheckLine(str(Ipc_Line)) != 0:
Done += 1
Ipc_Fill = 0
else:
if Ipc_Fill >= IPC_LINE_CAP - 1:
Ipc_Fill = 0 # 超长行:丢弃该行(不越界、不与下一行粘连)
else:
Ipc_Line[Ipc_Fill] = char(Ch)
Ipc_Fill += 1
# ============================================================
# 父进程侧:拉起 / 停止看门狗
# ============================================================
# 取自身 exe 全路径(GetModuleFileNameA(NULL,...))。返回 1=成功(Out 已 NUL 结尾),0=失败。
def SelfExePath(Out: bytes, Cap: int) -> int:
if Out is None or Cap < 2:
return 0
n: ULONG = wf.GetModuleFileNameA(None, Out, ULONG(Cap))
if n == 0:
return 0
if sizet(n) >= sizet(Cap):
return 0 # 缓冲不足(路径被截断)⇒ 视为失败
Out[int(n)] = 0
return 1
# 拉起看门狗子进程。返回 1=已拉起,0=失败。
# IntervalSec:--need-confirm 的秒数(>0,调用方保证);TimeoutMs:确认等待超时(毫秒)。
# 说明:
# * lpApplicationName=NULL,命令行首 token 为自身全路径(与 VProc 的 CreateProcessA 用法一致)。
# * stdout/stderr/stdin 三个标准句柄都显式指向父进程当前句柄(STARTF_USESTDHANDLES),
# 因此看门狗的 JSON 与日志与父进程落同一处(控制台或重定向),不新建管道、不复制输出。
# * dwCreationFlags=0:让子进程共享父进程控制台(不新开窗口),保证控制台输入句柄在子进程内
# 仍可读、且 Ctrl+C 能同时送达(双保险:另有父进程句柄监控)。
# * 拉起成功后做一次**存活校验**(WaitForSingleObject(hProcess, WATCH_ALIVE_PROBE_MS)):
# CreateProcessA 返回非 0 只说明进程对象建起来了,不代表看门狗真在跑——它可能因
# 自身参数/环境问题立刻退出(历史现场:--parent-pid 被截断成 -1 判非法 → exit 1),
# 那样 --need-confirm 会静默失效(README 的"拉起失败=致命"落空)。
# WAIT_OBJECT_0(已退出)⇒ 取退出码打 stderr + stdout 打唯一一行失败 JSON + 关句柄
# ⇒ 返回 0(main.vp 的既有失败分支据此中止 scan)。WAIT_TIMEOUT(仍在跑)⇒ 视为存活,
# 继续。其余等待返回值(如 WAIT_FAILED)信息不足,按存活处理(宁可少报也不误杀 scan)。
# 时序:CreateProcessA → 探一次(<=400ms) → 存活则打"已启动"日志并继续扫盘 /
# 已退出则打诊断 + 失败 JSON 并返回 0(致命)。
def SpawnWatchdog(IntervalSec: int, TimeoutMs: int) -> int:
global Watch_Process, Watch_Thread
if IntervalSec <= 0:
return 1 # 关闭:不拉起(调用方仅在 >0 时调用),视为"无需看门狗"
StopWatchdog() # 幂等:如已有旧看门狗先回收
Self: bytes = _mbuddy.alloc(SELF_PATH_CAP)
Cmd: bytes = _mbuddy.alloc(SELF_PATH_CAP + WATCH_CMD_EXTRA_CAP)
if Self is None or Cmd is None:
if Self is not None:
_mbuddy.free(t.CPtr(Self))
if Cmd is not None:
_mbuddy.free(t.CPtr(Cmd))
VLog.LogStr("[confirm] 警告: 看门狗缓冲分配失败,未启动\n")
return 0
if SelfExePath(Self, SELF_PATH_CAP) == 0:
_mbuddy.free(t.CPtr(Self))
_mbuddy.free(t.CPtr(Cmd))
VLog.LogStr("[confirm] 警告: GetModuleFileNameA 取自身路径失败,看门狗未启动\n")
return 0
# check 请求通道:先建管道(写端数值要写进命令行,必须在 CreateProcessA 之前拿到)。
# 失败即致命(与看门狗拉起失败同一处理):用户显式要求心跳确认,其附带的 check 通道
# 若静默缺失,等于悄悄少了一项被请求的能力。
IpcW: wb.HANDLE = GuardIpcCreate()
if IpcW is None:
_mbuddy.free(t.CPtr(Self))
_mbuddy.free(t.CPtr(Cmd))
VLog.LogStr("[confirm] 警告: check 通道管道建立失败,看门狗未启动\n")
return 0
# 命令行首 token 为自身全路径(引号包裹,兼容路径含空格);
# 父进程为 --verbose 时为子进程追加同名开关,使看门狗按同一详细度规则输出。
# --ipc-write <数值>:check 请求通道的管道**写端句柄数值**(父进程按 u64 位模式给出,
# 看门狗用 t.CPtr(数值) 还原句柄)。缺该参数(如手工调用)时看门狗侧按"无通道"处理。
Pid: ULONG = wp.GetCurrentProcessId()
Extra: str = ""
if VCli.Verbose() != 0:
Extra = " --verbose"
viperlib.snprintf(Cmd, sizet(SELF_PATH_CAP + WATCH_CMD_EXTRA_CAP),
"\"%s\" %s --interval %u --timeout %u --parent-pid %u --ipc-write %llu%s",
str(Self), CONFIRM_WATCH_CMD,
sizet(IntervalSec), sizet(TimeoutMs), sizet(Pid),
uint64(IpcW), Extra)
# 标准句柄:与父进程同一(继承控制台/重定向);stdin 也一并交给看门狗
hIn: wb.HANDLE = wf.GetStdHandle(wf.STD_INPUT_HANDLE)
hOut: wb.HANDLE = wf.GetStdHandle(wf.STD_OUTPUT_HANDLE)
hErr: wb.HANDLE = wf.GetStdHandle(wf.STD_ERROR_HANDLE)
if hIn is None or hIn == wb.INVALID_HANDLE_VALUE:
wb.CloseHandle(IpcW)
GuardIpcClose()
_mbuddy.free(t.CPtr(Self))
_mbuddy.free(t.CPtr(Cmd))
VLog.LogStr("[confirm] 警告: stdin 句柄无效,看门狗未启动(心跳确认需要 stdin)\n")
return 0
# 尽力把标准句柄标记为可继承(控制台句柄通常本就随控制台继承;失败忽略)
wf.SetHandleInformation(hIn, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT)
if hOut is not None and hOut != wb.INVALID_HANDLE_VALUE:
wf.SetHandleInformation(hOut, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT)
if hErr is not None and hErr != wb.INVALID_HANDLE_VALUE:
wf.SetHandleInformation(hErr, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT)
Si: wp.STARTUPINFOA = wp.STARTUPINFOA()
Si.cb = uint32(wp.STARTUPINFOA.__sizeof__())
Si.dwFlags = wp.STARTF_USESTDHANDLES
Si.hStdInput = hIn
Si.hStdOutput = hOut
Si.hStdError = hErr
Pi: wp.PROCESS_INFORMATION = wp.PROCESS_INFORMATION()
Ok: BOOL = wp.CreateProcessA(t.CPtr(0), Cmd, t.CPtr(0), t.CPtr(0), 1, 0,
t.CPtr(0), t.CPtr(0), &Si, &Pi)
CreateErr: ULONG = wb.GetLastError() # 紧跟调用读取(free 会覆盖 GetLastError)
_mbuddy.free(t.CPtr(Self))
_mbuddy.free(t.CPtr(Cmd))
if Ok == 0:
wb.CloseHandle(IpcW)
GuardIpcClose()
VLog.LogFmt1("[confirm] 警告: 看门狗 CreateProcessA 失败 (GetLastError=%u),未启动\n",
sizet(CreateErr))
return 0
# 写端已由看门狗继承 ⇒ 父进程立即关掉自己的副本:这样后续任何 CreateProcessA
# (--version 探测)都不会把这个写端再继承出去。
wb.CloseHandle(IpcW)
Watch_Process = Pi.hProcess
Watch_Thread = Pi.hThread
# 存活校验(详见函数头注释):只把 WAIT_OBJECT_0(子进程已退出)判为致命。
Wp: ULONG = wsync.WaitForSingleObject(Watch_Process, WATCH_ALIVE_PROBE_MS)
if Wp == wb.WAIT_OBJECT_0:
Ec: ULONG = 0
wp.GetExitCodeProcess(Watch_Process, &Ec)
VLog.LogFmt1("[confirm] 看门狗拉起后立即退出 (exit=%u),心跳确认无法生效\n",
sizet(Ec))
# 致命路径与 StopWatchdog 一样各自 CloseHandle 一次,并清空模块级句柄
# (避免随后 StopWatchdog/再次 Spawn 造成双重关闭)。
wb.CloseHandle(Watch_Process)
Watch_Process = None
wb.CloseHandle(Watch_Thread)
Watch_Thread = None
GuardIpcClose() # 看门狗已退出 ⇒ check 通道随之作废,读端一并关闭
# 与看门狗自身失败路径(GuardWatchFail)逐字节相同的那一行:格式仍是单行 JSON。
# 保留同一 msg(而非新造 "watchdog exited"):消费方按 "exit"==1 判中止,
# 且 README 已把这行标为看门狗失败的唯一 stdout 约定;精确原因走上一条 stderr。
VLog.StdoutWrite("{\"exit\":1, \"msg\": \"timeout/ok!=1\"}\n")
return 0
VLog.LogFmt2("[confirm] 心跳看门狗已启动 (pid=%u, 每 %u 秒一次",
sizet(Pi.dwProcessId), sizet(IntervalSec))
VLog.LogFmt1(", 确认超时 %u ms)\n", sizet(TimeoutMs))
return 1
# 停止看门狗(幂等;父进程正常结束/提前返回时调用)。
# TerminateProcess 立即置"终止"标记,子进程不会再执行用户态代码;再 WaitForSingleObject
# 有界等待其真正退出,确保父进程结束前无遗留;最后关闭两个句柄与 check 通道的管道读端。
def StopWatchdog():
global Watch_Process, Watch_Thread
if Watch_Process is not None:
wp.TerminateProcess(Watch_Process, 0)
wsync.WaitForSingleObject(Watch_Process, 5000)
wb.CloseHandle(Watch_Process)
Watch_Process = None
if Watch_Thread is not None:
wb.CloseHandle(Watch_Thread)
Watch_Thread = None
GuardIpcClose()
# ============================================================
# 输入解析(沿用原判定口径)
# ============================================================
# 严格判定"存在 ok 且其值为 1"(容忍 key/值与冒号之间的空格 32 与制表 9)。
# 结构约束:`"ok"` 前一个非空白字符必须是 '{'(123) 或 ','(44)(或缓冲区起点)——
# 避免把某个字符串值里的 "ok" 误当成键;值必须是数字 1(49) 且其后不得紧跟数字
# (拒绝 12/01)。返回 1=ok==1;其它一切情况(ok:0 / 空行 / 非 JSON / ok 缺失 /
# 值带引号 / 非数字 / 负数)返回 0。
def ParseOkOne(Buf: bytes, Len: int) -> int:
if Buf is None or Len <= 0:
return 0
ub: BYTEPTR = BYTEPTR(Buf)
for i in range(Len):
if int(ub[i]) == 34: # '"'
KeyPos: int = 0
if i == 0:
KeyPos = 1
else:
k: int = i - 1
while k >= 0 and (int(ub[k]) == 32 or int(ub[k]) == 9):
k -= 1
if k < 0:
KeyPos = 1
else:
pk: int = int(ub[k])
if pk == 123 or pk == 44: # '{' 或 ','
KeyPos = 1
if KeyPos != 0 and i + 3 < Len:
# 依次为 'o'(111) 'k'(107) '"'(34)
if int(ub[i + 1]) == 111 and int(ub[i + 2]) == 107 and int(ub[i + 3]) == 34:
j: int = i + 4
while j < Len and (int(ub[j]) == 32 or int(ub[j]) == 9):
j += 1
if j < Len and int(ub[j]) == 58: # ':'
j += 1
while j < Len and (int(ub[j]) == 32 or int(ub[j]) == 9):
j += 1
if j < Len and int(ub[j]) == 49: # '1'
if j + 1 >= Len:
return 1
nx: int = int(ub[j + 1])
if nx < 48 or nx > 57: # 后随非数字 ⇒ 值恰为 1
return 1
return 0
# 行分类(快修;判定顺序见模块头「快修:批量输入与可疑行」)。返回值:
# GUARD_LINE_SUSPECT(0) = 形状可疑 ⇒ 忽略该行、继续在剩余期限内等待(不判失败);
# GUARD_LINE_OK(1) = 合法 {"ok": 1} ⇒ 本行确认成功;
# GUARD_LINE_BADOK(2) = 形状合法但值不对(如 {"ok": 0} / {"ok": 2})⇒ 致命失败。
# 顺序:先做形状检查(三项任一不满足即"可疑"),形状通过后才用既有的 ParseOkOne 判值——
# ParseOkOne 的口径一字未改,本函数只负责"值不对"与"形状可疑"的分流。
# 三项形状判据(字节视角,容忍前导空格 32 / 制表 9):
# ① 去掉前导空白后的首字符不是 '{'(123)(含空行、全空白行);
# ② 整行不含 '}'(125)(被撕裂/截断的残行);
# ③ 行长超过 GUARD_OK_LINE_MAX(256)(明显异常)。
# 注意分类的调用位置:check 请求行由 VCheck.ParseCheckLine **先**判(该行优先转发,且长 check 行
# 可远超 256 字节),故第 ③ 项不会误伤 check 行。
def GuardClassifyLine(Buf: bytes, Len: int) -> int:
if Buf is None or Len <= 0:
return GUARD_LINE_SUSPECT # ①(空行/无内容)
if Len > GUARD_OK_LINE_MAX:
return GUARD_LINE_SUSPECT # ③
ub: BYTEPTR = BYTEPTR(Buf)
i: int = 0
while i < Len and (int(ub[i]) == 32 or int(ub[i]) == 9):
i += 1
if i >= Len:
return GUARD_LINE_SUSPECT # ①(全空白行)
if int(ub[i]) != 123: # '{'
return GUARD_LINE_SUSPECT # ①
j: int = i
while j < Len:
if int(ub[j]) == 125: # '}'
break
j += 1
if j >= Len:
return GUARD_LINE_SUSPECT # ②
if ParseOkOne(Buf, Len) != 0:
return GUARD_LINE_OK
return GUARD_LINE_BADOK
# 把当前这一行(Guard_Line / Guard_Fill)逐字节转发到 check 通道的 IPC 写端,并补一个 '\n'
# 作为行分隔(父进程按 '\n' 切行)。行内容与用户输入逐字节相同(含空格与大小写)。
# 写失败/无通道只记 stderr:该行本就不参与心跳判定,**不改变心跳语义**(期限继续走)。
def GuardForwardLine():
if Guard_IpcWrite is None or Guard_IpcWrite == wb.INVALID_HANDLE_VALUE:
VLog.LogStr("[confirm] 警告: 收到 check 请求但无 check 通道(未传 --ipc-write),已丢弃该行\n")
return
bw: ULONG = 0
if Guard_Fill > 0:
if wf.WriteFile(Guard_IpcWrite, Guard_Line, ULONG(Guard_Fill),
&bw, None) == 0:
VLog.LogStr("[confirm] 警告: check 请求转发失败(写管道失败),已丢弃该行\n")
return
if wf.WriteFile(Guard_IpcWrite, "\n", 1, &bw, None) == 0:
VLog.LogStr("[confirm] 警告: check 请求转发失败(写行分隔符失败),该行可能不完整\n")
# ============================================================
# 非阻塞取数(控制台 / 管道 / 重定向两路共用同一 deadline 与同一行累积)
# ============================================================
# 取剩余毫秒:GetTickCount64 差值,已过期饱和到 0(调用方据此判超时,不出现负值)。
# 上界依据:--timeout 上界 CONFIRM_TIMEOUT_MAX_MS = 86400000(VCli.vp:36 校验),
# 故 Deadline - Now < 2^32,转 ULONG 不截断。
def GuardRemainMs(Deadline: ULONGLONG) -> ULONG:
Now: ULONGLONG = wb.GetTickCount64()
if Now >= Deadline:
return 0
return ULONG(Deadline - Now)
# 把一个字节累积进行缓冲(两路唯一的累积点)。返回 1=已凑满一行,0=尚未成行。
# 行结束口径与旧 ReadConfirmLine 逐条一致:
# * '\n'(10) 结束一行且不入缓冲,其前紧邻的 '\r'(13) 去掉(重定向输入的 CRLF);
# * 缓冲写满(GUARD_LINE_CAP-1)按一行结束处理(旧实现同样返回截断行);
# * 每次结束/追加后写 NUL,Guard_Fill 为本行内容长度(供 ParseOkOne 与日志使用)。
# 控制台路径把 Enter 的 '\r' 归一成 '\n' 再送入本函数,故两种输入形态共用同一口径。
def GuardFeedByte(Ch: int) -> int:
global Guard_Fill
ub: BYTEPTR = BYTEPTR(Guard_Line) # 读取用字节视图(同 ParseOkOne/旧 ReadConfirmLine)
if Ch == 10: # '\n'
if Guard_Fill > 0 and int(ub[Guard_Fill - 1]) == 13:
Guard_Fill -= 1
Guard_Line[Guard_Fill] = 0
return 1
if Guard_Fill >= GUARD_LINE_CAP - 1:
Guard_Line[Guard_Fill] = 0
return 1
Guard_Line[Guard_Fill] = char(Ch)
Guard_Fill += 1
Guard_Line[Guard_Fill] = 0
return 0
# ---- 快修:未消费输入 FIFO 的三个小函数(两路取数共用;见模块头「快修:批量输入与可疑行」)----
# 清空 FIFO(新一轮确认开始时调用:新窗口不带上一轮的残留)。
def GuardPendReset():
global Guard_PendLen, Guard_PendPos
Guard_PendLen = 0
Guard_PendPos = 0
# 把 FIFO 里尚未消费的字节按顺序逐个喂给行缓冲。返回 1=途中凑满了一行(剩余字节留在 FIFO,
# 由下一次调用继续喂),0=FIFO 已耗尽且未成行(此时两个游标归零,FIFO 回到空且可从头部复用)。
def GuardFeedPending() -> int:
global Guard_PendLen, Guard_PendPos
ub: BYTEPTR = BYTEPTR(Guard_Pend)
while Guard_PendPos < Guard_PendLen:
Ch: int = int(ub[Guard_PendPos])
Guard_PendPos += 1
if GuardFeedByte(Ch) != 0:
return 1
Guard_PendPos = 0
Guard_PendLen = 0
return 0
# 把一个字节写入 FIFO(写入端游标 Guard_PendLen)。带边界守卫:满则丢弃该字节并记一行 stderr
# 告警,绝不越界写。按构造成立的不变量(见 GUARD_PEND_CAP 的推导),入队时 FIFO 必为空且
# 单批入队量 <= 64 < GUARD_PEND_CAP,故"满"不可达;守卫只为让将来改大批上限时退化为"丢字节"
# 而不是写穿缓冲。
def GuardPendPut(Ch: int):
global Guard_PendLen
if Guard_PendLen >= GUARD_PEND_CAP:
VLog.LogFmt1("[confirm] 警告: 未消费输入 FIFO 已满(容量 %u),丢弃该批字节\n",
sizet(GUARD_PEND_CAP))
return
Guard_Pend[Guard_PendLen] = char(Ch)
Guard_PendLen += 1
# 判定 stdin 形态:控制台 / 管道 / 其它流。
# 依据:GetFileType 返回 FILE_TYPE_CHAR 的除控制台外还有 LPT 等字符设备,
# 故再用 GetConsoleMode 确认(成功 = 该句柄是控制台输入缓冲 ⇒ 可按记录读)。
def GuardInputKind(hIn: wb.HANDLE) -> int:
T: ULONG = wf.GetFileType(hIn)
if T == ULONG(FILE_TYPE_CHAR):
Mode: ULONG = 0
if wc.GetConsoleMode(hIn, &Mode) != 0:
return GUARD_IN_CONSOLE
return GUARD_IN_STREAM
if T == ULONG(FILE_TYPE_PIPE):
return GUARD_IN_PIPE
# FILE_TYPE_DISK(`< file` 重定向)/ FILE_TYPE_UNKNOWN(NUL 等)等一律按"其它流"处理
return GUARD_IN_STREAM
# ============================================================
# 加固 1:ReadConsoleInputExA 动态解析
# ============================================================
# 解析 kernel32!ReadConsoleInputExA 到 Guard_ReadEx(整个进程只做一次)。
# 返回 1=可用(含此前已解析成功),0=不可用(该导出不存在 ⇒ 调用方走降级路径)。
# 解析不到的情形(旧系统无该导出)在这里被"记住"(GUARD_EX_MISSING),不再重复
# GetProcAddress —— 取数路径每次判定都会调本函数,重复解析会白烧一次 API。
# 两个字符串以字面量传(与 VDirs.vp:186 把 "USERPROFILE" 传给 LPCSTR 形参同一写法):
# * "kernel32.dll":该 DLL 在所有 Win32 进程里都已加载,GetModuleHandleA 只取句柄、不增引用;
# * "ReadConsoleInputExA":DLL 导出名带 A 后缀(无 Ex 这个名字),见模块头"加固 1"。
def GuardResolveReadEx() -> int:
global Guard_ReadEx, Guard_ReadExState
if Guard_ReadExState == GUARD_EX_OK:
return 1
if Guard_ReadExState == GUARD_EX_MISSING:
return 0
Guard_ReadExState = GUARD_EX_MISSING # 先按"不可用"记:任何一步失败都不再重试
Hm: wb.HANDLE = wb.GetModuleHandleA("kernel32.dll")
if Hm is None or Hm == wb.INVALID_HANDLE_VALUE:
return 0
Fp: VOIDPTR = wb.GetProcAddress(Hm, "ReadConsoleInputExA")
if Fp is None:
return 0
Guard_ReadEx = Fp
Guard_ReadExState = GUARD_EX_OK
return 1
# ============================================================
# 加固 2:控制事件处理例程(SetConsoleCtrlHandler)
# ============================================================
# 控制事件处理例程。职责极小:把进入"原始按键"模式前的控制台模式写回(幂等),
# 然后一律返回 FALSE(交还默认处理 ⇒ Ctrl+C/Ctrl+Break/关窗仍按原语义终止本进程)。
# 取舍见模块头"加固 2"(返回 TRUE 必须自行 ExitProcess,否则会把"用户主动 Ctrl+C"
# 变成"看门狗继续跑到下一轮再杀父")。
# 线程安全:本函数可能被系统在**新线程**上调用,只读三个模块级标量(Guard_RawActive /
# Guard_ConHandle / Guard_SavedMode)并调用一次 SetConsoleMode;不分配内存、不写缓冲、
# 不碰 _mbuddy 池与 VLog 缓冲,故不与主线程的池/日志/扫描缓冲并发写。
# 被 TerminateProcess 强杀(父进程 StopWatchdog 回收、心跳失败杀父)时不执行本函数。
# 返回值写 0(= FALSE;本模块只 import 了 w32.win32base as wb,没有 FALSE 这个裸名)
def GuardCtrlHandler(CtrlType: ULONG) -> BOOL:
if Guard_RawActive == 0:
return 0
if Guard_ConHandle is not None:
wc.SetConsoleMode(Guard_ConHandle, Guard_SavedMode)
return 0
# 摘除控制事件处理例程(幂等;未注册时一次 API 都不调用)。
def GuardRemoveCtrlHandler():
global Guard_CtrlHandlerOn
if Guard_CtrlHandlerOn == 0:
return
SetConsoleCtrlHandler(GuardCtrlHandler, GUARD_CTRL_REMOVE)
Guard_CtrlHandlerOn = 0
# 进入"原始按键"控制台模式:清 ENABLE_LINE_INPUT 与 ENABLE_ECHO_INPUT(取舍见模块头)。
# 返回 1=可用(已切换,或两位本就未置位而无需切换),0=GetConsoleMode/SetConsoleMode 失败
# (此时模式未改动,调用方按失败处理)。
# 加固 2 的注册顺序(都在"确实要改模式"的分支里,两位本就未置位时不注册):
# ① 置 Guard_RawActive=1 并登记控制台句柄(先让 handler 具备写回所需的一切);
# ② 注册 handler(best-effort:失败只告警,正常路径仍在 GuardLeaveRaw 写回模式);
# ③ 真正 SetConsoleMode。这样 Ctrl+C 落在②与③之间时 handler 写回的是"尚未改动的原模式"
# (幂等空操作),不会留下"已清行输入 + 无 handler"的窗口。
def GuardEnterRaw(hIn: wb.HANDLE) -> int:
global Guard_SavedMode, Guard_RawActive, Guard_ConHandle, Guard_CtrlHandlerOn
Mode: ULONG = 0
if wc.GetConsoleMode(hIn, &Mode) == 0:
return 0
Guard_SavedMode = Mode
Guard_RawActive = 0
New: ULONG = Mode & ULONG(GUARD_RAW_CLEAR_MASK)
if New == Mode:
return 1 # 无需改模式 ⇒ 也无需 handler
Guard_ConHandle = hIn
Guard_RawActive = 1
if SetConsoleCtrlHandler(GuardCtrlHandler, GUARD_CTRL_ADD) != 0:
Guard_CtrlHandlerOn = 1
else:
VLog.LogStr("[confirm] 警告: SetConsoleCtrlHandler 注册失败,Ctrl+C 时该终端可能残留已清行输入的模式\n")
if wc.SetConsoleMode(hIn, New) == 0:
# 先清 Guard_RawActive 再清 Guard_SavedMode:handler 只在 RawActive!=0 时写回,
# 这个顺序保证它绝不会把"清零后的 SavedMode(=0)"写进控制台(此刻模式其实没变)。
Guard_RawActive = 0
Guard_SavedMode = 0
GuardRemoveCtrlHandler()
return 0
return 1
# 写回进入前的控制台模式(幂等;未实际切换过则一次 API 都不调用)。
# 顺序:写回模式 → 清 Guard_RawActive → 摘除 handler。这个顺序保证任何时刻都不存在
# "已清行输入但没有 handler"的窗口(摘除后已无待恢复的状态);反序则会在两步之间留一个
# 这样的窗口。注册与"已切换模式"同生命周期 ⇒ Guard_RawActive==0 ⇔ 未注册(无泄漏)。
def GuardLeaveRaw(hIn: wb.HANDLE):
global Guard_RawActive
if Guard_RawActive == 0:
return
wc.SetConsoleMode(hIn, Guard_SavedMode)
Guard_RawActive = 0
GuardRemoveCtrlHandler()
# 控制台路径:一次非阻塞取数。返回 1=已凑满一行,0=无待读记录/尚未成行,-1=读取失败。
# 只处理 KEY_EVENT(0x0001) 且 bKeyDown 的字符:可打印 ASCII(32..126) 与制表符(9) 累积,
# Enter 的 '\r'(13) 归一成 '\n' 后入队;其余(按键抬起、方向键/功能键、退格、非 ASCII 输入)
# 一律忽略(本路径不做行编辑)。
# 快修后的三步(与 GuardPumpStream 同构,未消费字节不再丢):
# ① 先 GuardFeedPending():FIFO 里可能已有上一批留下的完整行 ⇒ 凑满即 return 1(不发起读取);
# ② 需要新数据时才取一批记录(本轮仍是"一次调用最多 GUARD_REC_MAX 条");
# ③ 过滤后的字符**整批**压入 FIFO(GuardPendPut),再 GuardFeedPending() 并返回其结果——
# 该批可能自成一行,也可能只成一行的一部分,剩余字节留在 FIFO 由下次调用继续喂。
# 取数分两路(决定走哪一路的只有"ReadConsoleInputExA 是否解析得到"):
# * 主路径(加固 1):ReadConsoleInputExA + CONSOLE_READ_NOWAIT —— 文档明写"输入缓冲
# 为空也立即返回",故这里**没有**"先查数量再读"的两步组合,读取不再可能阻塞;
# 返回 0 条记录即"本轮暂无数据"。
# * 降级路径(解析不到该导出):有界等"可读"(上限 = min(剩余毫秒, GUARD_LEGACY_WAIT_MS))
# → GetNumberOfConsoleInputEvents → ReadConsoleInputA。残余竞态:查询到 N>0 之后若
# 另一读者取走记录,ReadConsoleInputA 会等**下一条记录**(该函数无超时参数)——
# 窗口极小但非零,见模块头"加固 1"。此处能做的收紧是:①这一步之前的等待不占满剩余
# 预算;②剩余为 0 时直接返回、不发起这次读(绝不在"已过期"的 deadline 上进入阻塞读)。
def GuardPumpConsole(hIn: wb.HANDLE, Deadline: ULONGLONG) -> int:
if GuardFeedPending() != 0: # ① FIFO 里已有整行 ⇒ 直接交回,不发起读取
return 1
Read: ULONG = 0
if GuardResolveReadEx() != 0:
# Guard_ReadEx 为模块级 t.Callable,其返回类型签名已随模块级 AnnAssign 登记
# (TranslatorGenMixin.handle_module_level_var),故可直接间接调用。
if Guard_ReadEx(hIn, t.CPtr(Guard_Recs), ULONG(GUARD_REC_MAX),
&Read, USHORT(CONSOLE_READ_NOWAIT)) == 0:
return -1
else:
Rem: ULONG = GuardRemainMs(Deadline)
if Rem == 0:
return 0
if Rem > ULONG(GUARD_LEGACY_WAIT_MS):
Rem = ULONG(GUARD_LEGACY_WAIT_MS)
if wsync.WaitForSingleObject(hIn, Rem) == ULONG(wb.WAIT_FAILED):
return -1
Cnt: ULONG = 0
if wc.GetNumberOfConsoleInputEvents(hIn, &Cnt) == 0:
return -1
if Cnt == 0:
return 0
N: ULONG = Cnt
if N > ULONG(GUARD_REC_MAX):
N = ULONG(GUARD_REC_MAX)
if wc.ReadConsoleInputA(hIn, t.CPtr(Guard_Recs), N, &Read) == 0:
return -1
ub: BYTEPTR = BYTEPTR(Guard_Recs)
i: int = 0
while i < int(Read):
Base: int = i * GUARD_REC_SIZE
i += 1
if VPe.ReadU16(ub, Base) != uint32(wc.KEY_EVENT):
continue
if VPe.ReadU32(ub, Base + GUARD_KEY_DOWN_OFF) == 0:
continue
Ch: int = int(VPe.ReadU16(ub, Base + GUARD_KEY_CHAR_OFF))
if Ch == 13:
Ch = 10 # Enter → 行结束(与 '\n' 同一口径)
elif Ch != 9 and (Ch < 32 or Ch > 126):
continue # 非可打印/非 ASCII:忽略
GuardPendPut(Ch) # ③ 过滤后压入 FIFO(本次调用至多 16 字节)
if GuardFeedPending() != 0: # ③ 再喂 FIFO;剩余字节留给下次调用
return 1
return 0
# 管道/重定向路径:一次非阻塞取数。返回 1=已凑满一行,0=当前无可读字节(尚未成行),
# -1=读取失败,GUARD_PUMP_EOF=不可能再出现输入(ReadFile 读到 0 字节)。
# 管道路径先 PeekNamedPipe 问"当前真正可用的字节数",只读 min(可用, 块上限);
# 非管道(磁盘文件重定向等)无法预询可读量,改读一块有界字节(<= GUARD_CHUNK_CAP),
# 读到 0 字节即 EOF。ReadFile 只在"已知有数据(管道路径)"或"有界块(其它路径)"时调用。
# 快修后的三步(同 GuardPumpConsole):① 先 GuardFeedPending()(FIFO 里可能已有整行);
# ② 需要新数据才读一批(读入量同时受 GUARD_CHUNK_CAP 与 GUARD_PEND_CAP 约束,保证整批放得进
# FIFO);③ 把该批**全部**字节压入 FIFO(不再"凑满一行就丢弃同批剩余字节"),再 GuardFeedPending()
# 并返回其结果——同批的剩余字节留在 FIFO,由下一次调用继续喂。
# 取舍:PeekNamedPipe 报"可用 0 字节"时统一按"暂无数据"推进(不据此判 EOF)——
# 该函数无法区分"写端仍开、暂无数据"与"写端已关、无数据",据此判 EOF 有误杀风险;
# 由调用方的 deadline 兜底(到点按超时失败收尾,与旧实现"EOF 当空行失败"的最终结果一致,
# 仅失败时机由"读到 EOF"变为"到点")。EOF 仅在非管道流上可判(读盘重定向读完即 0 字节)。
def GuardPumpStream(hIn: wb.HANDLE, Kind: int) -> int:
if GuardFeedPending() != 0: # ① FIFO 里已有整行 ⇒ 直接交回,不发起读取
return 1
N: ULONG = ULONG(GUARD_CHUNK_CAP)
if Kind == GUARD_IN_PIPE:
Avail: ULONG = 0
if PeekNamedPipe(hIn, t.CPtr(0), 0, t.CPtr(0), &Avail, t.CPtr(0)) == 0:
return -1
if Avail == 0:
return 0
if Avail < N:
N = Avail
# ② 读入量的两个上界各夹一次(顺序无关,两者都必须成立):
# * 必须 ≤ 读块缓冲的实际大小 GUARD_CHUNK_CAP —— Guard_Chunk 正是按该值分配的,
# 给 ReadFile 传更大的 N 会写穿缓冲(当前 N 初值即 GUARD_CHUNK_CAP,本夹是防将来改动);
# * 必须 ≤ GUARD_PEND_CAP —— 本批要整批放进 FIFO,故声明处要求 GUARD_PEND_CAP >= GUARD_CHUNK_CAP。
if N > ULONG(GUARD_CHUNK_CAP):
N = ULONG(GUARD_CHUNK_CAP)
if N > ULONG(GUARD_PEND_CAP):
N = ULONG(GUARD_PEND_CAP)
Br: ULONG = 0
if wf.ReadFile(hIn, Guard_Chunk, N, &Br, None) == 0:
return -1
if Br == 0:
return GUARD_PUMP_EOF
ub: BYTEPTR = BYTEPTR(Guard_Chunk)
for i in range(int(Br)):
GuardPendPut(int(ub[i])) # ③ 先整批入队(Br <= N <= GUARD_PEND_CAP)
if GuardFeedPending() != 0: # ③ 再喂 FIFO;剩余字节留给下次调用
return 1
return 0
# ============================================================
# 看门狗侧:失败处理 + 主循环
# ============================================================
# 失败路径:stderr 打明原因 → stdout 逐字输出唯一一行 {"exit":1, "msg": "timeout/ok!=1"}\n
# → TerminateProcess(父进程) → sys.exit(1)。
# 父进程被强杀(TerminateProcess 不做任何收尾):不写缓存、不再打本轮数组 JSON。
def GuardWatchFail(hParent: wb.HANDLE, Reason: str):
if Reason is not None:
VLog.LogFmtS1("[confirm] 失败: %s\n", Reason)
VLog.StdoutWrite("{\"exit\":1, \"msg\": \"timeout/ok!=1\"}\n")
if hParent is not None and hParent != wb.INVALID_HANDLE_VALUE:
wp.TerminateProcess(hParent, 1)
wb.CloseHandle(hParent)
sys.exit(1)
# 看门狗侧缓冲一次性分配;返回 1=可用,0=失败
def GuardWatchInit() -> int:
global Guard_Line, Guard_Stamp, Guard_Dl, Guard_Recs, Guard_Chunk, Guard_Pend
if Guard_Line is None:
Guard_Line = _mbuddy.alloc(GUARD_LINE_CAP)
Guard_Stamp = _mbuddy.alloc(GUARD_STAMP_CAP)
Guard_Dl = _mbuddy.alloc(GUARD_STAMP_CAP)
Guard_Recs = _mbuddy.alloc(GUARD_REC_CAP)
Guard_Chunk = _mbuddy.alloc(GUARD_CHUNK_CAP)
Guard_Pend = _mbuddy.alloc(GUARD_PEND_CAP)
if Guard_Line is None or Guard_Stamp is None or Guard_Dl is None:
return 0
if Guard_Recs is None or Guard_Chunk is None or Guard_Pend is None:
return 0
return 1
# 从提示发出起算,在 TimeoutMs 内等"完整的一行且分类通过"(控制台/管道/重定向两路共用)。
# 返回 1=确认成功(Guard_Line/Guard_Fill 为本轮输入);0=父进程已退出(调用方自退)。
# 失败(期限到点仍未收到合法 {"ok": 1} / 形状合法但值不对 / 读失败 / EOF)在本函数内收尾:
# 先恢复控制台模式,再 GuardWatchFail(打 stdout 失败 JSON + 杀父 + 退出码 1),故失败路径不返回。
#
# 全程非阻塞、由 deadline 驱动(快修后:**先取数、再等待/节拍**):
# ① 进入时清空未消费输入 FIFO 与行缓冲(GuardPendReset):新窗口不带上一轮的残留;
# ② 循环每轮先取数(GuardPump* 内部先喂 FIFO)——FIFO 里可能已有上一批留下的整行;若反过来
# 先 WaitForMultipleObjects,会因"stdin 此刻无可读数据、句柄不置信号"白等到期限耗尽;
# ③ 取到整行后先分类(GuardClassifyLine;check 行由 ParseCheckLine 优先判并转发);
# ④ 取数无果时才等:WaitForMultipleObjects({stdin, 父进程句柄}, min(剩余, GUARD_POLL_MS))——
# 上限取 min(剩余, 20ms) 而非整个剩余预算:读端已关的管道会永久置信号而可读字节为 0
# (PeekNamedPipe 无法区分"写端仍在/已关"),"全剩余"等待会立即返回并形成忙等;短上限把节拍
# 固定为 20ms(等价于旧实现取数后的 Sleep(20ms)),且父进程退出在 ≤20ms 内仍然可见;
# ⑤ 取数本身不阻塞(控制台:ReadConsoleInputExA + CONSOLE_READ_NOWAIT 立即返回;管道:可用字节
# > 0 才读;其它流:有界块),不存在等下一次按键/下一次回车才返回的读取;控制台降级路径
# (老系统无该导出)残余竞态见模块头"加固 1";
# ⑥ 分类结果三态:合法 {"ok": 1} ⇒ 成功返回;形状合法但值不对 ⇒ 致命失败(口径不放宽);
# 形状可疑 ⇒ stderr 回显(截断)后忽略该行并继续等,**期限(deadline)不变**;
# ⑦ 被忽略的行(可疑行 / 转发出去的 check 行)**不重置期限**,也不会绕过期限:两条忽略路径
# 都落到③④之间的"剩余为 0 ⇒ 超时失败"检查上 ⇒ 持续涌入的这类行同样会在期限到点收尾
# ("期限到点仍未收到合法 {"ok": 1} 才判失败"对"无输入"与"只有被忽略的行"一致成立)。
def GuardWaitConfirm(hIn: wb.HANDLE, hParent: wb.HANDLE, Kind: int, TimeoutMs: int) -> int:
global Guard_Fill
GuardPendReset() # ① 新一轮:FIFO 与行缓冲都不带上一轮残留
Guard_Fill = 0
Guard_Line[0] = 0
if Kind == GUARD_IN_CONSOLE:
# 清 ENABLE_LINE_INPUT:清掉后回车不再由系统"行输入"消化,而以 KEY_EVENT 到达,
# 我们才能自己判"一整行"(依据见模块头"控制台模式取舍")。
# 拿不到/改不了控制台模式 ⇒ 无法保证"timeout 内可判定"(旧行为是 ReadFile 等到回车),
# 故按既有约定失败收尾,不静默降级为会挂死的阻塞等待。
if GuardEnterRaw(hIn) == 0:
GuardWatchFail(hParent, "控制台模式读取/切换失败")
return 0
Deadline: ULONGLONG = wb.GetTickCount64() + ULONGLONG(uint32(TimeoutMs))
while True:
# ② 先取数(GuardPump* 内部先喂 FIFO):FIFO 里已有整行时不会白等一次 20ms
R: int = 0
if Kind == GUARD_IN_CONSOLE:
R = GuardPumpConsole(hIn, Deadline)
else:
R = GuardPumpStream(hIn, Kind)
if R == 1:
# 一行凑满 ⇒ 先分类(用仓库 json 库解析,不做字符串猜测):
# * check 请求(对象含 "check" 字符串 与 "id" 整数)⇒ 原样转发到 IPC 写端;
# 该行**不计入心跳、也不判失败**:期限继续走,用户仍需在期限内发 {"ok": 1}。
# * 其余 ⇒ 交 GuardClassifyLine 三态判定(ParseOkOne 口径不变)。
if VCheck.ParseCheckLine(str(Guard_Line)) != 0:
GuardForwardLine()
else:
Cls: int = GuardClassifyLine(Guard_Line, Guard_Fill)
if Cls == GUARD_LINE_OK:
break # 合法 {"ok": 1} ⇒ 出循环后收尾成功
if Cls == GUARD_LINE_BADOK:
# 形状合法但值不对("用户真的输错")⇒ 与旧实现同一失败口径与同一 stderr 文案
GuardLeaveRaw(hIn)
VLog.LogFmtS1("[confirm] 收到非 {\"ok\": 1} 的输入: %s\n", str(Guard_Line))
GuardWatchFail(hParent, "ok != 1")
return 0
# 形状可疑(首字符非 '{' / 不含 '}' / 长度异常,含空行)⇒ 忽略该行并回显(截断)
if Guard_Fill > GUARD_SUSPECT_ECHO_MAX:
Guard_Line[GUARD_SUSPECT_ECHO_MAX] = 0 # 只回显前缀(该行随后整个丢弃)
VLog.LogFmtS1("[confirm] 忽略可疑输入行: %s\n", str(Guard_Line))
Guard_Fill = 0
Guard_Line[0] = 0
# 两条"忽略"路径(check 转发 / 可疑行)都**落到下方的期限检查 + 等待**:
# 忽略不重置期限(Deadline 在循环外算好),但期限一旦到点仍按超时失败收尾
# ⇒ 持续涌入的可疑行/check 行不会让本窗口无限延长("期限到点仍未收到合法
# {"ok": 1} 才判失败"这一条对"无输入"与"只有被忽略的行"同样成立)。
# 此处 R==1,故下方 "if R < 0" 分支不会命中,控制流直接进入等待段。
if R < 0:
GuardLeaveRaw(hIn)
if R == GUARD_PUMP_EOF:
GuardWatchFail(hParent, "stdin 已到 EOF,不可能再有输入")
else:
GuardWatchFail(hParent, "读取 stdin 失败")
return 0
# ③ 本轮取数无果 ⇒ 等"stdin 可读 / 父进程退出 / 到点",上限 = min(剩余, GUARD_POLL_MS)
Rem2: ULONG = GuardRemainMs(Deadline)
if Rem2 == 0:
GuardLeaveRaw(hIn)
GuardWatchFail(hParent, "超时无输入")
return 0
Sl: ULONG = Rem2
if Sl > ULONG(GUARD_POLL_MS):
Sl = ULONG(GUARD_POLL_MS)
Hs: WATCH_HANDLES = WATCH_HANDLES()
Hs.H0 = hIn
Hs.H1 = hParent
Wm: ULONG = wsync.WaitForMultipleObjects(ULONG(WATCH_HANDLE_COUNT),
&Hs, 0, Sl)
if Wm == ULONG(wb.WAIT_OBJECT_0) + ULONG(1): # 父进程句柄(索引 1)置信号
GuardLeaveRaw(hIn)
return 0
if Wm == ULONG(wb.WAIT_FAILED):
GuardLeaveRaw(hIn)
GuardWatchFail(hParent, "等待输入失败")
return 0
# 其余取值(stdin 置信号 / WAIT_TIMEOUT)⇒ 回到循环顶部取数
GuardLeaveRaw(hIn)
return 1
# confirm-watch 入口(App/main.vp 分派)。返回进程退出码。
# 只做心跳确认,不碰扫盘/目录表/缓存。
def RunConfirmWatch() -> int:
global Guard_IpcWrite
if VCli.ParseWatchArgs() != 0:
return 1
# 详细度由父进程经 --verbose 透传(父为 --verbose 时 SpawnWatchdog 会追加该开关)
VLog.SetVerbose(VCli.WatchVerbose())
IntervalSec: int = VCli.WatchInterval()
TimeoutMs: int = VCli.WatchTimeout()
ParentPid: LONGLONG = VCli.WatchParentPid()
IntervalMs: ULONG = ULONG(IntervalSec) * ULONG(1000)
if GuardWatchInit() == 0:
VLog.LogStr("[confirm] 看门狗: 行/记录/时间戳/未消费输入缓冲分配失败\n")
return 1
VLog.LogFmt3("[confirm] 看门狗启动: 每 %u 秒要求一次 {\"ok\": 1},超时 %u ms,父进程 pid=%u\n",
sizet(IntervalSec), sizet(TimeoutMs), sizet(ParentPid))
# 父进程句柄:同时用于"父退出检测(SYNCHRONIZE)"与"失败时终止父进程(PROCESS_TERMINATE)"
hParent: wb.HANDLE = wp.OpenProcess(ULONG(WATCH_PARENT_ACCESS), 0, ULONG(ParentPid))
if hParent is None or hParent == wb.INVALID_HANDLE_VALUE:
# 拿不到父进程句柄 ⇒ 既无法监控也无法终止,无法履行契约:只记 stderr 并以 1 退出
VLog.LogFmt1("[confirm] 看门狗: OpenProcess 父进程失败 (GetLastError=%u),自身退出\n",
sizet(wb.GetLastError()))
return 1
hIn: wb.HANDLE = wf.GetStdHandle(wf.STD_INPUT_HANDLE)
if hIn is None or hIn == wb.INVALID_HANDLE_VALUE:
GuardWatchFail(hParent, "stdin 句柄无效")
return 1
# check 通道写端:父进程经 `--ipc-write <数值>` 传入的**句柄数值**,这里还原成 HANDLE
# (数值按 u64 位模式传递与解析;0/缺参 ⇒ 无通道,收到 check 行时只记 stderr)。
if VCli.WatchIpcWrite() != 0:
Guard_IpcWrite = t.CPtr(VCli.WatchIpcWrite())
else:
Guard_IpcWrite = None
Ch0: int = 0
if Guard_IpcWrite is not None:
Ch0 = 1
VLog.VerboseFmt1("[confirm] 看门狗: check 通道=%u (1=已配置 0=无)\n", sizet(Ch0))
# 输入形态只判一次(句柄在进程生命周期内不变);控制台与管道/重定向的取数路径不同。
Kind: int = GuardInputKind(hIn)
VLog.VerboseFmt1("[confirm] 看门狗: stdin 形态=%u (1=控制台 2=管道 3=其它流)\n",
sizet(Kind))
# 控制台取数路径(加固 1):主路径=ReadConsoleInputExA+CONSOLE_READ_NOWAIT,
# 降级=GetNumberOfConsoleInputEvents+ReadConsoleInputA(该导出不存在的老系统)。
# 这里提前解析一次(后续判定复用缓存的状态),顺带把实际走哪一路写进 --verbose 日志。
if Kind == GUARD_IN_CONSOLE:
GuardResolveReadEx()
VLog.VerboseFmt1("[confirm] 看门狗: 控制台取数路径=%u (1=ReadConsoleInputExA+NOWAIT 2=降级查询+ReadConsoleInputA)\n",
sizet(Guard_ReadExState))
while True:
# 阶段 1:等 interval;父进程先退出则立即自退(无需父侧配合,杜绝孤儿)
Ws: ULONG = wsync.WaitForSingleObject(hParent, IntervalMs)
if Ws == wb.WAIT_OBJECT_0:
VLog.LogStr("[confirm] 看门狗: 父进程已退出,自身退出\n")
wb.CloseHandle(hParent)
return 0
if Ws != wb.WAIT_TIMEOUT:
VLog.LogFmt1("[confirm] 看门狗: 等待父进程失败 (wait=%u)\n", sizet(Ws))
GuardWatchFail(hParent, "等待父进程失败")
return 1
# 阶段 2:stderr 提示(期限自本行起算)+ 在 timeout 内非阻塞等"完整一行且分类通过"
VLog.LogFmt2("[confirm] 请在 %u ms 内输入 {\"ok\": 1}(每 %u 秒一次)\n",
sizet(TimeoutMs), sizet(IntervalSec))
VLog.TimeStampNow(Guard_Stamp, GUARD_STAMP_CAP)
VLog.TimeStampAfter(Guard_Dl, GUARD_STAMP_CAP, sizet(TimeoutMs))
VLog.LogFmtS2("[confirm] 当前 %s, 本轮期限 %s\n", str(Guard_Stamp), str(Guard_Dl))
# 失败路径(期限到点/形状合法但值不对/读失败/EOF)已在 GuardWaitConfirm 内收尾并退出进程;
# 返回 0 只表示"父进程已退出",此时看门狗自退(不杀父、不打失败 JSON)。
if GuardWaitConfirm(hIn, hParent, Kind, TimeoutMs) == 0:
VLog.LogStr("[confirm] 看门狗: 父进程已退出,自身退出\n")
wb.CloseHandle(hParent)
return 0
VLog.LogFmt1("[confirm] 确认成功 (ok=1),下一次约 %u 秒后\n", sizet(IntervalSec))