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:
c, sys, math, etc.).aussom-script (browser-side)
or the aussom CLI on top of aussom-base.You should be comfortable with Java 8+ and have a basic feel for Aussom syntax before starting.
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. |
<dependency>
<groupId>io.github.rsv-code</groupId>
<artifactId>aussom.base</artifactId>
<version>1.2.3</version>
</dependency>
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.
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().
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)
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.
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.
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.
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();
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.
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.
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.
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.
| 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. |
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));
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:
getProperty(String), which returns null for
an unknown name and will throw a NullPointerException if you cast
it straight to Boolean."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).
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().
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.
Use the parse / run pipeline (Section 3) for:
.aus files with main().run() returns the int result of
main, or 1 on exception).Use script mode for:
class and
main.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.
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:
__script_main class's main body.Environment whose Members persists across calls.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.
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.
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.
Script mode and the classical run pipeline coexist on one engine without interference:
__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.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
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.
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.
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.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.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:
extern class declaration that binds method
names to the Java class.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.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();
}
}
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.
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.)
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.
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.
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.
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 |
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.
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;
As covered in Section 6.4:
return env.getClassInstance();
externObject slotAussomObject 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.
The Aussom runtime distinguishes two failure shapes:
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.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:
try { ... } catch (Exception e) { return new AussomException(...) } so nothing
leaks out as a Java throwable.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.
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.
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.
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.
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.
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:
synchronized (eng) { ... } block.Once parsing is complete, concurrent reads of the class registry
are safe (the underlying classes and staticClasses maps are
ConcurrentHashMap).
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.
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.
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.
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.
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.
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());
}
}
}
/* 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);
}
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
}
}
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));
}
}
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.
A condensed reference for things that bite host developers most often.
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.
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.
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.
Return env.getClassInstance() for chainable methods. Any
method that doesn't have a useful value should return the
receiver so script callers can chain.
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.
Parse before fanning out. All parseFile / parseString /
addInclude calls must finish before you start running scripts
on multiple threads.
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.
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.
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.
AussomInt is 64-bit. Use (long)((AussomInt)v).getValue()
or (int)((AussomInt)v).getValue(); never assume int.
AussomMap.getValue() is a ConcurrentHashMap -- key
lookup is safe but compound read-modify-write across threads
needs your own coordination.
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.
hasParseErrors is sticky. After a failed parse,
eng.run() will refuse to execute until you call
eng.clearParseError().
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.
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.
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.
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.
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.
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:
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.
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.
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();
}
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.
long bytes = eng.measureRetainedFootprint();
This walks the engine's own values and adds up an estimate of what they hold. It counts:
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.
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.
design/usage-docs/aussom-lang-jsr223-usage.md -- shorter, more
language-neutral, fits when you don't need direct Engine control.design/aussom-jsr-223.md -- the design doc behind the JSR 223
layer; covers the threading model in depth.src/main/java/com/aussom/stdlib/ -- the
implementations of c, sys, math, etc. are the canonical
reference for "how to write an extern class."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.design/script-mode-design.md -- the design behind
Engine.setScriptMode / evalLine / getScriptClass.