Files
ecw-charge-validation/src/ecw_bot/core/run_lock.py
T
ManikandanandClaude Opus 5.5 c049cece23 Portal server mode for Dokploy: Dockerfile, PORTAL_URLS, PORTAL_SERVER
The results Portal in a Linux container (python 3.13-slim + ODBC Driver
18). PORTAL_SERVER=1: no Start / Stop (the bot runs on the Windows PC).
PORTAL_URLS: allowed hosts / origins, Secure cookie when all https.
RESULTS_DB_CONN (a SQL login) goes in Dokploy's environment, never here.
71 test files, 1222 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:05:14 +05:30

233 lines
8.2 KiB
Python

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)