v0.4.0 (2026-08-16)
Security and lifecycle release. Two independent reviews — an internal audit and
a downstream integrator reading the same commit — found the same two defects:
the Tyrex.apply bridge was an unrestricted Elixir gateway installed
unconditionally, and there was no way to stop a runaway program. Both are
closed here.
Changed
Breaking: :permissions now defaults to :none instead of :allow_all.
Existing code loses Deno I/O with no compile error. Migration is one line:
Tyrex.start(permissions: :allow_all)A one-time Logger.warning is emitted when :permissions is omitted.
Breaking: the Tyrex.apply JS→Elixir bridge is now opt-in and allowlisted.
It was installed unconditionally and consulted no permissions at all, so under
permissions: :none Deno's own readTextFileSync was correctly denied while
the bridge still delivered File.read! and shell execution via :os.cmd. The
new :apply option takes false (the default) or an explicit MFA allowlist:
Tyrex.start(apply: [{Enum, :sum, 1}, {String, :upcase, 1}])With the bridge disabled, globalThis.Tyrex is deleted after bootstrap, so
guest code holds no reference to it. There is deliberately no apply: true.
Enforcement lives in the GenServer, not in JavaScript, and an unexported MFA is
rejected at start rather than at first call.
:timeoutoneval/2is now a real wall-clock deadline. It was previously only aGenServer.call/3timeout: the caller gave up and the JavaScript kept running. On expiry the V8 isolate is terminated and{:error, %Tyrex.Error{name: :timeout}}is returned. TheGenServer.calltimeout is the deadline plus a 1s grace, which covers scheduler jitter — but not GenServer occupancy:arm_deadline/3runs insidehandle_call, so a caller queued behind a blocking eval has no deadline armed while its own call timeout runs out, and still exits:timeoutrather than receiving an error tuple.- Termination is a one-way door: a runtime that hits its deadline or heap cap is dead and is replaced, never reused. V8 termination is uncatchable and sticky, so a "recovered" isolate would be a silent brick.
stop/1's:timeoutnow defaults to5_000instead of:infinity, and escalates to a kill rather than hanging forever on a wedged runtime.eval_blockingmoved fromDirtyCputoDirtyIo— it parks on a channel, which is I/O-shaped waiting, not compute.blocking: trueis now refused with:unsupported_optionwhen the:applybridge is enabled, or when:timeoutis:infinity.- Aligned the rustler pair: Elixir
~> 0.38.0and the Rust crate=0.38.0, the latter pinned with=and carrying thenif_version_2_16feature. Note only those are pinned: theNIF_VERSIONlabel in the release workflow and thenif_versions:list inlib/tyrex/native.exare kept in agreement by hand.
Added
Tyrex.kill/0,1— interrupt a runtime that is wedged inside a guest that never yields.while (true) {}cannot be stopped cooperatively, only terminated.:max_heap_mboption capping the V8 heap. Without it a guest that exhausts memoryabort()s the entire BEAM; with it the guest is terminated and the caller gets%Tyrex.Error{name: :heap_limit_error}.- New
Tyrex.Errornames::timeout,:heap_limit_error,:unsupported_option. Tyrex.Native.terminate_runtime/1, and@specs on all NIF stubs (there were none).Tyrex.Poolforwards:applyand:max_heap_mbto every runtime.
Fixed
- Dynamic
import()bypassed every read permission, anddeny_importwas inert. Underpermissions: :none,Deno.readTextFileSync("/etc/passwd")was denied whileimport("file:///etc/passwd", {with: {type: "json"}})returned the parsed file — a documented control that did nothing. The module loader now checks a dynamicfile:import against read permissions and a dynamic non-file:import againstallow_import/deny_import. The module named by:main_module_pathand its static import graph remain exempt: they are operator-supplied and loaded once at bootstrap, which is the same static-versus-dynamic specifier distinction Deno makes internally. :applyauthorization trusted a guest-writable runtime id.op_applyread the id fromTyrex._runtimeIdon the JS global, so guest code could name another runtime and have that runtime's allowlist authorize the call. The id now lives in per-runtimeOpState, unreachable from JavaScript, andTyrex._runtimeIdis gone.- Runaway programs leaked a 100%-CPU OS thread for the life of the VM.
stop/1returning:okwas not evidence of reclamation: it killed the Elixir process while the per-runtime thread kept spinning. Measured at 299.6% CPU with three runaways after every runtime was stopped and every Elixir process was dead. Because this is not a dirty scheduler, the leak was uncapped bydirty_cpu_schedulersand invisible to BEAM scheduler-utilization monitoring. The runtime resource now terminates the isolate on drop, so the thread is reclaimed even on a brutal kill. Covered by a regression test that asserts CPU returns to baseline. - The near-heap-limit callback could outlive the state it pointed into. The
Archolding the callback state was declared after the worker, so onworker::new's error paths it was freed while the isolate still held a raw pointer into it. The hand-rolled callback is replaced byJsRuntime::add_near_heap_limit_callback, which owns the boxed closure in a field declared to outlive the isolate — no raw pointer, nounsafe, and no drop-order invariant for the next refactor to break silently. - A small
:max_heap_mbaborted the entire BEAM atTyrex.start/1— exactly what the option exists to prevent. The callback cannot be armed before the isolate exists, so Deno's bootstrap and snapshot deserialization, the heaviest allocation phase in a runtime's life, always run under V8's defaultabort(). Measured on arm64 macOS with V8 146.4.0: 13 MB aborts inside bootstrap, 14 MB boots reliably.:max_heap_mbnow has a floor of 32 MB — roughly 2.3x the measured minimum, because the failure mode is loss of the whole node — and smaller values are rejected with a message naming both numbers. - Panics on the worker thread no longer leak a runtime slot and hang callers.
The worker runs on a bare
std::thread::spawn, outside rustler'scatch_unwind, so a panic —serde_v8unwrapping an emptyMaybeLocalunder a pending termination, for instance — skipped both the slab removal and the drain of pending promises. The worker body is now wrapped incatch_unwindso that cleanup runs either way. This release made termination asynchronous and arbitrary, where it was previously observed only between operations. blocking: truedeadlocked permanently against the bridge.handle_callparked in the NIF whileop_applyneeded that same GenServer; the call timed out and the runtime was then unusable forever. The blocking receive is now bounded and the combination is refused outright.- Permission parsing failed open. Malformed JSON or an unexpected shape fell
back to
PermissionsContainer::allow_allwhile reporting success. It now returns an error and the runtime refuses to start. allow_x: falsewas inverted underallow_all: true. The explicit denial parsed toNoneand was then swallowed by theallow_alldefault, re-granting unrestricted access.allow_read: []granted the whole filesystem. An empty allowlist now grants nothing, matching what it reads like.falsewas documented as "deny all" for all sixteen permission keys. True for the eightallow_*keys, inverted for the eightdeny_*, wheredeny_read: falsedenies nothing and the read succeeds. The docs now split by direction:falseis the absence of a rule in whichever direction the key names, and an empty list allows nothing but denies nothing.- Unknown permission keys were silently dropped, so
[deny_nett: true]yielded a permissive runtime reporting success. They now raiseArgumentError, as do non-string list entries and malformed values. - Non-string entries in a permission list were silently filtered out, quietly widening the grant.
apply_replymatched only{:ok, {}}, so a dead worker raised aMatchErrorand took the GenServer down with an unhelpful reason.- An allowlisted Elixir function that raises now rejects the JavaScript promise instead of destroying the runtime.
- The blocking-eval path replied before stopping on a dead runtime; it previously stopped without replying, leaving the caller blocked until its own call timeout.
- The GenServer now tracks in-flight requests, so a timeout can be attributed to the request that caused it and every other in-flight caller is told the runtime died under it.
native/tyrex/.cargo/config.tomlis now packaged. It was missing frommix.exspackage.fileswhile the README sends Alpine/musl and NixOS users to a source build, so the documented install path was broken from Hex.- The crate now enables rustler's
nif_version_2_16feature, so the binaries match thenif-2.16label they have carried since v0.3.0. rustler removed env-var NIF selection in 0.30 — it is a Cargo feature now, andrustler-0.38.0defaults tonif_version_2_15— sorustler = "=0.38.0"with nofeaturescompiled against 2.15 while every artifact name said 2.16.RUSTLER_NIF_VERSIONwas inert everywhere it appeared, including inrelease.yml; that plumbing is deleted rather than kept and described as a fix. - Releases are now all-or-nothing. Each matrix leg published its own archive
with
if: always()andfail-fast: false, so three of four targets could ship against a four-entry checksum file.
Security scope
Bridge-off-by-default, a permission-checked module loader, a real kill, and a
heap cap close the known holes. The one deliberate exemption is the module named
by :main_module_path and its static imports, which load regardless of
:permissions because they are operator-supplied. None of this makes tyrex an
audited sandbox boundary: the runtime is in-process, so a V8 escape is a BEAM
compromise. For a hard boundary against untrusted code, run Deno out of process.
v0.3.0 (2026-05-23)
Added
- New
:startup_timeoutoption forTyrex.start/1andTyrex.start_link/1(defaults to 30s); the GenServer init callback now returns{:stop, :nif_startup_timeout}if the NIF does not acknowledge in time. - New
examples/error_handling.exsdemonstrating pattern-matching onTyrex.Error(:execution_error,:promise_rejection,:conversion_error,:dead_runtime_error) and onTyrex.eval!raising. - New
examples/least_loaded.exsimplementing the LeastLoaded customTyrex.Pool.Strategyfrom the README as a real runnable script. - New
examples/ink_tui/example demonstrating terminal UI rendering. - Documented the
deny_importpermission key (always supported by the Rust side; previously missing from Elixir docs and the README permission table).
Changed
Tyrex.eval!/1,2andTyrex.Pool.eval!/2,3now raiseTyrex.Erroron failure instead of crashing withMatchError.Tyrex.Pool.Strategy.RoundRobin's first selection now returns index0(previously1). The counter is now seeded atsize - 1so the firstupdate_counter/3wraps to0.Tyrex.Poolis now supervised as:rest_for_onewith a dedicated internal registry GenServer that owns the:persistent_termentry and the strategy state so they get cleaned up on supervisor shutdown.- Tightened the
mix.exspackage description.
Fixed
- ~16 panic sites in the Rust NIF and Deno worker event loop (BEAM crash risk) replaced with proper error propagation or best-effort logging.
- Pending promises now receive a
:dead_runtime_errorreply when the worker shuts down — callers no longer hang onTyrex.stop/1. Tyrex.Poolnow erases its:persistent_termentry and invokes the strategy'sterminate/1callback (cleaning up ETS tables forRoundRobin) on supervisor shutdown, so create/destroy cycles no longer leak VM state.- Recover from mutex poisoning in the Rust runtime registry rather than
panicking on
lock().unwrap(). - The Rust→JS apply-reply path now dispatches via a JS bridge function
(
Tyrex._applyReply) instead offormat!-interpolating attacker-influenceable values into aTyrex._applications[...].fn(...)expression. - The
~JSsigil now raises aCompileErrorfor unknown modifiers (previously silently ignored). - The
TyrexGenServer now has a configurable startup timeout (default 30s) instead of hanging forever on the innerreceive. [allow_all: true, deny_X: true]now actually honors the deny overrides — the Rust permission parser used to short-circuit toallow_alland ignore any siblingdeny_*keys, contradicting the README's documented pattern.- Removed false "TypeScript main module" claim from the README and dropped
the (broken)
examples/typescript/. The defaultFsModuleLoaderdoes not transpile.tsfiles; first-class TypeScript module loading is tracked for a future release. JavaScript (.js) main modules continue to work.
v0.2.1 (2026-03-15)
Fixed
- Fixed precompiled NIF archive packaging — files inside tar.gz are now named
to match RustlerPrecompiled convention (
libtyrex-v{version}-nif-2.16-{target}.so) - Fixed CI: V8 source builds now work on all targets (tolerate bindgen failure, use pre-generated bindings from rusty_v8 releases)
- Replaced
philss/rustler-precompiled-actionwith manual cargo build + tar (action installed cross even withuse-cross: false) - Fixed macOS runner:
macos-13deprecated, switched tomacos-15 - Fixed LLVM 19 → 20 in docker-build.sh Phase 2
Changed
- NIF version 2.15 → 2.16 (requires OTP 27+)
- Removed Windows (
x86_64-pc-windows-msvc) target (not building it) - Upgraded Deno embedded runtime to v2.7.5 (see v0.2.0 for Deno changelog)
Deno 2.7.5 highlights
deno compileimprovements with npm/jsr package supportdeno init --npm vitescaffoldingdeno tasksupportsdependenciesfield for task orderingTemporalAPI support (behind--unstable-temporal)- Node.js compatibility improvements (http2, worker_threads, async_hooks)
- V8 engine upgraded to 14.6
v0.2.0 (2026-03-11)
Changed
- Upgraded embedded Deno runtime to v2.7.5
- deno_core 0.330.0 → 0.391.0
- deno_runtime 0.194.0 → 0.246.0
- deno_fs 0.96.0 → 0.148.0
- deno_resolver 0.17.0 → 0.69.0
- serde_v8 0.239.0 → 0.300.0
- sys_traits 0.1.7 → 0.1.24
- Relaxed serde version pin
v0.1.0 (2026-03-08)
Initial release.
Features
- Embedded Deno runtime — Full Deno JS/TS runtime embedded in Elixir via Rustler NIFs
- JavaScript & TypeScript evaluation —
Tyrex.eval/1,2with automatic promise awaiting - Blocking & async modes — Choose NIF-blocking (fast, <1ms) or async eval
- Bidirectional calls — Call Elixir functions from JavaScript via
Tyrex.apply() - Module loading — Import ES modules with
import/export, load main modules at startup ~JSsigil — Write JavaScript inline in Elixir code withTyrex.SigilTyrex.Inline— Process-local runtime binding withset_runtime/1andwith_runtime/2- Granular permissions — Control network, filesystem, env, subprocess, FFI, and system access per runtime
- Runtime pool —
Tyrex.Poolsupervisor with pluggable dispatch strategies:RoundRobin(default) — Lock-free ETS atomic counterRandom— Random runtime selectionHash— Key-based sticky sessions
- Named runtimes — Add Tyrex to supervision trees with
start_link/1 - Deno APIs —
fetch,Deno.readTextFile,setTimeout, Node.js compatibility, and more
Precompiled binaries
aarch64-apple-darwinaarch64-unknown-linux-gnux86_64-apple-darwinx86_64-pc-windows-msvcx86_64-unknown-linux-gnu