OapiCodemode.Executor.ZapCode (oapi_codemode v0.3.1)

Copy Markdown View Source

In-process executor on ex_zapcode, a pure-Rust TypeScript-subset interpreter shipped as a NIF. No subprocess, no runtime binary in the image, no container config — a hex dependency is the whole deployment story, and the engine enforces a hard cap on live guest memory (the blocker that ruled Deno out for some hosts).

The engine has no host bindings at all (no filesystem/network/env), so the :request callback is the sandbox's only door out, same as Deno. Guest code suspends at each apis.<name>.request(...) call (zapcode's start/resume model), the callback runs in Elixir, and the run resumes with its return value. The apis object itself is built by a JS preamble prepended to the code — one entry per globals["apiNames"] name, each forwarding to the single registered external function.

Requires zapcode >= engine commit eaa546a: earlier revisions clobber the apis base variable on the second namespaced call and cannot snapshot with a pending builtin call (Promise.all) on the operand stack — both found integrating this module, both covered by regression tests here.

Known divergences from OapiCodemode.Executor.Deno, all inherent to the engine rather than this module:

  • Callbacks are serial. Promise.all over api calls works but resolves one call at a time (accepted zapcode constraint): wall-clock for N calls is the sum, not the max.
  • Callback errors abort the run. resume can only inject a return value, not a throwable, so a raising callback comes back to the model as this run's {:error, %{message: ..., logs: ...}} instead of a JS exception the guest could catch.
  • Logs are partial. zapcode-core does not capture stdout produced after a resume, and its error tuple carries no stdout — so console.log output after the first API call, or in a failing segment, is dropped. Output before the first suspension is captured.
  • No regex. Regex literals are rejected at parse time; guest code must filter with .includes()/.startsWith()/.endsWith().

Timeout is a wall-clock deadline enforced at every suspension point, and the engine's own max_duration_secs (set to the same :timeout) bounds guest execution inside a segment — so a run that never suspends is cut by the engine at timeout, and one that suspends is cut at the first suspension past the deadline. Worst case is just under 2× timeout (a segment entered right before the deadline gets a fresh engine allowance). A callback still in flight at the deadline is killed before returning — it runs in an unlinked, monitored worker precisely so it can be cancelled and so nothing (EXITs, DOWNs, straggler replies) leaks into a trap_exit caller's mailbox after run/3 returns.

Callback results are round-tripped through JSON before entering the sandbox, keeping the behaviour's JSON-native boundary guarantee exact.