Menu

Embedding Aussom Directly via the Engine API

This guide is for Java developers who want to embed Aussom in a host JVM application using the direct com.aussom.Engine API rather than going through javax.script (JSR 223). The direct path gives you finer control over security policy, extern class loading, the parse/run lifecycle, and threading.

If you only need to run scripts and pass a few values back and forth, prefer the JSR 223 guide (design/usage-docs/aussom-lang-jsr223-usage.md). It is shorter and more language-neutral. Use this guide when you need to:

  • Customize Aussom's security policy at the Java level.
  • Wrap your own Java classes as Aussom extern classes (the way the stdlib does for c, sys, math, etc.).
  • Drive the parse / run lifecycle directly (multiple includes, search paths, doc-mode parsing, mid-script reflection).
  • Build a non-standard runner like aussom-script (browser-side) or the aussom CLI on top of aussom-base.
  • Run scripts you did not write, and bound, stop or measure them (Section 13).

You should be comfortable with Java 8+ and have a basic feel for Aussom syntax before starting.


1. The big picture

aussom-base ships three layers:

+---------------------------------------------------+
|  Aussom source code (.aus files / strings)        |
|  - your scripts, your stdlib, the host's hooks    |
+---------------------------------------------------+
|  com.aussom.Engine       parse, run, instantiate  |
|  com.aussom.Environment  per-call scope + locals  |
|  com.aussom.CallStack    per-call stack trace     |
|  stdlib.LangRegistry     the stdlib's own source  |
+---------------------------------------------------+
|  Java extern classes (com.aussom.stdlib.*, yours) |
|  - implement Aussom-callable methods              |
+---------------------------------------------------+

The interpreter walks an AST built by the CUP/JFlex parser stack. Most of the moving parts you interact with are on Engine and the com.aussom.types.* value classes. You will rarely need to touch the AST directly.

Concrete pieces you'll work with:

Class What it is
Engine One interpreter instance.
LangRegistry The standard library's Aussom source, ready to parse. Each engine gets its own copy.
Environment Per-call wrapper holding class instance, locals, callstack, current object.
CallStack Linked stack-frame chain for tracebacks.
Members ConcurrentHashMap of an object's instance members.
SecurityManagerInt Property-based access control.
LoggingInt + console Output sink. One per engine; set it with eng.setLogger.
Limits The numeric runtime settings, read from policy (Section 13).
AussomType (+ subclasses) Runtime value types -- everything is one of these.

2. Quick start

2.1 Add the dependency

<dependency>
    <groupId>io.github.rsv-code</groupId>
    <artifactId>aussom.base</artifactId>
    <version>1.2.3</version>
</dependency>

2.2 Run a file

import com.aussom.DefaultSecurityManagerImpl;
import com.aussom.Engine;
import com.aussom.ast.aussomException;
import com.aussom.DefaultLoggingImpl;

public class RunFile {
    public static void main(String[] args) throws Exception {
        // 1. Construct the engine with a security policy.
        Engine eng = new Engine(new DefaultSecurityManagerImpl());

        // Optional: give this engine a logger so c.log lands somewhere
        // visible. Output belongs to the engine, not to the thread.
        DefaultLoggingImpl logger = new DefaultLoggingImpl();
        logger.setLevel(DefaultLoggingImpl.INFO);
        eng.setLogger(logger);

        // 2. Tell the engine where to find stdlib resources packaged
        //    inside the aussom-base jar.
        eng.addResourceIncludePath("/com/aussom/stdlib/aus/");

        // 3. Parse a file.
        eng.parseFile("script.aus");

        // 4. Run. The engine looks for any class with a main() and
        //    calls it. The return value is the exit code.
        int rc = eng.run();
        System.exit(rc);
    }
}

The CLI entry point (com.aussom.Main) is essentially the snippet above plus argument parsing. If you only need to "run a script," this is the whole story.

2.3 Run a string

Engine eng = new Engine(new DefaultSecurityManagerImpl());
eng.addResourceIncludePath("/com/aussom/stdlib/aus/");

eng.parseString("inline.aus",
    "include sys;\n" +
    "class App {\n" +
    "    public main(args) {\n" +
    "        c.log(\"hello from inline\");\n" +
    "        return 0;\n" +
    "    }\n" +
    "}\n");

int rc = eng.run();

parseString takes a virtual filename (used in stack traces) plus the source text. You can call it multiple times to layer multiple files into one engine before calling run().


3. The Engine lifecycle

A typical Engine usage cycle is:

new Engine(SecurityManagerInt)
   |
   v
addIncludePath / addResourceIncludePath / addExcludePath
   |
   v
parseFile / parseString / addInclude     (any number of times)
   |
   v
run()                                    OR   instantiateObject(...) + call methods
   |
   v
(engine state preserved -- you can call again)

3.1 Construction

There are two constructors, and both require a security manager -- there is no no-argument form, because an engine without a policy is not a thing this library will build for you:

Engine eng = new Engine(new DefaultSecurityManagerImpl());
Engine eng = new Engine(new MyCustomSecurityManager());

// Second form: supply your own standard library source as well.
Engine eng = new Engine(new MyCustomSecurityManager(), myLangRegistry);

Every engine is independent. Constructing one parses the standard library, lang.aus, into that engine's own class table and creates its own copies of the static classes such as c, sys and math. Nothing is shared between engines: two engines in one JVM cannot see each other's classes, static-class fields, output, or security policy. That isolation is the point -- it is what makes it safe to give separate tenants separate engines in one process.

It is not free, though, and the numbers are worth knowing if you plan to build a lot of engines. Measured on an 8-core laptop: about 7 ms to construct one engine once the JIT has warmed up, of which roughly 94% is parsing the standard library, and about 0.81 MB of heap per idle engine. Building 1,000 engines took about 6 seconds and 809 MB. Engine construction also does not get much faster with more threads, so build engines lazily on first use or warm a pool in the background rather than constructing a fleet at startup. See design/multi-engine-fleet-test.md in the aussom-base repository for the full measurements.

The second constructor takes a LangRegistry, which holds the standard library source. Handing one in lets you ship your own modules as part of the standard library, but note that it does not save the parse: the engine copies the registry and still parses the source into its own class table.

3.2 Includes and search paths

addResourceIncludePath(String) registers a JAR-internal resource path. The default stdlib is at /com/aussom/stdlib/aus/. Add the path before any include statement in user code can resolve.

addIncludePath(String) registers a filesystem search path for include statements in user code.

addExcludePath(String) blocks a filesystem path. If user code tries to include from an excluded path, the engine throws.

addInclude(String) programmatically pulls in a stdlib module by name -- for example eng.addInclude("sys");. User scripts normally do this themselves with the include keyword.

Symbolic links. By default an include may be reached through a symbolic link, which is normal filesystem behaviour and often what you want: a shared module directory, or a versioned library directory, linked into a search path. If your search path is a directory that untrusted users can write to, you may not want that -- a link placed there can point at a file outside the path. Set the aussom.include.symlink.follow property to false in your security manager and the engine refuses any include whose name passes through a link:

this.props.put("aussom.include.symlink.follow", false);

Every part of the include name is checked, so a linked directory in the middle of a dotted name is refused just like a linked file. Links at or above the search path itself are not checked, because that is your own choice of where the path lives: a search path that is itself a link keeps working.

One thing this does not do: it blocks symbolic links, not every way one file can have two names. A hard link inside a search path is not a link on the path and is not caught. Both cases need write access inside your search path, so treat the path itself as the boundary that matters.

3.3 Parsing

eng.parseFile("path/to/script.aus");
eng.parseString("name.aus", "<source>");

These add class definitions to the engine. They do not run anything. You can parse many sources into the same engine; the order matters only for extends references between user classes.

If parsing fails, eng.hasParseErrors() returns true. Engine.run() will refuse to execute when this flag is set. You can clear it with eng.clearParseError() if you want to reparse.

3.4 Running

int rc = eng.run();

run() searches every registered class for a main function, picks the first one it finds, instantiates that class, and calls main(args) (or main() if no overload accepts args). The integer return value of main is what run() returns. If main throws, run() returns 1.

Pass arguments to main(args) before calling run():

eng.addMainArg("--verbose");
eng.addMainArg("input.txt");
int rc = eng.run();

3.5 Calling without main

You don't have to use main at all. If your scripts are libraries, instantiate classes directly and call methods on them:

import com.aussom.types.AussomList;
import com.aussom.types.AussomObject;
import com.aussom.types.AussomString;
import com.aussom.types.AussomType;

eng.parseString("tools.aus",
    "class Greeter {\n" +
    "    public hi(string name) { return \"hi \" + name; }\n" +
    "}\n");

AussomList ctorArgs = new AussomList();
AussomObject g = eng.instantiateObject("Greeter", ctorArgs);

AussomList callArgs = new AussomList();
callArgs.add(new AussomString("alice"));
// You'd typically dispatch via the class's call() through Environment;
// see Section 8 for the full pattern in concurrent contexts.

For long-running hosts that call the same script repeatedly, this pattern is much faster than rebuilding the engine each time.

3.6 Static classes

Static classes (static class Foo {...}) are auto-instantiated when their definition is added. Retrieve their singleton instance with:

AussomType foo = eng.getStaticClass("Foo");

Use this to read script-side configuration set up at startup, or to hand a Java caller a stable reference to a script-defined service.


4. The security model

Engine always carries a SecurityManagerInt. Every privileged action in the stdlib (filesystem reads, network calls, reflection, documentation generation, mock injection) goes through it as a property check.

4.1 SecurityManagerInt at a glance

public interface SecurityManagerInt {
    Object getProperty(String PropName);             // Java side
    AussomType getProp(Environment, ArrayList<AussomType>);
    AussomType keySet(Environment, ArrayList<AussomType>);
    AussomType getMap(Environment, ArrayList<AussomType>);
    AussomType setProp(Environment, ArrayList<AussomType>);
    AussomType setMap(Environment, ArrayList<AussomType>);
}

SecurityManagerImpl is the concrete base class. It stores properties in a ConcurrentHashMap<String,Object> named props. Stdlib code reads them through getProperty and refuses operations whose property is false. Aussom scripts can read or mutate them through the secman static class via getProp / setProp -- both checked against securitymanager.property.get and securitymanager.property.set.

4.2 Built-in implementations

Class Use case
SecurityManagerImpl Locked-down baseline. Most actions disallowed.
DefaultSecurityManagerImpl Baseline + aussomdoc.* allowed. CLI default.
TestSecurityManagerImpl Adds test.mock.inject, test.mock.spy, aussom.script.mode.enable.

4.3 Building a custom security manager

Subclass SecurityManagerImpl and override property defaults in your constructor:

package com.example;

import com.aussom.SecurityManagerImpl;

public class HostSecurityManager extends SecurityManagerImpl {
    public HostSecurityManager(boolean trustedScript) {
        super();   // installs every default first

        if (trustedScript) {
            // Allow filesystem + reflection only for trusted scripts.
            this.props.put("file.read",            true);
            this.props.put("file.write",           true);
            this.props.put("reflect.eval.string",  true);
            this.props.put("reflect.include.module", true);
        }

        // Always permit reading our internal config map keyset.
        this.props.put("config.list", true);

        // Add custom properties that your own extern classes can
        // check. The key namespace is up to you.
        this.props.put("myhost.allowMutation", false);
        this.props.put("myhost.netCallsPerMinute", 60L);
    }
}

Pass it to the engine:

Engine eng = new Engine(new HostSecurityManager(/*trusted=*/true));

4.4 Reading properties from your own extern classes

Inside any extern class method, the security manager is available through the engine handle on Environment. Read your property with the typed accessor that matches the kind of value you stored:

public AussomType saveSettings(Environment env, ArrayList<AussomType> args) {
    if (!env.getEngine().getSecurityManager()
            .getPropertyBoolean("myhost.allowMutation", false)) {
        return new AussomException(
            "saveSettings: action 'myhost.allowMutation' not permitted.");
    }
    // ... do the work ...
    return env.getClassInstance();
}

SecurityManagerInt declares six typed reads:

Method Returns
getPropertyBoolean(String name, boolean dflt) the stored boolean, or dflt
getPropertyInt(String name, int dflt) a long, or dflt
getPropertyDouble(String name, double dflt) a double, or dflt
getPropertyString(String name, String dflt) the stored String, or dflt
getPropertyList(String name) a copied List<Object>, or null
getPropertyMap(String name) a copied Map<String, Object>, or null

Two rules worth knowing, because they are what make these safe to call on a policy you did not write:

  • A missing property is not an error. You get the default you passed, so there is no null to check and no cast to get wrong. Prefer these to the untyped getProperty(String), which returns null for an unknown name and will throw a NullPointerException if you cast it straight to Boolean.
  • No value is converted. A property stored as the string "true" is not read as a boolean, and "42" is not read as a number: you get your default instead. This is deliberate. A policy decision should come from a value the host meant to store, not from a string that happened to look right.

getPropertyInt returns a long even though its default is an int, so a policy number can be larger than an int while the common case stays easy to write.

The check pattern -- "read a boolean property, return an AussomException if it is false" -- is the convention every stdlib class follows. Sticking to it keeps your security policy auditable in one place (HostSecurityManager's constructor).

4.5 Locking a security manager at runtime

You can flip properties on or off after construction. The props map on SecurityManagerImpl is protected, so expose what you need from your own subclass rather than reaching into it from outside:

public class HostSecurityManager extends SecurityManagerImpl {
    public HostSecurityManager(boolean readWrite) {
        this.props.put("file.read", readWrite);
        // ... the rest of your policy ...
    }

    /** Lets host code adjust one action at runtime. */
    public void setAction(String name, boolean allowed) {
        this.props.put(name, allowed);
    }
}
HostSecurityManager sm = new HostSecurityManager(false);
Engine eng = new Engine(sm);
eng.parseFile("untrusted.aus");

sm.setAction("file.read", true);    // open up just before run
int rc = eng.run();
sm.setAction("file.read", false);   // and close again afterwards

This is occasionally useful for setup/teardown code that needs elevated permissions while user code does not. Be careful though -- script code with securitymanager.property.set = true can change properties from inside Aussom as well. Lock that property down for untrusted code.

Note that the numeric limits in Section 13 are read once per program rather than on every operation, so changing one of those mid-run has no effect until the next run().


5. Script mode

Script mode is an alternative entry point on Engine for embedders that want to evaluate top-level Aussom statements incrementally -- the shape of a REPL prompt, an "evaluate selection" command in an editor, or a server endpoint that runs ad-hoc expressions against a long-lived state.

It is independent of the parse / run pipeline covered in Section 3. The synthetic class script mode builds is not registered in the engine's class registry, so Engine.run() continues to look for a user-declared main exactly as it does today. You can enable script mode and call evalLine on the same engine that you also parseFile and run() -- the two sides do not see each other.

5.1 When to use it

Use the parse / run pipeline (Section 3) for:

  • Complete .aus files with main().
  • Today's exit-code behavior (run() returns the int result of main, or 1 on exception).

Use script mode for:

  • Building a REPL or interactive editor command.
  • Hosts that accept source from a user prompt or a network request and want each fragment to share state with earlier fragments.
  • Evaluating top-level statements without a wrapping class and main.

5.2 The security property

Script mode is gated by a security property, aussom.script.mode.enable. Defaults across the shipped managers:

Manager aussom.script.mode.enable
SecurityManagerImpl (base) false
DefaultSecurityManagerImpl false (inherits)
TestSecurityManagerImpl true

For a host that wants script mode in production, enable the property in your custom SecurityManagerImpl subclass:

public class HostSecurityManager extends SecurityManagerImpl {
    public HostSecurityManager() {
        super();
        this.props.put("aussom.script.mode.enable", true);
    }
}

Without it, setScriptMode(true) throws an aussomException:

Engine.setScriptMode: Security exception, action
'aussom.script.mode.enable' not permitted.

The check fires on setScriptMode(true) and on every evalLine call (matching the per-call check pattern other stdlib actions use). A script that flips the property off via secman.setProp (with securitymanager.property.set enabled) will block subsequent evalLine calls too.

5.3 Enabling and using evalLine

import com.aussom.Engine;
import com.aussom.types.AussomType;

Engine eng = new Engine(new HostSecurityManager());
eng.addResourceIncludePath("/com/aussom/stdlib/aus/");
eng.setScriptMode(true);

eng.evalLine("x = 5;");
eng.evalLine("y = 7;");
AussomType last = eng.evalLine("c.log(\"sum = \" + (x + y));");

Each evalLine call:

  1. Parses the supplied source as a script-mode fragment.
  2. Appends every parsed top-level statement to the synthetic __script_main class's main body.
  3. Walks just the newly-appended slice against a long-lived Environment whose Members persists across calls.
  4. Returns the AussomType produced by the last evaluated statement (or AussomNull if the source produced no executable statements).

include and class declarations in the source still flow through the existing addInclude / addClass paths on the engine -- only bare top-level statements end up in the synthetic main's body.

A whole file can be passed in one call:

String src = new String(Files.readAllBytes(Paths.get("script.aus")));
eng.evalLine(src);

Two overloads exist:

public AussomType evalLine(String source) throws Exception;
public AussomType evalLine(String source, int lineNumber) throws Exception;

The single-argument overload delegates to the two-argument one with lineNumber = 1. See Section 5.4 for what lineNumber does.

5.4 File name and line numbers for error attribution

By default every node parsed by evalLine is tagged with file name "<script>". Set a meaningful name once after enabling script mode:

eng.setScriptFileName("session.aus");

The two-argument evalLine(String, int) tells the lexer which line the first source line should report as. Use it when feeding a snippet from the middle of a larger file:

// Snippet that begins on line 42 of session.aus.
eng.evalLine("a = 1;\nb = 2;\n1/0;", 42);

The third snippet line (1/0;) reports as session.aus line 44. The offset propagates through the lexer into every AST node, so attributions are correct for top-level statements, sub-expressions, and any class or include declarations parsed in the same call.

5.5 Persistent locals across calls

The Environment script mode uses lives until script mode is disabled (which discards it; re-enabling builds a fresh one). Its Members is the persistent locals store. Anything an earlier evalLine call bound -- through assignment, for iteration, a try body -- stays visible to later calls until overwritten or shadowed.

This is the "REPL feel" the API is built around. There is no rollback on failure: a runtime error from an earlier statement in the same evalLine call leaves any locals it bound up to that point intact for the next call.

5.6 Independence from Engine.run

Script mode and the classical run pipeline coexist on one engine without interference:

  • The synthetic __script_main class is not in the engine's classes map. setMainClassAndFunct and Engine.run() do not see it.
  • evalLine does not invoke setMainClassAndFunct, callMain, or astClass.call against the synthetic class.
  • Static classes (Section 3.6) are still resolved normally from top-level statements, so c.log(...), sys.getSysInfo(), and any host-provided static externs work the same as in main.
eng.parseFile("user.aus");      // user file with class Foo { main(args) {...} }
eng.setScriptMode(true);
eng.evalLine("x = 99;");        // touches script-mode state only
int rc = eng.run();             // calls Foo.main, ignoring x = 99
eng.evalLine("c.log(x);");      // x is still 99

5.7 Errors

Parse errors throw aussomException. evalLine rolls back any statements the parser appended before the syntax error so the synthetic main never accumulates half-parsed nodes:

try {
    eng.evalLine("x = ;");        // syntax error
} catch (aussomException pe) {
    System.err.println("parse error: " + pe.getMessage());
}
eng.evalLine("x = 5;");           // recovers, parses cleanly

Runtime errors do not throw. A statement that returns or throws an exception during eval is caught by evalLine and returned as an AussomException value:

AussomType ret = eng.evalLine("z = 1/0;");
if (ret.isEx()) {
    AussomException ex = (AussomException) ret;
    System.err.println("runtime: " + ex.getText()
        + " at line " + ex.getLineNumber());
}

The line on the exception comes from the failing AST node's parserInfo, which carries the file name and the caller-supplied line offset (Section 5.4). So an error on a top-level statement attributes to the original source location straight from the AST.

evalLine itself only throws for parse errors and security- property denials. Statement-level failures are returned as values.

5.8 Inspecting the synthetic class

Tooling (an LSP analyzer, a debugger, a session "show history" command) can introspect the accumulated script via Engine.getScriptClass():

import com.aussom.ast.astClass;
import com.aussom.ast.astFunctDef;
import com.aussom.ast.astNode;

astClass synth = eng.getScriptClass();      // null if script mode is off
if (synth != null) {
    // The synthetic main(args) has an untyped wildcard arg, so
    // it lives in the class's wildcardOverloads list rather
    // than the flat dispatchMap. Reach it via getFunctionsByName.
    astFunctDef mainFn = synth.getFunctionsByName("main").get(0);
    List<astNode> stmts = mainFn.getInstructionList().getStatements();
    // Walk stmts as needed.
}

The accessor is read-only and side-effect-free. The synthetic class's name is Engine.SCRIPT_CLASS_NAME (currently "__script_main") -- avoid declaring your own class with that name in script source.

5.9 What script mode does not do

  • No top-level function definitions. A public foo(x) { ... } at the top level is rejected with a parse error. Scripts that need reusable functions declare a class -- which then registers normally and is available to subsequent evalLine calls.
  • Single-threaded per engine. The script-mode Environment is shared across calls on one engine. Concurrent evalLine calls on the same engine race on its Members. Use one engine per script-mode session.

6. Wrapping a Java class as an Aussom extern class

This is the main extension point of aussom-base. It is how the stdlib exposes c.log, sys.getSysInfo, math.sqrt, and so on.

The pattern has three parts:

  1. A Java class with one method per Aussom-callable function.
  2. An Aussom-side extern class declaration that binds method names to the Java class.
  3. Registering the Aussom-side declaration with the engine (load it like any other source file, or include it).

6.1 The Java side

Every Aussom-callable method has the same signature:

public AussomType methodName(Environment env, ArrayList<AussomType> args)
  • env carries the engine, the current class instance, the locals map, the callstack, and a "current object" pointer.
  • args is the list of arguments the script passed, in order, all marshalled into AussomType instances.
  • Return value is also AussomType. Never throw a checked Java exception out of one of these methods; either return an AussomException (script-visible) or let an uncaught aussomException propagate.

Concrete example -- a "counter" extern class:

package com.example;

import java.util.ArrayList;

import com.aussom.Environment;
import com.aussom.types.AussomException;
import com.aussom.types.AussomInt;
import com.aussom.types.AussomType;

public class ACounter {
    private long value = 0L;

    public AussomType inc(Environment env, ArrayList<AussomType> args) {
        this.value++;
        return env.getClassInstance();   // see Section 6.4 on chaining
    }

    public AussomType incBy(Environment env, ArrayList<AussomType> args) {
        try {
            long n = ((AussomInt) args.get(0)).getValue();
            this.value += n;
        } catch (Exception e) {
            return new AussomException(
                "counter.incBy: expected one int arg: " + e.getMessage());
        }
        return env.getClassInstance();
    }

    public AussomType get(Environment env, ArrayList<AussomType> args) {
        return new AussomInt(this.value);
    }

    public AussomType reset(Environment env, ArrayList<AussomType> args) {
        this.value = 0L;
        return env.getClassInstance();
    }
}

6.2 The Aussom side

A matching extern class declaration tells the engine which Aussom methods route to which Java class:

/*
 * counter.aus
 * A simple monotonically-increasing counter.
 */
extern class counter : com.example.ACounter {
    /**
     * Increments the counter by one.
     * @r The counter object for chaining.
     */
    public extern inc();

    /**
     * Increments the counter by the given amount.
     * @p amount is an int with how much to add.
     * @r The counter object for chaining.
     */
    public extern incBy(int amount);

    /**
     * Returns the current value.
     * @r An int with the count.
     */
    public extern get();

    /**
     * Resets the counter to zero.
     * @r The counter object for chaining.
     */
    public extern reset();
}

The colon syntax extern class counter : com.example.ACounter binds the Aussom name on the left to the fully qualified Java class on the right. The public extern foo(); lines list the methods -- note that there is no body; the body lives on the Java side.

6.3 Registering with the engine

If your extern class lives outside the stdlib, ship the .aus file on the JVM resource path or on the filesystem and tell the engine where to look:

// JAR resource:
eng.addResourceIncludePath("/com/example/aus/");
// or filesystem:
eng.addIncludePath("/opt/myapp/aus/");

eng.addInclude("counter");

Now scripts can use it:

include counter;

class App {
    public main(args) {
        c = new counter();
        c.inc().inc().incBy(5).inc();
        c.log("count = " + c.get());
        return 0;
    }
}

(Note: c here shadows the c console singleton inside this scope -- that's fine, but in real code you'd pick a different name.)

6.4 Method chaining: returning env.getClassInstance()

A method that doesn't have a useful value to return should still return something that lets script-side callers chain. The convention is return env.getClassInstance(), which gives the caller back the receiver of the call. That makes this work in Aussom:

counter.inc().inc().incBy(5);

Without that, every chained call would have to be split onto its own line:

counter.inc();
counter.inc();
counter.incBy(5);

env.getClassInstance() is the exact receiver -- it's the AussomObject whose method is being called, not just any instance of that class. So returning it is safe even when multiple instances of the class exist.

If you do have a useful value, return that instead. The script caller will see whatever AussomType you return.

6.5 Static extern classes

For singletons (the equivalent of Math or console), declare the class static:

static extern class chrono : com.example.AChrono {
    public extern now();
    public extern sleep(int ms);
}

The Aussom engine instantiates the Java class once at engine construction time, and scripts reference it directly without new:

chrono.sleep(100);
t = chrono.now();

Static classes have all the same threading caveats as static state in Java -- if your script mutates fields on a static class from multiple threads, you must coordinate that yourself. The engine will not synchronize individual field writes for you.

6.6 Inheritance: extending an existing extern class

To add behavior to an existing Aussom type (or another extern class), have your Java class extend the appropriate base. For example, the stdlib int type's Java side is AussomInt; an extension would extend AussomInt. For ordinary objects, extend AussomObject. The Aussom side then declares the extension's parent the usual way (extern class myInt extends int : ...).

In practice, most host extern classes do not need this -- a plain Java class with AussomType methods is enough.


7. Type marshalling and AussomType helpers

Every Aussom value at runtime is an AussomType. Subclasses live in com.aussom.types:

Aussom type Java class Underlying value
cnull AussomNull --
bool AussomBool boolean
int AussomInt long
double AussomDouble double
string AussomString String
list AussomList ArrayList<AussomType>
map AussomMap ConcurrentHashMap<String,AussomType>
object AussomObject external object + members
callback AussomCallback parsed lambda / function
exception AussomException message, line, stack

7.1 Reading args inside an extern method

The args ArrayList<AussomType> mirrors the Aussom call site positionally. Cast each entry to its expected type:

public AussomType setName(Environment env, ArrayList<AussomType> args) {
    String name = ((AussomString) args.get(0)).getValueString();
    int    age  = (int) ((AussomInt) args.get(1)).getValue();
    // ...
    return env.getClassInstance();
}

The engine's overload dispatcher already enforces argument types at the Aussom level if you typed them in the extern declaration (public extern setName(string name, int age);), so the cast at the top of the method is normally safe. If you used untyped args in the extern declaration, defend with instanceof first.

7.2 Producing a return value

Constructors on the type classes are simple:

return new AussomNull();
return new AussomBool(true);
return new AussomInt(42);
return new AussomDouble(3.14);
return new AussomString("hello");
return new AussomList();          // empty list, then .add(...)
return new AussomMap();           // empty map, then .put(key, ...)

For a list:

AussomList xs = new AussomList();
xs.add(new AussomInt(1));
xs.add(new AussomInt(2));
xs.add(new AussomInt(3));
return xs;

For a map:

AussomMap m = new AussomMap();
m.put("name", new AussomString("Ada"));
m.put("year", new AussomInt(1815));
return m;

7.3 Returning the receiver for chaining

As covered in Section 6.4:

return env.getClassInstance();

7.4 The externObject slot

AussomObject has a slot named externObject that holds the backing Java object. The runtime sets it automatically for the primitive types. For your own extern class, the linkage is also automatic: when an Aussom script does new mything(), the engine calls Class.newInstance() on the linked Java class, then assigns the result as the externObject of a fresh AussomObject. Inside your method, env.getClassInstance().getExternObject() gives you back the same Java instance you're currently executing on -- which is just this, so you rarely need it.

You only need to think about externObject if you are wrapping an existing Java object (e.g. one your host already constructed and wants to hand to a script). In that case build the AussomObject explicitly:

AussomObject ao = new AussomObject();
ao.setExternObject(myExistingJavaObject);
ao.setClassDef(eng.getClassByName("myJavaWrapperType"));

If your extern object holds a lot of memory, say so. When a host asks the engine how much memory a program is holding (Section 13.4), the engine can measure Aussom's own values but has no way to know what is behind an extern object. Yours is charged only for the small Aussom object wrapping it, however large the array or document inside. Let the engine ask by implementing com.aussom.AussomFootprintInt:

import com.aussom.AussomFootprintInt;

public class ADocument implements AussomFootprintInt {
    private byte[] content;

    // ... your Aussom-callable methods, as in Section 6.1 ...

    @Override
    public long getRetainedBytes() {
        if (this.content == null) return 0L;
        return this.content.length;
    }
}

That is the only member the interface adds, so you can put it on an extern class you already have.

Keep it a field read rather than a walk: the method is called while the engine is paused or stopped, and a slow answer makes that pause longer. The stdlib's Buffer implements this, which is how buffer bytes end up in a measurement.

This is accounting, not enforcement. The number is your own claim about your own object, and the engine takes it at face value -- which is fine, because extern code is Java running in the same process and can already allocate whatever it likes. Do not build a quota on top of it and assume a hostile module will be honest.


8. Exception handling

The Aussom runtime distinguishes two failure shapes:

  1. com.aussom.ast.aussomException -- a Java Exception subclass, thrown by the parser, by instantiateObject, and from inside extern code that wants to halt execution. Catch it in your host. The engine catches it during run() and returns 1.
  2. com.aussom.types.AussomException -- an AussomType (a value), returned from an extern method to signal a script-level failure. The runtime checks .isEx() after every dispatch and propagates the exception up the call stack as if it were thrown.

Inside an extern class method, always handle Java exceptions locally and convert them. Never let a RuntimeException or IOException escape -- it bypasses the Aussom call stack and surfaces as a raw Java stack trace through eng.run():

public AussomType readFile(Environment env, ArrayList<AussomType> args) {
    String path;
    try {
        path = ((AussomString) args.get(0)).getValueString();
    } catch (ClassCastException cce) {
        return new AussomException("readFile: arg 0 must be string");
    }

    if (!env.getEngine().getSecurityManager()
            .getPropertyBoolean("file.read", false)) {
        return new AussomException(
            "readFile: action 'file.read' not permitted.");
    }

    try {
        String contents = new String(
            java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(path)),
            java.nio.charset.StandardCharsets.UTF_8);
        return new AussomString(contents);
    } catch (java.io.IOException ioe) {
        return new AussomException(
            "readFile: " + ioe.getClass().getSimpleName()
            + ": " + ioe.getMessage());
    } catch (Exception e) {
        return new AussomException(
            "readFile: unexpected error: " + e.getMessage());
    }
}

Three rules of thumb:

  1. Validate, then act. Cast args at the top, security-check next, do the work last.
  2. Catch broadly. Wrap the body in a try { ... } catch (Exception e) { return new AussomException(...) } so nothing leaks out as a Java throwable.
  3. Use clear exception messages. They appear verbatim in the Aussom stack trace the user sees.

If an extern method does have a legitimate reason to abort the whole engine (a corrupt configuration, a security breach detected mid-run), throw aussomException directly:

import com.aussom.ast.aussomException;

throw new aussomException("internal: panic, bailing out.");

This is for fatal-only situations; the regular pattern is to return AussomException.

8.1 Reading the Aussom stack on the host side

After eng.run() throws aussomException:

try {
    eng.run();
} catch (aussomException ae) {
    // Pretty Aussom-style stack trace.
    System.err.println(ae.getAussomStackTrace());
}

getAussomStackTrace() produces the same indented multi-line trace that the CLI prints. Use it -- the default Throwable.toString() output is much less readable.


9. Threading

Aussom is already used in multi-threaded environments (the downstream AussomThread stdlib, JavaFX, GTK4). The runtime supports concurrency, but you have to follow the rules.

9.1 The model: one Engine, fresh Environment per thread

Environment carries everything that is per-call: the class instance, locals, callstack, current object. One Engine and the classes parsed into it are shared by every thread you run against that engine.

Be careful with the word "shared" here: it means shared between the threads of one engine. Two separate engines share nothing at all, not even the parsed standard library.

The pattern, taken straight from AussomThread.run():

import com.aussom.CallStack;
import com.aussom.Environment;
import com.aussom.types.Members;

void runOnNewThread(Engine eng, AussomCallback cb) {
    new Thread(() -> {
        // CRUCIAL: clone the captured environment so this thread
        // does not share the locals/callstack with the spawner.
        Environment env = cb.getEnv().clone(cb.getEnv().getCurObj());
        AussomList args = new AussomList();
        AussomType ret = cb.call(env, args);
        // ... handle ret.isEx() etc.
    }).start();
}

The Environment.clone(curObj) call returns a new Environment sharing the same engine but with a fresh curObj. Without the clone, a callback running on a worker thread would mutate the spawner's locals during overload dispatch -- subtle, hard-to-debug data corruption.

For host code that calls Aussom methods directly from multiple threads (rather than through an AussomCallback), build a fresh Environment per call:

import com.aussom.CallStack;
import com.aussom.Environment;
import com.aussom.types.AussomList;
import com.aussom.types.Members;

AussomType callMethod(Engine eng, AussomObject receiver,
                       String name, AussomList args) {
    Environment env = new Environment(eng);
    env.setEnvironment(receiver, new Members(), new CallStack());
    env.setCurObj(receiver);
    return receiver.getClassDef().call(env, false, name, args);
}

This callMethod is safe to call from many threads on the same Engine because every invocation gets its own Environment, locals (Members), and CallStack. The runtime's hot path (astClass.call, astFunctDef.call) reads only AST state set up at parse time.

9.2 What Engine.run() is NOT safe for

Engine.run() mutates engine-level fields (mainCallStack, mainClassDef, mainClassInstance, etc.). It is not safe to call concurrently. If you need parallel execution, run main in one thread and dispatch into your scripts via per-thread Environments after that, as shown above. The JSR 223 layer is built exactly this way -- look at AussomScriptEngine.runCompiled for a worked example.

9.3 Parsing is not thread-safe

Parsing mutates engine-level collections. fileNames and includes are plain ArrayLists with no locking; classes and staticClasses are ConcurrentHashMaps, and addInclude is synchronized, but parseFile and parseString are not. So two threads parsing into the same engine at the same time is not safe. Either:

  • Do all parsing on a single thread before any worker threads start, or
  • Wrap parse calls in your own synchronized (eng) { ... } block.

Once parsing is complete, concurrent reads of the class registry are safe (the underlying classes and staticClasses maps are ConcurrentHashMap).

9.4 First-time class instantiation

The JSR 223 layer deliberately instantiates each newly parsed class once before letting concurrent work at it, to avoid racing on the setup of inherited members (AussomScriptEngine.java:205-209). That pre-warm is in the code for a reason, so the same caution is worth repeating for direct embedding:

AussomType warm = eng.instantiateObject("MyClass");
// Throw it away. Later instantiations only read class state.

Being straight about how much this matters: a stress test of 300 rounds of eight threads racing to be the first to instantiate a three-deep inheritance chain produced no failures and no missing members, so the race was not reproducible on current code. Treat the warm-up as cheap insurance rather than a known bug fix. If you never instantiate the same class from several threads at once, you can ignore this entirely.

9.5 Output routing is per engine

Script output belongs to the engine, not to the thread that happens to be running. c.log in a script calls through to env.getEngine().getLogger(), so one eng.setLogger(myLogger) covers every thread that runs that engine's code, and two engines in one JVM never cross-route their output.

eng.setLogger(myLogger);     // covers all threads on this engine
eng.setLogger(null);         // back to the default System.out sink

This was once a ThreadLocal, which meant output followed whichever thread registered it and a worker thread would silently lose it. If you have older host code that registers a logger per thread, that API is gone; call setLogger on the engine instead. It is safe to call while the engine is running -- the JSR 223 layer swaps loggers around each eval exactly this way.

9.6 Static-class fields are shared

A static class instance lives once per engine. If a script writes to a field on a static class from many threads, the writes race just like they would in plain Java. The engine does not synchronize individual field writes -- the script writer (or the host through the security manager) is responsible.


10. The console / LoggingInt

c is the console object a script writes to (c.log, c.err and so on). On the host side, every one of those calls goes to the LoggingInt attached to the engine that is running, so capturing script output means giving the engine your own implementation. To capture it:

import com.aussom.LoggingInt;
import com.aussom.stdlib.console;

class CapturingLogger implements LoggingInt {
    private final StringBuilder out = new StringBuilder();
    private final StringBuilder err = new StringBuilder();

    @Override public void log(String s)   { out.append(s).append('\n'); }
    @Override public void trc(String s)   { out.append(s).append('\n'); }
    @Override public void dbg(String s)   { out.append(s).append('\n'); }
    @Override public void info(String s)  { out.append(s).append('\n'); }
    @Override public void warn(String s)  { out.append(s).append('\n'); }
    @Override public void err(String s)   { err.append(s).append('\n'); }
    @Override public void print(String s)   { out.append(s); }
    @Override public void println(String s) { out.append(s).append('\n'); }

    public String getOut() { return out.toString(); }
    public String getErr() { return err.toString(); }
}

CapturingLogger cap = new CapturingLogger();
eng.setLogger(cap);

// ... run scripts ...

eng.setLogger(null);          // back to the default sink
String captured = cap.getOut();

The logger belongs to the engine, so this one call covers every thread that runs scripts on it, and a second engine in the same JVM is unaffected. setLogger(null) restores the default rather than silencing output. All eight methods in the table above are required by LoggingInt.

DefaultLoggingImpl is provided for the common case of writing to System.out / System.err with severity filtering. The CLI uses it.


11. End-to-end example: a host service exposing an API to scripts

Pulling everything together. The host is a small "key-value store" service. We want users to write .aus rules that read and write the store, with the host enforcing access control.

11.1 The host service

package com.example.kv;

import java.util.ArrayList;
import java.util.concurrent.ConcurrentHashMap;

import com.aussom.Environment;
import com.aussom.types.AussomException;
import com.aussom.types.AussomNull;
import com.aussom.types.AussomString;
import com.aussom.types.AussomType;

public class AKvStore {
    /*
     * Backing store. ConcurrentHashMap makes single-key reads/writes
     * thread-safe; compound check-and-set ops still need user-side
     * coordination if they matter.
     */
    private final ConcurrentHashMap<String, String> data =
        new ConcurrentHashMap<>();

    public AussomType get(Environment env, ArrayList<AussomType> args) {
        try {
            if (!env.getEngine().getSecurityManager()
                    .getPropertyBoolean("kv.read", false)) {
                return new AussomException(
                    "kv.get: action 'kv.read' not permitted.");
            }
            String key = ((AussomString) args.get(0)).getValueString();
            String val = this.data.get(key);
            if (val == null) return new AussomNull();
            return new AussomString(val);
        } catch (Exception e) {
            return new AussomException("kv.get: " + e.getMessage());
        }
    }

    public AussomType put(Environment env, ArrayList<AussomType> args) {
        try {
            if (!env.getEngine().getSecurityManager()
                    .getPropertyBoolean("kv.write", false)) {
                return new AussomException(
                    "kv.put: action 'kv.write' not permitted.");
            }
            String key = ((AussomString) args.get(0)).getValueString();
            String val = ((AussomString) args.get(1)).getValueString();
            this.data.put(key, val);
            return env.getClassInstance();   // chainable
        } catch (Exception e) {
            return new AussomException("kv.put: " + e.getMessage());
        }
    }

    public AussomType remove(Environment env, ArrayList<AussomType> args) {
        try {
            if (!env.getEngine().getSecurityManager()
                    .getPropertyBoolean("kv.write", false)) {
                return new AussomException(
                    "kv.remove: action 'kv.write' not permitted.");
            }
            String key = ((AussomString) args.get(0)).getValueString();
            this.data.remove(key);
            return env.getClassInstance();
        } catch (Exception e) {
            return new AussomException("kv.remove: " + e.getMessage());
        }
    }
}

11.2 The Aussom binding

/* kv.aus -- ships at /com/example/kv/aus/kv.aus inside the host jar */

/**
 * Host-provided key-value store. Backed by an in-memory
 * ConcurrentHashMap on the Java side.
 */
static extern class kv : com.example.kv.AKvStore {
    /**
     * Looks up the value for the given key.
     * @p key is a string with the key to read.
     * @r The value as a string, or null when missing.
     */
    public extern get(string key);

    /**
     * Stores the value under the given key.
     * @p key is a string with the key.
     * @p val is a string with the value.
     * @r The kv store for chaining.
     */
    public extern put(string key, string val);

    /**
     * Removes the entry for the given key.
     * @p key is a string with the key.
     * @r The kv store for chaining.
     */
    public extern remove(string key);
}

11.3 The host security policy

package com.example.kv;

import com.aussom.SecurityManagerImpl;

public class KvSecurityManager extends SecurityManagerImpl {
    public KvSecurityManager(boolean readWrite) {
        super();
        this.props.put("kv.read",  true);          // always readable
        this.props.put("kv.write", readWrite);     // mutation gated
    }
}

11.4 The host bootstrap

package com.example.kv;

import com.aussom.Engine;
import com.aussom.ast.aussomException;
import com.aussom.DefaultLoggingImpl;

public class KvHost {
    public static int run(String script, boolean writable) throws Exception {
        Engine eng = new Engine(new KvSecurityManager(writable));

        DefaultLoggingImpl log = new DefaultLoggingImpl();
        log.setLevel(DefaultLoggingImpl.INFO);
        eng.setLogger(log);

        // stdlib + host-provided extern classes
        eng.addResourceIncludePath("/com/aussom/stdlib/aus/");
        eng.addResourceIncludePath("/com/example/kv/aus/");

        eng.addInclude("kv");
        eng.parseFile(script);

        try {
            return eng.run();
        } catch (aussomException ae) {
            eng.getLogger().err(ae.getAussomStackTrace());
            return 1;
        }
    }

    public static void main(String[] args) throws Exception {
        System.exit(run(args[0], /*writable=*/true));
    }
}

11.5 A user script

include kv;

class Job {
    public main(args) {
        kv.put("greeting", "hello").put("name", "world");
        c.log(kv.get("greeting") + " " + kv.get("name"));
        return 0;
    }
}

Run it:

java -cp myhost.jar com.example.kv.KvHost greet.aus
# -> hello world

Run it with the policy locked down:

KvHost.run("greet.aus", /*writable=*/false);
// kv.put returns an AussomException; the script halts and the
// trace shows: "kv.put: action 'kv.write' not permitted."

That's the whole pattern. Every host-provided extern class follows the same shape: declare it, write Java methods that take (Environment, ArrayList<AussomType>) and return AussomType, gate sensitive ops on the security manager, return env.getClassInstance() for chaining, and never let a Java exception escape.


12. Common gotchas

A condensed reference for things that bite host developers most often.

  1. Always register your security manager. The default SecurityManagerImpl() (no constructor arg) locks almost everything out. Use DefaultSecurityManagerImpl if you want stdlib defaults plus aussomdoc, or write your own subclass.

  2. addResourceIncludePath("/com/aussom/stdlib/aus/") is mandatory if your scripts use include sys;, include math;, or any other stdlib include. Without it the parser refuses to resolve the include.

  3. Never let a Java exception escape an extern method. Wrap the body in try { ... } catch (Exception e) { return new AussomException(...) }. Anything that escapes shows up as a raw Java stack trace from eng.run(), breaking the user's mental model.

  4. Return env.getClassInstance() for chainable methods. Any method that doesn't have a useful value should return the receiver so script callers can chain.

  5. Engine.run() is single-threaded. Concurrent call into the same Engine.run() will corrupt engine state. For multi- threaded execution, build per-call Environment + Members + CallStack and dispatch through astClass.call directly.

  6. Parse before fanning out. All parseFile / parseString / addInclude calls must finish before you start running scripts on multiple threads.

  7. Consider pre-warming classes you'll hit hard concurrently. A single instantiateObject(name) on one thread before workers start is cheap insurance for the first concurrent instantiation. See Section 9.4 for how much this is actually known to matter.

  8. Output belongs to the engine, not the thread. Call eng.setLogger(yourLogger) once and it covers every thread running that engine. There is no per-thread registration and no console.get(); older code that used them will not compile.

  9. Environment.clone() is shallow. It shares locals and callstack with the original. If you need true isolation, build a fresh Environment + Members + CallStack instead of cloning.

  10. AussomInt is 64-bit. Use (long)((AussomInt)v).getValue() or (int)((AussomInt)v).getValue(); never assume int.

  11. AussomMap.getValue() is a ConcurrentHashMap -- key lookup is safe but compound read-modify-write across threads needs your own coordination.

  12. Engines share nothing. Each one parses its own copy of the standard library into its own class table and builds its own static classes, so two engines in a JVM cannot see each other's classes, static-class fields, output or policy. The cost of that isolation is about 0.81 MB and 7 ms per engine (Section 3.1). If you read about a process-wide Universe registry in older notes, it no longer exists.

  13. hasParseErrors is sticky. After a failed parse, eng.run() will refuse to execute until you call eng.clearParseError().

  14. Static extern classes are instantiated once at engine construction. Their <init> runs in the engine builder's thread before any Aussom code runs. Make those constructors cheap and side-effect free.

  15. cancel() is sticky. A cancelled engine stays cancelled and will refuse the next program too. Call eng.clearCancel() before reusing it. See Section 13.2.

  16. measureRetainedFootprint() returns -1 while a program is running. It is only meaningful on an engine that is between programs or fully paused, so it refuses rather than returning a number taken from a graph that is being rewritten. See Section 13.4.

  17. Numeric limits are read once per program, not on every operation. Changing aussom.limit.call.depth in the middle of a run has no effect until the next run(). Set it before you start.

  18. An include directory cannot be named with an Aussom keyword. Include names are ordinary identifiers, so include private.creds; fails to parse -- private is a keyword. Worth knowing before you lay out a module tree; vault, internal or secrets are fine.


13. Resource limits, host control, and measurement

If you are running scripts you wrote yourself, you can skip this section: everything in it is optional and the defaults leave the engine behaving exactly as it always has. It matters when the scripts are written by someone else -- a customer, a plug-in author, a user typing into a box -- and you need to bound what they can do, stop them when they misbehave, and know what they cost you.

There are three separate tools here, and it helps to keep them apart:

  • Limits bound a few specific things before they happen.
  • Control lets you pause or stop a program that is already running.
  • Measurement tells you what a program has used or is holding.

The engine does none of this on its own. It never watches a clock, never enforces a budget of its own devising, and never kills a program because it looked expensive. It gives you the numbers and the switches; the policy is yours.

13.1 The four numeric limits

Each of these bounds something the JVM will not bound for you. They are properties on your security manager, like every other setting, and the names are also available as constants on com.aussom.Limits.

Property Default What it bounds
aussom.limit.call.depth 1000 How deep Aussom function calls may nest
aussom.limit.regex.steps 0 (off) How many characters of its subject one regular expression may read
aussom.limit.sleep.slice 50 (ms) How long a sleeping program runs between control checks
aussom.limit.source.bytes 0 (off) The largest source file the engine will parse
public class HostSecurityManager extends SecurityManagerImpl {
    public HostSecurityManager() {
        this.props.put(Limits.CALL_DEPTH_PROP, 300L);
        this.props.put(Limits.REGEX_STEPS_PROP, 1000000L);
        this.props.put(Limits.SOURCE_BYTES_PROP, 262144L);   // 256 KB
    }
}

Call depth bounds runaway recursion. Either way it ends as an Aussom exception rather than a raw Java error escaping run(), but there are two different endings and they are not equally useful:

  • CALL_DEPTH_EXCEEDED -- the depth limit fired. This is an ordinary exception: the script can catch it and carry on, and you can log it.
  • STACK_OVERFLOW -- the Java stack ran out first. The engine converts it at the run() boundary, so your host gets a clean exception, but the script cannot catch this one. The program is over.

Which one you get is a race between the limit and the JVM's stack, and the limit does not always win, because the number of Java frames per Aussom call depends on how complicated the code is. Measured with a method whose body is return this.down(n + 1);: limits of 100, 200 and 300 all produced a catchable CALL_DEPTH_EXCEEDED, while 500 lost the race and produced STACK_OVERFLOW. Your own crossover point will differ with the code and the thread stack size.

So the default of 1000 is chosen to be out of the way -- switching it on cannot refuse a program that works today -- and in practice it is the stack-overflow conversion that catches infinite recursion. If you want the recoverable, script-visible failure instead, set the limit low, in the hundreds, and test it against the shape of code your users actually write.

Regex steps exists because java.util.regex has no timeout and does not respond to thread interrupts. A short pattern on a short subject can burn a core for a long time, and once it starts there is no polite way to stop it. Giving it a budget lets the engine refuse instead, with the id REGEX_BUDGET_EXCEEDED. It is off by default because any budget is a behaviour change; a million is a reasonable starting point for untrusted patterns.

Sleep slice decides how quickly a sleeping program notices that you paused or cancelled it. A program sitting in sys.sleep(60000) wakes every 50 ms to check, then goes back to sleep. Setting it to 0 turns that off and restores a single uninterruptible wait, which means a pause or a cancel waits for the whole sleep to finish.

Source bytes bounds parsing. A source file is read into memory whole before the parser sees a token, and what it becomes is much larger than the text: measured on this codebase, 405 KB of source retains about 15 MB of parsed definitions. The check happens against the file's length before the file is read, so an oversized file costs you nothing. Note it applies to files only, not to source you hand to parseString -- you built that string, so you already know how big it is -- which is also why setting it can never refuse the standard library.

A value that is missing, negative, or stored as something other than a number falls back to the default. Nothing throws.

Limits are read once per program, when the Engine is constructed and again at the start of each run(). That keeps them off the interpreter's hot path. Change your policy between runs and it is honoured; change it in the middle of a run and it is not. eng.getLimits() returns the snapshot in force.

13.2 Pausing and stopping a program

eng.cancel();          // ask the program to stop
eng.isCancelled();     // has it been asked?
eng.clearCancel();     // let this engine run again

eng.pause();           // ask the program to stop where it is
eng.resume();          // let it continue
eng.isFullyPaused();   // has every thread actually parked?
eng.awaitPaused(200, TimeUnit.MILLISECONDS);   // wait until it has
eng.getControlState(); // RUNNING, PAUSED or CANCELLED

All of these are safe to call from another thread, which is the point: the program is running on one thread and you are deciding about it on another.

cancel() unwinds the program with an exception whose id is EXECUTION_CANCELLED. A script cannot swallow it. Aussom's try/catch deliberately refuses to catch a cancellation and hands it straight back to unwind, because stopping is your decision and not a fault in the program the program gets to handle.

The state is sticky: an engine stays cancelled until you call clearCancel(). If you reuse engines, clear before the next run.

pause() is a request, and awaitPaused is how you find out whether it took effect. A false return is information, not a failure: it means at least one thread is somewhere the engine does not control. Decide then whether to keep waiting or to cancel.

The engine notices these requests at its checkpoints: every loop back edge, every Aussom function call, every batch of characters a regular expression reads, every sleep slice, and between files while it is loading includes. In practice, any Aussom code that is looping or calling stops promptly.

What control cannot do is interrupt your own Java code. If a script calls an extern method that blocks on a socket read or a latch, the engine has no checkpoint to reach until that method returns: awaitPaused will return false, and a cancel takes effect when the call comes back. The engine does not use Thread.interrupt() for this, because one Engine may be driving several threads and one thread's interrupt is not a reason to stop the rest. If you write long-running externs, make them cooperative:

public AussomType crunch(Environment env, ArrayList<AussomType> args) {
    Engine eng = env.getEngine();
    while (moreWork()) {
        if (eng.getControlState() != ControlState.RUNNING) {
            return new AussomException("crunch: cancelled by host.");
        }
        doSomeWork();
    }
    return env.getClassInstance();
}

13.3 CPU and allocation used

long cpu   = eng.getCpuNanos();        // CPU nanoseconds, or -1
long bytes = eng.getAllocatedBytes();  // bytes allocated, or -1
eng.resetAccounting();                 // zero both and start a new window

These cover every thread that has run this engine's code, including threads that have since finished, and including parsing as well as execution -- so a host that compiles scripts on demand sees the compile cost too. A -1 means this JVM does not report per-thread counters; it does not mean zero.

Two things to understand about the allocation number, because it is easy to expect the wrong thing from it. It is cumulative: the total ever allocated, not what the program is holding now. And that is what makes it useful -- a program churning through short-lived data holds almost nothing at any instant while still working the garbage collector hard, and this is the only number that sees that. For "what is it holding right now", use Section 13.4.

Call resetAccounting() at the start of a request and the numbers become a per-request measurement rather than a lifetime total.

13.4 Measuring the memory a program holds

long bytes = eng.measureRetainedFootprint();

This walks the engine's own values and adds up an estimate of what they hold. It counts:

  • static class instances, the main class instance and the arguments it was called with;
  • script-mode locals, if you are using script mode;
  • the locals of every function the program is paused inside, which is where a running program keeps most of its data;
  • the parsed class definitions;
  • extern objects whose class implements AussomFootprintInt (Section 7.4).

What it does not count is native and JIT memory, which belongs to nobody in particular, and extern objects that do not report themselves.

You must not be running. The walk reads the value graph while nothing is holding it still, so measuring a running program would describe a shape that is being rewritten underneath it. The engine refuses rather than making something up: if threads are running and the engine is not fully paused, you get -1. The two cases that work are an engine between programs, and an engine you have paused:

eng.pause();
if (eng.awaitPaused(200, TimeUnit.MILLISECONDS)) {
    long held = eng.measureRetainedFootprint();
    log.info("tenant is holding about {} MB", held / (1024 * 1024));
}
eng.resume();

How close is the estimate? Measured against the JVM's own heap accounting on a 4 GB heap, with the same data held in each case:

Workload JVM held Estimate Ratio
A million strings in a list 105 MB 82 MB 0.78
A million ints in a list 44 MB 53 MB 1.20
A 200,000 entry map 40 MB 35 MB 0.87
200,000 small objects 80 MB 56 MB 0.69

So it lands within roughly 30% either way. That is the right shape for a threshold or a trend -- "this tenant is holding far more than the others" -- and the wrong shape for billing someone to the byte. It is an estimate from a published model, and the model is written down in the javadoc of com.aussom.AussomFootprint so you can check the arithmetic rather than trust it.

Buffers are worth a note because they look like an error and are not. Byte buffers are counted exactly, but the JVM may hold more than you asked for: with the common G1 collector, an array over half a heap region takes a whole region, so a hundred 1 MB buffers can occupy 200 MB while the estimate correctly says 100 MB. Use a smaller buffer size or a larger region and the two agree exactly.

The measurement costs time, and your program is paused for all of it. It is proportional to how many values there are: about 51 ms for 200,000 values and about 785 ms for two million. Treat it as something you sample every few seconds or minutes, not something you poll in a loop.

eng.getClassDefinitionBytes() returns just the class-definition part of the estimate, which is worth watching separately if your host lets scripts load a lot of code into a long-lived engine.

13.5 Putting it together

A worker that gives a script a CPU budget and a wall-clock deadline, and takes a memory reading on the way past:

Engine eng = new Engine(new HostSecurityManager());
eng.parseFile("tenant.aus");
eng.resetAccounting();

Thread worker = new Thread(() -> {
    try {
        eng.run();
    } catch (Exception e) {
        log.warn("script ended: {}", e.getMessage());
    }
});
worker.start();

long deadline = System.currentTimeMillis() + 30000;
while (worker.isAlive()) {
    worker.join(250);
    if (eng.getCpuNanos() > CPU_BUDGET_NANOS
            || System.currentTimeMillis() > deadline) {
        eng.cancel();       // reaches loops, calls, regex, sleep
        break;
    }
}
worker.join(5000);

Two habits worth picking up from that example. Use the CPU number rather than wall-clock time alone when tenants share a machine, because wall clock also counts the time your program spent waiting for somebody else's, and you will end up stopping the victim instead of the culprit. And keep the deadline as well as the budget: the deadline is what saves you when a script is stuck inside an extern where the CPU counter is barely moving.


14. Where to go next

  • JSR 223 alternative: design/usage-docs/aussom-lang-jsr223-usage.md -- shorter, more language-neutral, fits when you don't need direct Engine control.
  • Engine internals + threading audit: design/aussom-jsr-223.md -- the design doc behind the JSR 223 layer; covers the threading model in depth.
  • Aussom language overview: Language Overview
  • Aussom style guide: Style Guide
  • Stdlib source: src/main/java/com/aussom/stdlib/ -- the implementations of c, sys, math, etc. are the canonical reference for "how to write an extern class."
  • Reference embedding: src/main/java/com/aussom/Main.java is about thirty lines of CLI plumbing on top of Engine. Read it if you want a minimal end-to-end Engine consumer.
  • Script mode design: design/script-mode-design.md -- the design behind Engine.setScriptMode / evalLine / getScriptClass.