Embedding in C++

Scrii is designed to be hosted inside C++ applications. The entire public
surface is a single header:

#include "scrii.hpp"     # engine (scrii), exec/submit, value API, make_ref

Link against the static library Scrii (C++23). You do not need
plugin.hpp for embedding — that header is only for writing unloadable
native plugins, which is a separate feature.

See Getting Started — Using Scrii as a dependency
for CMake and Meson project setup.

Running scripts

The engine object owns the shared variable table (the state) and runs scripts
on its own worker pool:

#include "scrii.hpp"

int main() {
  scrii::scrii engine;          # owns state + worker pool

  scrii::result r = engine.exec("print(\"hello from scrii\")");
  if (!r.ok())
    std::cerr << r.status_name() << ": " << r.msg() << "\n";
}

result never throws: check ok() and read status() / msg().
Common statuses: FILE_NOT_FOUND, UNKNOWN_VARIABLE, TYPE_ERROR,
STACK_OVERFLOW, UNSUPPORTED.

Constructor arguments

scrii() takes optional arguments that tune the worker pool, the resource
sandbox, and the capability guard:

scrii::scrii engine;                         # defaults: auto workers, unlimited
scrii::scrii engine(4);                      # exactly 4 worker threads
scrii::SandboxLimits limits{1 << 30, 100000000, 5000};  # 1 GiB, 1e8 steps, 5 s
scrii::GuardedConfig guard{true};            # guarded mode (see below)
scrii::scrii engine(0, limits, guard);       # everything at once
  • workers — number of OS threads in the pool. 0 (the default) uses
    hardware concurrency. Scripts run cooperatively as fibers over this pool, so
    more workers lets independent scripts/fibers make progress in parallel; a
    single script’s main flow runs on one fiber (spawn/defer create
    additional ones).
  • limits (SandboxLimits{max_bytes, max_steps, max_millis}) — per-engine
    resource caps, all 0 = unlimited. Breaches throw catchable sandbox errors
    (OUT_OF_MEMORY, STEP_LIMIT_EXCEEDED, TIME_LIMIT_EXCEEDED) and the
    engine stays usable afterwards. sandbox_used() / sandbox_steps_used()
    report consumption, so you can size caps by running a workload unlimited
    first.
  • guard (GuardedConfig{enabled, ...}) — capability guard
    (default-deny). Denied stdlib names are never installed, so scripts cannot
    call what they cannot see. Individual flags (allow_fs_read,
    allow_net_egress, allow_subprocess, …) reopen capabilities one bit at
    a time; import_roots confines import(). Guarded construction with
    all-zero limits refuses — a guarded spinner with no budget is a trivial
    DoS, so this mode fails closed.

These are tunables for embedding, not script-facing values. See
Concurrency model for how the pool drives
exec()/submit().

Process isolation

For untrusted scripts, set guard.isolate = true. exec()/exec_file()
then fork() a child, run the script there, and ship only the result
(status + message) back over a pipe:

  • a native crash inside the script becomes a structured failure — the host
    process survives;
  • the wall clock is enforced with SIGKILL (driven by max_millis), not
    just in-engine checks;
  • non-POSIX builds fail closed with UNSUPPORTED rather than silently
    running in-process.

Trade-offs: script state does not propagate back (the child’s variable
table dies with it — read results from the returned result, not from
at()), and the fork runs from the calling thread, so fork before starting
host worker threads when possible. POSIX only.

exec vs exec_file vs submit

These three entry points differ in where the script comes from and in
whether they block:

Call Source Blocks? Use it for
exec(script) inline string Yes — returns when done one-shot scripts, short snippets, driving a script you control
exec_file(path) a file on disk Yes — returns when done running a script file; sets import-relative resolution to the file’s directory
submit(script) inline string No — returns immediately launching background work you’ll join later, or several concurrent scripts
submit_file(path) a file on disk No — returns immediately background file work with the file’s directory as import base; file errors surface via result_of(), never throw

exec and exec_file are convenience wrappers: they submit a fiber and block
until that fiber finishes, returning the result directly.

// inline
scrii::result r1 = engine.exec("var x = 1 + 2");

// from a file — relative import() paths resolve against the file's dir
scrii::result r2 = engine.exec_file("scripts/boot.scr");

submit is asynchronous. You get a FiberPtr back immediately; the script is
queued on the pool and runs cooperatively. Block for a specific fiber with
join, then read its outcome with result_of:

auto job = engine.submit("compute_heavy()");

// ...do other host work while the script runs...
engine.join(job);                          # wait for THIS fiber
scrii::result r = engine.result_of(job);   # its result
if (!r.ok()) std::cerr << r.status_name() << "\n";

Concurrency notes:

  • All fibers — whether from exec, exec_file, or submit — share the same
    state table and the same worker pool. submit several scripts and they run
    concurrently, interleaving at cooperative yield points.
  • A script can also submit itself (std.async.spawn), so background work can
    be created from within a script too.
  • Everything funnels through one recursive mutex, so concurrent access is safe.
  • If you want isolated state for a task (its own globals, no interference),
    create a separate scrii engine instance for it rather than sharing one.

Exchanging values

The host reads and writes script variables through a mutex-guarded API that is
safe to call from any thread, including while a script runs.

engine.insert("player_name", scrii::Var("Cory"));
engine.insert("max_hp",      scrii::Var(100, scrii::var_type::INT));

// an optional description string is attached to the value (visible via the
// script's `->` operator, info(), and help())
engine.insert("welcome", scrii::Var("hello!"), "a greeting message");
engine.insert("level_cap", scrii::Var(99, scrii::var_type::INT),
              "maximum player level");

engine.exec("player_name = player_name + \"!\"");
# script.scr
info(level_cap)                # type + "maximum player level"
welcome -> "updated greeting"  # -> replaces the description

exec runs the script synchronously and blocks until it finishes;
exec_file does the same from a file on disk.

if (engine.contains("max_hp")) {
  auto hp = engine.at("max_hp");          # returns Var (copy)
  int  v  = hp.get<int>();
}

switch (engine.type("player_name")) {
  case scrii::var_type::STRING: /* ... */ break;
  default: break;
}

scrii::Var is a variant: object, array, string, char, int, float, double,
long long, bool, function (std::function<Var(std::vector<Var>)>), reference,
or nil. Use get<T>(), type_of(), to_string(), to_bool().

Nested values can be inserted or read at a path of object keys:

engine.insert({"player", "stats", "hp"}, scrii::Var(80));
auto hp = engine.at({"player", "stats", "hp"});   # 80

insert_path does the same but takes an explicit path vector and works on the
stored table directly (existing siblings are preserved, no copies are made):

engine.insert_path({"config", "retries"}, scrii::Var(3));
engine.remove("player_name");                    # top-level
engine.remove_path({"config", "retries"});       # nested

remove / remove_path erase a variable; removing a missing name is a no-op.

Restricted and const values

By default an inserted value is normal script data: the script can reassign or
modify it like any var. Mark a value restricted (read-only — callable but
never reassigned or modified) or const (deeply immutable) with
Var::set_restricted() / Var::set_constant() before inserting:

scrii::Var api_key = scrii::Var("secret");
api_key.set_restricted();               # script can read it, never write it
engine.insert("api_key", api_key);

scrii::Var schema = scrii::Var(42, scrii::var_type::INT);
schema.set_constant();                  # deeply immutable
engine.insert("answer", schema);
# script.scr
print(api_key)                          # "secret" — reading is fine
api_key = "overwritten"                 # error: REASSIGN_RESTRICTED

Script-side consequences are described in
Syntax — Restricted names and
const. set_restricted() is what
builtins and native plugins use to register their read-only globals.

ref and shared storage

A ref declared in a script points at the same slot as another binding, so
writes through the alias reach the original (see ref in the language
reference
). The host-side
equivalent is make_ref: it hands the script a Var that is a reference to
a box the host still owns. See make_ref
below.

Calling host functions from scripts

Any scrii::function inserted into the state is callable by the script:

engine.insert("log_line", scrii::Var(
  [](std::vector<scrii::Var> args) -> scrii::Var {
    for (auto &a : args) std::cout << a.to_string();
    std::cout << "\n";
    return {};                            # nil
  },
  scrii::var_type::FUNCTION));
# script.scr
log_line("score:", 42)

Arguments are passed by value. Exceptions thrown from the callback are caught
by the interpreter and surface as script errors — prefer throwing
scrii::ScriptError{status::..., msg} so scripts get a clean message.

But see Trust & safety — this
is not a sandbox.

Concurrency model

  • exec() / exec_file() run the whole script on a fiber over the internal
    worker pool and return when it finishes.
  • submit() returns immediately; the script runs cooperatively on the pool
    and is awaited via join().
  • Long-running scripts call tick() every few statements which cooperatively
    yields when other fibers are waiting.
  • Blocking std-library calls (sleep, timers, network) release the state
    mutex while they wait, so detached callbacks keep running.

make_ref — handing the script shared storage

Normally insert copies a value into the state table, so the script gets its
own value. make_ref is the opposite: it hands the script shared storage
that the host still controls, so both sides read and write the same box.

// a box the host owns
auto box = std::make_shared<scrii::Var>(scrii::Var(10, scrii::var_type::INT));

// two names that alias the SAME box
engine.insert("x", scrii::make_ref(box));
engine.insert("y", scrii::make_ref(box));
# script.scr
print(x)            # 10
x = 99              # writes through to the box the host holds
print(y)            # 99 — same storage
  • make_ref(box) wraps an existing shared_ptr<Var> you own.
  • make_ref(value) boxes a fresh copy of value and returns it.

The engine dereferences a reference on every read/write, so a script write
through any alias lands in the boxed storage (this is the same mechanism
behind the script-side ref name = <lvalue> keyword — a ref binding holds a
reference to a box). Callbacks you install into std.async/timers/network that
close over such values keep seeing the live storage.

make_ref gotchas

  • Lifetime is the host’s job. A make_ref Var keeps its box alive only
    while the Var is reachable from the state. If you drop your only
    shared_ptr to box and the script variable is removed, the box is freed
    and any remaining aliases go stale. Keep the box (or the engine) alive for
    as long as scripts may touch it.
  • Writes go both ways. Making x and y alias one box means a script can
    mutate host-owned data and vice versa. Use this deliberately; do not share
    host-owned boxes with untrusted scripts you don’t want mutating host state.
Host-side

Engine API calls (at, insert, merge, …) are mutex-guarded, but that guard
covers only the state table, not the boxed storage behind a make_ref. If
you read or write the same shared_ptr<Var> box from a non-engine thread —
including your own std::async C++ threads or callback fibers — while a script
may also touch it, you must guard the box yourself (mutex, atomic, or hand-off
between phases). The engine will not serialize accesses to host-owned storage.

  • Recommendation: make host-supplied refs restricted. If the host exposes a
    make_ref box that scripts should read but never reassign or modify, mark it
    set_restricted() before inserting:

    auto box = std::make_shared<scrii::Var>(scrii::Var(0, scrii::var_type::INT));
    scrii::Var ref = scrii::make_ref(box);
    ref.set_restricted();                # scripts may read it but never write it
    engine.insert("counter", ref);
    

    A restricted ref keeps the shared-box footguns narrow: the script can read the
    live host value but cannot reassign the name or mutate through it, so the only
    writer left is you. Writes go both ways above still applies to any box you
    leave unrestricted.

  • Don’t use it for plain copies. If the script should get its own value,
    insert the Var directly (pass by value) instead of make_ref.

Merging engines

scrii::scrii config;
config.exec("var theme = \"dark\"");
game.merge(config);            # copies every global across, restricted included

Import search paths

Scripts use the import() builtin to load other scripts. Where an import
resolves from depends on how the importing script was launched:

  • exec_file(path) — the script’s directory becomes the base. Relative
    imports resolve against the directory of the file that called them, so a
    script at scripts/boot.scr can import("libs/helper") and it finds
    scripts/libs/helper.scr.
  • exec / submit (inline strings) — there is no source file, so imports
    resolve against the process working directory.

On top of either base, the engine searches extra import paths in order. The
host can add and remove search directories at any time:

engine.add_import_path("/srv/mods");
engine.exec("import(\"helpers\")");   # looks in /srv/mods
engine.remove_import_path("/srv/mods");

File lookup is straightforward: each import path is probed in order, and the
first existing file wins (an optional .scr extension is appended). When an
imported file itself imports, it resolves from its own directory — the same
rule that applied to the original script. This keeps module trees portable:
a file that imports its neighbors works wherever it is copied, regardless of
how the host launched it.

Note that import() merges the imported file’s globals into the root scope
and returns them as an object — see
import in the language reference.

Trust & safety: calling C++ is not guarded

Scrii is not a sandbox. When a script calls a host function (via
engine.insert with a FUNCTION value), the interpreter invokes that C++
lambda directly on the engine’s worker thread, with whatever arguments the
script passed and with full host privileges. There is:

  • no capability isolation or per-call checking,
  • no memory limit on the C++ side as such: the sandbox ledger counts
    allocations made on engine threads while a script (or a script-invoked
    callback) runs — so a callback that allocates heavily can trip the
    script’s own budget — but anything you allocate on other threads, or
    outside script execution, is uncounted and unlimited,
  • no separate process, VM, or syscall filtering.

The callback must validate its own arguments — check sizes, types, and ranges
before dereferencing (a[0].get<T>() on a missing/typed-wrong argument throws
TYPE_ERROR, not a security stop). The interpreter’s exception handling only
converts a thrown C++ exception into a script error; it is a reporting
mechanism, not a safety boundary.

The practical consequence: run only scripts you trust, or build your own
sandbox around the engine. Anything you expose becomes a capability the script
can call with arbitrary inputs. In particular, be very cautious about exposing
(or letting scripts reach) std.system, std.file, networking, or any host
function that takes a path or command string — an untrusted script could invoke
them to touch the host’s files, processes, and network with the embedding
process’s privileges.

Do not confuse this with the plugin “guard”. A function registered through a
native plugin is wrapped so that calls fail cleanly after the plugin is
unloaded
— that guard is purely about safe unloading, and it does not add any
sandboxing to the calls themselves.

Notes & gotchas

  • No lexical closures: functions access globals only, not enclosing
    locals. Callbacks installed into async/timers/network objects must reach
    their data through globals.
  • One engine, many threads: all state access funnels through one
    recursive mutex. Host threads may call at()/insert() while scripts run.
  • Recursion limit: 256 nested script calls (clean STACK_OVERFLOW error).
  • Scripts are not sandboxed — see Trust & safety.
  • make_ref lifetime — keep boxed storage alive as long as scripts may
    touch it (see make_ref).
  • Host-side threads are not synchronized against boxed storage — see the
    danger note under make_ref gotchas and prefer
    set_restricted() on host-supplied refs.
  • import() (loading another script) is a language feature — see
    import in the language reference. Loading native
    plugins is covered separately in Native Plugins.
  • The scheduler spins up hardware-concurrency workers on first use and joins
    them in the destructor; keep the scrii::scrii object alive as long as any
    fibers, futures, or function values are reachable.