feat(compile): POST /compile — MQL5 source in, .ex5 out - #16
Conversation
2ddc6a4 to
311b702
Compare
|
I tested the current endpoint implementation. The happy-path tests pass, but two boundary failures remain. First, Second, the broad exception handler returns the exception class and message directly to the caller. This leaks internal paths and implementation details whenever a compiler or filesystem error occurs. The existing Please add explicit, documented source and artifact limits, reject oversized requests before writing them, reject oversized artifacts before Base64 encoding, and cover both limits. Keep detailed exception context in server logs, but return a generic failure message to the caller. |
6956f86 to
cd2755a
Compare
|
Both fixed in 1. Per-request size caps, enforced before the resource they bound is spentTwo documented settings, defaulting sane and clamped (not raised) on bad values —
Documented in 2. The 500 body is generic now
Six new tests, all failing against the previous handler: the 413 fires before the compiler runs and before the work dir exists, the declared-length refusal precedes parsing, the artifact refusal carries no binary, within-cap requests are unaffected both ways, and the leak test above. Also: evicted a stray file
Merge order#15 → #16 → #18 → #10. This second: #18 and #10 both build near this branch's Full suite green (72 compile tests, 483 total offline), lint clean. |
|
Thanks for taking the cross-process serialization further. I cannot approve this revision yet. The new lock still has an ownership race that can reintroduce concurrent MetaEditor runs:
I reproduced that exact sequence against this head: acquire the first lock, make it stale, acquire a replacement, release the original owner, then acquire a third lock successfully. Please make the lock ownership-aware. For example, write a unique owner token and unlink only when the token still belongs to the caller, keep a live holder fresh while the subprocess runs, and keep the stale interval safely beyond the maximum hold plus cleanup time. Add a real multi-process regression test, with synchronized separate processes, that proves no second compiler enters while the first is live. The current helper-level test is not a cross-process proof. This PR is also currently conflicting with |
252a1b5 to
838af07
Compare
|
You were right, and the sequence you reproduced is exactly what happened. Fixed, rebased onto master, and squashed to a single commit so the racy revisions are not in the history. The release-side race you described. Each holder now writes a The stale window. It is no longer derived from A second race in the same area, which your finding led me to. Tokens alone are not enough. Waiters poll, so the instant a lock does expire they all judge it stale in the same moment — and a loser's On the tests, and a correction worth stating plainly. I first wrote the multi-process race exactly as you asked — real spawn-started interpreters, barrier-synchronised, recording their critical-section intervals and asserting no overlap. It passes. But when I reverted the reap fix to check it was actually load-bearing, it still passed — six processes doing reap-then-create simply do not hit the bad interleaving reliably. A test that cannot fail proves nothing, so the sweep race is now pinned by a deterministic regression that holds the window open explicitly (the loser is parked between judging and acting while a winner takes ownership). Each of the three regressions is verified to fail when its own fix is removed:
The cross-process tests stay, now covering both an empty directory and a pre-staled lock. They are the mutual-exclusion proof across real processes; the deterministic one is what actually guards the race. Rebased on current master (only the CHANGELOG and |
MetaEditor is the only thing that can produce an .ex5 and it only runs on
Windows, which this stack already has. Anything that generates or patches
EA source elsewhere -- CI, a code generator, a web app, an agent -- can
now get a binary back over HTTP instead of putting a human on an RDP
session.
Source text only: no caller-supplied path anywhere. `filename` is reduced
to a bare stem and re-suffixed, so "../../terminal64" cannot escape the
per-request temp directory; /log: and /inc: are computed here, never
taken from the caller. The handler cannot trade, cannot restart a
terminal, and never touches the MT5 SDK. A second compile-only credential
(`compile_api_token`) lets a caller that only needs to build hold a token
that cannot also place orders. Source and artifact are both size-capped
and exception detail is kept out of responses.
Serialization is the substance of this change. MetaEditor is
single-instance per installation directory, and a threading.Lock only
orders calls inside ONE process -- while every mt5api process on a VM
exposes /compile and, by default, resolves to the SAME install. Verified
live: two separate OS processes each holding their own empty lock ran
MetaEditor fully concurrently. So real compiles and the warm-up compile
alike take an O_CREAT|O_EXCL lock file in the toolchain directory.
Getting that lock right took three passes, because two different races
hide in "expire a dead lock and take it over":
* RELEASE side. Each holder writes a unique token; release reads it
back and unlinks ONLY while the token is still its own. Without that,
a holder that finished late deletes whoever owns the lock now,
admitting a second concurrent MetaEditor.
* REAP side. Waiters poll, so the instant a lock expires they all judge
it stale together -- and a loser's `os.remove` can land AFTER a
winner recreated the file, deleting the new owner's lock. Tokens do
not help: the damage is done during acquisition, before any release.
The sweep therefore claims the dead file by renaming it to a name
only the caller knows (atomic on POSIX and Windows alike), verifies
the file it moved really is the one it judged, and puts it back
untouched if a winner recreated it in between.
* STALENESS itself. A live holder refreshes its lock's mtime from a
heartbeat thread while its subprocess runs, so the window is a count
of missed beats (60s against a 5s beat) rather than
COMPILE_TIMEOUT_SECONDS + 120. When it was the latter -- the same
value as the warm-up budget -- a slow but live holder could be
declared stale mid-compile, which is what opened the release race.
The heartbeat logs and retries transient utime failures rather than
silently giving up on liveness for the rest of the hold.
Tests: tests/test_compile.py covers the response contract, the path and
token hardening, the size caps, and the lock. Three regressions pin the
races specifically -- a late holder must not delete the lock that
replaced its own, a stale sweep must not delete the lock that replaced
the dead one, and a live holder must not be reaped while it works -- and
each is verified to fail when its own fix is removed. The sweep race is
forced deterministically rather than left to luck, because a plain
multi-process race reproduces it only sometimes. Mutual exclusion is also
proven across REAL processes: spawn-started interpreters race for the
lock on a barrier, from both an empty directory and a pre-staled lock,
recording when they entered and left the critical section and asserting
no two intervals overlap. tests/compile_lock_worker.py exists so that
target module is importable in a fresh interpreter without pulling in
mt5api before the MetaTrader5 stub is installed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
838af07 to
a564eb2
Compare
|
Follow-up — head moved to An abandoned lock that cannot be READ wedged the endpoint forever. The stale sweep began by reading the owner token and bailed out when there was none — which conflates "file is absent" with "file exists but is unreadable". The previous implementation used bare Every future compile from every process sharing that install returns 504, permanently — the exact outcome the stale window exists to prevent. An existing-but-unreadable lock now falls through to the age check and is reaped if abandoned, with a warning logged. Regression test Worth noting how it hid: every other lock test reads a lock file it can read, so the whole suite, the review pass, and your own reproduction all exercised the readable path only. Two smaller things from the same audit:
|
Adds
POST /compile— MQL5 source text in, compiled.ex5out — so a caller can build an EA without a Windows box, a MetaEditor install, or file access to the host.Anyone developing an EA against this API currently has to compile out-of-band and copy the binary in by hand. This closes that gap, and pairs naturally with
POST /backtest— compile, then test, over the same API.Happy to change any of the contract below; it's the shape that fell out of our use, not a proposal I'm attached to. Everything here has been running on a real workload, and the measurements quoted are from that rather than from a bench.
Contract
sourceis required.filenameis cosmetic.ea_versionis recorded in the log line so a compile can be correlated with a build.200{"ok": true, "ex5_base64": "...", "log": "...", "warnings": 0, "include_hash": "sha256:..."}422{"ok": false, "log": "<MetaEditor diagnostics>", "errors": 3}400/401/413/500/504{"ok": false, "log": "..."}Two invariants the tests pin, because clients end up depending on them:
/compileauth branch returnsjsonify(...), 401rather thanabort(401), which would render Flask's HTML page.ok: truealways carries a non-emptyex5_base64. The handler re-reads and verifies the artifact before claiming success.Source text only. No caller-controlled paths, no compiler flags, no include uploads —
filenameis reduced to a bare stem, so../../evilandC:\x\y.mq5both becomeevil/y. Everything runs in a per-request temp dir that is removed on every exit path including timeout and crash.Notable implementation details
Warnings are not failures. MetaEditor exits non-zero on warnings, so exit code alone would reject a perfectly good build. The handler parses the log's
Result: N errors, M warningsline and treats errors, not exit status, as the verdict. A build with warnings returns200and a binary.The log is UTF-16LE with a BOM. Decoding it as UTF-8 yields either mojibake or an exception depending on content. Decoded explicitly, with a latin-1 fallback so a malformed log degrades to unreadable rather than 500ing the request.
A missing
#includeis a422, not a500. It's a defect in the submitted source, and callers should not retry it.Compiles are serialized across processes, not just threads — see Serializing MetaEditor below, which is where most of the review went. Concurrent callers wait; a caller that waits past its deadline gets a JSON
504rather than a hung connection.The include tree is the sharp edge
Most of the non-obvious work here is about one failure: a compile that succeeds against the wrong library. It returns
ok: truewith a valid binary, and nothing downstream can tell it apart from a correct build. Three mechanisms guard it, and each exists because the simpler version was wrong in practice:The mirror is incremental, not a re-copy.
compile_local_cachemirrors MetaEditor + Config + MQL5 to local disk, which is the difference between 29s and 1.3s when the terminals sit on a host-shared mount (MetaEditor64.exe is ~105MB and the page cache does not save you). The first version re-copied the whole tree on every process start — invisible with a handful of includes, and 103 seconds once the stock MQL5Includetree (~260 files) was in place, landing directly in front of the first caller after every restart. Now only missing or changed files are copied, compared on size and whole-second mtime.The mirror prunes. Copy-only left it one-way: a header deleted from the source stayed in the mirror and kept resolving, so
#include <Gone.mqh>still compiled against a file nobody maintains. Pruning is guarded on a non-empty source walk — if the mount is unreachable the walk yields nothing, and pruning against that would delete the entire mirror over a transient failure.The include tree is re-validated while running (
INCLUDE_REFRESH_SECONDS, 60s). Resolving it once per process meant an edited.mqhwas invisible until the next restart while compiles kept reporting success.include_hashmakes the remaining risk observable. It identifies the library a binary was built against — sha256 over relative paths and contents under the/inc:root. Computed from the tree the compiler actually read, never from the source it was mirrored from: if the mirror were stale, hashing the source would assert the build used a library it did not, which is worse than reporting nothing. A test pins that direction specifically. It caught a real divergence on its first run against a live host, which is how the pruning bug above was found.Startup warm-up
Even with the mirror warm, the first compile after a restart pays MetaEditor's cold load — 30–55s on a busy host against ~2–3s warm, recurring on any host that restarts VMs automatically. With a local cache configured, the server compiles a throwaway EA in the background instead.
The gates matter more than the compile: delayed 180s, because the VM launches every terminal at boot and a MetaEditor run added to that contention slows the guest exactly when its health probe is most marginal; claimed once per host via
O_CREAT|O_EXCLin the shared cache, because every API process exposes/compileand shares that directory, so ungated this starts one MetaEditor per terminal (twenty, on the host this was built for); and it takes the compile lock non-blocking, so a caller never queues behind a warm-up. The claim expires after an hour so a process killed mid-warm-up cannot disable warm-up permanently. Every failure is swallowed and logged — an optimisation must not be able to take the process down.Concurrency guidance, corrected
docs/compiling.mdoriginally said MetaEditor compiles take well under a second and left callers to their own concurrency. Both were measured warm on an idle host and neither survived a loaded one, so the docs now say plainly: compile one at a time.Since the lock serializes them anyway, concurrency buys no throughput while stacking waits onto a fixed deadline. Measured, same EA, same host:
Sustained parallel compiles also saturate the guest CPU hard enough to fail a short-timeout health probe, so a supervisor restarts a VM that was merely busy — turning a slow batch into an outage. That is how this was found; the probe-side fix is in #15.
scripts/config_helper.pyis touched for the same reason:docs/compiling.mdtells operators to raise nginx's 60sproxy_read_timeout, but the generator in this repo still emitted the default, so the advice only helped people running a hand-rolled proxy. Under a queue of compiles that produces an nginx HTML error page — breaking the JSON invariant above, and pre-empting the API's own JSON504.Serializing MetaEditor
MetaEditor is single-instance per installation directory, so two concurrent invocations against one install corrupt each other's output. The first version used a
threading.Lock, which orders calls inside one process — while every mt5api process on a VM exposes/compileand, by default, resolves to the same install. Verified live: two separate OS processes, each holding its own empty lock, ran MetaEditor fully concurrently with no serialization at all.So the lock is a file (
O_CREAT|O_EXCL) in the toolchain directory, covering real compiles and the warm-up compile alike. Getting it right took three passes, because two different races hide in "expire a dead lock and take it over":os.removecan land after a winner recreated the file. Tokens do not help: the damage is done during acquisition, before anyone releases. The sweep claims the dead file by renaming it to a name only the caller knows (atomic on POSIX and Windows alike), verifies what it moved really is what it judged, and puts it back untouched if a winner recreated it in between.COMPILE_TIMEOUT_SECONDS + 120. When it was the latter — the same value as the warm-up budget — a slow but live holder could be declared stale mid-compile, which is what opened the release race.One more, found by auditing the above rather than by review: an abandoned lock that exists but cannot be read used to wedge the endpoint permanently, because the sweep bailed out whenever it could not identify the holder.
statandunlinkneed no read permission, so such a lock is now reaped if it is past the window, with a warning logged.Four regressions pin these, each verified to fail when its own fix is removed: a late holder must not delete the lock that replaced its own; a stale sweep must not delete the lock that replaced the dead one; a live holder must not be reaped while it works; an unreadable abandoned lock must still be recoverable. The sweep race is forced deterministically rather than left to luck — a plain multi-process race reproduces it only sometimes, which I found out by writing that test first and watching it pass against the bug.
Mutual exclusion is also proven across real processes: spawn-started interpreters race for the lock on a barrier, from both an empty directory and a pre-staled lock, recording when they entered and left the critical section and asserting no two intervals overlap. And because exclusion now depends on the heartbeat, that is measured too: a holder keeps the lock for 75s — past the 60s stale window, covering the warm-up's longest possible hold — while three separate processes poll aggressively to reap it. Nothing steals it.
Auth
/compileaccepts the normalapi_token, so nothing changes for existing users.It additionally accepts an optional
compile_api_token, accepted only on this path — every other route falls through to the unchanged check againstapi_tokenand rejects it. The motivation: a build service that compiles untrusted source shouldn't hold a credential that can also place orders, close positions, or restart a terminal. Leave it unset and the feature is inert.Config
All optional, env var or
config.yaml, env wins:compile_api_tokencompile_terminal_dirterminals/metaquotes/basecompile_include_dirMQL5/inc:rootcompile_work_dircompile_timeout30scompile_local_cacheTests
94 tests in
tests/test_compile.py; 658 passing overall, 3 skipped, withmake test-integrationat 25 passed and lint clean. Beyond the contract, the ones worth pointing at are the pairs that pin a decision in both directions: a warm mirror copies nothing on the next process start and an edited.mqhis still picked up; a deleted header is pruned and an unreachable source does not wipe the mirror; the hash follows the compiled tree and not the source; exactly one process out of twenty wins the warm-up claim and an abandoned claim expires.Docs in
docs/compiling.md, linked fromREADME.mdanddocs/rest-api.md, plus aCHANGELOG.mdentry andconfig/config.yaml.exampleblock.Not included
No compile queue or async job handle — the synchronous lock was sufficient at this volume, and a job API felt like a bigger decision than this PR should make. No PID-liveness check in the stale sweep either: it would make liveness authoritative instead of inferred from a heartbeat, but it needs Windows-specific process probing and the heartbeat measures out fine. No caller-supplied
.mqhuploads: the include dir stays server-managed, since accepting arbitrary include trees from a caller reopens the path-safety surface this deliberately closes. No per-file include digests alongsideinclude_hash— one tree hash answered the question that prompted it.