diff --git a/docs/research/watchdog-desktop-diagnostic-plan.md b/docs/research/watchdog-desktop-diagnostic-plan.md new file mode 100644 index 00000000..af40bc8b --- /dev/null +++ b/docs/research/watchdog-desktop-diagnostic-plan.md @@ -0,0 +1,64 @@ +# #160 owned desktop 有界诊断准备 + +状态:独立有界诊断已执行,见[固定结果与身份](../../experiments/shell/results/2026-10-29-watchdog-desktop/README.md)。下文保留初始准备、false计划及执行前修正的历史合同,历史0次/null不代表当前次数。原CI未复现、原因未明,#160/#38/R2仍开放;生产监督器和首次判据未改,也不注册CTest、CI或默认项。未来执行须独立冻结新源码/构建/环境/二进制与输出,旧false计划不授权重跑。 + +## 固定失败与问题 + +固定源 `01a979da926c05c8e6c34e3f5e5ed7b8d6916de1` 的 PR157/default job123121553707:原 watchdog 3秒到期,`timed_out=true/exit99/watchdog_ready=true`,Job5/6/0→0/6/0;父 creator handle 关闭成功后首次 OpenDesktop 返回 handle,`desktop_gone=false`。它只证明那个时点可打开;没有后续采样或持有者证据,不能称永久泄漏。 + +原 archive 4794486B/SHA256 `05392812a79a0b38d48384b47905c85c9f46285cbedcf0b4afa2275550c9efbb`;LastTest 167717B/SHA256 `02830475404963817d9aaa240e1f859f9aff322101b6477a6fc1854110507afd`;shell watchdog sample 548B/SHA256 `a058824d202f7ad8bbc16975028b1fd14d162dd574dd820593210358b082682b`。原 archive、sample、路径、PID、desktop/window/thread identity 都仅私有保存。本项不混入同 archive 中独立 ConPTY watchdog 的结果。 + +现有 `desktop_gone` 在成功 OpenDesktop 后没有检查 CloseDesktop 返回。该盲点会影响后续观察的自持引用排除,但发生在首次 Open 已成功之后,不能据此解释首次成功。新入口只在诊断本身检查每次关闭,不修改原函数或原 oracle。 + +## 两 case,均最多一次 + +1. `retained-second-handle`:复用 OwnedTestDesktop 创建自有 non-input desktop,再 Open 一个明确记账的 second handle。关闭 creator,保留首次存在 probe 的 false;确认 extra handle 尚持有时可打开。检查关闭 extra 后,以其 CloseDesktop 完成时点为 anchor,在0/50/250/1000ms各作一次独立存在观察。无子进程,Job/root 不适用。 +2. `original-watchdog`:仍调用原 `execute(exe, "watchdog", out, timeout=3)`,保持真实 launcher/shell 树、GO、原 communicate/Job drain/异常清理期限与输出 oracle。诊断进程内部临时替换该调用使用的 Desktop/Job/Popen 观察 seam,结束即恢复;不编辑原 `run.py` 或 `OwnedJob`。Job assign 使用原 Popen handle 的非继承 duplicate,核对 PID/image/creation、精确 Job 成员及 GO 前1/1/0;所有关闭只操作本次自有 handle。 + +第二 case 的 creator close 与首次 Open 位于原执行边界,首 probe不插入窗口枚举。其完成后(存在时包含已检查的 probe Close)为 followup anchor,再在0/50/250/1000ms各观察一次。原 execute 的 finally 在返回前继续运行,故实际采样可能晚于目标时点;每个目标/实际 offset、API调用与关闭时间分列,不补采、不重排、不假称精确调度。每个 followup 即使之前已 absent 也独立记录;不是重试到绿。 + +首 `desktop_gone`、原 sample/status/error 永久保留。后续 gone 不覆盖首 false,不写产品通过或根因。若原 watchdog 不满足3秒 timeout、99、唯一 readiness JSON、空 stderr、deadline Job active≥2、最终 Job0及原清理,则停止,不作 followup。 + +## 观察与停止 + +原生 API 在入口真正获准执行后才装载/调用。记录 creator/probe/control/Job/duplicate-process 的 owned handle ledger、每次 Close 返回/错误;Popen 原 handle 单独注明所有权,不由 observer 关闭。记录保留根 handle 的 signaled 观察时点、GetProcessTimes 实际 exit FILETIME、Job accounting/terminate/drain/close、creator close、首次 Open。FILETIME和monotonic是不同时间域。 + +存在的 followup 仅对该自有 desktop 调用 EnumDesktopWindows,上限32个顶层窗口;仅查询调用者线程和这些窗口已知线程的 GetThreadDesktop。所得借用 handle 只读,不 CloseDesktop;不扫描其他 desktop/进程句柄或全局线程,不切换 desktop、不输入、不调用服务或个人设置。 + +OpenDesktop 只有原协议的 error2/3判为该时点 absent;其他错误、任意 Close 失败、枚举/已知线程查询错误、超上限、源/环境/文件 identity 漂移或已有输出目录均停止。失败 Close 不重试;原清理仍限原 gated root/精确 Job,自有剩余 handle 每个只关闭一次。第一 case 机制失败则不执行第二 case。不全局 kill、不重发 GO、不加长原3秒、不自动重跑。 + +最初 template `experiments/shell/watchdog-desktop-plan.json` 的 false/null 原件与 `a95166e` 身份保持不变;当前入口要求 v2 合同,后续使用新 `experiments/shell/watchdog-desktop-plan-r1.json`。root 另复制到私有新计划,填实际 HEAD、完整 sys.version/platform.version、六份源 SHA、实际 shell EXE、完整运行目录 inventory(相对路径→bytes/SHA)、构建证据文件与旧 archive identity;所有输入验证成功后才建 attempt/output。runtime inventory 应置于运行目录之外。新修订的后续执行命令(**本修订未运行**): + +```powershell +python -B -X utf8 experiments/shell/diagnose_watchdog_desktop.py --plan +``` + +`batch.json/diagnostic_complete` 和 CLI0只表示两组固定观察机械完成,`product_acceptance/root_cause` 始终null;first false 或未复现均不构成产品修复。单次原监督器已有有限等待、followup最多1秒;Win32同步API/文件I/O没有新强制中断,whole lifecycle bound=null。root执行租约须另给外层监督,不能把后续四个时间点或旧分段期限合称整生命周期硬上限。 + +## 能区分与仍未知 + +显式 second handle 对照能验证自持引用确实影响“可重新打开”;ledger可排查本诊断自己的 creator/probe/control 未关闭。已退出 root、精确 Job0与后续 gone 只能描述生命周期顺序;不能独自区分异步内核释放与未观察的外部/无窗口持有者。EnumDesktopWindows 无窗口也不证明无引用,不把 Job0当 desktop 删除;保留 borrowed thread association 的观测限制。任何 root cause 或生产修复均须后续同类 red 和独立证据。 + +API依据:[CloseDesktop](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closedesktop)只关闭给定 open handle且不接受GetThreadDesktop结果;[GetThreadDesktop](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getthreaddesktop)返回线程关联的借用handle;[EnumDesktopWindows](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumdesktopwindows)只枚举指定desktop的顶层窗口。 + +## 执行前 P2 修正与纯 seam 证据 + +`a95166e` 原准备源有两项审查缺口:诊断 `Job.close` 的 observer OSError 可能越过原 execute finally 的 sample写出;新输出可放入冻结 runtime,且批次完成前没有再次复核冻结输入。当前仅修诊断,不改原五份生产/监督源或任何原件。Job close先保留完整失败ledger并延迟observer错误,原 `sample.json` 写出且复制原 stdout/stderr/status/error 后,再失败停止;未成功 Close 不重试,也不将其记为 closed。原首probe、3秒watchdog、四个followup及两case各一次不变。 + +输出目录按 resolve后的路径比较,拒绝等于runtime及其任何子目录/别名;允许合法新 sibling目录。计划原字节、六源、EXE、完整runtime文件集合、inventory、build证据、原archive和HEAD在创建attempt前及batch完成前均复核;后验漂移或多出runtime文件会令 `diagnostic_complete=false`,错误私有保留。 + +首次修订的 `test_watchdog_desktop_seams.py` 只AST提取实际校验函数、Native.close、嵌套Job.close和原run.py的execute;不import诊断模块、不执行诊断main,不调用Win32/desktop/target。合成CloseHandle失败在原finally真实路径重现旧样本未写;旧a951的5项seam为2过/3失败(另两红为runtime子目录和`existing/..`别名误放)。当时修后普通与`-O`各10项通过,其中后验覆盖script/config/source/EXE/inventory/build/archive七种文件漂移、额外runtime成员和HEAD漂移;main wiring只静态核对AST调用顺序,未执行main。原日志/exit/hash留 `local/watchdog-desktop-p2/`。这些是执行前的历史纯mock记录,当时实际编译/desktop/目标执行均0、实际EXE为null,不能转记为后来原生尝试或当前新修订的运行结果。 + +## 唯一原生尝试与退出后观察污染 + +root在`6e67be541b134084ca8b0a64a94308e8e3d23242`唯一执行过两case批次,原件留私有`local/native-r160-0/`。retained-second-handle正对照完成;original-watchdog的retained根已退出、Job已0后,新增`QueryFullProcessImageNameW`返回WinError31。该observer异常从Job.drain覆写抛入原execute的`record.update(...)`求值,导致stdout/stderr/exit_code/job_after均未赋值;原finally虽写出sample,但error/cleanup_errors也被observer污染。首次desktop_gone=true,未复现旧CI首次false;不能用被污染的status失败推导原watchdog失败或CI根因。外层自然exit1、Job0/18/0,不等于诊断完成。旧计划、样本、观察和原始字段永久保留,不补填或回写。 + +当前只改诊断collector与纯测试。两次GO前root_state仍要求真实API成功、live wait258、PID与Popen相同、image与实际launcher相同、非零creation;同一retained handle的PID/image/creation证明保存后不变,第二次核验仍严格。每次root_state逐项记录API是否调用、return/error及取得的值;失败前已得字段仍保留,未取得值保持unknown/null。 + +只有已有该同handle的严格pre-GO证明、仍匹配PID/creation且wait=signaled时,image-query31才记录为明确观测缺口:本次image=null,API returned=false/error31,单列identity_reference引用旧证明,不能写成退出后查询成功。live时31、其他错误、PID/creation/handle漂移仍是诊断错误,不放松GO前检查或伪造当前image。 + +Job.drain在原super().drain完成后返回其真实accounting;附加root查询、begin/end事件及accounting事件错误只记为deferred observer error,原sample落盘并被run_case复制后再判诊断失败。terminate的额外accounting/事件观测同样defer,再调用原terminate;原drain/accounting/terminate本身的API错误照原路径抛出,不吞掉或转成通过。原run.py、OwnedJob与另外三份launcher/shell源逐字节不变,3秒、首probe、0/50/250/1000ms及两case最多各一次不变。 + +纯mock继续AST提取实际root_state、Job方法和原execute(含原finally),替换API/进程/Job为内存fake,仅写普通临时sample。旧6e67在新7项seam中5过/2失败,两个RED均为退出后31使stdout字段缺失;没有以不兼容mock或TypeError冒充反例。当前普通与`-O`各19/19:原passed及原`owned desktop remained`失败verdict分别保留;输出、99和真实fake Job0落盘;31的unknown/image-reference/error记录完整;其他root查询、drain/terminate/accounting事件与额外terminate-accounting错误推迟至样本之后;原drain API错误仍保留原错误;pre-GO错误及identity漂移仍拒绝。run_case先复制sample再验证deferred错误只做AST顺序核对,未运行诊断main。 + +最终日志、exit/source哈希和红绿sample精确字节及observer记录保留`local/watchdog-desktop-postexit-p2/`的`*-1`原件;早期18项结果另存不覆盖、不合并。纯mock没有Win32/desktop/C++GUI/VM/真实应用或诊断执行,不能证明Windows后续观察已成功,也不补成旧CI复现。新原生执行仍为0,后续由root另审新源码/租约后决定。 diff --git a/experiments/shell/diagnose_watchdog_desktop.py b/experiments/shell/diagnose_watchdog_desktop.py new file mode 100644 index 00000000..a6275cbf --- /dev/null +++ b/experiments/shell/diagnose_watchdog_desktop.py @@ -0,0 +1,621 @@ +"""Opt-in #160 desktop lifetime observation; never a product acceptance test.""" +from __future__ import annotations + +import argparse +import ctypes +from ctypes import wintypes +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import platform +import struct +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from experiments.shell import run as shell + +BASE = "6a10e5322a1bddeb038670e50f32ff6deac145cf" +SOURCE_FILES = ( + "experiments/shell/diagnose_watchdog_desktop.py", + "experiments/shell/run.py", "experiments/conpty/run.py", + "experiments/shell/test_launcher.cpp", "experiments/shell/shell.cpp", + "experiments/shell/shell_launch.cpp", +) +OFFSETS = (0, 50, 250, 1000) +FIXED = { + "schema": "termforge-watchdog-desktop-plan-v2", + "source_base_commit": BASE, + "cases": ["retained-second-handle", "original-watchdog"], + "maximum_attempts_per_case": 1, + "watchdog_timeout_seconds": 3, + "followup_offsets_ms": list(OFFSETS), + "followup_anchor": {"retained-second-handle": "second-handle-close-completed", + "original-watchdog": "original-first-probe-completed"}, + "maximum_windows_per_probe": 32, + "whole_lifecycle_bound_seconds": None, + "baseline_archive_sha256": "05392812a79a0b38d48384b47905c85c9f46285cbedcf0b4afa2275550c9efbb", +} +VARIABLE = {"execution_authorized", "execution_source_commit", "windows_version", + "python_version", "source_sha256", "actual_compiled_executable", + "runtime_inventory", "build_evidence", "baseline_archive", "output_directory"} + + +def identity(path): + data = Path(path).read_bytes() + return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + + +def absolute(value): + if not isinstance(value, str) or not Path(value).is_absolute(): + raise ValueError("An actual absolute path is required") + return Path(value).resolve(strict=True) + + +def bound_file(value): + if not isinstance(value, dict) or set(value) != {"path", "bytes", "sha256"}: + raise ValueError("A frozen file identity is required") + path = absolute(value["path"]) + if type(value["bytes"]) is not int or identity(path) != {k: value[k] for k in ("bytes", "sha256")}: + raise ValueError("Frozen file bytes differ") + return path + + +def output_directory(value, runtime): + if not isinstance(value, str) or not Path(value).is_absolute(): + raise ValueError("A new absolute output directory is required") + output, runtime = Path(value).resolve(), runtime.resolve(strict=True) + if output == runtime or runtime in output.parents: + raise ValueError("Diagnostic output must stay outside the frozen runtime") + if output.exists(): + raise ValueError("Diagnostic output must be new") + return output + + +def runtime_files(runtime): + result = {} + for member in runtime.rglob("*"): + if member.is_symlink(): + raise ValueError("Runtime symlinks are unsupported") + if member.is_file(): + result[member.relative_to(runtime).as_posix()] = identity(member) + return result + + +def git_head(): + return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + + +def verify_inputs(frozen): + if git_head() != frozen["source_commit"]: + raise ValueError("Execution source commit changed") + for name, expected in frozen["files"].items(): + if identity(Path(name)) != expected: + raise ValueError("A frozen input changed: " + name) + if runtime_files(Path(frozen["runtime_directory"])) != frozen["runtime_files"]: + raise ValueError("Complete frozen runtime inventory changed") + + +def finalize_batch(batch, frozen): + # Success is assigned only after the postcheck, including file additions. + batch["diagnostic_complete"] = False + try: + verify_inputs(frozen) + batch["postcheck_inputs_unchanged"] = True + except Exception as error: + batch["postcheck_inputs_unchanged"] = False + batch["postcheck_error"] = str(error) + return + batch["diagnostic_complete"] = len(batch["cases"]) == 2 and all( + case["status"] == "observations_complete" for case in batch["cases"]) + + +def require_observer_cleanup(record): + if record.get("deferred_observer_errors"): + raise OSError("Observer failed after the original sample was saved") + + +def load_plan(path): + plan_bytes = path.read_bytes() + plan = json.loads(plan_bytes.decode("utf-8-sig")) + if not isinstance(plan, dict) or set(plan) != set(FIXED) | VARIABLE: + raise ValueError("Unexpected plan fields") + for key, expected in FIXED.items(): + if json.dumps(plan[key], sort_keys=True, allow_nan=False) != json.dumps(expected, sort_keys=True): + raise ValueError("Unreviewed fixed plan contract: " + key) + if type(plan["execution_authorized"]) is not bool or not plan["execution_authorized"]: + raise ValueError("Execution is not authorized; no attempt/output created") + if os.name != "nt" or struct.calcsize("P") != 8: + raise ValueError("Windows x64 is required") + if plan["windows_version"] != platform.version() or plan["python_version"] != sys.version: + raise ValueError("Actual Windows/Python identity differs") + head = git_head() + if plan["execution_source_commit"] != head: + raise ValueError("Execution source commit differs") + hashes = plan["source_sha256"] + if not isinstance(hashes, dict) or set(hashes) != set(SOURCE_FILES): + raise ValueError("Complete source bindings are required") + sources = {str(ROOT / name): identity(ROOT / name) for name in SOURCE_FILES} + if any(sources[str(ROOT / name)]["sha256"] != hashes[name] for name in SOURCE_FILES): + raise ValueError("Source bytes differ from the frozen plan") + archive = absolute(plan["baseline_archive"]) + archive_identity = identity(archive) + if archive_identity["sha256"] != FIXED["baseline_archive_sha256"]: + raise ValueError("Original archive identity differs") + exe = bound_file(plan["actual_compiled_executable"]) + if exe.name != "termforge_shell.exe": + raise ValueError("Expected the original Win32 shell entry") + inventory_path = bound_file(plan["runtime_inventory"]) + inventory = json.loads(inventory_path.read_text(encoding="utf-8-sig")) + if not isinstance(inventory, dict) or not inventory: + raise ValueError("Frozen runtime inventory is required") + actual = runtime_files(exe.parent) + if inventory != actual or not {"termforge_shell.exe", "shell_test_launcher.exe"} <= set(actual): + raise ValueError("Complete runtime bytes differ") + build_evidence = bound_file(plan["build_evidence"]) + output = output_directory(plan["output_directory"], exe.parent) + files = {str(path): {"bytes": len(plan_bytes), "sha256": hashlib.sha256(plan_bytes).hexdigest()}, + str(archive): archive_identity, **sources} + for key, bound in (("actual_compiled_executable", exe), ("runtime_inventory", inventory_path), + ("build_evidence", build_evidence)): + files[str(bound)] = {field: plan[key][field] for field in ("bytes", "sha256")} + frozen = {"files": files, "source_commit": head, "runtime_directory": str(exe.parent), + "runtime_files": inventory} + verify_inputs(frozen) + return plan, exe, output, frozen + + +class Native: + """Only owned handles/desktops and threads already known to this case.""" + def __init__(self, record): + self.record = record + self.user = ctypes.WinDLL("user32", use_last_error=True) + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + self.user.OpenDesktopW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + self.user.OpenDesktopW.restype = wintypes.HANDLE + self.user.CloseDesktop.argtypes = [wintypes.HANDLE] + self.user.CloseDesktop.restype = wintypes.BOOL + self.callback = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + self.user.EnumDesktopWindows.argtypes = [wintypes.HANDLE, self.callback, wintypes.LPARAM] + self.user.EnumDesktopWindows.restype = wintypes.BOOL + self.user.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] + self.user.GetWindowThreadProcessId.restype = wintypes.DWORD + self.user.GetThreadDesktop.argtypes = [wintypes.DWORD] + self.user.GetThreadDesktop.restype = wintypes.HANDLE + self.user.GetUserObjectInformationW.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, + wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] + self.user.GetUserObjectInformationW.restype = wintypes.BOOL + self.kernel.GetCurrentProcess.restype = wintypes.HANDLE + self.kernel.GetCurrentThreadId.restype = wintypes.DWORD + self.kernel.DuplicateHandle.argtypes = [wintypes.HANDLE, wintypes.HANDLE, wintypes.HANDLE, + ctypes.POINTER(wintypes.HANDLE), wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + self.kernel.DuplicateHandle.restype = wintypes.BOOL + self.kernel.CloseHandle.argtypes = [wintypes.HANDLE] + self.kernel.CloseHandle.restype = wintypes.BOOL + self.kernel.IsProcessInJob.argtypes = [wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL)] + self.kernel.IsProcessInJob.restype = wintypes.BOOL + self.kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + self.kernel.WaitForSingleObject.restype = wintypes.DWORD + self.kernel.GetProcessId.argtypes = [wintypes.HANDLE] + self.kernel.GetProcessId.restype = wintypes.DWORD + self.kernel.QueryFullProcessImageNameW.argtypes = [wintypes.HANDLE, wintypes.DWORD, + wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)] + self.kernel.QueryFullProcessImageNameW.restype = wintypes.BOOL + self.kernel.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 + self.kernel.GetProcessTimes.restype = wintypes.BOOL + self.kernel.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + self.kernel.GetExitCodeProcess.restype = wintypes.BOOL + self.root = None + self.root_identity = None + self.desktop = None + self.first = None + self.last_job = None + self.process = None + self.root_observation_error = None + self.followup_anchor = None + + def event(self, kind, **fields): + row = {"kind": kind, "monotonic_ns": time.monotonic_ns(), + "utc": datetime.now(timezone.utc).isoformat(), **fields} + self.record["events"].append(row) + return row + + def defer_observer_error(self, stage, error, **fields): + self.record.setdefault("deferred_observer_errors", []).append( + {"stage": stage, "error": str(error), "type": type(error).__name__, **fields}) + + def own(self, value, kind): + row = {"value": int(value), "kind": kind, "ownership": "owned", "close_attempted": False, + "closed": False, "acquired_monotonic_ns": time.monotonic_ns()} + self.record["handle_ledger"].append(row) + return row + + def close(self, row, desktop=False): + if row["close_attempted"]: + raise OSError("A failed close is not retried") + row["close_attempted"] = True + ctypes.set_last_error(0) + ok = (self.user.CloseDesktop if desktop else self.kernel.CloseHandle)(row["value"]) + row.update(close_return=bool(ok), close_error=0 if ok else ctypes.get_last_error(), + close_monotonic_ns=time.monotonic_ns(), closed=bool(ok)) + if not ok: + raise ctypes.WinError(row["close_error"]) + + def known_thread(self, tid, reason): + ctypes.set_last_error(0) + handle = self.user.GetThreadDesktop(tid) + error = 0 if handle else ctypes.get_last_error() + row = {"thread_id": tid, "reason": reason, "handle": int(handle) if handle else None, + "ownership": "borrowed; never CloseDesktop", "error": error} + self.record["borrowed_thread_desktops"].append(row) + if not handle: + raise ctypes.WinError(error) + if handle: + text = ctypes.create_unicode_buffer(256) + needed = wintypes.DWORD() + if not self.user.GetUserObjectInformationW(handle, 2, text, ctypes.sizeof(text), ctypes.byref(needed)): + raise ctypes.WinError(ctypes.get_last_error()) + row["desktop_name"] = text.value + return row + + def windows(self, handle): + rows, callback_errors = [], [] + def visit(hwnd, unused): + try: + if len(rows) >= 32: + raise ValueError("Owned-desktop window observation exceeded 32") + pid = wintypes.DWORD() + tid = self.user.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if not tid: + raise ctypes.WinError(ctypes.get_last_error()) + rows.append({"hwnd": int(hwnd), "pid": pid.value, "thread_id": tid, + "thread_desktop": self.known_thread(tid, "owned desktop window")}) + return True + except Exception as error: + callback_errors.append(str(error)) + return False + callback = self.callback(visit) + ctypes.set_last_error(0) + ok = self.user.EnumDesktopWindows(handle, callback, 0) + if callback_errors: + raise OSError(callback_errors[0]) + if not ok: + raise ctypes.WinError(ctypes.get_last_error()) + return rows + + def probe(self, name, label, enumerate_windows=False, offset=None, anchor=None): + row = self.event("desktop_probe", label=label, requested_offset_ms=offset, + actual_offset_ms=None if anchor is None else (time.monotonic_ns() - anchor) / 1e6) + ctypes.set_last_error(0) + handle = self.user.OpenDesktopW(name, 0, False, 1) # Original DESKTOP_READOBJECTS access. + row.update(open_returned_handle=bool(handle), open_error=0 if handle else ctypes.get_last_error(), + open_completed_monotonic_ns=time.monotonic_ns()) + if not handle: + if row["open_error"] not in (2, 3): + raise ctypes.WinError(row["open_error"]) + row["desktop_gone_at_this_probe"] = True + return row + ledger = self.own(handle, "desktop existence probe") + try: + row["desktop_gone_at_this_probe"] = False + row["windows"] = self.windows(handle) if enumerate_windows else None + finally: + try: + self.close(ledger, desktop=True) + finally: + row["probe_handle_close"] = dict(ledger) + return row + + def root_state(self, label, *, strict=True): + if self.root is None: + return None + row = self.event("retained_root", label=label, strict_before_GO=strict, + retained_handle=self.root["value"], pid=None, image=None, wait=None, + exit_code=None, creation_filetime_100ns=None, exit_filetime_100ns=None, + identity_reference=None, observation_gaps=[], api_results={name: { + "attempted": False, "returned": None, "error": None} for name in ( + "WaitForSingleObject", "GetProcessTimes", "GetExitCodeProcess", + "GetProcessId", "QueryFullProcessImageNameW")}) + def observed(name, returned, ok): + error = 0 if ok else ctypes.get_last_error() + row["api_results"][name].update(attempted=True, returned=returned, error=error) + if not ok: + raise ctypes.WinError(error) + ctypes.set_last_error(0) + wait = self.kernel.WaitForSingleObject(self.root["value"], 0) + row["wait"] = wait + observed("WaitForSingleObject", wait, wait in (0, 258)) + if strict and wait != 258: + raise OSError("Original retained root is not live before GO") + created, exited, kernel, user = [wintypes.FILETIME() for _ in range(4)] + ctypes.set_last_error(0) + ok = self.kernel.GetProcessTimes(self.root["value"], ctypes.byref(created), ctypes.byref(exited), + ctypes.byref(kernel), ctypes.byref(user)) + observed("GetProcessTimes", bool(ok), bool(ok)) + value = lambda item: (item.dwHighDateTime << 32) | item.dwLowDateTime + row["creation_filetime_100ns"] = value(created) + row["exit_filetime_100ns"] = value(exited) if wait == 0 else None + code = wintypes.DWORD() + ctypes.set_last_error(0) + ok = self.kernel.GetExitCodeProcess(self.root["value"], ctypes.byref(code)) + observed("GetExitCodeProcess", bool(ok), bool(ok)) + row["exit_code"] = code.value + ctypes.set_last_error(0) + pid = self.kernel.GetProcessId(self.root["value"]) + row["pid"] = pid + observed("GetProcessId", pid, bool(pid)) + if not pid or pid != self.process.pid: + raise OSError("Retained root PID differs from original Popen") + if not row["creation_filetime_100ns"]: + raise OSError("Retained root creation time is unknown") + identity = self.root_identity + if identity is not None: + row["identity_reference"] = dict(identity) + if (self.root["value"], pid, row["creation_filetime_100ns"]) != ( + identity["retained_handle"], identity["pid"], identity["creation_filetime_100ns"]): + raise OSError("Retained root handle/PID/creation differs from its pre-GO identity") + image, size = ctypes.create_unicode_buffer(32768), wintypes.DWORD(32768) + ctypes.set_last_error(0) + ok = self.kernel.QueryFullProcessImageNameW(self.root["value"], 0, image, ctypes.byref(size)) + error = 0 if ok else ctypes.get_last_error() + row["api_results"]["QueryFullProcessImageNameW"].update(attempted=True, returned=bool(ok), error=error) + if not ok: + if strict or wait != 0 or error != 31 or identity is None: + raise ctypes.WinError(error) + row["observation_gaps"].append("Exited retained handle image query failed31; current image unknown; identity reference was verified before GO") + return row + row["image"] = image.value + if Path(image.value).resolve() != self.expected_root_image: + raise OSError("Retained root image differs from original launcher") + current = {"retained_handle": self.root["value"], "pid": pid, "image": str(Path(image.value).resolve()), + "creation_filetime_100ns": row["creation_filetime_100ns"]} + if identity is None: + if not strict: + raise OSError("Missing strictly verified pre-GO retained root identity") + self.root_identity = current + self.record["pre_GO_root_identity"] = dict(current) + elif current != identity: + raise OSError("Retained root identity differs from its pre-GO proof") + row["identity_reference"] = dict(self.root_identity) + return row + + def later_probes(self): + anchor = self.followup_anchor + if anchor is None: + first = self.first + anchor = first.get("probe_handle_close", {}).get("close_monotonic_ns", first["open_completed_monotonic_ns"]) + self.record["followup_anchor_monotonic_ns"] = anchor + for offset in OFFSETS: + remaining = anchor + offset * 1_000_000 - time.monotonic_ns() + if remaining > 0: + time.sleep(remaining / 1e9) + self.probe(self.desktop.name, "independent-followup", True, offset, anchor) + + +@contextmanager +def observed_shell(native): + """Process-local observation seam; production launcher and source stay unchanged.""" + original_desktop, original_job, original_subprocess = shell.OwnedTestDesktop, shell.OwnedJob, shell.subprocess + class Desktop(original_desktop): + def __init__(self): + super().__init__() + native.desktop = self + self.ledger = native.own(self.handle, "creator desktop") + native.event("desktop_created", name=self.name, full_name=self.full_name) + + def close(self): + if self.handle: + native.close(self.ledger, desktop=True) + self.handle = None + native.event("creator_closed", handle_ledger=dict(self.ledger)) + if native.first is not None: + raise OSError("The original first desktop probe is not repeated") + native.first = native.probe(self.name, "original-first-probe") + native.record["original_first_desktop_gone"] = native.first["desktop_gone_at_this_probe"] + return native.first["desktop_gone_at_this_probe"] + + class Job(original_job): + def __init__(self): + super().__init__() + self.ledger = native.own(self.handle, "exact supervisor Job") + + def assign(self, pid): + if native.root_observation_error: + raise OSError(native.root_observation_error) + if native.root is None or native.kernel.GetProcessId(native.root["value"]) != pid: + raise OSError("Retained Popen root identity differs before GO") + self.check(self.api.AssignProcessToJobObject(self.handle, native.root["value"])) + member = wintypes.BOOL() + self.check(native.kernel.IsProcessInJob(native.root["value"], self.handle, ctypes.byref(member))) + if not member.value: + raise OSError("Retained root is not in its exact Job") + before = self.accounting() + state = native.root_state("assigned-before-original-GO") + if before != {"active": 1, "total": 1, "terminated": 0} or state["wait"] != 258: + raise OSError("Original gated root is not alone and live before GO") + native.event("exact_job_membership_before_GO", pid=pid, member=True, accounting=before) + + def accounting(self): + result = super().accounting() + if result != native.last_job: + native.last_job = result + try: + native.event("job_accounting", value=result) + except Exception as error: + native.defer_observer_error("Job accounting observation", error, value=result) + return result + + def terminate(self): + before = None + try: + before = self.accounting() + native.event("original_job_terminate", accounting=before) + except Exception as error: + native.defer_observer_error("Job terminate observation", error, accounting=before) + super().terminate() + + def drain(self, timeout=5): + try: + native.event("original_job_drain_begin", timeout_seconds=timeout) + except Exception as error: + native.defer_observer_error("Job drain begin observation", error) + result = super().drain(timeout) + try: + native.root_state("after-original-drain", strict=False) + except Exception as error: + native.defer_observer_error("Retained root after drain observation", error) + try: + native.event("original_job_drain_end", value=result) + except Exception as error: + native.defer_observer_error("Job drain end observation", error, value=result) + return result + + def close(self): + if self.handle: + # __init__ failure can enter the inherited cleanup before ledger exists. + ledger = getattr(self, "ledger", None) or native.own(self.handle, "exact supervisor Job") + self.ledger = ledger + if ledger["close_attempted"]: + return + try: + native.close(ledger) + except Exception as error: + # Original execute must reach its finally sample write. + # Defer observer failure only; never change its verdict. + native.defer_observer_error("exact Job close", error, handle_ledger=dict(ledger)) + return + self.handle = None + + class Processes: + def __getattr__(self, key): + return getattr(original_subprocess, key) + + def Popen(self, *args, **kwargs): + if native.process is not None: + raise OSError("Only one original watchdog process launch is allowed") + process = original_subprocess.Popen(*args, **kwargs) + native.process = process # Keep the original Popen handle/object alive through observations. + try: + native.expected_root_image = Path(args[0][0]).resolve(strict=True) + duplicate = wintypes.HANDLE() + current = native.kernel.GetCurrentProcess() + if not native.kernel.DuplicateHandle(current, int(process._handle), current, ctypes.byref(duplicate), + 0, False, 2): + raise ctypes.WinError(ctypes.get_last_error()) + native.root = native.own(duplicate.value, "retained duplicate of original Popen process handle") + native.record["popen_handle"] = {"value": int(process._handle), "ownership": "Popen; borrowed by observer"} + native.root_state("created-before-assignment-and-GO") + except Exception as error: + # Return the Popen object before reporting this error from assign(). + # Original execute must own the gated, unassigned root for cleanup. + native.root_observation_error = str(error) + native.event("root_observation_failed_before_assignment", error=str(error)) + return process + + shell.OwnedTestDesktop, shell.OwnedJob, shell.subprocess = Desktop, Job, Processes() + try: + yield Desktop + finally: + shell.OwnedTestDesktop, shell.OwnedJob, shell.subprocess = original_desktop, original_job, original_subprocess + + +def save(path, value): + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def run_case(name, exe, output): + record = {"case": name, "status": "failed", "events": [], "handle_ledger": [], + "borrowed_thread_desktops": [], "original_first_desktop_gone": None, + "original_product_verdict": None, "root_cause": None, + "whole_lifecycle_bound_seconds": None} + native = Native(record) + held = None + try: + native.known_thread(native.kernel.GetCurrentThreadId(), "diagnostic caller; no desktop switch") + with observed_shell(native) as Desktop: + if name == "retained-second-handle": + desktop = Desktop() + extra = native.user.OpenDesktopW(desktop.name, 0, False, 1) + if not extra: + raise ctypes.WinError(ctypes.get_last_error()) + held = native.own(extra, "positive-control retained second desktop handle") + if desktop.close(): + raise ValueError("Positive control vanished while its second handle was retained") + record["positive_control_open_with_second_handle"] = True + native.close(held, desktop=True) + native.followup_anchor = held["close_monotonic_ns"] + native.event("positive_control_second_handle_released") + else: + original_output = output / "original" + try: + shell.execute(exe, "watchdog", original_output, timeout=3) + except Exception as error: + record["original_execute_exception"] = str(error) + sample = json.loads((original_output / "sample.json").read_text(encoding="utf-8")) + record["original_sample"] = sample + record["original_sample_identity"] = identity(original_output / "sample.json") + record["original_product_verdict"] = sample["status"] + require_observer_cleanup(record) + if (sample.get("timed_out") is not True or sample.get("exit_code") != 99 or + json.loads(sample.get("stdout", "")) != {"watchdog_ready": True} or sample.get("stderr") != "" or + sample.get("job_at_timeout", {}).get("active", 0) < 2 or + sample.get("job_after", {}).get("active") != 0 or sample.get("cleanup_errors") or + sample.get("error") not in (None, "owned desktop remained")): + raise ValueError("Original watchdog/cleanup contract differs; no followup") + if native.first is None or native.desktop.handle: + raise ValueError("Creator close/first probe did not complete; no followup") + native.later_probes() + record["status"] = "observations_complete" + except Exception as error: + record["error"] = str(error) + finally: + # Close remaining self-owned references once. Never retry a failed API close. + for ledger in record["handle_ledger"]: + if not ledger["close_attempted"]: + try: + native.close(ledger, desktop="desktop" in ledger["kind"]) + except Exception as error: + record.setdefault("cleanup_errors", []).append(str(error)) + if any(not row["closed"] for row in record["handle_ledger"]): + record["status"] = "failed" + if record.get("cleanup_errors"): + record["status"] = "failed" + save(output / "diagnostic.json", record) + return record + + +def main(): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--plan", type=Path, required=True) + args = cli.parse_args() + plan, exe, output, frozen = load_plan(args.plan.resolve(strict=True)) + output.mkdir(parents=False, exist_ok=False) + save(output / "attempt.json", {"plan": frozen["files"][str(args.plan.resolve(strict=True))], "source_commit": plan["execution_source_commit"], + "maximum_attempts_per_case": 1, "automatic_retry": False}) + batch = {"schema": "termforge-watchdog-desktop-diagnostic-v1", "source_commit": plan["execution_source_commit"], + "cases": [], "diagnostic_complete": False, "product_acceptance": None, "root_cause": None} + try: + for name in FIXED["cases"]: + directory = output / name + directory.mkdir(exist_ok=False) + result = run_case(name, exe, directory) + batch["cases"].append({"case": name, "status": result["status"], + "original_first_desktop_gone": result["original_first_desktop_gone"], + "original_product_verdict": result["original_product_verdict"]}) + if result["status"] != "observations_complete": + break + finally: + finalize_batch(batch, frozen) + save(output / "batch.json", batch) + print(json.dumps({"diagnostic_complete": batch["diagnostic_complete"], "cases_observed": len(batch["cases"]), + "product_acceptance": None, "root_cause": None})) + return 0 if batch["diagnostic_complete"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/shell/results/2026-10-29-watchdog-desktop/README.md b/experiments/shell/results/2026-10-29-watchdog-desktop/README.md new file mode 100644 index 00000000..ba693be6 --- /dev/null +++ b/experiments/shell/results/2026-10-29-watchdog-desktop/README.md @@ -0,0 +1,32 @@ +# 自有 desktop 生命周期的有界诊断(#160) + +两组固定观察已完成;本地原watchdog首次probe为已消失,**未复现CI首次可重新打开的现象,原CI原因仍未知**。正对照验证显式第二个handle存活时可重新打开,释放后四次独立probe均不存在。本次只修补充观察器,未修改生产监督器或首次判据,不宣布产品修复或关闭#160/#38/R2。 + +## 实际身份与执行 + +collector源为 `e8455b82d1cdd682a7dfde9f2303c7f1b43f63be`;复用源 `6e67be541b134084ca8b0a64a94308e8e3d23242` 已编译的Release x64默认C++/system ConPTY/Win32。shell为1236480B/SHA256 `85094afb626c162140608f8f5303cd30558c82cd98f534725b1dbeaf072aa2ff`,launcher为47616B/SHA256 `5165f20be105f529ae2c14b885aab7b537735852091e319ce0fb7dc261273cf4`,完整runtime12文件;此次未重编译。五份原监督/launcher/shell源与原CI `01a979d` 相同,不合并Qt/固定微软后端或其他配置的成功。 + +环境为Windows x64 10.0.26200、Python3.12.10;原构建为MSVC19.42.34433.0/CMake3.31.0。configure/build根均返回0,但即时Job尚有成员,各请求一次终止后归0,只证明编译成功,不证明构建整树自然回收。 + +原负例为 [PR157/default job123121553707](https://github.com/JavaZeroo/termforge/actions/runs/40774909044/job/123121553707):原3秒watchdog、timeout/99/readiness和Job0满足,creator关闭后首次Open成功。原ZIP SHA256 `05392812a79a0b38d48384b47905c85c9f46285cbedcf0b4afa2275550c9efbb`。本地观察不覆盖该负例。 + +本批两个case各一次: + +| case | 原首次probe | 后续四次独立probe | 原verdict | +|---|---|---|---| +| retained-second-handle | 第二handle仍持有,可打开 | 释放后均Open error2、已消失 | 不适用 | +| original-watchdog | creator关闭后error2、已消失 | 均error2、已消失 | passed | + +原watchdog仍为3秒:期限时Job5/6/0,原终止后root99、唯一readiness、stderr空、Job0/6/0、creator CloseDesktop成功。两次GO前严格核对retained root活性、PID/creation/image及精确Job;退出后同handle/PID/creation不变,但两次补充image query返回31,当前image明确unknown,引用既有预GO证明,不伪称查询成功。其余补充observer错误为0。 + +目标offset为0/50/250/1000ms;两个case的实际值均为0/47/250/1000ms。事后只读clock metadata为GetTickCount64、分辨率0.015625s;**目标不保证精确调度或最小等待**。原实际值全部保留,不补采、不覆盖首次判据,不作释放延迟的性能验收。 + +所有owned creator/control/probe/Job/duplicate-process handle均一次成功Close;borrowed thread desktop不关闭。外层执行30秒+共享清理5秒:root自然exit0、无timeout/外层Terminate、Job0/18/0,根句柄关闭。原watchdog由其原协议主动终止,不能称它自然退出。白名单投影见 [validation.json](validation.json)。 + +## 保留的失败与未知 + +旧collector `6e67be5` 已执行过一批:正对照完成;watchdog退出后补充image-query31打断原record.update求值,旧sample缺exit/stdout/job_after。旧原件保持,不补造字段、不拿它解释CI。修订只允许同retained证明下退出后image31为gap;其他observer错误延迟到原sample落盘和复制后再使诊断失败。真实AST反例为旧7项中2项RED,新普通与`-O`各19/19,纯mock和本次原生观察分列。 + +发布只离线投影,47个已有文件后验零变化,未重跑。公开JSON只含合成协议、布尔、计数、相对offset和哈希;desktop/window/thread/handle/PID、绝对路径、原始计划和完整日志私有保存。同步Win32/文件调用没有内部强制中断,硬回收来自外层自有Job;CLI0/diagnostic_complete仅表示固定观察完成。 + +仍未知:CI首次Open成功的持有者、释放时序及未观察的外部/无窗口引用。本地first absent不证明CI修复;若继续在CI调查,应另行冻结一次计划,保留原期限和原首次判据。 diff --git a/experiments/shell/results/2026-10-29-watchdog-desktop/validation.json b/experiments/shell/results/2026-10-29-watchdog-desktop/validation.json new file mode 100644 index 00000000..2e590b2c --- /dev/null +++ b/experiments/shell/results/2026-10-29-watchdog-desktop/validation.json @@ -0,0 +1,307 @@ +{ + "schema": "termforge-watchdog-desktop-sanitized-result-v1", + "collector_source_commit": "e8455b82d1cdd682a7dfde9f2303c7f1b43f63be", + "executable_build_source_commit": "6e67be541b134084ca8b0a64a94308e8e3d23242", + "configuration": "default-cpp-core-system-conpty-win32", + "executable": { + "bytes": 1236480, + "sha256": "85094afb626c162140608f8f5303cd30558c82cd98f534725b1dbeaf072aa2ff" + }, + "launcher": { + "bytes": 47616, + "sha256": "5165f20be105f529ae2c14b885aab7b537735852091e319ce0fb7dc261273cf4" + }, + "runtime_member_count": 12, + "windows_version": "10.0.26200", + "python_version": "3.12.10 (tags/v3.12.10:0cc8128, Apr 8 2025, 12:21:36) [MSC v.1943 64 bit (AMD64)]", + "source_sha256": { + "experiments/shell/diagnose_watchdog_desktop.py": "61cab85d80db79511c07ed4c6c7b34cc9e6e6a6bfdc50196c60d202b3c33fc2e", + "experiments/shell/run.py": "d77454ce626be025e088e6adea9d5f12150c039296397161b2bb8e00e08c1d66", + "experiments/conpty/run.py": "c8eef1e36b6d200b2d508cef91eaa5ca79a91fa87c5daf6265b2475c31dce329", + "experiments/shell/test_launcher.cpp": "19cab4a960f47f252c02f92a36316d43eed2481da1f72b5a7e97003c4fa1c2ed", + "experiments/shell/shell.cpp": "cc3830a52aebfad69871d974fed6c3f295f58e1e75feb451f7cfc11e0e56a3ef", + "experiments/shell/shell_launch.cpp": "03fac4122d8151539adf9e88c273e9fbaf632f2e80ba10ea77c3873f588db052" + }, + "execution_plan": { + "bytes": 2524, + "sha256": "585aa8283fac299746ecf57a80a19cf0e3b8ce9e6019f365ad175136c53aab38" + }, + "outer_lease": { + "bytes": 1751, + "sha256": "a612160a60dd6da928f187b746ac85de0caee096e4b6940e4d99250c2151e58e" + }, + "cases": [ + { + "case": "retained-second-handle", + "status": "observations_complete", + "first_desktop_gone": false, + "original_verdict": null, + "probes": [ + { + "label": "original-first-probe", + "requested_offset_ms": null, + "actual_offset_ms": null, + "open_returned_handle": true, + "open_error": 0, + "desktop_gone_at_this_probe": false + }, + { + "label": "independent-followup", + "requested_offset_ms": 0, + "actual_offset_ms": 0.0, + "open_returned_handle": false, + "open_error": 2, + "desktop_gone_at_this_probe": true + }, + { + "label": "independent-followup", + "requested_offset_ms": 50, + "actual_offset_ms": 47.0, + "open_returned_handle": false, + "open_error": 2, + "desktop_gone_at_this_probe": true + }, + { + "label": "independent-followup", + "requested_offset_ms": 250, + "actual_offset_ms": 250.0, + "open_returned_handle": false, + "open_error": 2, + "desktop_gone_at_this_probe": true + }, + { + "label": "independent-followup", + "requested_offset_ms": 1000, + "actual_offset_ms": 1000.0, + "open_returned_handle": false, + "open_error": 2, + "desktop_gone_at_this_probe": true + } + ], + "owned_handle_closures": [ + { + "kind": "creator desktop", + "ownership": "owned", + "close_attempted": true, + "closed": true, + "close_return": true, + "close_error": 0 + }, + { + "kind": "positive-control retained second desktop handle", + "ownership": "owned", + "close_attempted": true, + "closed": true, + "close_return": true, + "close_error": 0 + }, + { + "kind": "desktop existence probe", + "ownership": "owned", + "close_attempted": true, + "closed": true, + "close_return": true, + "close_error": 0 + } + ], + "retained_root_observations": [], + "raw_identity": { + "bytes": 4919, + "sha256": "9534a3e5a89c06c238638ae23a4782a801bb1725cf2f1fcf26831340d4bc25af" + }, + "deferred_observer_errors": 0 + }, + { + "case": "original-watchdog", + "status": "observations_complete", + "first_desktop_gone": true, + "original_verdict": "passed", + "probes": [ + { + "label": "original-first-probe", + "requested_offset_ms": null, + "actual_offset_ms": null, + "open_returned_handle": false, + "open_error": 2, + "desktop_gone_at_this_probe": true + }, + { + "label": "independent-followup", + "requested_offset_ms": 0, + "actual_offset_ms": 0.0, + "open_returned_handle": false, + "open_error": 2, + "desktop_gone_at_this_probe": true + }, + { + "label": "independent-followup", + "requested_offset_ms": 50, + "actual_offset_ms": 47.0, + "open_returned_handle": false, + "open_error": 2, + "desktop_gone_at_this_probe": true + }, + { + "label": "independent-followup", + "requested_offset_ms": 250, + "actual_offset_ms": 250.0, + "open_returned_handle": false, + "open_error": 2, + "desktop_gone_at_this_probe": true + }, + { + "label": "independent-followup", + "requested_offset_ms": 1000, + "actual_offset_ms": 1000.0, + "open_returned_handle": false, + "open_error": 2, + "desktop_gone_at_this_probe": true + } + ], + "owned_handle_closures": [ + { + "kind": "exact supervisor Job", + "ownership": "owned", + "close_attempted": true, + "closed": true, + "close_return": true, + "close_error": 0 + }, + { + "kind": "creator desktop", + "ownership": "owned", + "close_attempted": true, + "closed": true, + "close_return": true, + "close_error": 0 + }, + { + "kind": "retained duplicate of original Popen process handle", + "ownership": "owned", + "close_attempted": true, + "closed": true, + "close_return": true, + "close_error": 0 + } + ], + "retained_root_observations": [ + { + "label": "created-before-assignment-and-GO", + "strict_before_GO": true, + "wait": 258, + "exit_code": 259, + "identity_reference_present": true, + "current_image_known": true, + "image_query_returned": true, + "image_query_error": 0, + "observation_gaps": [] + }, + { + "label": "assigned-before-original-GO", + "strict_before_GO": true, + "wait": 258, + "exit_code": 259, + "identity_reference_present": true, + "current_image_known": true, + "image_query_returned": true, + "image_query_error": 0, + "observation_gaps": [] + }, + { + "label": "after-original-drain", + "strict_before_GO": false, + "wait": 0, + "exit_code": 99, + "identity_reference_present": true, + "current_image_known": false, + "image_query_returned": false, + "image_query_error": 31, + "observation_gaps": [ + "Exited retained handle image query failed31; current image unknown; identity reference was verified before GO" + ] + }, + { + "label": "after-original-drain", + "strict_before_GO": false, + "wait": 0, + "exit_code": 99, + "identity_reference_present": true, + "current_image_known": false, + "image_query_returned": false, + "image_query_error": 31, + "observation_gaps": [ + "Exited retained handle image query failed31; current image unknown; identity reference was verified before GO" + ] + } + ], + "raw_identity": { + "bytes": 13530, + "sha256": "e28a9c04c64ae9421a3e88a70b7274152b9948e7b47ad109da391a9290e7dc8c" + }, + "deferred_observer_errors": 0, + "original_sample": { + "mode": "watchdog", + "status": "passed", + "timed_out": true, + "exit_code": 99, + "job_at_timeout": { + "active": 5, + "total": 6, + "terminated": 0 + }, + "job_after": { + "active": 0, + "total": 6, + "terminated": 0 + }, + "desktop_gone": true, + "job_after_cleanup": { + "active": 0, + "total": 6, + "terminated": 0 + }, + "watchdog_ready": true, + "stderr_empty": true, + "cleanup_errors": [] + }, + "original_sample_identity": { + "bytes": 510, + "sha256": "a5cf1398199580a6aa9bc0e1bb58d248e806da93b2aa140c5d9a953891aba145" + } + } + ], + "outer": { + "timed_out": false, + "output_overflow": false, + "job_termination_requests": 0, + "root_reaped": true, + "returncode": 0, + "job_after": { + "active": 0, + "total": 18, + "terminated": 0 + }, + "job_before_close": { + "active": 0, + "total": 18, + "terminated": 0 + }, + "elapsed_supervisor_seconds": 5.60899999999674 + }, + "original_ci_archive_sha256": "05392812a79a0b38d48384b47905c85c9f46285cbedcf0b4afa2275550c9efbb", + "first_failed_collector_source_commit": "6e67be541b134084ca8b0a64a94308e8e3d23242", + "first_failed_batch_identity": { + "bytes": 620, + "sha256": "28cdcb854fa3f6699a22d16b61a02b25653187b18deaf4ce7b5992722d7ced2c" + }, + "diagnostic_complete": true, + "original_ci_cause_established": false, + "product_acceptance": null, + "followup_timing": "requested offsets are not exact scheduling guarantees; raw actual offsets retained", + "clock_metadata_post_execution": { + "implementation": "GetTickCount64()", + "resolution_seconds": 0.015625 + }, + "native_case_runs_in_this_batch": 2, + "native_reruns_during_publication": 0 +} diff --git a/experiments/shell/test_watchdog_desktop_seams.py b/experiments/shell/test_watchdog_desktop_seams.py new file mode 100644 index 00000000..3e8874e3 --- /dev/null +++ b/experiments/shell/test_watchdog_desktop_seams.py @@ -0,0 +1,463 @@ +"""AST-only #160 seams: no diagnostic imports/main, Win32, desktop or target.""" +import argparse +import ast +import copy +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import subprocess +import tempfile +import time +from types import MethodType, SimpleNamespace +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE_COMMIT = None +ARTIFACTS = None + + +def diagnostic_tree(): + name = "experiments/shell/diagnose_watchdog_desktop.py" + data = (subprocess.check_output(["git", "show", SOURCE_COMMIT + ":" + name], cwd=ROOT).decode("utf-8") + if SOURCE_COMMIT else (ROOT / name).read_text(encoding="utf-8")) + return ast.parse(data) + + +def execute_nodes(nodes, namespace): + # Only explicitly selected definitions/constants; never module imports/main. + exec(compile(ast.fix_missing_locations(ast.Module(body=copy.deepcopy(nodes), type_ignores=[])), + "", "exec"), namespace) + + +def functions(namespace): + nodes = [node for node in diagnostic_tree().body if isinstance(node, ast.FunctionDef) + and node.name not in {"main", "run_case", "observed_shell"}] + execute_nodes(nodes, namespace) + return namespace + + +def expected(data): + return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + + +class OutputBoundaryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.runtime = self.root / "runtime" + self.runtime.mkdir() + self.scope = {"Path": Path, "json": json, "hashlib": hashlib, "ROOT": self.root, + "os": SimpleNamespace(name="nt"), "struct": SimpleNamespace(calcsize=lambda _: 8), + "platform": SimpleNamespace(version=lambda: "synthetic-Windows"), + "sys": SimpleNamespace(version="synthetic-Python"), + "subprocess": SimpleNamespace(check_output=lambda *a, **k: "b" * 40)} + constants = [node for node in diagnostic_tree().body if isinstance(node, ast.Assign) + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id in {"BASE", "SOURCE_FILES", "OFFSETS", "FIXED", "VARIABLE"}] + execute_nodes(constants, self.scope) + functions(self.scope) + # Synthetic archive/OS/Git seams only; all actual load_plan validation runs. + archive = self.root / "synthetic-archive.bin" + archive.write_bytes(b"synthetic archive") + self.scope["FIXED"]["baseline_archive_sha256"] = expected(archive.read_bytes())["sha256"] + plan = copy.deepcopy(self.scope["FIXED"]) + source_hashes = {} + for name in self.scope["SOURCE_FILES"]: + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(name.encode()) + source_hashes[name] = expected(path.read_bytes())["sha256"] + exe = self.runtime / "termforge_shell.exe" + exe.write_bytes(b"not an executable; pure file fixture") + launcher = self.runtime / "shell_test_launcher.exe" + launcher.write_bytes(b"not launched") + inventory = self.root / "inventory.json" + inventory.write_text(json.dumps({exe.name: expected(exe.read_bytes()), + launcher.name: expected(launcher.read_bytes())}), encoding="utf-8") + build = self.root / "build.txt" + build.write_bytes(b"synthetic build identity; no compiler") + bound = lambda path: {"path": str(path), **expected(path.read_bytes())} + plan.update(execution_authorized=True, execution_source_commit="b" * 40, + windows_version="synthetic-Windows", python_version="synthetic-Python", + source_sha256=source_hashes, actual_compiled_executable=bound(exe), + runtime_inventory=bound(inventory), build_evidence=bound(build), + baseline_archive=str(archive), output_directory=None) + self.plan, self.path = plan, self.root / "authorized-fixture.json" + + def load(self, output): + self.plan["output_directory"] = str(output) + self.path.write_text(json.dumps(self.plan), encoding="utf-8") + return self.scope["load_plan"](self.path) + + def test_runtime_equal_is_rejected(self): + with self.assertRaises(ValueError): + self.load(self.runtime) + + def test_nested_output_is_rejected(self): + with self.assertRaises(ValueError): + self.load(self.runtime / "new-results") + + def test_dotdot_alias_output_is_rejected(self): + (self.runtime / "existing-alias").mkdir() + with self.assertRaises(ValueError): + self.load(self.runtime / "existing-alias" / ".." / "new-results") + + def test_sibling_output_is_allowed_without_creating_it(self): + output = self.root / "runtime-other" + self.load(output) + self.assertFalse(output.exists()) + + +class JobCloseSeamTests(unittest.TestCase): + def test_close_failure_preserves_original_finally_sample(self): + tree = diagnostic_tree() + native_class = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "Native") + observed = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "observed_shell") + job_class = next(n for n in observed.body if isinstance(n, ast.ClassDef) and n.name == "Job") + native_close = copy.deepcopy(next(n for n in native_class.body if isinstance(n, ast.FunctionDef) and n.name == "close")) + job_close = copy.deepcopy(next(n for n in job_class.body if isinstance(n, ast.FunctionDef) and n.name == "close")) + native_close.name, job_close.name = "native_close", "job_close" + calls = [] + native = SimpleNamespace(record={}, kernel=SimpleNamespace(CloseHandle=lambda value: calls.append(value) or False)) + scope = {"native": native, "time": time, "ctypes": SimpleNamespace( + set_last_error=lambda value: None, get_last_error=lambda: 5, + WinError=lambda value: OSError(value, "synthetic CloseHandle failure"))} + execute_nodes([native_close, job_close], scope) + native.close = MethodType(scope["native_close"], native) + deferred = next((n for n in native_class.body if isinstance(n, ast.FunctionDef) + and n.name == "defer_observer_error"), None) + if deferred is not None: + execute_nodes([deferred], scope) + native.defer_observer_error = MethodType(scope["defer_observer_error"], native) + class Job: + close = scope["job_close"] + def __init__(self): + self.handle = 123 + self.ledger = {"value": 123, "close_attempted": False, "closed": False} + def assign(self, pid): pass + def accounting(self): return {"active": 5, "total": 6, "terminated": 0} + def terminate(self): pass + def drain(self): return {"active": 0, "total": 6, "terminated": 0} + class Desktop: + name, full_name, handle = "synthetic", "synthetic\\owned", 456 + def close(self): self.handle = None; return False + class Process: + pid, returncode = 789, None + def communicate(self, data=None, timeout=None): + if data is not None: + raise subprocess.TimeoutExpired("synthetic, never launched", timeout) + self.returncode = 99 + return b'{"watchdog_ready":true}\r\n', b'' + def poll(self): return self.returncode + process = Process() + production = ast.parse((ROOT / "experiments/shell/run.py").read_text(encoding="utf-8")) + original_execute = next(n for n in production.body if isinstance(n, ast.FunctionDef) and n.name == "execute") + run_scope = {"OwnedJob": Job, "OwnedTestDesktop": Desktop, "json": json, + "subprocess": SimpleNamespace(Popen=lambda *a, **k: process, CREATE_NO_WINDOW=0, PIPE=-1, + TimeoutExpired=subprocess.TimeoutExpired)} + execute_nodes([original_execute], run_scope) + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "original" + caught = None + try: + run_scope["execute"](Path(directory) / "synthetic.exe", "watchdog", output, timeout=3) + except Exception as error: + caught = error + self.assertTrue((output / "sample.json").exists(), "Job.close prevented the actual finally sample write") + sample = json.loads((output / "sample.json").read_text(encoding="utf-8")) + self.assertEqual(sample["stdout"], '{"watchdog_ready":true}\r\n') + self.assertEqual(sample["stderr"], "") + self.assertEqual((sample["status"], sample["error"]), ("failed", "owned desktop remained")) + self.assertEqual(str(caught), "owned desktop remained") + self.assertEqual(calls, [123]) + self.assertEqual(native.record["deferred_observer_errors"][0]["handle_ledger"]["close_error"], 5) + verifier = functions({})["require_observer_cleanup"] + with self.assertRaises(OSError): + verifier(native.record) + + +def root_drain_fixture(test, *, desktop_gone=True, image_error=31, observer_fault=None, drain_error=False): + """Memory-only API fakes around actual root_state/Job and original execute.""" + temporary = tempfile.TemporaryDirectory() + test.addCleanup(temporary.cleanup) + directory = Path(temporary.name) + class DWord: + def __init__(self, value=0): self.value = value + class FileTime: + dwHighDateTime = dwLowDateTime = 0 + class Kernel: + exited, error, pid, creation, live_image_error, times_error = False, 0, 789, 100, 0, 0 + def WaitForSingleObject(self, handle, timeout): return 0 if self.exited else 258 + def GetProcessTimes(self, handle, created, exited, kernel, user): + if self.exited and self.times_error: + self.error = self.times_error + return False + created.dwLowDateTime = self.creation + exited.dwLowDateTime = 200 if self.exited else 0 + return True + def GetExitCodeProcess(self, handle, code): code.value = 99 if self.exited else 259; return True + def GetProcessId(self, handle): return self.pid + def QueryFullProcessImageNameW(self, handle, flags, image, size): + failure = image_error if self.exited else self.live_image_error + if failure: + self.error = failure + return False + image.value = str(directory / "shell_test_launcher.exe") + return True + def IsProcessInJob(self, handle, job, member): member.value = True; return True + def CloseHandle(self, value): return True + kernel = Kernel() + fake_ctypes = SimpleNamespace(byref=lambda value: value, + create_unicode_buffer=lambda size: SimpleNamespace(value=""), + set_last_error=lambda value: setattr(kernel, "error", value), get_last_error=lambda: kernel.error, + WinError=lambda value: OSError(value, "synthetic retained-root API failure")) + record = {"events": [], "handle_ledger": []} + native = SimpleNamespace(record=record, kernel=kernel, root_identity=None, process=None, + expected_root_image=(directory / "shell_test_launcher.exe").resolve(), + last_job=None, root_observation_error=None) + tree = diagnostic_tree() + native_class = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "Native") + methods = [n for n in native_class.body if isinstance(n, ast.FunctionDef) + and n.name in {"root_state", "event", "own", "close", "defer_observer_error"}] + scope = {"native": native, "Path": Path, "time": time, "datetime": datetime, "timezone": timezone, + "ctypes": fake_ctypes, "wintypes": SimpleNamespace(DWORD=DWord, BOOL=DWord, FILETIME=FileTime)} + execute_nodes(methods, scope) + for method in methods: + setattr(native, method.name, MethodType(scope[method.name], native)) + native.root = native.own(42, "synthetic retained duplicate; no native handle acquired") + actual_event = native.event + def event(kind, **fields): + if observer_fault == kind and (kind != "job_accounting" or kernel.exited): + raise RuntimeError("synthetic observer event: " + kind) + return actual_event(kind, **fields) + native.event = event + class Process: + pid, returncode, started = 789, None, False + def communicate(self, data=None, timeout=None): + if data is not None: + self.started = True + raise subprocess.TimeoutExpired("synthetic; never launched", timeout) + self.returncode = 99 + kernel.exited = True + return b'{"watchdog_ready":true}\r\n', b'' + def poll(self): return self.returncode + process = Process() + class BaseJob: + def __init__(self): + self.handle = 123 + self.accounting_calls = 0 + self.api = SimpleNamespace(AssignProcessToJobObject=lambda *unused: True) + @staticmethod + def check(value): + if not value: raise OSError("synthetic original Job API failure") + def accounting(self): + self.accounting_calls += 1 + if observer_fault == "terminate-accounting-api" and self.accounting_calls == 3 and not kernel.exited: + raise OSError("synthetic extra terminate accounting API failure") + return {"active": 0 if kernel.exited else (5 if process.started else 1), + "total": 6 if process.started else 1, "terminated": 0} + def terminate(self): pass + def drain(self, timeout=5): + if drain_error: raise OSError("synthetic original drain API failure") + return self.accounting() + scope["original_job"] = BaseJob + observed = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "observed_shell") + job = next(n for n in observed.body if isinstance(n, ast.ClassDef) and n.name == "Job") + execute_nodes([job], scope) + class Desktop: + name, full_name, handle = "synthetic", "synthetic\\owned", 456 + def close(self): self.handle = None; return desktop_gone + def popen(*args, **kwargs): + native.process = process + native.root_state("created-before-assignment-and-GO") + return process + production = ast.parse((ROOT / "experiments/shell/run.py").read_text(encoding="utf-8")) + original_execute = next(n for n in production.body if isinstance(n, ast.FunctionDef) and n.name == "execute") + original = {"OwnedJob": scope["Job"], "OwnedTestDesktop": Desktop, "json": json, + "subprocess": SimpleNamespace(Popen=popen, CREATE_NO_WINDOW=0, PIPE=-1, TimeoutExpired=subprocess.TimeoutExpired)} + execute_nodes([original_execute], original) + def run(): + caught = None + try: + original["execute"](directory / "synthetic.exe", "watchdog", directory / "original", timeout=3) + except Exception as error: + caught = error + sample = json.loads((directory / "original/sample.json").read_text()) + if ARTIFACTS is not None: + destination = ARTIFACTS / ("case-" + str(len(list(ARTIFACTS.iterdir())))) + destination.mkdir() + (destination / "sample.json").write_bytes((directory / "original/sample.json").read_bytes()) + (destination / "observer.json").write_text(json.dumps(record, indent=2), encoding="utf-8") + (destination / "fixture.json").write_text(json.dumps({"test": test.id(), "desktop_gone": desktop_gone, + "image_error_after_exit": image_error, "observer_fault": observer_fault, "original_drain_error": drain_error, + "caught_original_exception": str(caught) if caught else None, + "scope": "Synthetic APIs/process/Job only; exact sample bytes written by actual original execute finally"}, indent=2), encoding="utf-8") + return sample, caught + return SimpleNamespace(native=native, kernel=kernel, run=run, output=directory / "original", process=process) + + +class RootDrainPollutionTests(unittest.TestCase): + def check_original(self, gone): + fixture = root_drain_fixture(self, desktop_gone=gone) + sample, caught = fixture.run() + self.assertEqual(sample.get("stdout"), '{"watchdog_ready":true}\r\n', + "post-exit observer exception interrupted original record.update") + self.assertEqual(sample["stderr"], "") + self.assertEqual(sample["exit_code"], 99) + self.assertEqual(sample["job_after"], {"active": 0, "total": 6, "terminated": 0}) + self.assertEqual(sample["job_after_cleanup"], sample["job_after"]) + self.assertNotIn("cleanup_errors", sample) + self.assertEqual(sample["desktop_gone"], gone) + self.assertEqual(sample["status"], "passed" if gone else "failed") + if gone: + self.assertIsNone(caught) + self.assertNotIn("error", sample) + else: + self.assertEqual(str(caught), "owned desktop remained") + self.assertEqual(sample["error"], "owned desktop remained") + after = [row for row in fixture.native.record["events"] if row.get("label") == "after-original-drain"] + self.assertEqual(len(after), 2) + for row in after: + self.assertIsNone(row["image"]) + self.assertEqual(row["api_results"]["QueryFullProcessImageNameW"], + {"attempted": True, "returned": False, "error": 31}) + self.assertTrue(row["observation_gaps"]) + self.assertEqual(row["identity_reference"]["creation_filetime_100ns"], 100) + self.assertNotIn("deferred_observer_errors", fixture.native.record) + def test_exited_image31_preserves_original_passed_sample(self): self.check_original(True) + def test_exited_image31_preserves_original_failed_desktop_verdict(self): self.check_original(False) + + +class RootObserverErrorTests(unittest.TestCase): + def test_actual_case_copies_original_sample_before_observer_verifier(self): + case = next(n for n in diagnostic_tree().body if isinstance(n, ast.FunctionDef) and n.name == "run_case") + copy_sample = next(n for n in ast.walk(case) if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Subscript) and isinstance(t.slice, ast.Constant) + and t.slice.value == "original_sample" for t in n.targets)) + verifier = next(n for n in ast.walk(case) if isinstance(n, ast.Call) + and isinstance(n.func, ast.Name) and n.func.id == "require_observer_cleanup") + self.assertLess(copy_sample.lineno, verifier.lineno) + def test_other_root_query_error_is_failed_only_after_original_sample(self): + fixture = root_drain_fixture(self, image_error=5) + sample, caught = fixture.run() + self.assertIsNone(caught) + self.assertEqual(sample["status"], "passed") + self.assertEqual(sample["exit_code"], 99) + self.assertEqual(sample["job_after"]["active"], 0) + self.assertNotIn("error", sample) + self.assertEqual(len(fixture.native.record["deferred_observer_errors"]), 2) + with self.assertRaises(OSError): + functions({})["require_observer_cleanup"](fixture.native.record) + self.assertTrue((fixture.output / "sample.json").exists()) + def test_all_drain_related_event_failures_preserve_original_fields(self): + for fault in ("job_accounting", "original_job_terminate", "original_job_drain_begin", "original_job_drain_end", "terminate-accounting-api"): + with self.subTest(fault=fault): + fixture = root_drain_fixture(self, image_error=0, observer_fault=fault) + sample, caught = fixture.run() + self.assertIsNone(caught) + self.assertEqual((sample["status"], sample["exit_code"], sample["job_after"]["active"]), ("passed", 99, 0)) + self.assertTrue(fixture.native.record["deferred_observer_errors"]) + with self.assertRaises(OSError): + functions({})["require_observer_cleanup"](fixture.native.record) + def test_original_drain_error_remains_original_error(self): + fixture = root_drain_fixture(self, image_error=0, drain_error=True) + sample, caught = fixture.run() + self.assertEqual(str(caught), "synthetic original drain API failure") + self.assertEqual(sample["error"], str(caught)) + self.assertEqual(sample["status"], "failed") + self.assertNotIn("deferred_observer_errors", fixture.native.record) + def test_pre_go_image_and_creation_remain_strict(self): + for field, value in (("live_image_error", 31), ("creation", 0), ("pid", 999)): + with self.subTest(field=field): + fixture = root_drain_fixture(self) + fixture.native.process = fixture.process + setattr(fixture.kernel, field, value) + with self.assertRaises(OSError): fixture.native.root_state("created-before-assignment-and-GO") + self.assertIsNone(fixture.native.root_identity) + def test_retained_creation_change_is_not_accepted_after_exit(self): + fixture = root_drain_fixture(self) + fixture.native.process = fixture.process + fixture.native.root_state("created-before-assignment-and-GO") + fixture.kernel.creation += 1 + fixture.kernel.exited = True + with self.assertRaises(OSError): fixture.native.root_state("after-original-drain", strict=False) + def test_image31_requires_signaled_handle_and_prior_identity(self): + fixture = root_drain_fixture(self) + fixture.native.process = fixture.process + fixture.kernel.exited = True + with self.assertRaises(OSError): fixture.native.root_state("after-original-drain", strict=False) + fixture.kernel.exited = False + fixture.native.root_state("created-before-assignment-and-GO") + fixture.kernel.live_image_error = 31 + with self.assertRaises(OSError): fixture.native.root_state("after-original-drain", strict=False) + + +class PostcheckTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.runtime = self.root / "runtime" + self.runtime.mkdir() + self.files = {} + for name in ["driver.py", "config.json", "sources.hpp", "inventory.json", "archive.zip", "build.txt"]: + path = self.root / name + path.write_bytes(name.encode()) + self.files[str(path)] = expected(name.encode()) + exe = self.runtime / "termforge_shell.exe" + exe.write_bytes(b"synthetic-exe") + self.files[str(exe)] = expected(b"synthetic-exe") + self.frozen = {"files": self.files, "source_commit": "b" * 40, + "runtime_directory": str(self.runtime), + "runtime_files": {exe.name: expected(b"synthetic-exe")}} + self.scope = functions({"Path": Path, "hashlib": hashlib}) + self.scope["git_head"] = lambda: "b" * 40 # No process invocation. + self.batch = {"cases": [{"status": "observations_complete"}] * 2} + + def check(self): + self.scope["finalize_batch"](self.batch, self.frozen) + return self.batch["diagnostic_complete"] + + def test_unchanged_inputs_allow_diagnostic_completion(self): + self.assertTrue(self.check()) + + def test_script_config_source_exe_and_other_bound_drift_fail(self): + for name in self.files: + with self.subTest(name=Path(name).name): + path = Path(name) + original = path.read_bytes() + path.write_bytes(original + b"drift") + self.assertFalse(self.check()) + self.assertFalse(self.batch["postcheck_inputs_unchanged"]) + path.write_bytes(original) + + def test_additional_runtime_file_fails(self): + (self.runtime / "unexpected.dll").write_bytes(b"extra") + self.assertFalse(self.check()) + + def test_source_head_drift_fails(self): + self.scope["git_head"] = lambda: "c" * 40 + self.assertFalse(self.check()) + + def test_actual_main_wires_postcheck_before_batch_save(self): + main = next(n for n in diagnostic_tree().body if isinstance(n, ast.FunctionDef) and n.name == "main") + cleanup = next(n for n in main.body if isinstance(n, ast.Try)).finalbody + self.assertEqual(cleanup[0].value.func.id, "finalize_batch") + self.assertEqual(cleanup[1].value.func.id, "save") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-commit") + parser.add_argument("--artifacts", type=Path) + args = parser.parse_args() + SOURCE_COMMIT = args.source_commit + ARTIFACTS = args.artifacts + if ARTIFACTS is not None: + ARTIFACTS.mkdir(parents=True, exist_ok=False) + classes = ([OutputBoundaryTests, JobCloseSeamTests, RootDrainPollutionTests] if SOURCE_COMMIT else + [OutputBoundaryTests, JobCloseSeamTests, RootDrainPollutionTests, RootObserverErrorTests, PostcheckTests]) + suite = unittest.TestSuite(unittest.defaultTestLoader.loadTestsFromTestCase(case) for case in classes) + raise SystemExit(0 if unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful() else 1) diff --git a/experiments/shell/watchdog-desktop-plan-r1.json b/experiments/shell/watchdog-desktop-plan-r1.json new file mode 100644 index 00000000..49e678b3 --- /dev/null +++ b/experiments/shell/watchdog-desktop-plan-r1.json @@ -0,0 +1,25 @@ +{ + "schema": "termforge-watchdog-desktop-plan-v2", + "execution_authorized": false, + "source_base_commit": "6a10e5322a1bddeb038670e50f32ff6deac145cf", + "execution_source_commit": null, + "windows_version": null, + "python_version": null, + "source_sha256": null, + "actual_compiled_executable": null, + "runtime_inventory": null, + "build_evidence": null, + "baseline_archive": null, + "baseline_archive_sha256": "05392812a79a0b38d48384b47905c85c9f46285cbedcf0b4afa2275550c9efbb", + "output_directory": null, + "cases": ["retained-second-handle", "original-watchdog"], + "maximum_attempts_per_case": 1, + "watchdog_timeout_seconds": 3, + "followup_offsets_ms": [0, 50, 250, 1000], + "followup_anchor": { + "retained-second-handle": "second-handle-close-completed", + "original-watchdog": "original-first-probe-completed" + }, + "maximum_windows_per_probe": 32, + "whole_lifecycle_bound_seconds": null +} diff --git a/experiments/shell/watchdog-desktop-plan.json b/experiments/shell/watchdog-desktop-plan.json new file mode 100644 index 00000000..e8dda7a5 --- /dev/null +++ b/experiments/shell/watchdog-desktop-plan.json @@ -0,0 +1,25 @@ +{ + "schema": "termforge-watchdog-desktop-plan-v1", + "execution_authorized": false, + "source_base_commit": "6a10e5322a1bddeb038670e50f32ff6deac145cf", + "execution_source_commit": null, + "windows_version": null, + "python_version": null, + "source_sha256": null, + "actual_compiled_executable": null, + "runtime_inventory": null, + "build_evidence": null, + "baseline_archive": null, + "baseline_archive_sha256": "05392812a79a0b38d48384b47905c85c9f46285cbedcf0b4afa2275550c9efbb", + "output_directory": null, + "cases": ["retained-second-handle", "original-watchdog"], + "maximum_attempts_per_case": 1, + "watchdog_timeout_seconds": 3, + "followup_offsets_ms": [0, 50, 250, 1000], + "followup_anchor": { + "retained-second-handle": "second-handle-close-completed", + "original-watchdog": "original-first-probe-completed" + }, + "maximum_windows_per_probe": 32, + "whole_lifecycle_bound_seconds": null +}