fix(backtest): scope job sweep to this terminal, stop leaking tester processes, tail the real log - #13
Conversation
8594627 to
41e9996
Compare
…il the real log Three defects found while investigating backtests reported as failed on terminals that had in fact run them. They compound each other, so they are easier to read together than apart. Startup sweep failed other terminals' running jobs -------------------------------------------------- Every backtest API on a host shares one logs/backtest-jobs directory, and sibling terminals also share broker and account — they differ only by instance, which the job record did not carry. sweep_orphans() therefore failed every recently-touched in-flight job it found, so restarting one terminal's API marked every other terminal's running backtest as "API restarted before completion" while those runs went on to finish and write valid reports. The job now records its instance and the sweep skips jobs belonging to another terminal. A job that names no terminal is still swept: that is what a single-terminal install writes, and what every job written before this field existed looks like, so installs with nothing to distinguish keep their behaviour exactly. Failure messages carried a months-old MetaEditor tail ------------------------------------------------------ _tail_terminal_log() took the alphabetically last *.log in the terminal's log directory. That is always metaeditor.log — "m" sorts after every "<date>.log" — and it is written once at install and never touched again, so every failure message quoted stale compile output instead of the run that had just died. Not cosmetic: it is why a genuine agent "bind error on 127.0.0.1:3000 [10048]" sat unread in the real log while the failure text showed unrelated MetaEditor lines. Select by mtime, exclude metaeditor.log. Leaked tester processes kept the agent ports bound ---------------------------------------------------- A test runs as terminal64.exe plus one metatester64.exe per agent, and it is the agents that bind the localhost ports MT5 allocates from 3000 up. subprocess.run kills the process it started, so the timeout path looked covered, but it does not touch the agents, nor a terminal MT5 relaunched in place of the one we spawned (the case _await_self_relaunch exists for). Those survivors keep their ports bound for as long as the host stays up, and every later run on that terminal then dies instantly with "bind error [10048]" and no report. The timeout path now clears the whole terminal directory's tester processes, and startup does the same. Terminate first, then kill whatever ignores it. Matching is by terminal directory so a sibling instance is never touched, and the startup pass is gated on backtest mode because that cleanup thread also runs in live mode, where the terminal is meant to stay up. The startup kill is 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 that would leave exactly the process that is holding the ports. Self-heal wickworks sidecar orphaned when the VM container restarts --------------------------------------------------------------------- The wickworks TA sidecar shares the mt5 VM container's netns via network_mode: service:mt5. When the VM container is recreated or restarted, Docker gives it a fresh netns but leaves the sidecar running in the old, now-orphaned one. The sidecar's own loopback /health keeps answering, so the image's built-in healthcheck stays green while the Windows VM can no longer reach wickworks — every /rates/ta call then fails with connection refused and surfaces as a 502. Add a self-heal healthcheck (scripts/wickworks-healthcheck.py) that probes the dockurr gateway services (20.20.20.1:445/139/5900/5700), which only exist while the sidecar still shares the LIVE mt5 netns, and kills the uvicorn child when they stop answering, letting `restart: unless-stopped` recreate it into the current netns. Wired into the j2 compose template and the example via a read-only volume mount plus a healthcheck with a 30s start_period so a slow boot is not mistaken for orphaning. No configuration changes are required and single-terminal installs behave as before. 25 new tests; the full suite passes in the container test image.
b3cd464 to
71cb801
Compare
psyb0t
left a comment
There was a problem hiding this comment.
Re-checked against this exact head rather than relying on the earlier pass. The intended fixes are sound, but these three cases still let one terminal affect another or prevent the new self-heal from firing. The existing test/lint checks pass; these need fixing before merge.
| 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(): |
There was a problem hiding this comment.
This is substring matching, not a directory-boundary check: when this terminal is ...\\a, a sibling at ...\\a2\\terminal64.exe (or ...\\aa\\metatester64.exe) is yielded and then terminated/killed. The new test only checks a versus b, so it misses the collision. Please compare normalized Windows path components and add an a/a2 regression case.
| account = job.get("account") | ||
| if account is not None and account != ACCOUNT: | ||
| return False | ||
| instance = job.get("instance") |
There was a problem hiding this comment.
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.
| healthcheck: | ||
| test: ["CMD", "python", "/wickworks-healthcheck.py"] | ||
| interval: 15s | ||
| timeout: 5s |
There was a problem hiding this comment.
The four probes are serial and each can wait PROBE_TIMEOUT = 2, so the all-unreachable orphan path takes up to 8 seconds before _kill_main_process() runs. Docker aborts this healthcheck after 5 seconds, so the self-termination/restart never happens in precisely that case. Please keep the aggregate probe time below this timeout (or raise it) and add a regression test for the relationship.
Three defects found while investigating backtests reported as failed on terminals that had in fact run them. They compound each other, so they ship together. No configuration changes are required and single-terminal installs behave exactly as before.
1. The startup sweep failed other terminals' running jobs
Every backtest API on a host shares one
logs/backtest-jobsdirectory — thesweep_orphans()docstring already says so. Sibling terminals also sharebrokerandaccount; they differ only byinstance, which the job record did not carry. So the sweep failed every recently-touched in-flight job it found, and restarting one terminal's API marked every other terminal's running backtest as"API restarted before completion"while those runs went on to finish and write valid reports.The job now records its
instance, and the sweep skips jobs belonging to another terminal. A job that names no terminal is still swept — that is what a single-terminal install writes, and what every job written before this field existed looks like — so installs with nothing to distinguish are unaffected.2. Failure messages carried a months-old MetaEditor tail
_tail_terminal_log()took the alphabetically last*.login the terminal's log directory. That is alwaysmetaeditor.log("m"sorts after every"<date>.log"), and it is written once at install and never touched again. So every backtest failure message quoted stale compile output instead of the run that had just died.Not cosmetic: it is why a genuine agent
bind error on 127.0.0.1:3000 [10048]sat unread in the real log while the failure text showed unrelated MetaEditor lines. Now selected by modification time, withmetaeditor.logexcluded.3. Leaked tester processes kept the agent ports bound
A test runs as
terminal64.exeplus onemetatester64.exeper agent, and it is the agents that bind the localhost ports MT5 allocates from 3000 upward.subprocess.runkills the process it started, so the timeout path looked covered — but it does not touch the agents, nor a terminal MT5 relaunched in place of the one we spawned (the case_await_self_relaunchexists for). Those survivors keep their ports bound for as long as the host stays up, and every later run on that terminal then dies instantly withbind error [10048]and no report.The timeout path now clears the whole terminal directory's tester processes, and startup does the same. Terminate first, then kill whatever ignores it. Matching is by terminal directory, so a sibling instance is never touched, and the startup pass is gated on
mode: backtestbecause that cleanup thread also runs in live mode, where the terminal is meant to stay up.The startup kill is 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 that would leave exactly the process that is holding the ports.
Testing
20 new tests across
tests/test_backtest_jobs.py,tests/test_backtest_log_tail.pyandtests/test_backtest_process_cleanup.py, covering sibling-terminal isolation, the legacy job shape, instance normalisation,metaeditor.logexclusion and mtime selection, kill escalation, the live-mode guard, and the not-swept-but-still-leaking case.Full suite in the container test image (
Dockerfile.test, the same onemake testbuilds): 399 passed, 2 skipped, 1 xfailed.Scope
These fixes do not attempt to stop concurrent terminals colliding on agent ports — only the persistent damage, where a leaked process keeps ports bound and every later run on that terminal fails until the host is rebooted.
Worth knowing if you are reading
[10048]lines while chasing this: MT5 appears to retry upward when a port is taken. Terminals here have accumulatedTester/Agent-127.0.0.1-<port>directories spanning 3000–3332, the newest of them well above the base, so a single bind error is not on its own evidence that a run died of a collision.4. The wickworks TA sidecar silently orphans when the VM container restarts
The TA sidecar shares the VM container's network namespace via
network_mode: service:mt5. When the VM container is restarted or recreated, Docker leaves the sidecar in the old, now-orphaned namespace. Its own loopback/healthkeeps answering, so the image's built-in healthcheck stays green while the VM can no longer reach wickworks — every/rates/tacall then fails with connection refused and surfaces as a 502 ("wickworks unreachable").This shipped as commit
b3cd464. It addsscripts/wickworks-healthcheck.py, a self-heal healthcheck that probes the dockurr gateway services (20.20.20.1:445/139/5900/5700) which only exist while the sidecar still shares the LIVE mt5 netns, and kills the uvicorn child when they stop answering — makingrestart: unless-stoppedrecreate the container into the current netns. (kill 1from an exec'd healthcheck is not delivered to the container init, so the uvicorn child is targeted instead.)Wired into
docker-compose.yml.j2anddocker-compose.yml.examplevia a read-only volume mount plus a healthcheck (30s start period so a slow boot is not mistaken for orphaning). No configuration changes required. 5 new tests: 4 unit tests for the healthcheck and 1 compose-generation test asserting the rendered wickworks services carry the mount and healthcheck.