OapiCodemode.Executor.SafeJS (oapi_codemode v0.3.0)

Copy Markdown View Source

In-process executor on ex_safejs, the QuickJS-NG engine embedded as a Rustler NIF (our hard fork of lpgauth/quicksand). Like Executor.ZapCode it's a pure dependency with precompiled binaries — no runtime binary in the image, no subprocess — but unlike zapcode it enforces a genuine hard memory cap (QuickJS's own allocator is the sole memory authority, so even typed-array/ArrayBuffer allocations are bounded, the vector that escapes V8's heap limit) and runs a mature, correct engine with O(1) container access (no O(n²) spec scans).

Dialect: same async arrow as Executor.Deno

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. 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. 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 includes callback time.

Injection differs from Deno: 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 apis object and a console.log capture shim are built in the same preamble.

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.

Divergences from Executor.Deno, inherent to the engine

  • Serial Promise.all (above): requests resolve one at a time.
  • Raising callbacks are opaque to the guest. A callback that raises surfaces to JS as a generic "host function failed" exception; if uncaught, this run's {:error, %{message: ..., logs: ...}} carries the real exception message (host-side only).
  • No regex. (Shared with zapcode; steer codegen to string methods.)