Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ COPY tests ./tests
# docker-compose.yml.j2 is here because the compose-generation test renders the
# REAL template — a stub would assert nothing about what actually ships.
COPY requirements-api.txt requirements-mcpunifier.txt docker-compose.yml.example docker-compose.yml.j2 run.sh ./
COPY scripts/config_helper.py scripts/start.bat scripts/check_health.py scripts/healthcheck.sh scripts/verify_binaries.py ./scripts/
COPY scripts/config_helper.py scripts/start.bat scripts/check_health.py scripts/healthcheck.sh scripts/verify_binaries.py scripts/wickworks-healthcheck.py ./scripts/
COPY assets/binaries.lock.json ./assets/

ENV PYTHONPATH=/app
Expand Down
18 changes: 18 additions & 0 deletions docker-compose.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ services:
# ONLY from the mt5 container (and from the Windows VM via the dockurr
# gateway 20.20.20.1:8000). No ports published; nothing else on the
# docker network can talk to it.
#
# netns-shared sidecars are NOT re-joined when the owning VM container is
# recreated: Docker leaves wickworks running in the old, now-orphaned
# network namespace, where its own loopback healthcheck still passes while
# the VM can no longer reach it (every TA call then 502s). This healthcheck
# detects the orphan and kills wickworks so `restart: unless-stopped`
# recreates it into the current mt5 netns. See scripts/wickworks-healthcheck.py.
wickworks:
image: psyb0t/wickworks:v0.3.1
restart: unless-stopped
Expand All @@ -44,6 +51,17 @@ services:
LOG_LEVEL: INFO
MAX_BARS: "5000"
MIN_BARS: "50"
volumes:
- ./scripts/wickworks-healthcheck.py:/wickworks-healthcheck.py:ro
healthcheck:
test: ["CMD", "python", "/wickworks-healthcheck.py"]
interval: 15s
# Must exceed the script's worst case (self-health probe + concurrent
# gateway sweep, each up to 2s), or Docker aborts the check before the
# orphan path can kill the process and the self-heal never fires.
timeout: 12s
start_period: 30s
retries: 3
depends_on:
- mt5

Expand Down
17 changes: 17 additions & 0 deletions docker-compose.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ services:
LOG_LEVEL: INFO
MAX_BARS: "5000"
MIN_BARS: "50"
volumes:
- ./scripts/wickworks-healthcheck.py:/wickworks-healthcheck.py:ro
# netns-shared sidecars are NOT re-joined when the owning VM container is
# recreated: Docker leaves wickworks running in the old, now-orphaned
# network namespace, where its own loopback healthcheck still passes while
# the VM can no longer reach it (every TA call then 502s). This healthcheck
# detects the orphan and kills wickworks so `restart: unless-stopped`
# recreates it into the current mt5 netns. See scripts/wickworks-healthcheck.py.
healthcheck:
test: ["CMD", "python", "/wickworks-healthcheck.py"]
interval: 15s
# Must exceed the script's worst case (self-health probe + concurrent
# gateway sweep, each up to 2s), or Docker aborts the check before the
# orphan path can kill the process and the self-heal never fires.
timeout: 12s
start_period: 30s
retries: 3
depends_on:
- {{ vm.service }}

Expand Down
116 changes: 103 additions & 13 deletions mt5api/backtest/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from mt5api.config import (
ACCOUNT,
BROKER,
INSTANCE,
LOG_DIR,
TERMINAL_DIR,
TERMINAL_PATH,
Expand Down Expand Up @@ -254,21 +255,38 @@ def _tail(text, limit=DIAGNOSTIC_TAIL_CHARS):


def _tail_terminal_log(lines=20):
"""Tail of the terminal's most recently written run log.

Picked by modification time, not by name. The logs are named `<date>.log`,
but the directory also holds `metaeditor.log`, which sorts after every one
of them ("m" > "2") and never changes — so an alphabetical pick attached a
months-old compile tail to every failure message and hid the actual reason
the run died.
"""
log_dir = os.path.join(TERMINAL_DIR, "logs")
if not os.path.isdir(log_dir):
return ""

try:
candidates = sorted(
file_name for file_name in os.listdir(log_dir) if file_name.endswith(".log")
)
candidates = [
entry
for entry in os.scandir(log_dir)
if entry.is_file()
and entry.name.endswith(".log")
and entry.name.lower() != "metaeditor.log"
]
except OSError:
return ""

if not candidates:
return ""

latest_path = os.path.join(log_dir, candidates[-1])
try:
newest = max(candidates, key=lambda entry: entry.stat().st_mtime)
except OSError:
return ""

latest_path = newest.path
try:
with open(latest_path, "r", encoding="utf-16-le", errors="replace") as handle:
content = handle.read()
Expand All @@ -281,22 +299,77 @@ def _tail_terminal_log(lines=20):
return "\n".join(tail_lines[-lines:])


def _terminal_process_alive():
"""True while a terminal64.exe belonging to THIS terminal directory runs.
#: The tester runs as terminal64.exe plus one metatester64.exe per agent. The
#: agents are what hold the localhost ports a later run needs, so a cleanup that
#: only accounts for terminal64.exe leaves the terminal unusable.
TESTER_PROCESS_NAMES = frozenset({"terminal64.exe", "metatester64.exe"})


def _in_terminal_dir(exe):
"""True when ``exe`` lives inside THIS terminal directory.

Compared by normalized path components, not by substring: a sibling at
``...\\a2\\terminal64.exe`` must never match a terminal at ``...\\a``.
"""
if not exe:
return False
base = TERMINAL_DIR.replace("\\", "/").lower().rstrip("/")
path = exe.replace("\\", "/").lower().rstrip("/")
return path == base or path.startswith(base + "/")


Matched by directory rather than by the PID we spawned on purpose: the
point of this check is to see the process MT5 started to *replace* the one
we launched, which we never get a handle on.
def _terminal_processes(names=TESTER_PROCESS_NAMES):
"""Processes of the given names running from THIS terminal directory.

Matched by directory rather than by the PID we spawned on purpose: MT5 may
replace the process we launched (see _await_self_relaunch), and the agents
are never ours to begin with. Sibling terminals live in sibling directories,
so this never reaches across to another instance's processes.
"""
for proc in psutil.process_iter(["name", "exe"]):
try:
if (proc.info.get("name") or "").lower() != "terminal64.exe":
if (proc.info.get("name") or "").lower() not in names:
continue
exe = proc.info.get("exe") or ""
if exe and TERMINAL_DIR.lower() in exe.lower():
return True
if _in_terminal_dir(exe):
yield proc
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue


def kill_terminal_processes(grace_seconds=10):
"""Stop this terminal's tester processes. Returns how many were signalled.

Terminate first, then kill whatever is still standing, so MT5 gets the
chance to release its files cleanly before being shot.
"""
victims = list(_terminal_processes())
if not victims:
return 0
for proc in victims:
try:
proc.terminate()
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
_, alive = psutil.wait_procs(victims, timeout=grace_seconds)
for proc in alive:
try:
proc.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if alive:
psutil.wait_procs(alive, timeout=grace_seconds)
return len(victims)


def _terminal_process_alive():
"""True while a terminal64.exe belonging to THIS terminal directory runs.

Only terminal64.exe: this answers "is a run still going", and the agents
come and go within one.
"""
for _ in _terminal_processes({"terminal64.exe"}):
return True
return False


Expand Down Expand Up @@ -461,6 +534,9 @@ def run_backtest():
"status": "queued",
"broker": BROKER,
"account": ACCOUNT,
# Sibling terminals share broker+account and differ only by instance, so
# without this the startup sweep cannot tell whose job it is looking at.
"instance": INSTANCE,
"submittedAt": jobs.now_iso(),
"startedAt": None,
"finishedAt": None,
Expand Down Expand Up @@ -560,10 +636,24 @@ def _execute_job(job_id):
)
except subprocess.TimeoutExpired:
duration = round(time.time() - start_time, 3)
# subprocess.run kills the process it started, but not the
# metatester64.exe agents it spawned, and not a terminal MT5
# relaunched in place of ours. Those keep the terminal's
# localhost agent ports bound, so every later run on this
# terminal dies with "bind error [10048]" until the host is
# rebooted. Clear the whole directory's tester processes.
killed = kill_terminal_processes()
error = f"Backtest timed out after {job['timeoutSeconds']}s"
if killed:
error = f"{error} (killed {killed} leftover tester process(es))"
log.warning(
"backtest timed out broker=%s account=%s job=%s killed=%d",
BROKER, ACCOUNT, job_id, killed,
)
jobs.update_job(
job_id,
status="failed",
error=f"Backtest timed out after {job['timeoutSeconds']}s",
error=error,
durationSeconds=duration,
finishedAt=jobs.now_iso(),
)
Expand Down
53 changes: 52 additions & 1 deletion mt5api/backtest/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@
from datetime import datetime, timezone

from mt5api.config import (
ACCOUNT,
BACKTEST_JOB_DIR,
BACKTEST_JOB_RETENTION_SECONDS,
BACKTEST_SWEEP_LOOKBACK_SECONDS,
BROKER,
INSTANCE,
load_yaml_config,
normalize_instance,
)
from mt5api.logger import log

Expand Down Expand Up @@ -157,8 +162,47 @@ def public_payload(job: dict) -> dict:
return payload


def _configured_terminals(broker: str, account: str) -> int:
"""How many terminals in config.yaml target this broker/account.

Reads the real terminal list so the ownership decision reflects how the
install is actually configured, not just this API process's own identity.
"""
terms = load_yaml_config().get("terminals") or []
return sum(
1
for t in terms
if t.get("broker") == broker and t.get("account", "") == account
)


def owns_job(job: dict) -> bool:
"""Is this job ours, rather than a sibling terminal's?

Every backtest API on a host shares one job directory, so the sweep sees
every terminal's jobs. A job that names a terminal is ours only if that
terminal is this one. A job that does not name a terminal is ambiguous and
is claimed ONLY when this broker/account has a single configured terminal —
that is the shape a single-terminal install writes, and what every job
written before the instance field existed looks like. In a multi-clone
install, an un-instanced job belongs to no clone in particular, and claiming
it here would reproduce the cross-terminal failure this sweep exists to
prevent.
"""
broker = job.get("broker")
if broker is not None and broker != BROKER:
return False
account = job.get("account")
if account is not None and account != ACCOUNT:
return False
instance = job.get("instance")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upgrade case is still unowned. Pre-PR job JSON already contains broker and account; this diff only starts adding instance. Thus an old active job for the same broker/account but no instance is claimed by every clone (a, b, and default). The new legacy test omits broker/account too, so it does not cover that actual old record shape. Please make missing instance safe for multi-clone installs and regression-test that shape.

if instance is not None:
return normalize_instance(instance) == normalize_instance(INSTANCE)
return _configured_terminals(BROKER, ACCOUNT) <= 1


def sweep_orphans(lookback_seconds: int | None = None) -> int:
"""Mark any queued/running jobs on disk as failed.
"""Mark this terminal's queued/running jobs on disk as failed.

Called at API startup. Only state files touched within the last
``lookback_seconds`` are considered: a live job rewrites its file on every
Expand All @@ -167,6 +211,11 @@ def sweep_orphans(lookback_seconds: int | None = None) -> int:
parsed tens of thousands of files on every boot — and because every backtest
API on a VM shares the same job directory, that full scan repeated once per
process. Returns the number of jobs swept.

Only jobs belonging to THIS terminal are swept. The directory is shared, so
sweeping everything meant restarting one terminal's API failed every other
terminal's in-flight backtest with "API restarted before completion" —
silently, and while those runs went on to finish perfectly well.
"""
if lookback_seconds is None:
lookback_seconds = SWEEP_LOOKBACK_SECONDS
Expand Down Expand Up @@ -198,6 +247,8 @@ def sweep_orphans(lookback_seconds: int | None = None) -> int:
continue
if job.get("status") not in ACTIVE_STATUSES:
continue
if not owns_job(job):
continue
job["status"] = "failed"
job["error"] = "API restarted before completion"
job["finishedAt"] = now_iso()
Expand Down
23 changes: 23 additions & 0 deletions mt5api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,29 @@ def _run_backtest_startup_cleanup():
swept = backtest_jobs.sweep_orphans()
if swept:
log.warning("Backtest sweep marked %d orphaned job(s) as failed.", swept)

# Any tester process still running for this terminal is a leftover: we
# are the only thing that launches one here, and we have only just
# started. It holds this terminal's localhost agent ports, so leaving it
# makes every subsequent run fail with "bind error [10048]" until the
# host is rebooted.
#
# Deliberately not conditional on the sweep having found anything. A run
# whose state file predates the sweep lookback — an API killed while a
# long test was live — is never swept, and gating the kill on `swept`
# would leave exactly that process holding the ports.
#
# Backtest mode only: this cleanup thread runs in every mode, and in
# live mode the terminal is meant to stay up.
if MODE == "backtest":
from mt5api.backtest.handler import kill_terminal_processes

killed = kill_terminal_processes()
if killed:
log.warning(
"Backtest startup killed %d leftover tester process(es).", killed
)

pruned = backtest_jobs.prune_old_jobs()
if pruned:
log.info("Backtest retention retired %d old job(s).", pruned)
Expand Down
Loading