r""" The run record — `Logs\bot_run.json`, the latest bot run (spec docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md). Written by the bot itself at the start of `main()` and finished when `main()` returns, so the results page sees a run started in the terminal as well as one it started. While a run is live a second `main()` refuses to start: two runs would share one eCW login guard and one Charge Summary. {"pid", "started_at", "log_path", "source": "web" | "terminal", "ended_at", "exit_code", "stopped_by_operator"} Live = no `ended_at`, the pid is a running python process, and that process was created no later than the record's `started_at` (plus a few seconds) — so a record left unended by a killed run, whose pid Windows later hands to another python process, does not read as live. A killed run cannot finish its own record; its pid is gone or reused, so it reads as ended. """ import os import time import json import ctypes try: from ctypes import wintypes except ValueError: # some non-Windows Pythons; the server Portal wintypes = None # (PORTAL_SERVER=1) never calls the kernel32 parts from datetime import datetime, timedelta from .. import settings as cfg FILE_NAME = "bot_run.json" _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 _STILL_ACTIVE = 259 # started_at is written after the process exists, truncated to the second. _CREATED_SLACK = timedelta(seconds=5) _FILETIME_UNIX_EPOCH = 116444736000000000 # 1601-01-01 -> 1970-01-01, 100 ns class Busy(Exception): """Another bot run is live.""" def path(): """Read at call time: tests point cfg.LOGS elsewhere.""" return cfg.LOGS / FILE_NAME def _now() -> str: return datetime.now().isoformat(timespec="seconds") def pid_alive(pid) -> bool: """ A running process with this pid whose program is python. Never `os.kill(pid, 0)`: on Windows that TERMINATES the process. The program-name check keeps a stale record whose pid Windows gave to some other program from reading as a live bot run. """ try: pid = int(pid) except (TypeError, ValueError): return False if pid <= 0: return False if os.name != "nt": try: os.kill(pid, 0) return True except OSError: return False k32 = ctypes.WinDLL("kernel32", use_last_error=True) k32.OpenProcess.restype = wintypes.HANDLE k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] k32.QueryFullProcessImageNameW.argtypes = [ wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)] k32.CloseHandle.argtypes = [wintypes.HANDLE] handle = k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid) if not handle: return False try: code = wintypes.DWORD() if not k32.GetExitCodeProcess(handle, ctypes.byref(code)): return False if code.value != _STILL_ACTIVE: return False buf = ctypes.create_unicode_buffer(1024) size = wintypes.DWORD(len(buf)) if not k32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)): return True return "python" in os.path.basename(buf.value).lower() finally: k32.CloseHandle(handle) def process_created(pid): """ When process `pid` was created, as a local naive datetime (the same clock as the record's `started_at`); None when it cannot be read or the process is no longer running. """ if os.name != "nt": return None try: pid = int(pid) except (TypeError, ValueError): return None if pid <= 0: return None k32 = ctypes.WinDLL("kernel32", use_last_error=True) k32.OpenProcess.restype = wintypes.HANDLE k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] k32.GetProcessTimes.argtypes = ( [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4) k32.CloseHandle.argtypes = [wintypes.HANDLE] handle = k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid) if not handle: return None try: # An ended process can still be opened while some handle to it is # held (its parent's, say) and still has times: not a running one. code = wintypes.DWORD() if (not k32.GetExitCodeProcess(handle, ctypes.byref(code)) or code.value != _STILL_ACTIVE): return None created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4)) if not k32.GetProcessTimes(handle, ctypes.byref(created), ctypes.byref(exited), ctypes.byref(kernel), ctypes.byref(user)): return None ticks = (created.dwHighDateTime << 32) | created.dwLowDateTime if ticks <= _FILETIME_UNIX_EPOCH: return None return datetime.fromtimestamp( (ticks - _FILETIME_UNIX_EPOCH) / 10_000_000) except (OSError, OverflowError, ValueError): return None finally: k32.CloseHandle(handle) def read(): """The record, or None when there is none or it cannot be read.""" try: data = json.loads(path().read_text(encoding="utf-8")) except (OSError, ValueError): return None return data if isinstance(data, dict) else None def is_live(rec) -> bool: if not rec or rec.get("ended_at") or not pid_alive(rec.get("pid")): return False # Same pid, but a younger process: the pid was reused after the run # died without finishing its record. If either time cannot be read, # trust the pid. try: started = datetime.fromisoformat(rec["started_at"]) except (KeyError, TypeError, ValueError): return True created = process_created(rec.get("pid")) try: if created is not None and created > started + _CREATED_SLACK: return False except TypeError: pass # started_at carries a UTC offset: keep the pid verdict return True def _write(rec: dict) -> None: target = path() target.parent.mkdir(parents=True, exist_ok=True) tmp = target.with_suffix(".tmp") tmp.write_text(json.dumps(rec, indent=2), encoding="utf-8") # On Windows the replace is refused for a moment while another process # (the page polling, a scanner) holds the file open. for attempt in range(10): try: os.replace(tmp, target) return except PermissionError: if attempt == 9: raise time.sleep(0.05) def acquire(log_path, source=None) -> dict: """This process's record, written; raises Busy over a live one.""" rec = read() if is_live(rec): raise Busy(f"A bot run is already going (pid {rec.get('pid')}, " f"started {rec.get('started_at') or '?'}) — not starting " f"a second one") rec = {"pid": os.getpid(), "started_at": _now(), "log_path": str(log_path), "source": source or os.environ.get("ECW_RUN_SOURCE") or "terminal", # The Portal User for a Portal run (EEWA-32), else Windows. "started_by": (os.environ.get("ECW_STARTED_BY") or os.environ.get("USERNAME") or ""), "ended_at": None, "exit_code": None, "stopped_by_operator": False} _write(rec) return rec def finish(exit_code) -> None: """End this process's own record. Never raises.""" try: rec = read() if not rec or rec.get("pid") != os.getpid(): return rec.update(ended_at=_now(), exit_code=exit_code) _write(rec) except Exception: pass def mark_stopped(pid) -> None: """The page killed run `pid`: say so on its record.""" rec = read() if not rec or rec.get("pid") != pid or rec.get("ended_at"): return rec.update(ended_at=_now(), exit_code=None, stopped_by_operator=True) _write(rec)