Files
2026-10-17 21:57:25 +08:00

1076 lines
56 KiB
Plaintext

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 win32.win32file as wf
import win32.win32base as wb
import win32.win32sync as wsync
import win32.win32process as wp
import win32.win32console as wc
# ============================================================
# VGuard - heartbeat confirmation watchdog (--need-confirm <sec>; scan mode only)
#
# Design:
# * The parent (VET.exe scan --need-confirm x --need-confirm-timeout t) spawns itself as an
# independent watchdog child via CreateProcessA in App/main.vp:
# VET.exe confirm-watch --interval x --timeout t --parent-pid <pid>
# The parent never blocks on it. The child loops:
# sleep(interval) -> prompt on stderr -> wait up to timeout for one stdin line {"ok": 1}
# on success renew and loop; on failure (deadline reached without a valid {"ok": 1} /
# valid shape but bad value / read error / invalid stdin) it prints
# {"exit":1, "msg": "timeout/ok!=1"}\n to stdout, calls TerminateProcess(parent) and
# exits with code 1. "Deadline reached" is the only timeout failure exit; suspect lines
# are ignored and the loop keeps waiting (see "Quick-fix").
# * The scan side does not participate in confirmation; stdout stays "JSON only".
#
# Non-blocking input, driven by a single deadline (both paths share the same deadline, FIFO, line
# accumulation, classification and value rule; ParseOkOne is unchanged):
# * Console (GetFileType=FILE_TYPE_CHAR and GetConsoleMode succeeds): clear
# ENABLE_LINE_INPUT|ENABLE_ECHO_INPUT on entry (mask GUARD_RAW_CLEAR_MASK; see "Console mode
# tradeoff"). Primary path: ReadConsoleInputExA + CONSOLE_READ_NOWAIT (see "Hardening 1");
# fallback (export absent): bounded wait -> GetNumberOfConsoleInputEvents -> ReadConsoleInputA.
# Only KEY_EVENT(0x0001) with bKeyDown is accepted; chars are pushed into the FIFO as a batch,
# fed byte-by-byte into the line buffer until Enter ('\r' normalized to '\n'), then the mode
# is restored.
# * Pipe / redirect / other: for pipes peek the truly available byte count and read only
# min(available, buffer); otherwise read one bounded block (<= GUARD_CHUNK_CAP), 0 bytes = EOF.
# The batch is pushed into the FIFO, then fed into the line buffer; when idle, wait on the
# handle for min(remaining, GUARD_POLL_MS).
# * Every wait/read is bounded by the remaining milliseconds (GuardRemainMs); reads happen only
# after data is known available, so no read can block indefinitely.
#
# Console mode tradeoff (why clear ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT):
# * Clearing ENABLE_LINE_INPUT is required to detect a full line ourselves: with line input on,
# Enter is consumed by the system and never reaches the input buffer as a KEY_EVENT.
# * Clearing ENABLE_ECHO_INPUT removes a flag that only affects ReadFile/ReadConsole echo; this
# module never calls those, so the bit had no effect. Cost: no keystroke echo (the prompt
# already states the expected input).
# * All other bits stay, notably ENABLE_PROCESSED_INPUT (Ctrl+C semantics unchanged).
# * Restore: the original mode is saved via GetConsoleMode and Guard_RawActive is set only when
# the switch happened; GuardLeaveRaw writes it back on success / failure / parent-exit paths.
# Ctrl+C / Ctrl+Break / close is handled by SetConsoleCtrlHandler (see "Hardening 2"); only
# TerminateProcess cannot restore the mode.
#
# Hardening 1: read via ReadConsoleInputExA + CONSOLE_READ_NOWAIT (removes a race).
# * GetNumberOfConsoleInputEvents -> ReadConsoleInputA has a race: if another reader consumes the
# record between the two calls, ReadConsoleInputA blocks for at least one record (no timeout)
# and breaks the deadline. ReadConsoleInputExA(..., CONSOLE_READ_NOWAIT) returns immediately on
# an empty buffer, decoupling the read from "is data present now".
# * The export has no import library, so it is resolved dynamically via GetModuleHandleA /
# GetProcAddress (no new link-time symbol, loadable on any system). A static extern def would
# fail at load time on systems lacking the export, taking the whole exe down.
# * If resolution fails (older system), fall back to bounded wait -> GetNumberOfConsoleInputEvents
# -> ReadConsoleInputA, with the pre-wait capped at min(remaining, GUARD_LEGACY_WAIT_MS) and no
# read when remaining is 0.
# * Resolution is done once and cached in Guard_ReadExState.
#
# Hardening 2: SetConsoleCtrlHandler covers console-mode residue.
# * Ctrl+C / Ctrl+Break / close would otherwise terminate the process before the mode is written
# back. The registered handler GuardCtrlHandler restores the saved mode (idempotent).
# * Registered in GuardEnterRaw only when the mode actually changed (Guard_RawActive=1), removed
# by GuardLeaveRaw right after the write-back; both share one lifetime, so Guard_RawActive==0
# <=> not registered (no leak).
# * The handler always returns FALSE (default handling preserved: Ctrl+C/close still terminate
# the process). Returning TRUE would require ExitProcess to avoid turning a user Ctrl+C into a
# watchdog timeout that kills the parent.
# * The handler runs on a new system thread and only reads module scalars and calls SetConsoleMode
# once: no pool / VLog buffer access, so no concurrent write.
#
# check request channel (execution side: VCheck.vp):
# * The parent creates a pipe pair (GuardIpcCreate): the read end stays in the parent (inherit
# bit cleared); the write end is inheritable and its numeric handle value is passed via
# `--ipc-write <value>`; the child restores it with rawptr(value).
# * The child classifies each stdin line first (VCheck.ParseCheckLine): a line with a string
# "check" and an integer "id" is a check request, forwarded byte-for-byte (plus '\n') to the
# IPC write end, not counted as a heartbeat and never a failure; everything else goes to
# GuardClassifyLine.
# * The parent polls the read end without blocking at VScan poll points (PollIpc: PeekNamedPipe
# available bytes -> read only those -> split on '\n' -> VCheck.HandleCheckLine runs
# synchronously and replies).
#
# Quick-fix: batch input and suspect lines.
# * Fix 1 (dropped bytes in a batch): the old pump returned as soon as one line completed, losing
# the rest of the batch already read from the pipe. Now an unconsumed-input FIFO
# (Guard_Pend/Guard_PendLen/Guard_PendPos, capacity GUARD_PEND_CAP=128) is shared by both
# paths, three steps per round: (1) feed the FIFO first (it may already hold a full line);
# (2) read a new batch only when needed (bounded by GUARD_CHUNK_CAP/GUARD_REC_MAX/GUARD_PEND_CAP);
# (3) push the whole batch into the FIFO, then feed it. Return codes: 1=line complete,
# 0=no data / not yet a line, -1=read error, GUARD_PUMP_EOF=no more input possible.
# Invariant: GUARD_PEND_CAP >= GUARD_CHUNK_CAP and >= GUARD_REC_MAX.
# * Fix 2 (over-strict verdict): GuardClassifyLine(Buf, Len) returns SUSPECT(0) = ignore the line
# and keep waiting within the deadline (stderr echo, truncated to GUARD_SUSPECT_ECHO_MAX),
# OK(1) = ParseOkOne passed, BADOK(2) = valid shape but bad value (fatal failure, existing
# stderr text and GuardWatchFail(hParent, "ok != 1") path kept).
# A line is suspect if any of: (1) first char after leading space/tab is not '{'; (2) it contains
# no '}'; (3) it is longer than GUARD_OK_LINE_MAX(256). check lines are classified first, so (3)
# does not affect them.
# * The only timeout failure exit is "deadline reached without a valid {"ok": 1}"; ignored lines
# (check forwards / suspect lines) never reset the deadline, so a stream of them still ends at
# the deadline.
# * Accepted behavior change: an empty line is now ignored (suspect) instead of failing at once;
# the failure then occurs at the deadline with reason "timeout, no input".
#
# Why a subprocess instead of a thread:
# * Threads share the address space: VLog/VScan module buffers and the MemBuddy pool are
# process-wide singletons and not thread-safe; TerminateProcess cannot kill one thread.
# * A subprocess is memory-isolated (handle table, MemBuddy pool, module buffers); failure means
# TerminateProcess(parent) with no cleanup, matching the old sys.exit(1) "terminate now".
# * Cost: one extra process and one CreateProcessA; reclaimed precisely via the parent handle.
#
# Lifecycle / no orphans:
# * Normal parent exit: StopWatchdog() -> TerminateProcess(child) + CloseHandle.
# * Parent killed (Ctrl+C / TerminateProcess): the child holds an OpenProcess handle to the
# parent, so its wait returns immediately and the child exits on its own.
# * Child crash: the parent's Watch_Process handle is still closed by StopWatchdog.
#
# Dependencies: existing declarations in this repo (win32 file/sync/process/console/base,
# VPe.ReadU16/ReadU32, memhub.MemBuddy.alloc, VCheck.ParseCheckLine/HandleCheckLine, VLog,
# VCli.ParseUint/ParseUint64/ParseWatchArgs, sys.exit) plus the self-declared items below:
# SYNCHRONIZE (0x00100000), FILE_TYPE_DISK/CHAR/PIPE (0x0001/0x0002/0x0003),
# GUARD_RAW_CLEAR_MASK (~0x0006 = 0xFFFFFFF9), CONSOLE_READ_NOWAIT (0x0002),
# CTRL_C/BREAK/CLOSE/LOGOFF/SHUTDOWN_EVENT (0/1/2/5/6, not branched on),
# kernel32!PeekNamedPipe and kernel32!SetConsoleCtrlHandler (prototypes in the extern defs),
# kernel32!ReadConsoleInputExA (no import library; resolved dynamically, see "Hardening 1").
# ============================================================
define SYNCHRONIZE = 0x00100000 # winnt.h
# Access rights for OpenProcess: PROCESS_TERMINATE(0x0001) | SYNCHRONIZE(0x00100000)
define WATCH_PARENT_ACCESS = 0x00100001
define CONFIRM_WATCH_CMD = "confirm-watch" # internal hidden subcommand name
# stdin line buffer: holds both {"ok": 1} and a check request line ({"check":"<exe path>","id":N}),
# path up to MAX_PATH. 4096 (same as VCheck.CK_LINE_MAX); a longer line is treated as one line end.
define GUARD_LINE_CAP = 4096
define GUARD_STAMP_CAP = 64 # timestamp text buffer
define SELF_PATH_CAP = 1024 # GetModuleFileNameA output buffer
# Extra command-line room (subcommand + interval/timeout/parent-pid/ipc-write + --verbose + NUL;
# handles counted as up to 20 decimal digits). Enough slack to avoid a truncated command line.
define WATCH_CMD_EXTRA_CAP = 256
define WATCH_HANDLE_COUNT = 2 # number of handles passed to WaitForMultipleObjects
# Liveness-probe wait window after spawn (ms). Cost: each scan start waits this long.
# 400 balances "enough for the child to parse args/OpenProcess/first wait" and startup speed.
define WATCH_ALIVE_PROBE_MS = 400
# ---- non-blocking confirmation input (console / pipe / redirect) ----
# GetFileType return values (fileapi.h; not declared in this repo's includes)
define FILE_TYPE_DISK = 0x0001 # disk file (incl. '< file' redirect)
define FILE_TYPE_CHAR = 0x0002 # character device (console/LPT etc.)
define FILE_TYPE_PIPE = 0x0003 # pipe/socket (incl. '|' redirect)
# input kind (GuardInputKind return values)
define GUARD_IN_CONSOLE = 1 # console (CHAR + GetConsoleMode succeeds)
define GUARD_IN_PIPE = 2 # pipe (PeekNamedPipe can pre-query readable bytes)
define GUARD_IN_STREAM = 3 # other stream (disk file etc.: bounded read only)
# clear mask: complement of ENABLE_LINE_INPUT(0x0002)|ENABLE_ECHO_INPUT(0x0004) (wincon.h)
define GUARD_RAW_CLEAR_MASK = 0xFFFFFFF9
# KEY_EVENT_RECORD field offsets in INPUT_RECORD (x64 layout; see VET/README.md)
define GUARD_KEY_DOWN_OFF = 4 # KeyEvent.bKeyDown (BOOL)
define GUARD_KEY_CHAR_OFF = 14 # KeyEvent.uChar (first 2 bytes of the WCHAR/CHAR union)
define GUARD_REC_SIZE = 20 # sizeof(INPUT_RECORD)=EventType(2)+pad(2)+union(16)
define GUARD_REC_MAX = 16 # max records per ReadConsoleInputA call
define GUARD_REC_CAP = 320 # record buffer size in bytes (20*16)
define GUARD_CHUNK_CAP = 64 # max bytes per ReadFile for pipe/file
define GUARD_POLL_MS = 20 # idle tick with no readable data (same as VScan.PACE_SLEEP_MS)
# pump result codes (shared; -1 means read error)
define GUARD_PUMP_EOF = -2 # no more input possible (ReadFile returned 0 bytes)
# ---- unconsumed-input FIFO (shared by both paths; see module head "Quick-fix") ----
# Capacity 128 = max(GUARD_CHUNK_CAP=64, GUARD_REC_MAX=16) x 2. Invariant: keep GUARD_PEND_CAP
# >= GUARD_CHUNK_CAP and >= GUARD_REC_MAX so one batch fits (clamps in the pumps enforce it).
define GUARD_PEND_CAP = 128 # unconsumed-input FIFO capacity (bytes)
# suspect-line echo truncation length (stderr echoes only the prefix)
define GUARD_SUSPECT_ECHO_MAX = 120
# line classification codes (GuardClassifyLine; see module head "Quick-fix")
define GUARD_LINE_SUSPECT = 0 # suspect shape => ignore the line and keep waiting
define GUARD_LINE_OK = 1 # valid {"ok": 1} => this line confirms success
define GUARD_LINE_BADOK = 2 # valid shape but bad value => fatal failure (rule not relaxed)
# Shape-check length limit: a valid {"ok": 1} is normally <= 16 bytes; 256 leaves room for
# whitespace expansion like "{ \"ok\" : 1 }". Longer lines are treated as torn/noise.
define GUARD_OK_LINE_MAX = 256
# ---- check request channel (watchdog -> parent IPC pipe; attached to --need-confirm) ----
# line buffer cap, same as the watchdog's GUARD_LINE_CAP (lines are forwarded byte-for-byte)
define IPC_LINE_CAP = 4096
define IPC_READ_CHUNK = 1024 # max bytes per ReadFile (only the confirmed-available part)
define IPC_PIPE_SIZE = 65536 # CreatePipe nSize hint (default too small; keep the write from blocking)
# ---- Hardening 1: dynamic resolution of ReadConsoleInputExA (no import library) ----
define CONSOLE_READ_NOWAIT = 0x0002 # returns immediately on an empty input buffer (documented wFlags)
# resolution state for ReadConsoleInputExA (Guard_ReadExState; 0 = not tried)
define GUARD_EX_OK = 1 # resolved to a function address (primary path)
define GUARD_EX_MISSING = 2 # export absent (fallback path, no retry)
# Single wait cap (ms) for the fallback "wait readable": keep this step from eating the whole
# remaining budget. 5 balances "enough for one input to arrive" and promptness.
define GUARD_LEGACY_WAIT_MS = 5
# Add parameter for SetConsoleCtrlHandler (literal to avoid confusion with TRUE/FALSE)
define GUARD_CTRL_ADD = 1
define GUARD_CTRL_REMOVE = 0
# kernel32!PeekNamedPipe (not declared in this repo's includes): BOOL PeekNamedPipe(HANDLE,
# LPVOID, DWORD, LPDWORD, LPDWORD, LPDWORD). Handle must be a pipe read end; peeks without
# consuming and returns immediately; lpTotalBytesAvail = bytes currently readable.
extern def PeekNamedPipe(hNamedPipe: wb.HANDLE, lpBuffer: rawptr, nBufferSize: ULONG,
lpBytesRead: rawptr, lpTotalBytesAvail: ULONG*,
lpBytesLeftThisMessage: rawptr) -> BOOL
# kernel32!SetConsoleCtrlHandler (not declared in this repo's includes): BOOL SetConsoleCtrlHandler(
# PHANDLER_ROUTINE, BOOL Add), where PHANDLER_ROUTINE is BOOL (WINAPI*)(DWORD dwCtrlType).
# The handler name is passed directly and VPC bitcasts the function reference to a pointer.
# Returns 0 on failure (GetLastError); Add=FALSE removes the handler.
extern def SetConsoleCtrlHandler(HandlerRoutine: rawptr, Add: BOOL) -> BOOL
# injected by App/main.vp (same MemBuddy instance as other modules)
_mbuddy: memhub.MemBuddy*
# watchdog handles (parent side; StopWatchdog closes them)
Watch_Process: wb.HANDLE = None # CreateProcessA -> PROCESS_INFORMATION.hProcess
Watch_Thread: wb.HANDLE = None # PROCESS_INFORMATION.hThread
# watchdog-side buffers (allocated once inside confirm-watch)
Guard_Line: bytes = None # stdin line buffer
Guard_Stamp: bytes = None # "current time" text buffer
Guard_Dl: bytes = None # "round deadline" text buffer
Guard_Recs: bytes = None # console INPUT_RECORD array buffer (raw bytes, read by offset)
Guard_Chunk: bytes = None # pipe/file read chunk buffer
# Unconsumed-input FIFO (shared by both paths): a read batch is pushed here, then fed to Guard_Line.
# [Guard_PendPos, Guard_PendLen) is the range not yet fed; Pos==Len means empty.
Guard_Pend: bytes = None # unconsumed-input FIFO (capacity GUARD_PEND_CAP)
Guard_PendLen: int = 0 # bytes written into the FIFO (write cursor)
Guard_PendPos: int = 0 # bytes fed out of the FIFO (consume cursor)
Guard_Fill: int = 0 # current accumulated line length (in Guard_Line, excluding NUL)
Guard_SavedMode: ULONG = 0 # console mode before entering raw-key mode
Guard_RawActive: int = 0 # 1 = currently in switched mode (must be written back before exit)
# Watchdog-side write end of the check channel (passed via `--ipc-write <value>`; None = no channel).
# Only forwards check request lines verbatim; a write failure is logged to stderr only.
Guard_IpcWrite: wb.HANDLE = None
# ---- check request channel (parent side; non-blocking poll) ----
# The pipe is created by the parent: the read end stays here (inherit bit cleared); the write end
# is inheritable and its numeric handle is passed to the watchdog via `--ipc-write <value>`.
Ipc_Read: wb.HANDLE = None # pipe read end (parent-held; None = not created/closed)
Ipc_Line: bytes = None # line accumulation buffer (IPC_LINE_CAP)
Ipc_Chunk: bytes = None # single read chunk (IPC_READ_CHUNK)
Ipc_Fill: int = 0 # accumulated byte count (excluding NUL)
# Hardening 1: function pointer slot for ReadConsoleInputExA (t.Callable -> i8** slot, indirect call).
# ABI: HANDLE/PINPUT_RECORD/LPDWORD are i8*, DWORD=i32, wFlags=USHORT=i16. Filled once by
# GuardResolveReadEx; Guard_ReadExState is 0 / GUARD_EX_OK / GUARD_EX_MISSING.
Guard_ReadEx: callable[[rawptr, rawptr, ULONG, rawptr, USHORT], BOOL] = None
Guard_ReadExState: int = 0
# Hardening 2: control-handler registration state and the console handle whose mode was switched.
# Both are read on the system thread running GuardCtrlHandler, so they are written once before registration.
Guard_ConHandle: wb.HANDLE = None # console handle whose mode GuardEnterRaw switched
Guard_CtrlHandlerOn: int = 0 # 1 = registered (GuardLeaveRaw removes it)
# WaitForMultipleObjects needs a handle array (HANDLE is VOIDPTR, 8 bytes on x64). This typed
# struct of two HANDLE fields is stored contiguously (16 bytes), exactly as Win32 expects.
class WATCH_HANDLES():
H0: wb.HANDLE
H1: wb.HANDLE
# ============================================================
# check request channel: IPC pipe (parent side: create / close / non-blocking poll)
#
# Dedicated pipe (stdin/stdout/stderr are already taken; stdout's contract is single-line JSON).
# Created by the parent: read end stays here (inherit bit cleared), write end stays inheritable and
# its numeric handle is passed by value via `--ipc-write <value>` (u64 bit pattern, so high-bit
# handles survive; the child restores it with rawptr). The parent closes its write-end copy right
# after CreateProcessA so later --version probes do not inherit it.
#
# Reads never block: peek the currently available byte count, return if 0, else read only
# min(available, IPC_READ_CHUNK); the per-poll bound is what was already in the pipe, so the poll
# can sit on the scan hot path. '\n' ends a line (content excludes it); an overlong line is dropped.
# ============================================================
# Create the IPC pipe. Returns the write-end handle on success (caller passes it to the watchdog and
# closes it later); None on failure (treated as fatal). The read end is stored in Ipc_Read.
def GuardIpcCreate() -> wb.HANDLE:
global Ipc_Read, Ipc_Line, Ipc_Chunk, Ipc_Fill
GuardIpcClose() # idempotent: close any old pipe first
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 = None
Sa.bInheritHandle = 1 # create both inheritable first, then clear only the read end
hRead: wb.HANDLE = None
hWrite: wb.HANDLE = None
if wf.CreatePipe(&hRead, &hWrite,
&Sa, ULONG(IPC_PIPE_SIZE)) == 0:
VLog.LogFmt1("[confirm] Warning: check channel CreatePipe failed (GetLastError=%u)\n",
sizet(wb.GetLastError()))
return None
# read end is parent-only: clear inherit bit so no child (watchdog, --version probe) inherits it
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
# Close the IPC read end (idempotent; called by StopWatchdog and SpawnWatchdog failure paths).
def GuardIpcClose():
global Ipc_Read, Ipc_Fill
if Ipc_Read is not None:
wb.CloseHandle(Ipc_Read)
Ipc_Read = None
Ipc_Fill = 0
# Non-blocking poll of the IPC read end: drain currently available bytes, split on '\n', hand each
# line to VCheck (synchronously: PRVERSN read -> --version -> reply all inside this call).
# Returns the number of check request lines handled (0 = no data / non-check line / no channel).
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, None, 0, None, &Avail, None) == 0:
return Done # pipe error: stop reading this round (no block, no retry)
if Avail == 0:
return Done # no data => return immediately (frequent poll path)
N: ULONG = ULONG(IPC_READ_CHUNK)
if Avail < N:
N = Avail # read only the confirmed-available part => ReadFile will not block
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': end of a line
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 # overlong line: drop it (no overflow, no merging)
else:
Ipc_Line[Ipc_Fill] = char(Ch)
Ipc_Fill += 1
# ============================================================
# parent side: spawn / stop the watchdog
# ============================================================
# Get own exe full path (GetModuleFileNameA(NULL,...)). Returns 1 = success (Out NUL-terminated), 0 = failure.
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 # buffer too small (path truncated) => treat as failure
Out[int(n)] = 0
return 1
# Spawn the watchdog child. Returns 1 = spawned, 0 = failure. IntervalSec > 0 (caller guarantees);
# TimeoutMs = confirmation timeout (ms). Shares the parent's standard handles (STARTF_USESTDHANDLES)
# and console (dwCreationFlags=0). A liveness probe after spawn treats an immediate child exit
# (WAIT_OBJECT_0) as fatal (exit code + failure JSON, return 0); other wait results count as alive.
def SpawnWatchdog(IntervalSec: int, TimeoutMs: int) -> int:
global Watch_Process, Watch_Thread
if IntervalSec <= 0:
return 1 # disabled: do not spawn (caller only calls when >0)
StopWatchdog() # idempotent: reclaim any old watchdog first
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(rawptr(Self))
if Cmd is not None:
_mbuddy.free(rawptr(Cmd))
VLog.LogStr("[confirm] Warning: watchdog buffer allocation failed, not started\n")
return 0
if SelfExePath(Self, SELF_PATH_CAP) == 0:
_mbuddy.free(rawptr(Self))
_mbuddy.free(rawptr(Cmd))
VLog.LogStr("[confirm] Warning: GetModuleFileNameA failed to get own path, watchdog not started\n")
return 0
# Build the pipe first (the write-end value must be in the command line before CreateProcessA).
# Failure is fatal: silently lacking the requested check channel drops an explicit capability.
IpcW: wb.HANDLE = GuardIpcCreate()
if IpcW is None:
_mbuddy.free(rawptr(Self))
_mbuddy.free(rawptr(Cmd))
VLog.LogStr("[confirm] Warning: check channel pipe creation failed, watchdog not started\n")
return 0
# First token is the self path (quoted for spaces); --verbose is forwarded so the child uses the
# same verbosity. --ipc-write <value> is the pipe write-end handle value (u64), restored by the
# child with rawptr; a missing argument means "no channel".
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)
# Standard handles: same as the parent (inherited console/redirect); stdin is also handed over
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(rawptr(Self))
_mbuddy.free(rawptr(Cmd))
VLog.LogStr("[confirm] Warning: stdin handle invalid, watchdog not started (heartbeat confirmation requires stdin)\n")
return 0
# Best-effort mark standard handles inheritable (console handles usually inherit anyway; ignore)
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(None, Cmd, None, None, 1, 0,
None, None, &Si, &Pi)
CreateErr: ULONG = wb.GetLastError() # read right after the call (free overwrites GetLastError)
_mbuddy.free(rawptr(Self))
_mbuddy.free(rawptr(Cmd))
if Ok == 0:
wb.CloseHandle(IpcW)
GuardIpcClose()
VLog.LogFmt1("[confirm] Warning: watchdog CreateProcessA failed (GetLastError=%u), not started\n",
sizet(CreateErr))
return 0
# The write end was inherited by the watchdog => close our copy now, so later CreateProcess calls
# (--version probe) do not inherit it again.
wb.CloseHandle(IpcW)
Watch_Process = Pi.hProcess
Watch_Thread = Pi.hThread
# Liveness probe (see function head): only WAIT_OBJECT_0 (child exited) is fatal.
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] watchdog exited immediately after spawn (exit=%u), heartbeat confirmation cannot work\n",
sizet(Ec))
# Like StopWatchdog, close each handle once and clear the module handles (avoid a later
# StopWatchdog / re-Spawn double-closing).
wb.CloseHandle(Watch_Process)
Watch_Process = None
wb.CloseHandle(Watch_Thread)
Watch_Thread = None
GuardIpcClose() # watchdog exited => check channel is void, close the read end too
# Byte-identical to the watchdog failure line (GuardWatchFail), still single-line JSON. Keep
# the same msg because consumers abort on "exit"==1; the precise reason goes to stderr above.
VLog.StdoutWrite("{\"exit\":1, \"msg\": \"timeout/ok!=1\"}\n")
return 0
VLog.LogFmt2("[confirm] heartbeat watchdog started (pid=%u, every %u seconds",
sizet(Pi.dwProcessId), sizet(IntervalSec))
VLog.LogFmt1(", confirmation timeout %u ms)\n", sizet(TimeoutMs))
return 1
# Stop the watchdog (idempotent; called on normal/early parent exit). TerminateProcess marks it
# terminated (no more user-mode code); a bounded wait then ensures it is gone before closing handles.
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()
# ============================================================
# input parsing
# ============================================================
# Strictly match "key ok with value 1" (tolerating space 32 / tab 9 around key and colon). The char
# before '"ok"' must be '{'(123), ','(44) or the buffer start; the value must be digit 1(49) not
# followed by a digit. Returns 1 only for ok==1, else 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: # '{' or ','
KeyPos = 1
if KeyPos != 0 and i + 3 < Len:
# then '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: # next char not a digit => value is exactly 1
return 1
return 0
# Line classification (see module head "Quick-fix"). Returns SUSPECT(0) = ignore and keep waiting,
# OK(1) = valid {"ok": 1} (success), BADOK(2) = valid shape but bad value (fatal failure). Shape is
# checked first; ParseOkOne (unchanged) decides the value only after shape passes.
# Suspect if, in byte terms: (1) first char after leading space/tab is not '{'(123); (2) no '}'(125)
# in the line; (3) length > GUARD_OK_LINE_MAX(256). check lines are classified first, so (3) is safe.
def GuardClassifyLine(Buf: bytes, Len: int) -> int:
if Buf is None or Len <= 0:
return GUARD_LINE_SUSPECT # (1) empty line / no content
if Len > GUARD_OK_LINE_MAX:
return GUARD_LINE_SUSPECT # (3)
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 # (1) all-blank line
if int(ub[i]) != 123: # '{'
return GUARD_LINE_SUSPECT # (1)
j: int = i
while j < Len:
if int(ub[j]) == 125: # '}'
break
j += 1
if j >= Len:
return GUARD_LINE_SUSPECT # (2)
if ParseOkOne(Buf, Len) != 0:
return GUARD_LINE_OK
return GUARD_LINE_BADOK
# Forward the current line (Guard_Line / Guard_Fill) byte-for-byte to the IPC write end plus '\n'
# (the parent splits on '\n'). Content matches user input exactly. A write failure / no channel is
# stderr-only and does not change heartbeat semantics.
def GuardForwardLine():
if Guard_IpcWrite is None or Guard_IpcWrite == wb.INVALID_HANDLE_VALUE:
VLog.LogStr("[confirm] Warning: check request received but no check channel (--ipc-write not passed), line dropped\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] Warning: check request forward failed (pipe write failed), line dropped\n")
return
if wf.WriteFile(Guard_IpcWrite, "\n", 1, &bw, None) == 0:
VLog.LogStr("[confirm] Warning: check request forward failed (line separator write failed), the line may be incomplete\n")
# ============================================================
# non-blocking input pump (console / pipe / redirect share one deadline and line accumulation)
# ============================================================
# Remaining milliseconds: GetTickCount64 difference, saturated to 0 when expired (never negative).
# Since the --timeout cap (86400000) is < 2^32, the ULONG cast cannot truncate.
def GuardRemainMs(Deadline: ULONGLONG) -> ULONG:
Now: ULONGLONG = wb.GetTickCount64()
if Now >= Deadline:
return 0
return ULONG(Deadline - Now)
# Accumulate one byte (the only accumulation point). Returns 1 = a line completed, 0 = not yet.
# '\n'(10) ends a line without entering the buffer and drops a preceding '\r'(13); a full buffer
# (GUARD_LINE_CAP-1) also ends a line. A NUL is written after each append; Guard_Fill is the length.
def GuardFeedByte(Ch: int) -> int:
global Guard_Fill
ub: BYTEPTR = BYTEPTR(Guard_Line) # byte view for reading
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
# ---- unconsumed-input FIFO helpers (shared; see module head "Quick-fix") ----
# Clear the FIFO (called at the start of a new round: no residue from the previous round).
def GuardPendReset():
global Guard_PendLen, Guard_PendPos
Guard_PendLen = 0
Guard_PendPos = 0
# Feed unconsumed FIFO bytes one by one into the line buffer. Returns 1 = a line completed mid-way
# (rest stays in the FIFO) or 0 = FIFO drained without a line (cursors reset, FIFO reused from head).
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
# Write one byte into the FIFO (write cursor Guard_PendLen). A full-FIFO guard drops the byte with a
# stderr warning instead of overflowing; by the GUARD_PEND_CAP invariant "full" is unreachable.
def GuardPendPut(Ch: int):
global Guard_PendLen
if Guard_PendLen >= GUARD_PEND_CAP:
VLog.LogFmt1("[confirm] Warning: unconsumed-input FIFO full (capacity %u), dropping this byte batch\n",
sizet(GUARD_PEND_CAP))
return
Guard_Pend[Guard_PendLen] = char(Ch)
Guard_PendLen += 1
# Classify stdin: console / pipe / other stream. FILE_TYPE_CHAR also covers LPT etc., so
# GetConsoleMode is used to confirm a real console input buffer (=> read by records).
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' redirect) / FILE_TYPE_UNKNOWN (NUL etc.) are all treated as "other stream"
return GUARD_IN_STREAM
# ============================================================
# Hardening 1: dynamic resolution of ReadConsoleInputExA
# ============================================================
# Resolve kernel32!ReadConsoleInputExA into Guard_ReadEx (once per process). Returns 1 = available
# (incl. previously resolved), 0 = unavailable (export absent => fallback). A failure is remembered
# (GUARD_EX_MISSING) so GetProcAddress is not retried on every pump call.
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 # record "unavailable" first: any failed step stops retries
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
# ============================================================
# Hardening 2: control event handler (SetConsoleCtrlHandler)
# ============================================================
# Control event handler: restore the pre-raw console mode (idempotent) then always return FALSE
# (default handling preserved, so Ctrl+C/Ctrl+Break/close still terminate the process).
# Thread-safe by construction: runs on a system thread, only reads three module scalars and calls
# SetConsoleMode once (no allocation, no pool / VLog / scan buffer access).
# Not called when force-killed by TerminateProcess (StopWatchdog reclaim / heartbeat-fail kill).
# Return 0 (= FALSE; this module imports win32.win32base as wb and has no bare FALSE name)
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
# Remove the control handler (idempotent; no API call when not registered).
def GuardRemoveCtrlHandler():
global Guard_CtrlHandlerOn
if Guard_CtrlHandlerOn == 0:
return
SetConsoleCtrlHandler(GuardCtrlHandler, GUARD_CTRL_REMOVE)
Guard_CtrlHandlerOn = 0
# Enter raw-key console mode: clear ENABLE_LINE_INPUT and ENABLE_ECHO_INPUT (see module head).
# Returns 1 = usable (switched, or both bits were already clear), 0 = Get/SetConsoleMode failed.
# Registration order (Hardening 2): set Guard_RawActive=1 and record the handle, register the
# handler (best-effort), then SetConsoleMode, so a Ctrl+C between the last two steps restores the
# still-unchanged mode (idempotent no-op).
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 # mode unchanged => no handler needed
Guard_ConHandle = hIn
Guard_RawActive = 1
if SetConsoleCtrlHandler(GuardCtrlHandler, GUARD_CTRL_ADD) != 0:
Guard_CtrlHandlerOn = 1
else:
VLog.LogStr("[confirm] Warning: SetConsoleCtrlHandler registration failed; on Ctrl+C this console may keep the mode with line input cleared\n")
if wc.SetConsoleMode(hIn, New) == 0:
# Clear Guard_RawActive before Guard_SavedMode: the handler writes back only when
# RawActive != 0, so it never pushes the zeroed SavedMode to the console.
Guard_RawActive = 0
Guard_SavedMode = 0
GuardRemoveCtrlHandler()
return 0
return 1
# Write back the pre-entry console mode (idempotent; no API call if never switched). Order: write
# back, clear Guard_RawActive, remove the handler; this order leaves no "line input cleared but no
# handler" window. Registration and RawActive share one lifetime (RawActive==0 <=> not registered).
def GuardLeaveRaw(hIn: wb.HANDLE):
global Guard_RawActive
if Guard_RawActive == 0:
return
wc.SetConsoleMode(hIn, Guard_SavedMode)
Guard_RawActive = 0
GuardRemoveCtrlHandler()
# Console path: one non-blocking pump. Returns 1 = line complete, 0 = no pending record / not yet,
# -1 = read error. Accepts only KEY_EVENT(0x0001) with bKeyDown: printable ASCII(32..126) and tab(9),
# Enter '\r'(13) normalized to '\n'; all else ignored. Steps: feed the FIFO, else read one batch
# (primary: ReadConsoleInputExA+NOWAIT; fallback: bounded wait -> query -> ReadConsoleInputA), push
# filtered chars, then feed again.
def GuardPumpConsole(hIn: wb.HANDLE, Deadline: ULONGLONG) -> int:
if GuardFeedPending() != 0: # (1) FIFO already has a full line => return it, no read
return 1
Read: ULONG = 0
if GuardResolveReadEx() != 0:
# Guard_ReadEx is a module-level t.Callable whose return signature was registered with the
# module-level annotation, so it can be called indirectly.
if Guard_ReadEx(hIn, rawptr(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, rawptr(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 -> end of line (same as '\n')
elif Ch != 9 and (Ch < 32 or Ch > 126):
continue # non-printable / non-ASCII: ignore
GuardPendPut(Ch) # (3) push filtered chars into the FIFO (at most 16 bytes per call)
if GuardFeedPending() != 0: # (3) feed the FIFO again; remaining bytes stay for the next call
return 1
return 0
# Pipe/redirect path: one non-blocking pump. Returns 1 = line complete, 0 = no readable bytes,
# -1 = read error, GUARD_PUMP_EOF = no more input (0-byte ReadFile). Pipes peek the available count
# and read only that; other streams read one bounded block (<= GUARD_CHUNK_CAP). Steps: feed FIFO,
# read a batch, push the whole batch, feed again. A peek of 0 means "no data", not EOF (the deadline
# decides the timeout).
def GuardPumpStream(hIn: wb.HANDLE, Kind: int) -> int:
if GuardFeedPending() != 0: # (1) FIFO already has a full line => return it, no read
return 1
N: ULONG = ULONG(GUARD_CHUNK_CAP)
if Kind == GUARD_IN_PIPE:
Avail: ULONG = 0
if PeekNamedPipe(hIn, None, 0, None, &Avail, None) == 0:
return -1
if Avail == 0:
return 0
if Avail < N:
N = Avail
# (2) Clamp the read size by both bounds (order-independent): <= GUARD_CHUNK_CAP (the actual chunk
# buffer size; a larger N would overflow it) and <= GUARD_PEND_CAP (the whole batch must fit the FIFO).
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])) # (3) enqueue the whole batch first (Br <= N <= GUARD_PEND_CAP)
if GuardFeedPending() != 0: # (3) feed the FIFO again; remaining bytes stay for the next call
return 1
return 0
# ============================================================
# watchdog side: failure handling + main loop
# ============================================================
# Failure path: reason to stderr -> the single stdout line {"exit":1, "msg": "timeout/ok!=1"}\n ->
# TerminateProcess(parent) -> sys.exit(1). The parent is killed with no cleanup (no cache, no JSON).
def GuardWatchFail(hParent: wb.HANDLE, Reason: str):
if Reason is not None:
VLog.LogFmtS1("[confirm] Failure: %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)
# Allocate watchdog-side buffers once; returns 1 = usable, 0 = failure
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
# Wait from the prompt on, within TimeoutMs, for a complete classified line (all paths). Returns
# 1 = confirmed (Guard_Line/Guard_Fill hold the input), 0 = parent exited. Failures are finalized
# here (restore mode, then GuardWatchFail: failure JSON + kill parent + exit 1). Non-blocking and
# deadline-driven: clear FIFO/line buffer, pump first (feeding the FIFO), classify, wait only when
# nothing was read; ignored lines never reset the deadline.
def GuardWaitConfirm(hIn: wb.HANDLE, hParent: wb.HANDLE, Kind: int, TimeoutMs: int) -> int:
global Guard_Fill
GuardPendReset() # (1) new round: FIFO and line buffer carry no residue
Guard_Fill = 0
Guard_Line[0] = 0
if Kind == GUARD_IN_CONSOLE:
# Clear ENABLE_LINE_INPUT so Enter arrives as a KEY_EVENT we can judge ourselves (see module
# head "Console mode tradeoff"). If the mode cannot be read/switched, fail as usual.
if GuardEnterRaw(hIn) == 0:
GuardWatchFail(hParent, "console mode read/switch failed")
return 0
Deadline: ULONGLONG = wb.GetTickCount64() + ULONGLONG(uint32(TimeoutMs))
while True:
# (2) pump first (GuardPump* feeds the FIFO first): no wasted 20ms wait when a line is buffered
R: int = 0
if Kind == GUARD_IN_CONSOLE:
R = GuardPumpConsole(hIn, Deadline)
else:
R = GuardPumpStream(hIn, Kind)
if R == 1:
# A completed line is classified first (via the repo json library): a check request is
# forwarded verbatim and does not count as a heartbeat; anything else goes to
# GuardClassifyLine (ParseOkOne unchanged).
if VCheck.ParseCheckLine(str(Guard_Line)) != 0:
GuardForwardLine()
else:
Cls: int = GuardClassifyLine(Guard_Line, Guard_Fill)
if Cls == GUARD_LINE_OK:
break # valid {"ok": 1} => break; success finalized after the loop
if Cls == GUARD_LINE_BADOK:
# valid shape but bad value => fatal failure with the same stderr text
GuardLeaveRaw(hIn)
VLog.LogFmtS1("[confirm] received input that is not {\"ok\": 1}: %s\n", str(Guard_Line))
GuardWatchFail(hParent, "ok != 1")
return 0
# suspect shape (first char not '{' / no '}' / bad length, incl. empty) => echo (truncated) and ignore
if Guard_Fill > GUARD_SUSPECT_ECHO_MAX:
Guard_Line[GUARD_SUSPECT_ECHO_MAX] = 0 # echo only the prefix (the line is then dropped)
VLog.LogFmtS1("[confirm] ignoring suspect input line: %s\n", str(Guard_Line))
Guard_Fill = 0
Guard_Line[0] = 0
# Both ignore paths fall through to the deadline check + wait below. Ignoring does not
# reset the deadline, so a stream of suspect/check lines still ends at the deadline
# (R==1 here, so the "R<0" branch is skipped and control goes straight to the wait).
if R < 0:
GuardLeaveRaw(hIn)
if R == GUARD_PUMP_EOF:
GuardWatchFail(hParent, "stdin reached EOF, no more input possible")
else:
GuardWatchFail(hParent, "reading stdin failed")
return 0
# (3) nothing read: wait for stdin readable / parent exit / deadline, cap min(remaining, GUARD_POLL_MS)
Rem2: ULONG = GuardRemainMs(Deadline)
if Rem2 == 0:
GuardLeaveRaw(hIn)
GuardWatchFail(hParent, "timeout with no input")
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): # parent handle (index 1) signaled
GuardLeaveRaw(hIn)
return 0
if Wm == ULONG(wb.WAIT_FAILED):
GuardLeaveRaw(hIn)
GuardWatchFail(hParent, "waiting for input failed")
return 0
# other results (stdin signaled / WAIT_TIMEOUT) => loop back and pump again
GuardLeaveRaw(hIn)
return 1
# confirm-watch entry (dispatched by App/main.vp). Returns the process exit code.
# Only does heartbeat confirmation; never touches the scan / directory table / cache.
def RunConfirmWatch() -> int:
global Guard_IpcWrite
if VCli.ParseWatchArgs() != 0:
return 1
# Verbosity is forwarded by the parent via --verbose (SpawnWatchdog appends it)
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] watchdog: line/record/timestamp/unconsumed-input buffer allocation failed\n")
return 1
VLog.LogFmt3("[confirm] watchdog started: requires {\"ok\": 1} every %u seconds, timeout %u ms, parent pid=%u\n",
sizet(IntervalSec), sizet(TimeoutMs), sizet(ParentPid))
# Parent handle: used both for parent-exit detection (SYNCHRONIZE) and to terminate the parent
hParent: wb.HANDLE = wp.OpenProcess(ULONG(WATCH_PARENT_ACCESS), 0, ULONG(ParentPid))
if hParent is None or hParent == wb.INVALID_HANDLE_VALUE:
# No parent handle => cannot monitor or terminate; log to stderr and exit 1
VLog.LogFmt1("[confirm] watchdog: OpenProcess on parent failed (GetLastError=%u), exiting\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 handle invalid")
return 1
# check channel write end: the handle value passed by the parent via `--ipc-write <value>`,
# restored here (u64 bit pattern; 0 / missing => no channel, check lines only get a stderr note).
if VCli.WatchIpcWrite() != 0:
Guard_IpcWrite = rawptr(VCli.WatchIpcWrite())
else:
Guard_IpcWrite = None
Ch0: int = 0
if Guard_IpcWrite is not None:
Ch0 = 1
VLog.VerboseFmt1("[confirm] watchdog: check channel=%u (1=configured 0=none)\n", sizet(Ch0))
# Input kind is classified once (the handle never changes); console and pipe/redirect pump differently.
Kind: int = GuardInputKind(hIn)
VLog.VerboseFmt1("[confirm] watchdog: stdin kind=%u (1=console 2=pipe 3=other stream)\n",
sizet(Kind))
# Console pump path (Hardening 1): primary ReadConsoleInputExA+NOWAIT, fallback query+ReadConsoleInputA.
# Resolve once here (later rounds reuse the cached state) and log the chosen path under --verbose.
if Kind == GUARD_IN_CONSOLE:
GuardResolveReadEx()
VLog.VerboseFmt1("[confirm] watchdog: console read path=%u (1=ReadConsoleInputExA+NOWAIT 2=fallback query+ReadConsoleInputA)\n",
sizet(Guard_ReadExState))
while True:
# Phase 1: wait interval; if the parent exited first, exit immediately (no orphans)
Ws: ULONG = wsync.WaitForSingleObject(hParent, IntervalMs)
if Ws == wb.WAIT_OBJECT_0:
VLog.LogStr("[confirm] watchdog: parent exited, exiting\n")
wb.CloseHandle(hParent)
return 0
if Ws != wb.WAIT_TIMEOUT:
VLog.LogFmt1("[confirm] watchdog: waiting for parent failed (wait=%u)\n", sizet(Ws))
GuardWatchFail(hParent, "waiting for parent failed")
return 1
# Phase 2: stderr prompt (deadline starts here) + non-blocking wait for a complete classified line
VLog.LogFmt2("[confirm] please enter {\"ok\": 1} within %u ms (every %u seconds)\n",
sizet(TimeoutMs), sizet(IntervalSec))
VLog.TimeStampNow(Guard_Stamp, GUARD_STAMP_CAP)
VLog.TimeStampAfter(Guard_Dl, GUARD_STAMP_CAP, sizet(TimeoutMs))
VLog.LogFmtS2("[confirm] now %s, this-round deadline %s\n", str(Guard_Stamp), str(Guard_Dl))
# Failure paths (deadline / bad value / read error / EOF) already finalized the process inside
# GuardWaitConfirm; a 0 return only means "parent exited", so the watchdog exits itself.
if GuardWaitConfirm(hIn, hParent, Kind, TimeoutMs) == 0:
VLog.LogStr("[confirm] watchdog: parent exited, exiting\n")
wb.CloseHandle(hParent)
return 0
VLog.LogFmt1("[confirm] confirmed (ok=1), next in about %u seconds\n", sizet(IntervalSec))