The recommended executor: in-process on
ex_safejs, the QuickJS-NG engine
embedded as a Rustler NIF (our hard fork of lpgauth/quicksand). A hex
dependency with precompiled binaries is the whole deployment story — no
runtime binary in the image, no subprocess, no container config — and
QuickJS's own allocator is the sole memory authority, so a guest's live
memory is genuinely capped: typed-array/ArrayBuffer bombs, the vector
that escapes V8's heap limit, come back as a structured out-of-memory
error. The engine is complete and mature (regex included — global match,
named groups, lookaheads) with O(1) container access, so searching a
multi-MB spec as data is not pathological.
The other executors are alternatives measured against this one:
Executor.Deno for truly concurrent Promise.all at the cost of a
deno binary in the image, Executor.ZapCode behind ex_zapcode (execute
only), Executor.Mock for tests.
Dialect: async arrow, serial requests
Since ex_safejs 0.3.0 eval is async-aware, so guest code may be a sync or
an async arrow — await, .then chains, and Promise.all all work,
which is the dialect OapiCodemode.Tools.Descriptions teaches the model.
apis.<name>.request(...) blocks the JS thread while the Elixir callback
runs and returns the response as a plain value, which await passes
through unchanged; Promise.all over several requests therefore executes
them serially, not concurrently — correct results, wall time the sum
rather than the max. A promise that nothing can ever settle is reported
as a deadlock error immediately instead of burning the timeout.
Timeout is compute-only — wall clock is a separate opt
:timeout is a JS compute budget: host-callback time does not count
against it. For a single slow call that's a feature (a slow host call
never reads as guest misbehavior), but it means a guest looping over
cheap request() calls has unbounded wall-clock time — the engine
can never end it (ele P1). Hosts that meter runs (semaphores, billing,
request deadlines) must pass :wall_clock_ms: the eval then runs in a
throwaway worker killed at that deadline, returning
{:error, {:wall_clock, ms}} (logs captured before the kill are lost).
OapiCodemode.Tools' :max_calls bounds the same class at the tool
layer for every executor. (Executor.Deno's timeout, by contrast, is a
wall-clock deadline that already includes callback time, so it needs no
second knob.)
:memory_limit and :max_stack_size are passed through to
ExSafejs.start/1 from the same opts, so a host can size the cap per
workload via OapiCodemode.tools/1's :executor_opts.
Injection
ex_safejs has no globals API, so env.globals is handed in through a host
callback that returns the map (direct term→JS conversion — faster than
embedding JSON) and Object.assigned onto globalThis in a preamble. The
host and apis objects required by OapiCodemode.Executor are built in
the same preamble as object literals — one host entry per named callback,
one apis entry per globals["apiNames"] name, each closing over its own
JSON-encoded name; a console.log capture shim goes in alongside them.
Isolation
ex_safejs guarantees a straggler-free mailbox: eval messages are tagged
per-eval and a timed-out eval's late result is absorbed inside eval/3,
so run/3 executes directly in the calling process — safe for a
trap_exit GenServer caller (gentility's LoopServer). The quicksand-era
throwaway worker per eval is gone.
Sharp edges
- Serial
Promise.all(above): requests resolve one at a time.Executor.Denois the option when a run's latency is dominated by several independent calls. - Raising callbacks are opaque to the guest AND the caller. A
callback that raises surfaces to JS as a generic
"host function failed"exception; if uncaught, this run's{:error, %{message: ..., logs: ...}}carries a fixed redacted message — the real exception text goes toLoggeronly (C1: exception messages can embed paths, query fragments, credentials). - A wall-clock kill loses logs:
:wall_clock_ms(above) takes the worker's capturedconsole.logoutput with it; only the error shape survives.