feat(compile): POST /compile — MQL5 source in, .ex5 out - #16
Conversation
01185b6 to
2ddc6a4
Compare
Adds a compile endpoint so a caller can turn MQL5 source into a real
.ex5 without a Windows machine, a MetaEditor install, or file access to
the host. Pairs with POST /backtest: compile, then test, over one API.
POST /compile {"source": "...", "filename": "MyEA.mq5"} returns
{"ok": true, "ex5_base64": "...", "log": "...", "warnings": 0,
"include_hash": "sha256:..."}. Two invariants the tests pin, because
clients end up depending on them: every response is JSON including auth
failures and timeouts, so a non-JSON body unambiguously means a broken
host; and ok:true always carries a non-empty binary, re-read and verified
before success is claimed.
Source text only. No caller-controlled paths, flags or include uploads -
`filename` is reduced to a bare stem, so "../../evil" and "C:\x\y.mq5"
both become a harmless name. Each request compiles in its own temp
directory, removed on every exit path including timeout and crash.
MetaEditor specifics this absorbs:
* It exits NON-ZERO on warnings as well as errors, so the exit code
cannot decide the outcome. The log is parsed for counts and the
produced .ex5 is the tiebreaker; a warning-only build is a success.
* Its log is UTF-16LE with a BOM. Decoded as UTF-8 you get NUL-riddled
mojibake and every count regex silently stops matching.
* A missing #include is a 422, not a 500 - the source is wrong, and the
caller must not retry it.
Compiles serialize behind one lock and the request stays synchronous. A
queue would add lost jobs, status polling and restart recovery to buy
nothing, since the work cannot overlap. A caller waiting longer than the
deadline plus 30s gets a JSON 504 rather than a hung connection.
Adds compile_api_token, a second credential accepted ONLY on /compile.
api_token unlocks order placement, position management and terminal
restart; handing that to something whose only job is compiling hands it
the trading account too. Existing auth is unchanged and leaving the new
token empty changes nothing.
COMPILE_LOCAL_CACHE mirrors the toolchain to local disk, which is the
difference between 29s and 1.3s where the terminals sit on a host-shared
mount - MetaEditor is ~105MB and the page cache does not save you. The
mirror is incremental: copying it unconditionally put 103s in front of
the first caller after every process start, because the stock MQL5
Include tree is ~260 files. It also prunes, so a header deleted from the
source stops resolving instead of lingering forever.
Most of the non-obvious work here guards ONE failure: a compile that
succeeds against the wrong library, returning ok:true with a valid binary
that nothing downstream can tell apart from a correct one.
* The include tree is re-validated while running
(INCLUDE_REFRESH_SECONDS, 60s). Resolving it once per process meant
an edited .mqh was invisible until the next restart while compiles
kept reporting success.
* include_hash identifies the library a binary was built against,
computed from the tree the compiler actually read rather than from
the source, so drift stays detectable instead of being asserted away.
* include_files (opt-in via COMPILE_INCLUDE_DIGESTS) adds a digest per
named header, because the tree hash cannot say WHAT moved: upgrading
the stock library and editing a caller's own shared header both move
it, and the correct responses are opposites - no rebuild versus
rebuild everything.
With a local cache configured the server compiles a throwaway EA 180s
after start, so a real caller does not pay MetaEditor's 30-55s cold load.
It is gated: delayed until the VM has finished launching terminals,
claimed once per host via an exclusive file (every API process exposes
/compile and they share the cache, so ungated this starts one MetaEditor
per terminal), and it yields to real work rather than making a caller
queue behind it.
scripts/config_helper.py raises the generated proxy timeouts: a queue of
compiles outlives nginx's 60s default, and then the caller gets an HTML
error page instead of the JSON this endpoint documents.
Docs in docs/compiling.md, linked from README and docs/rest-api.md, with
a CHANGELOG entry and a config.yaml.example block.
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. |
…detail to callers From review: /compile accepted an unbounded body, wrote the source to disk, read the whole .ex5 back into memory, and base64-inflated it into the response - so one authenticated request could consume unbounded disk, memory, worker time and bandwidth. And the catch-all handler echoed the exception class and message to the caller, which for an OSError is an internal path. Two documented per-request caps, both enforced before the resource they bound is spent: - COMPILE_MAX_SOURCE_BYTES (default 2 MB): an oversized body is refused with 413 straight from its declared Content-Length, before parsing; the decoded source is then checked against the cap itself, before anything reaches disk. - COMPILE_MAX_EX5_BYTES (default 16 MB): the artifact is size-checked on disk, before it would be read or encoded. A refusal carries no binary at all, and is a 500, not a 422 - the caller's source compiled fine; the server is declining to return the result. The log names the knob. Both settings clamp rather than raise on bad values, matching the other numeric settings in config.py - it is imported by the whole API, so a typo in an optional endpoint's tuning must not stop trading. Unexpected errors now return a bare 'internal error'; the traceback goes to the server log only. The two remaining detail leaks on the 500 path (MetaEditor's absolute path, the OSError from launching it) are genericized the same way. Six new tests: the 413 fires before the compiler ever runs and before the work dir exists, the declared-length refusal happens before parsing (proven with a non-JSON payload - a 400 would mean the parser read it), the artifact refusal carries no ex5_base64, within-cap requests are unaffected both ways, and the 500 body contains neither the exception class nor its message nor a path. The existing kaboom test permitted the leak by asserting only that log was a string; the new one closes that hole. All six fail against the previous handler. Also evicted scripts/prune-terminal-logs.sh from this branch: the squash had swept it in from unrelated local work. It is PR psyb0t#18's file (byte- identical to that branch's copy, referenced by nothing here), and psyb0t#18's own review round has since fixed a selection bug in it - keeping a stale copy in this PR would both collide with psyb0t#18 on merge and reintroduce the bug that fix removes.
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. |
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 behind a
threading.Lock— one MetaEditor at a time, no queue object. Concurrent callers wait; a caller that waits past its deadline gets a JSON504rather 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.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
61 tests in
tests/test_compile.py, 476 total passing, 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 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.