AGuideto.|

Acton is a advanced general purpose programming language offering functional and object-oriented style of programming based on the actor-model and async I/O. Type safe and with capabilities based security, Acton is statically compiled for high performance and portability. In other words, pretty much perfect ;) We hope you enjoy it as much as we do. It's readily available to build anything from advanced "shell scripts" to low level databases.

Unique among programming languages, Acton offers orthogonal persistence, which means you don't have to think about how to persist data, or rather the state of your program, for durability. Acton will do it for you, using its fault-tolerant distribute database. Pretty damn cool!

Hello World

We follow tradition and introduce Acton with the following minimal example

Source:

# This is a comment, which is ignored by the compiler.

# An actor named 'main' is automatically discovered and recognized as the root 
# actor. Any .act file with a main actor will be compiled into a binary 
# executable and the main actor becomes the starting point.
actor main(env):
    print("Hello World!")
    env.exit(0)

Compile and run:

acton hello.act
./hello

Output:

Hello World!

Description

When an Acton program runs, it really consits of a collection of actors that interact with each other. In the above example, we have just a single actor, which has been given the name main and that acts as the root actor of our system. The root actor of a system takes a parameter env, which represents the execution environment. env has methods for accessing command line arguments and carries a reference to the capabilities of the surrounding world, WorldCap, for accessing the environment, e.g. reading from and writing to keyboard/screen and files, working with sockets etc.

Installation

For Debian derivative distributions that use .dpkg and the APT ecosystem. Add the Acton APT repo and install from there:

wget -q -O - https://apt.acton-lang.io/acton.gpg | sudo apt-key add -
echo "deb [arch=amd64] http://apt.acton-lang.io/ stable main" | sudo tee /etc/apt/sources.list.d/acton.list
sudo apt-get update
sudo apt-get install -qy acton

Shebang

While Acton is a compiled language and the acton compiler produces an executable binary, script style execution is also possible through the use of a shebang line.

Source:

#!/usr/bin/env runacton

actor main(env):
    print("Hello World!")
    env.exit(0)

Ensure the executable bit is set and run your .act file directly:

chmod a+x hello.act
./hello.act

Output:

Hello World!

Acton Projects

Besides compiling individual .act files, it is possible to organize Acton code into an Acton Project, which is suitable once you have more than one .act source code file.

Use acton to create a new project called foo:

acton new foo

Output:

Created project foo
Enter your new project directory with:
  cd foo
Compile:
  acton build
Run:
  ./out/bin/foo

Description

Use acton build to build a project. The current working directory must be the project directory or a sub-directory to the project directory. acton will discover all source files and compile them according to dependency order.

Add a main actor to any source file directly under src/ to produce an executable binary. For example, if src/hello.act contains a main actor, it will produce out/bin/hello using main as the root actor.

Running Tests

Writing tests is an integral part of writing software. In an Acton project, you can run all the tests by issuing acton test:

foo@bar:~hello$ acton test

Tests - module hello:
  foo:                   OK:  278 runs in 50.238ms

All 1 tests passed (23.491s)

foo@bar:~hello$ 

See the Testing section on how to write tests.

Variable data types

Acton supports a plethora of primitive data types.

  • int integers, like 1, 2, 123512, -6542 or 1267650600228229401496703205376
    • int is arbitrary precision and can grow beyond machine word sizes
    • i16 is a fixed size signed 16 bit integer
    • i32 is a fixed size signed 32 bit integer
    • i64 is a fixed size signed 64 bit integer
    • u16 is a fixed size unsigned 16 bit integer
    • u32 is a fixed size unsigned 32 bit integer
    • u64 is a fixed size unsigned 64 bit integer
  • float 64 bit float, like 1.3 or -382.31
  • bool boolean, like True or False
  • str strings, like foo
    • strings support Unicode characters
  • lists like [1, 2, 3] or ["foo", "bar"]
  • dictionaries like {"foo": 1, "bar": 3}
  • tuples like (1, "foo")
  • sets like {"foo", "bar"}

In Acton, mutable state can only be held by actors. Global definitions in modules are constant. Assigning to the same name in an actor will shadow the global variable.

Source:

foo = 3    # this is a global constant and cannot be changed

def printfoo():
    print("global foo:", foo)  # this will print the global foo

actor main(env):
    # this sets a local variable with the name foo, shadowing the global constant foo
    foo = 4
    print("local foo, shadowing the global foo:", foo)
    printfoo()

    a = u16(1234)
    print("u16:", a)

    env.exit(0)

Output:

local foo, shadowing the global foo: 4
global foo: 3
u16: 1234

Variable data types

Acton supports a plethora of primitive data types.

  • int integers, like 1, 2, 123512, -6542 or 1267650600228229401496703205376
    • int is arbitrary precision and can grow beyond machine word sizes
    • i16 is a fixed size signed 16 bit integer
    • i32 is a fixed size signed 32 bit integer
    • i64 is a fixed size signed 64 bit integer
    • u16 is a fixed size unsigned 16 bit integer
    • u32 is a fixed size unsigned 32 bit integer
    • u64 is a fixed size unsigned 64 bit integer
  • float 64 bit float, like 1.3 or -382.31
  • bool boolean, like True or False
  • str strings, like foo
    • strings support Unicode characters
  • lists like [1, 2, 3] or ["foo", "bar"]
  • dictionaries like {"foo": 1, "bar": 3}
  • tuples like (1, "foo")
  • sets like {"foo", "bar"}

In Acton, mutable state can only be held by actors. Global definitions in modules are constant. Assigning to the same name in an actor will shadow the global variable.

Source:

foo = 3    # this is a global constant and cannot be changed

def printfoo():
    print("global foo:", foo)  # this will print the global foo

actor main(env):
    # this sets a local variable with the name foo, shadowing the global constant foo
    foo = 4
    print("local foo, shadowing the global foo:", foo)
    printfoo()

    a = u16(1234)
    print("u16:", a)

    env.exit(0)

Output:

local foo, shadowing the global foo: 4
global foo: 3
u16: 1234

Scalars

Source:

actor main(env):
    i = 42       # an integer
    f = 13.37    # a float
    
    print(i)
    print(f)

    s = "Hello"  # a string
    print(s)
    # a slice of a string
    print(s[0:1])
    
    b = True     # a boolean
    print(b)
    
    env.exit(0)

Compile and run:

actonc scalars.act
./scalars

Output:

42
13.37
Hello
H
True

float

Source:

from math import pi

actor main(env):
    # round to 2 decimals
    a = round(pi, 2)

    # print 4 digits of pi
    print("%.4f" % pi)

    env.exit(0)

Output:

3.1416

Lists

Source:

actor main(env):
    l = ["foo", "foo"]
    l.append("bar")
    l.insert(0, "Firsty")
    l.append("banana")

    # Indexing starts at 0
    print("First item: " + l[0])
    # Negative index counts from the end
    print("Last item : " + l[-1])

    # Note how we need to explicitly cast the list to str before printing
    print("List items: " + str(l))
    # We can take a slice of a list with [start:stop] where the start index is
    # inclusive but the stop index is exclusive, just like in Python.
    # A slice of a list is also a list, so cast to str.
    print("A slice   : " + str(l[2:4]))
    
    print("Pop first item:", l.pop(0))
    print("Pop last item:", l.pop(-1))
    print("List items:", str(l))
    
    # Extend a list by adding another list to it
    l.extend(["apple", "orange"])
    print("List items:", str(l))
    
    unsorted_list = [9, 5, 123, 14, 1]
    sl = sorted(unsorted_list)
    print("Sorted list", str(sl))

    # Reverse a list inplace
    l.reverse()
    print("Reversed:", l)
    
    # Get a shallow copy of the list
    l2 = l.copy()
    print("Copy:", l2)
    
    # Clear list
    l.clear()
    print("List after clear:", str(l))

    env.exit(0)

Compile and run:

actonc lists.act
./lists

Output:

First item: Firsty
Last item : banana
List items: ['Firsty', 'foo', 'foo', 'bar', 'banana']
A slice   : ['foo', 'bar']
Pop first item: Firsty
Pop last item: banana
List items: ['foo', 'foo', 'bar']
List items: ['foo', 'foo', 'bar', 'apple', 'orange']
Sorted list [1, 5, 9, 14, 123]
Reversed: ['orange', 'apple', 'bar', 'foo', 'foo']
Copy: ['orange', 'apple', 'bar', 'foo', 'foo']
List after clear: []
  • All items in a list must be of the same type
    • It is not allowed to mix, like ["foo", 1] leads to a compiler error

Dictionaries

Source:

actor main(env):
    d = {"foo": 1, "bar": 2}
    d["cookie"] = 3

    print("Dict: " + str(d))
    print("len : " + str(len(d)))
    print("item foo: " + str(d["foo"]))
    print("item foo: " + str(d.get("foo", "default")))
    print("get default value when key nonexistent: " + str(d.get("foobar", "DEF_val")))
    
    d["bar"] = 42
    del d["foo"]
    print("Dict: " + str(d))
    
    print("Dict keys: " + str(list(d.keys())))
    print("Dict values: " + str(list(d.values())))

    print("Dict items:")
    for k, v in d.items():
        print("  dict key " + k + "=" + str(v))

    print("Pop last item:", d.popitem())
    print("Dict after .popitem():", d)

    # Get a shallow copy of a dict. Note the use of .items() to get keys and values
    new_d = dict(d.items())

    env.exit(0)

Compile and run:

actonc dicts.act
./dicts

Output:

Dict: {"foo":1, "bar":2, "cookie":3}
len : 3
item foo: 1
item foo: 1
get default value when key nonexistent: DEF_val
Dict: {"bar":42, "cookie":3}
Dict keys: ["bar", "cookie"]
Dict values: [42, 3]
Dict items:
  dict key bar=42
  dict key cookie=3
Pop last item: ("cookie", 3)
Dict after .popitem(): {"bar":42}

Tuples

Source:

actor main(env):
    # Items in a tuple can be of different types
    t = ("foo", 42)
    # Fields are accessed by their index and using field / attribute selection style:
    print(t)
    print(t.0)
    print(t.1)
    
    # Tuples can use named fields
    nt = (a="bar", b=1337)
    print(nt)
    print(nt.a)
    print(nt.b)
    
    r = foo()
    if r.b:
        print(r.c)

    env.exit(0)
    
def foo() -> (a: str, b: bool, c: int):
    """A function that returns a tuple with fields name a and b
    """
    return (a = "hello", b=True, c=123)

Compile and run:

actonc sets.act
./sets

Output:

('foo', 42)
foo
42
('bar', 1337)
bar
1337
123
  • fields in a tuple can be of different types
  • tuples have a fixed fields
  • tuples with named fields is like an anonymous data class, i.e. the data type itself has no name
  • tuples with named fields can be used like a simple record type

Sets

Source:

actor main(env):
    # set syntax is similar to dicts using {} but without keys
    s = {"foo", "bar"}

    # Although for an empty set, {} cannot be used as it means an empty dict
    # (and it is impossible to distinguish between the two from syntax alone).
    # Use set() to create an empty set.
    empty_set = set()

    print("Set content:", s)
    if "foo" in s:
        print("'foo' is in the set")
    if "a" not in s:
        print("'a' is not in the set")

    # Adding an item that is already in the set does nothing
    s.add("foo")
    print("Set without duplicate 'foo':", s)
    s.add("a")
    print("Set after adding 'a':", s)
    if "a" in s:
        print("'a' is in the set now")
    print("Entries in set:", len(s))
    s.discard("foo")
    print("Set after discarding 'foo':", s)
    
    # Add all items from another set or some other iterable
    s.update({"x", "y", "z"})
    print(s)

    env.exit(0)

Compile and run:

acton sets.act
./sets

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.032 s
  Final compilation step
   Finished final compilation step in   0.701 s
Set content: {'bar', 'foo'}
'foo' is in the set
'a' is not in the set
Set without duplicate 'foo': {'bar', 'foo'}
Set after adding 'a': {'bar', 'a', 'foo'}
'a' is in the set now
Entries in set: 3
Set after discarding 'foo': {'bar', 'a'}
{'x', 'bar', 'a', 'y', 'z'}

Functions

Functions are declared using the def keyword.

Use return foo to return variable foo. If no return keyword is used or a lone return without argument is given, the function will return None.

Source:

def multiply(a, b):
    print("Multiplying", a, "with", b)
    return a*b
    
actor main(env):
    result = multiply(3, 4)
    print("Result:", result)
    env.exit(0)

Output:

Multiplying 3 with 4
Result: 12

Actor methods

Actor methods are declared under an actor using the def keyword.

An actor method runs in the context of the actor and can access its private state. As Actors are sequential processes, calling other methods on the local actor or any function is going to be run sequentially.

Calling an actor method on the local actor can be done simply by calling it by its name, without any prefix such as self..

All actor methods are public. Call a method on another actor by calling actor_name.method_name(). Calling methods on other actors can be done synchronously or asynchronously.

Source:


def multiply(a, b):
    print("Multiplying", a, "with", b)
    return a*b
    
actor main(env):
    var secret = 42

    def compute(a):
        print("Computing result based on our secret", secret)
        res = multiply(a, secret)
        return res

    result = compute(3)
    print("Result:", result)
    env.exit(0)

Output:

Computing result based on our secret 42
Multiplying 3 with 42
Result: 126

Higher order functions

Acton supports higher order functions which means you can pass a function as an argument to another function.

Source:

def multiply_with_3(a):
    print("Multiplying with 3")
    return 3*a

def multiply_with_42(a):
    print("Multiplying with 42")
    return 42*a

def compute(a, fun):
    """Compute value from a using function fun"""
    return fun(a)
    
actor main(env):
    print( compute(7, multiply_with_3) )
    print( compute(7, multiply_with_42) )
    env.exit(0)

Output:

Multiplying with 3
21
Multiplying with 42
294

Types

Every value in Acton is of a certain data type, which tells Acton what kind of data is being specified so it knows how to work with the data. Acton is a statically typed language, which means the type of all values must be known when we compile the program. Unlike many traditional languges like Java or C++, where types must be explicitly stated everywhere, we can write most Acton programs without types. The Acton compiler features a powerful type inferencer which will infer the types used in the program.

This program does not have any explicit types specified.

Source:

def foo(a, b):
    if a > 4:
        print(len(b))

actor main(env):
    i1 = 1234      # inferred type: int
    s1 = "hello"   # inferred type: str
    foo(i1, s1)

    env.exit(0)

To see the inferred types of an Acton program, use the --sigs option of the compiler. As the name suggests, this will print out the type signatures for functions, actors and their attributes and methods in the specified Acton module.

Print type signatures with --sigs:

actonc types.act --sigs

Output:

#################################### sigs:

foo : [A(Ord, Number)] => (A, Collection[B]) -> None

actor main (Env):
    i1 : int
    s1 : str

Acton implements the Hindley-Milner (HM) type system, which is common in languages with static types, like Haskell and Rust. Acton extends further from HM to support additional features.

Explicit types

It is possible to explicitly specify the types of variables or arguments in a function. The syntax is similar to type hints in Python.

Source:

# 'a: int' means the first argument `a` should be of type int
# 'b: str means the second argument `b` should be of type str
# The function returns nothing
def foo(a: int, b: str) -> None:
    print(a, b)
    
# A functions type signature can also be written on a separate line
bar : (int, str) -> None
def bar(a, b):
    print(a, b)

actor main(env):
    # i1 is explicitly specified as an integer while s1 is a str
    i1 : int = 1234
    s1 : str = "hello"
    foo(i1, s1)
    bar(i1, s1)

    # The literal value 1234 is an integer, so when we assign it to i2, the
    # compiler can easily infer that the type of i2 is int. Similarly for s2
    # since the literal value "hello" is clearly a string
    i2 = 1234
    s2 = "hello"
    foo(i2, s2)
    bar(i2, s2)
    
    env.exit(0)

Compile and run:

actonc types.act
./types

Output:

1234 hello
1234 hello
1234 hello
1234 hello

Try changing the type of i1 or s1 and you will find that the compiler complains that it cannot solve the type constraints of the program.

Generic Types

Sometimes we want to be able to work with any type. We call such types generic types. For example, the builtin list type in Acton can store any other type, so we say it is a list of A, where A is a generic type (from the specification in Acton builtins):

class list[A] (object):
    ...

    def pop(self, n :?int=-1) -> A:
        """Pop and return an item from the list"

[A] says A is a generic type. And for example, the pop() function on list returns an element of type A.

class list[A] (object):
          ^^^ --- generic types are written here, between []
                  A is a generic type used in `class list`

    def pop(self, n :?int) -> A:
                              ^--- list.pop returns an item of type A

Classes and Objects

Acton supports the object-oriented paradigm, which means that it provides features to create classes and objects, which are instances of classes. Classes are a fundamental concept in an object-oriented world and they allow programmers to create their own data types with their own attributes and methods.

A class is defined using the class keyword followed by the name of the class. The convention is to use CamelCase for class names.

class Circle(object):
    radius: float

    def __init__(self, radius):
        self.radius = radius

    def diameter(self):
        return self.radius * 2

Attributes are variables that hold data for an object of a particular class and methods are functions that operate on that data. In the above example, radius is an attribute of the Circle class and diameter() is a method that returns the diameter.

Class methods must have self as the first argument, which refers to the object instance of the class that the method is called on.

Creating an object

A Class is like a blueprint and an object is an instance of such a blueprint. To create an object, or "instantiate", we use the "blueprint" (class), like so:

circle = Circle(3.14)

Here we create the object circle from the class Circle, passing the parameter 3.14 which will be used to set the radius attribute of the object.

print(circle.diameter())

And here we print the diameter of the circle by calling the .diameter() method.

Class Inheritance

Inheritance is a way of creating a new class from an existing class. The new class is called the derived class and the existing class from which we inherit is called the base class. The derived class inherits all the attributes and methods of the base class. The derived class can extend the base class by adding more attributes or methods. It is also possible to override methods to create more specific functionality.

We add the area() to Circle to get the area and realize that the unlike the diameter, area is common for all gemetric shapes. Thus we create a base class Shape that defines the area() method, but does not implement it since there is no generic way to compute the area for all shapes. Each concrete class, like Circle and Square, should implement area().

Source:

class Shape(object):
    def area(self) -> float:
        raise NotImplementedError("This method should be overridden by subclasses")


class Circle(Shape):
    radius: float

    def __init__(self, radius):
        self.radius = radius

    def diameter(self):
        return self.radius * 2

    def area(self):
        return 3.14 * self.radius ** 2


class Square(Shape):
    def __init__(self, side: float):
        self.side = side

    def area(self) -> float:
        return self.side ** 2


actor main(env):
    circle = Circle(3.14)
    square = Square(3.14)
    print(circle.area() + square.area())
    env.exit(0)

Inheritance is one of the primary methods, if not the primary method, of structuring programs in the object oriented paradigm.

Shape is an abstract class because it has no __init__ method.

Protocols

Protocols defines functionality in an abstract way that can be implemented by many classes.

Class and class inheritance is a good tool for structuring data and behavior. However, sometimes there is no clear parent / child relationship between classes, in which case using a protocol might be a more suitable choice.

Perhaps the simplest example is the Eq protocol, that defines equality between two objects, from the Acton builtins. Classes do not need to have any common inheritance in order to support equality.

protocol Eq:
    @staticmethod
    __eq__       : (Self,Self) -> bool

The __eq__ method takes two arguments, itself and the other object to compare with and returns True or False. Both arguments are of the same type Self, which means we can only compare two objects that are of the same type. It can be any type, as long as its the same type.

extension Circle (Eq):
    def __eq__(self, other):
        return self.radius == other.radius

Protocols allows us to express the requirement for a (generic) type to implement some particular functionality.

Let's say we have a function that compares two objects.

def comparator(a, b):
    print("Things:", a, b)
    print("Are they equal?", a == b)

We know that a and b must implement the Eq protocol and they must be of the same type. Let's see what the compiler constraint solver says:

acton --sigs generic.act


== sigs: generic ================================

comparator : [A(Eq)] => (a: A, b: A) -> None
=================================================

The type has been inferred to a generic type A that must implement the Eq protocol, which is written as A(Eq). We can write this explicitly:

def comparator[A(Eq)](a: A, b: A):
    print("Things:", a, b)
    print("Are they equal?", a == b)

Protocol Method Dispatch and Method Resolution Order

Method dispatch and Method Resolution Order (MRO) is about picking a particular implementation of a method when there are multiple to pick from. For class inheritance, it is pretty straight forward, the method used will always be that of the actual class. For protocols, it is not as simple. Dispatch of methods for protocols is based on the observed type, which can be somewhat surprising.

Here are two classes, the base class Point and the dervied class Point3D (which inherits from Point). Each implement the Eq protocol.

class Point(object):
    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y

extension Point (Eq):
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

class Point3D(Point):
    def __init__(self, x: int, y: int, z: int):
        self.x = x
        self.y = y
        self.z = z

extension Point3D (Eq):
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y and self.z == other.z

def comparator(a: Point, b: Point) -> bool:
    return a == b

actor main(env):
    # p1 and p2 are actually different in z. If seen as a 2D point, they appear
    # equal since the x and y values of p1 and p2 are the same
    p1 = Point3D(1, 2, 3)
    p2 = Point3D(1, 2, 4)

    # False, because we see p1 and p2 as Point3D objects and thus dispatch the Eq
    # extension __eq__ method to Point3D which correctly compares x, y and z
    print(p1 == p2)

    # True, because comparator takes (Point, Point) and thus we see p1 and p2
    # as Point objects and thus dispatch the Eq extension __eq__ method to Point
    # which only compares x and y and thus returns True despite the z difference
    print(comparator(p1, p2))

    env.exit(0)

The comparator function takes arguments of the base class Point type, which means that when dispatching, they will use the Eq protocol of Point rather than that of Point3D. Naive use typically dispatches to the protocol implementation of the derived class, which is what we intuitively want. It's only when we have forced the type to be observed, as here, that we end up with the "wrong" protocol implementation. Note that there are also cases where this is the desired behavior.

In this particular example, the natural and better solution is to write the comparator function as requiring the Eq type:

def generic_comparator[A(Eq)](a: A, b: A):
    return a == b

Another common scenario is when the type has been forced by storing items in a list or dict, which in turn is typed as storing a base class.

actor main(env):
    ref_point = Point3D(1, 2, 4)
    p1 = Point3D(1, 2, 3)
    p2 = Point3D(1, 2, 4)
    my_points: list[Point] = [p1, p2]
    for point in my_points:
        if point == ref_point:
            print("Found the reference (compared as Point)", point)
        if isinstance(point, Point3D) and point == ref_point:
            print("Found the reference (compared to Point3D)", point)

As shown in the above example, we can force the comparison to use the Eq protocol implemention of Point3D by using isinstance to check and thus coerce the observed type of point to be Point3D.

Actors

Actors is a key concept in Acton. Each actor is a small sequential process with its own private state. Actors communicate with each other through messages, in practice by calling methods on other actors or reading their attributes.

Source:

# An actor definition
actor Act(name):

    # Top level code in an actor runs when initializing an actor instance, like
    # __init__() in Python.
    print("Starting up actor " + name)
    
    def hello():
        # We can directly access actor arguments, like `name`
        print("Hello world from " + name)

actor main(env):
    # Create an actor instance a of Act
    a = Act("FOO")
    # Call the actor method hello
    await async a.hello()

    env.exit(0)

Compile and run:

actonc actors.act
./actors

Output:

No dependencies found, building project
Building project in /tmp/tmp_nwgl0ik/example
  Compiling example.act for release
   Finished compilation in   0.015 s
  Final compilation step
   Finished final compilation step in   0.437 s
Starting up actor FOO
Hello world from FOO

Root Actor

Like C has a main() function, Acton has a root actor. To compile a binary executable, there must be a root actor. Per default, if a source (.act) file contains an actor named main, it will be used as the root actor but it can also be specified using the --root argument. While the convention is to call the root actor main, you are free to name it anything.

Given this Acton program:

actor main(env):
    print("Hello World!")
    env.exit(0)

The following actonc commands will all produce the same output.

actonc hello.act
actonc hello.act --root main
actonc hello.act --root hello.main

The first invocation relies on the default rule of using an actor called main. The second invocation explicitly specifies that we want to use main as the root actor while the third uses a qualified name which includes both the actor name (main) as well as the module name (hello). Using qualified names can be particularly useful when building executable binaries in projects.

A normal Acton program consists of many actors that are structured in a hierarchical tree. The root actor is at the root of the tree and is responsible for starting all other actors directly or indirectly. The Acton Run Time System (RTS) will bootstrap the root actor.

rootAB12345

Any executable Acton program must have a root actor defined. Acton libraries (that are included in an another Acton program), do not need a root actor.

Lifetime

The main function in most imperative and functional programming languages start at the top and when they reach the end of the function, the whole program exits. Actors exist as long as another actor has a reference to it and will idle, passively waiting for the next method call. Actors without references will be garbage collected. The root actor of a program will always exist even without other references.

This means that a simple program like this modified helloworld (the env.exit() call has been removed) will run indefinitely. You need to deliberately tell the run time system to stop the actor world and exit, via env.exit(), in order to exit the program.

Source:

actor main(env):
    print("Hello world!")

Compile and run:

actonc noexit.act

Output:

$ ./noexit
<you will never get your prompt back>

Actor Attributes & Constants

Actors typically contain some private state. We define variable attributes at the top level in the actor using the var keyword and can then access them from any method within the local actor. Note how self. is not needed. Private variables are not visible from other actors.

Source:

actor Act():
    var something = 40  # private actor variable attribute
    fixed = 1234        # public constant
    
    def hello():
        # We can access local actor variable attributes directly, no need for 
        # self.something or similar
        something += 2
        print("Hello, I'm Act & value of 'something' is: " + str(something))

actor main(env):
    actor1 = Act()
    await async actor1.hello()
    print("Externally visible constant: ", actor1.fixed)
    # This would give an error, try uncommenting it
    # print(actor1.something)

    env.exit(0)

Compile and run:

actonc attrs.act

Output:

Hello, I'm Act & value of 'something' is: 42
Externally visible constant:  1234

Without the var keyword, an actor attribute is a constant. As constants are not mutable, it is safe to make it visible to other actors and it can be accessed like an attribute on an object.

Actor concurrency

Multiple actors run concurrently. In this example we can see how the two actors Foo and Bar run concurrently. The main actor is also running concurrently, although it doesn't do anything beyond creating the Foo and Bar actors and exiting after some time.

Source:

actor Counter(name):
    var counter = 0

    def periodic():
        print("I am " + name + " and I have counted to " + str(counter))
        counter += 1

        # 'after 1' tells the run time system to schedule the specified
        # function, in this case periodic(), i.e. ourselves, after 1 second
        after 1: periodic()

    # First invocation of periodic()
    periodic()


actor main(env):
    # Create two instances of the Counter actor, each with a unique name
    foo = Counter("Foo")
    bar = Counter("Bar")
    
    def exit():
        env.exit(0)

    # exit the whole program after 10 seconds
    after 10: exit()

Compile and run:

actonc concurrency.act
./concurrency

Output:

I am Foo and I have counted to 0
I am Bar and I have counted to 0
I am Foo and I have counted to 1
I am Bar and I have counted to 1
I am Foo and I have counted to 2
I am Bar and I have counted to 2
I am Foo and I have counted to 3
I am Bar and I have counted to 3
I am Foo and I have counted to 4
I am Bar and I have counted to 4
I am Bar and I have counted to 5
I am Foo and I have counted to 5
I am Bar and I have counted to 6
I am Foo and I have counted to 6
I am Bar and I have counted to 7
I am Foo and I have counted to 7
I am Foo and I have counted to 8
I am Bar and I have counted to 8
I am Foo and I have counted to 9
I am Bar and I have counted to 9

Sync Method calls

While async is good for performance it makes it somewhat convoluted, forcing use of callbacks, to just return a value. Acton makes it possible to call other actors in a synchronous fashion for ease of use.

A method is called synchronously when the return value is used.

Source:

import acton.rts

actor DeepT():
    def compute():
        # some heavy computation going on
        acton.rts.sleep(1)
        return 42

actor main(env):
    d1 = DeepT()

    answer = d1.compute()
    print("The answer is", answer)

    env.exit(0)

Compile and run:

actonc sync.act

Output:

The answer is 42

The call flow can be illustrated like this. We can see how the execution of main is suspended while it is waiting for the return value from actor d1.

maind1

While synchronous is bad because we block waiting for someone else, we are only ever going to wait for another actor to run its method. There is never any wait for I/O or other indefinite waiting, only blocking wait for computation within the Acton system. This is achieved by the lack of blocking calls for I/O, thus even if there is a chain of actors waiting for each other

Async Method calls

As actors are sequential programs and can only do one thing at a time, it is important not to spend time waiting in a blocking fashion. Acton leverages asynchronous style programming to allow actors to react and run only when necessary. Async is at the core of Acton!

A method is called asynchronously when the return value is not used.

Source:


def nsieve(n: int):
    """Sieve of Erathostenes to find primes up to n
    """
    count = 0
    flags = [True] * n
    for i in range(2, n, 1):
        if flags[i]:
            count += 1
            for j in range(i, n, i):
                flags[j] = False
    return count

actor Simon(idx):
    def say(msg, n):
        # Simon likes to compute primes and will tell you how many there are under a given number
        count = nsieve(n)
        print("Simon%d says: %s.... oh and there are %d primes under %d" % (idx, msg, count, n))

actor main(env):
    s1 = Simon(1)
    s2 = Simon(2)

    s1.say("foo", 1000000)
    s2.say("bar", 5)

    def exit():
        env.exit(0)
    after 0.2: exit()

Compile and run:

actonc async.act

Output:

Simon2 says: bar.... oh and there are 2 primes under 5
Simon1 says: foo.... oh and there are 78498 primes under 1000000

A method call like s1.say("foo", 100000) does not use the return value of and is thus called asynchronously. We ask s1 to compute primes under 1000000 while s2 only gets to compute primes up to 5 which will invariably run faster. Thus, s2 despite being called after s1, will print out its result before s1. The s1 and s2 actors are called asynchronously and are executed concurrently and in parallel.

The call flow can be illustrated like this. We can see how main asynchronously calls s1 and s2 that will be scheduled to run concurrently. The run time system (RTS) will run s1.say() and s2.say() in parallel if there are 2 worker threads available. Per default, there are as many worker threads as available CPU threads.

mains2s1after2

In addition we see how the call to after 2 schedules the main actor to run again after 2 seconds, specifically it will run the main.exit() method, which in turn exists the whole program.

Cleanup / Finalization

It is possible to run special code when an actor is about to be garbage collected. This should not be performed by normal actors, this is really only for I/O actors that interact with the environment and might need to also clean up in the environment when the actor is being garbage collected.

Define an actor action called __cleanup__ and the run time system will automatically install it to be run at garbage collection time. There is no hard guarantee when the __cleanup__ function will be called and it typically takes a couple of collection rounds to run all finalizers.

Source:

actor Foo():
    action def __cleanup__():
        print("Cleaning up after myself...")

actor main(env):
    # create a bunch of actors and do not assign reference, so they should be collected
    for i in range(20):
        Foo()

    # perform some busy work to eventually trigger the GC and thus finalization
    # & schedule __cleanup__
    a = 1
    for i in range(99999):
        a += i

    # Delay exit a little bit to let RTS workers pick up the asynchronously
    # scheduled __cleanup__ actions to run for the unreachable instances of the
    # Foo actor
    def _stop():
        env.exit(0)
    after 0.1: _stop()

Output:

Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...
Cleaning up after myself...

Control flow

A crucial part in any imperative program is to control the flow of execution. Acton supports a number of different constructs for this:

Control flow in an async actor world

The basic control flow of most programming languages involve a starting point, like a main function, which is run from top to bottom, after which the program implicitly terminates. The basic objective is to feed instructions to the CPU and this goal remains through increasing levels of abstractions. Acton is different. Once created, an actor will simply remain indefinitely, waiting for incoming messages in the form of actor method calls. See Actor Lifetime.

A mental model of actors

Actors in an Acton program form a vast web of interconnected actors. Some actors are on the edge of the Acton realm, bordering to the external world where they may be doing I/O with external entities, through files, sockets or other means. All I/O is callback based and thus event driven and reactive. When there is an event, an actors reacts, perhaps initiating calls to other actors. A ripple runs across the web of actors, each reacting to incoming messages and acting accordingly.

if / elif / else

Acton supports the if / elif / else construct - the corner stone of programming control flow.

The conditionals evaluated by if / elif / else are expressions.

Source:


def whatnum(n):

    if n < 0:
        print(n, "is negative")
    elif n > 0:
        print(n, "is positive")
    else:
        print(n, "is zero")


def inrange(n):
    if n < 10 and n > 5:
        print(n, "is between 5 and 10")
    else:
        print(n, "is outside of the range 5-10")

actor main(env):

    whatnum(5)
    whatnum(1337)
    whatnum(-7)
    whatnum(0)
    
    inrange(3)
    inrange(-7)
    inrange(7)

    env.exit(0)

Compile and run:

actonc if_else.act

Note that the output is random and you could get a different result.

Output:

5 is positive
1337 is positive
-7 is negative
0 is zero
3 is outside of the range 5-10
-7 is outside of the range 5-10
7 is between 5 and 10

for

Iteration is a core concept in programming and for loops are perhaps the most well known.

Acton supports the for in construct to iterate through an Iterator.

Source:


actor main(env):

    for n in range(1, 100, 1):
        if n % 15 == 0:
            print("fizzbuzz")
        elif n % 3 == 0:
            print("fizz")
        elif n % 5 == 0:
            print("buzz")
        else:
            print(n)

    env.exit(0)

Compile and run:

actonc while.act

Note that the output is random and you could get a different result.

Output:

1
2
fizz
4
buzz
fizz
7
8
fizz
buzz
11
fizz
13
14
fizzbuzz
16
17
fizz
19
buzz
fizz
22
23
fizz
buzz
26
fizz
28
29
fizzbuzz
31
32
fizz
34
buzz
fizz
37
38
fizz
buzz
41
fizz
43
44
fizzbuzz
46
47
fizz
49
buzz
fizz
52
53
fizz
buzz
56
fizz
58
59
fizzbuzz
61
62
fizz
64
buzz
fizz
67
68
fizz
buzz
71
fizz
73
74
fizzbuzz
76
77
fizz
79
buzz
fizz
82
83
fizz
buzz
86
fizz
88
89
fizzbuzz
91
92
fizz
94
buzz
fizz
97
98
fizz

while

The while construct can be used to run a loop while a condition is true.

Source:

import random

def throw_dice():
    number = random.randint(1,6)
    print("Dice:", number)
    return number

actor main(env):
    var luck = True

    while luck:
        if throw_dice() == 4:
            # ran out of luck, nobody likes a 4
            luck = False

    env.exit(0)

Compile and run:

actonc while.act

Note that the output is random and you could get a different result.

Output:

Dice: 3
Dice: 1
Dice: 5
Dice: 2
Dice: 4

after and sleep

In many languages, it is fairly common to use sleep() for things like timeouts, pacing and similar. In Acton, and more generally in async actor based languages, sleeping is frowned upon and often not even available.

The idiomatic control pattern in Acton is using after, like after 42.1337: foo(). This tells the run time system (RTS) to schedule the execution of the foo() function after 42.1337 seconds. Meanwhile, other methods on the actor can be invoked.

Source:

import time

"""Pace the sending of messages to once a second
"""

actor Receiver():
    def recv(msg):
        print("At " + str(time.now()) + ", I received a message:", msg)

actor main(env):
    var i = 0
    r = Receiver()

    def send_msg():
        # Send a message, increment i
        r.recv("Hello " + str(i))
        i += 1

        # ... and reschedule execution of ourselves in 1 second
        after 1: send_msg()
        
        # Exit after awhile
        if i > 4:
            env.exit(0)
        
    # Kick off the whole thing
    send_msg()

Compile and run:

actonc after_pace.md

Since the output includes time, you will naturally get a slightly different result if you run this.

Output:

At 2023-05-16T10:08:59.135806428+02, I received a message: Hello 0
At 2023-05-16T10:09:00.136484032+02, I received a message: Hello 1
At 2023-05-16T10:09:01.135585727+02, I received a message: Hello 2
At 2023-05-16T10:09:02.135695030+02, I received a message: Hello 3
At 2023-05-16T10:09:03.135811176+02, I received a message: Hello 4

There is in fact a sleep function in Acton, hidden away in the acton.rts module. Do NOT use it! It is intended only for debugging of the RTS itself and will probably disappear from the standard library before 1.0. Despite it, we consider the language to not have a sleep.

Actors should either be actively processing or at rest. Conceptually, a sleep is an active wait, in the sense that the RTS will just sit there waiting for the sleep to finish, it is blocked, while it really could process something else in between, like run a different actor method continuation. Similarly, the actor itself could have had other methods on it invoked instead of being actively blocked on a sleep. Being blocked is very bad, which is why all I/O is asynchronous in Acton and why there is no sleep.

sleep is evil, use after!

Security

Security is an important part of application development and is best considered throughout the entire design and development time of an application rather than as an bolted-on after-thought.

In Acton, the separation of actors offers the primary means of security. Access to actors (like being able to call their methods) requires a reference to the relevant actor. Anyone with a reference can access the actor in question. It is not possible to forge a reference.

This is similar to the object capability (OCAP) model.

Since there are no global variables, the only reachable state is local to an actor or reachable via a reference to another actor. This means you cannot reach something out of thin air. You have to be explicitly passed a reference to anything you need to access.

The security model based on capability references extends for accessing the world outside of the Acton system.

actor Foo():
    def foo():
        print("foofoo")
    
actor Bar():
    # Without a reference to f we cannot call its foo() function
    
actor main(env):
    f = Foo()
    f.foo()
    b = Bar()

Capabilities to access outside world

Any interesting program will need to interact with the outside world, like accessing the network or reading files. In C and many other languages, it is possible for any function at any time to simply make calls and access the external world, like read a file (maybe your private SSH key and send it over the network). Acton makes all such access to the outside world explicit through capability references.

In an Acton program, having a reference to an actor gives you the ability to do something with that actor. Without a reference, it is impossible to access an actor and it is not possible to forge a reference. This provides a simple and effective security model that also extends to accessing things outside of the Acton system, like files or remote hosts over the network.

Things outside of the actor world are represented by actors and to access such actors, a capability reference is required. For example, we can use TCPConnection to connect to a remote host over the network using TCP. The first argument is of the type TCPConnectCap, which is the capability of using a TCP socket to connect to a remote host. This is enforced by the Acton type system. Not having the correct capability reference will lead to a compilation error.

TCPConnectCap is part of a capability hierarchy, starting with the generic WorldCap and becoming further and further restricted:

WorldCap >> NetCap >> TCPCap >> TCPConnectCap

The root actor (typically main()) takes as its first argument a reference to Env, the environment actor. env.cap is WorldCap, the root capability for accessing the outside world.

import net

actor main(env):

    def on_connect(c):
        c.close()

    def on_receive(c, data):
        pass

    def on_error(c, msg):
        print("Client ERR", msg)

    connect_cap = net.TCPConnectCap(net.TCPCap(net.NetCap(env.cap)))
    client = net.TCPConnection(connect_cap, env.argv[1], int(env.argv[2]), on_connect, on_receive, on_error)

Capability based privilege restriction prevent some deeply nested part of a program, perhaps in a dependency to a dependency, to perform operations unknown to the application author. Access to capabilities must be explicitly handed out and a program can only perform operations based on the capabilities it has access to.

Restrict and delegate

Functions and methods taking a Cap argument normally takes the most restricted or refined capability. In the example with setting up a TCP connection, it is the TCPConnectCap capability we need, which is the most restricted.

Rather than handing over WorldCap to a function, consider what capabilities that function actually needs and only provide those. If a library asks for wider capabilities than it needs, do not use it.

Capability friendly interfaces

As a library author, you should only require precisely the capabilities that the library requires. Do not be lazy and require WorldCap. If the library offers multiple functionalities, for example logging to files or to a remote host, strive to make parts optional such that it the application developer and choose to only use a subset and only provide the capability required for that subset.

Environment

The environment of an Acton application is the outside world. Any useful application typically needs to interact with the environment in some way, like reading arguments or taking input from stdin and printing output.

Environment variables

It is possible to read, set and unset environment variables. The standard functions env.getenv, env.setenv and env.unsetenv all assume str input and output, which is a convenience based on the assumption that all data is encoded using UTF-8. POSIX systems really use binary encoding for both environment names and variables. To access the environment as bytes and handle decoding explicitly, use env.getenvb, env.setenvb and env.unsetenvb.

Source:

actor main(env):
    env_user = env.getenv("USER")
    if env_user is not None:
        print("User:", env_user)
    env.setenv("FOO", "bar")
    env.unsetenv("LANG")
    foo_env = env.getenv("FOO")
    if foo_env is not None:
        print("FOO:", foo_env)
    env.exit(0)

Output:

User: myuser
FOO: bar

Reading stdin input

Read input from stdin by installing a handler for stdin data. The returned data is str

actor main(env):
    def interact(input):
        print("Got some input:", input)
        
    env.stdin_install(interact)

It is possible to specify the encoding and an on_error() callback which is invoked if there are problem with decoding the data. When encoding is not specified (default None), an attempt is made to discover the encoding by reading the LANG environment variable. If no encoding is discovered, the default is to use utf-8.

actor main(env):
    def interact(input):
        print("Got some input:", input)
        
    def on_stdin_error(err, data):
        print("Some error with decoding the input data:", err)
        print("Raw bytes data:", data)
        
    env.stdin_install(on_stdin=interact, encoding="utf-8", on_error=on_stdin_error)

You can read the raw data in bytes form by installing a bytes handler instead:

actor main(env):
    def interact(bytes_input):
        # Note how the input might contain parts (some bytes) of a multi-byte
        # Unicode character in which case decoding will fail
        print("Got some input:", bytes_input.decode())
        
    env.stdin_install(on_stdin_bytes=interact)

This allows reading binary data and more explicit control over how to decode the data.

Interactive stdin

For interactive programs, like a text editor, input is not fed into the program line by line, rather the program can react on individual key strokes.

The default stdin mode is the canonical mode, which implies line buffering and that there are typically line editing capabilities offered that are implemented external to the Acton program. By setting stdin in non-canonical mode we can instead get the raw key strokes directly fed to us.

actor main(env):
    def interact(input):
        print("Got some input:", input)

    # Set non-canonical mode, so we get each key stroke directly
    env.set_stdin(canonical=False)
    # Turn off terminal echo
    env.set_stdin(echo=False)
    env.stdin_install(interact)

We can also disable the echo mode with the echo option.

The Acton run time system will copy the stdin terminal settings on startup and restore them on exit, so you do not need to manually restore terminal echo for example.

Modules

Acton modules can be used to hierarchically structure programs by splitting code into smaller logical units (modules).

Import modules by using the import keyword. The module will be available with its fully qualified name.

Use from .. import to import a single function from a module.

Functions and modules can be aliased using the as keyword.

import time
import time as timmy
from time import now
from time import now as rightnow

actor main(env):
    time.now()         # using the fully qualified name
    timmy.now()        # using aliased module name
    now()              # using the directly imported function
    rightnow()         # using function alias

    env.exit(0)

Remember, all state in an Acton program must be held by actors. There can be no mutable variables in an Acton module, only constants! Similarly, there can be no global instantiation code in a module.

Standard library

Regular expressions

Source:

import re

actor main(env):
    m = re.match(r"(foo[a-z]+)", "bla bla foobar abc123")
    if m is not None:
        print("Got a match:", m.group[1])

    env.exit(0)

Output:

Got a match: foobar

Testing

Testing your code is a really good idea! While Acton's type system allows interfaces to be precisely defined, it is imperative that the behavior of a function or actor is tested!

Test functions are automatically discovered by the compiler. Run tests with acton test. Import the testing module and name your test functions starting with _test. The type signature should match the test intended test category. Use the assertion functions available in the testing module. Here is a simple unit test:

Source:

import testing

def _test_simple():
    testing.assertEqual(1, 1)

Run:

acton test

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.016 s
  Final compilation step
   Finished final compilation step in   0.437 s

Tests - module example:
  simple:                OK: 1523 runs in 50.001ms

All 1 tests passed (0.604s)

There are 4 kinds of tests

  • unit tests
    • small simple tests of pure functions
  • synchronous actor tests
    • involving one or more actors, returning results synchronously
  • asynchronous actor tests
    • involving actors but use asynchronous callbacks for return values
  • environment tests
    • similar to async actor test in that a callback is used for the return value
    • these tests have access to the full environment via the env argument and can thus communicate with the outside world
      • this is a source of non-determinism so be mindful of this and try to avoid non-deterministic functions to the largest degree possible

When possible, strive to use unit tests rather than actor based tests and strive to avoid env tests.

Unit tests

Source:

import testing

def _test_simple():
    foo = 3+4
    testing.assertEqual(7, foo)

Run:

acton test

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.016 s
  Final compilation step
   Finished final compilation step in   0.442 s

Tests - module example:
  simple:                OK: 1565 runs in 50.079ms

All 1 tests passed (0.600s)

Unit tests are a good starting point for testing small units of your program. Pure functions are deterministic and are thus preferable for tests over non-deterministic tests using actors. You are limited in what you can do though, since all called functions must be pure.

Sync actor tests

Source:

import logging
import testing

actor MathTester():
    def add(a, b):
        return a + b

actor SyncTester(log_handler):
    def test():
        m = MathTester()
        testing.assertEqual(m.add(1, 2), 3, "1 + 2 = 3")

def _test_syncact(log_handler: logging.Handler) -> None:
    """A test using actors and synchronous control flow"""
    # We make use of an actor as the central point for running our test logic.
    # This _test_syncact function is just a wrapper picked up by the acton
    # test framework runner
    s = SyncTester(log_handler)
    return s.test()

Run:

acton test

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.027 s
  Final compilation step
   Finished final compilation step in   0.526 s

Tests - module example:
  syncact:               OK: 1175 runs in 50.005ms

All 1 tests passed (0.655s)

Since the Acton RTS is multi-threaded and actors are scheduled concurrently on worker threads, using actors imply a degree of non-determinism and so unlike unit tests, which are deterministic, actors tests are fundamentally non-deterministic. You can still write deterministic tests as long as you pay attention to how you construct your test results.

For example, actor A might be scheduled before or after actor B so if the test relies on ordering of the output, it could fail or succeed intermittently. Interacting with the surrounding environment by reading files or communicating over the network introduces even more sources of non-determinism. Avoid it if you can.

Async actor tests

Source:

import logging
import testing

actor MathTester():
    def add(a, b):
        return a + b

actor AsyncTester(report_result, log_handler):
    log = logging.Logger(log_handler)
    def test():
        log.info("AsyncTester.test()", None)
        report_result(True, None)

def _test_asyncact1(report_result: action(?bool, ?Exception) -> None, log_handler: logging.Handler) -> None:
    """A test using actors and asynchronous control flow"""
    # We make use of an actor as the central point for running our test logic.
    # This _test_asyncact function is just a wrapper picked up by the acton
    # test framework runner
    s = AsyncTester(report_result, log_handler)
    s.test()

Run:

acton test

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.028 s
  Final compilation step
   Finished final compilation step in   0.516 s

Tests - module example:
  asyncact1:             OK: 1171 runs in 56.181ms

All 1 tests passed (0.695s)

If a particular module is written to be called asynchronously, you will need to use asynchronous tests to test it.

Env tests

When you need to test functionality that accesses the environment, you need an env test. Do beware of errors related to test setup though, since you now depend on the external environment. TCP ports that you try to listen to might be already taken. Files that you assume exist might not be there.

Source:

import logging
import testing

actor EnvTester(report_result, env, log_handler):
    log = logging.Logger(log_handler)
    def test():
        log.info("EnvTester.test()", None)
        report_result(True, None)

def _test_envtest1(report_result: action(?bool, ?Exception) -> None, env: Env, log_handler: logging.Handler) -> None:
    """A test interacting with the environment"""
    # We make use of an actor as the central point for running our test logic.
    # This _test_envtest1 function is just a wrapper picked up by the acton
    # test framework runner
    s = EnvTester(report_result, env, log_handler)
    s.test()

Run:

acton test

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.023 s
  Final compilation step
   Finished final compilation step in   0.484 s

Tests - module example:
  envtest1:              OK: 1213 runs in 50.135ms

All 1 tests passed (0.689s)

Failures vs errors

Tests can have tree different outcomes; success, failure and error.

Success and failure are the two common cases where success is when the test meets the expected assertions and a failure is when it fails to meet a test assertiong like testing.assertEqual(1, 2). We also distinguish a third case for test errors which is when a test does not run as expected, hitting an unexpected exception. This could indicate a design issue or that the test environment is not as expected.

All test assertions raise exceptions inheriting from AssertionError which are considered test failures. Any other exception will be considered a test error.

For example, if a test attempts to retrieve https://dummyjson.com/products/1 and check that the returned JSON looks a certain way, it would be a test failure if the returned JSON does not mach the expected value and it would be a test error if we don't have an Internet connection and are thus unable to retrieve the JSON. It's probably a bad idea to try to connect to something on the Internet in a test, so avoid that and other sources of non-determinism when possible.

Unit tests

Source:

import random
import testing

def _test_failure():
    testing.assertEqual(1, 2)

def _test_flaky():
    i = random.randint(0, 2)
    if i == 0:
        return
    elif i == 1:
        testing.assertEqual(1, 2)
    else:
        raise ValueError("Random failure")

def _test_error() -> None:
    # Now we could never use a unit test to fetch things from the Internet
    # anyway, but it's just to show what the results look like
    raise ValueError()

Run:

acton test

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.020 s
  Final compilation step
   Finished final compilation step in   0.482 s

Tests - module example:
  error:                 ERR: 454 errors out of  454 runs in 52.733ms
    ValueError: 
  flaky:                 FLAKY FAIL: 231 failures out of  471 runs in 52.819ms
    testing.NotEqualError: Expected equal values but they are non-equal. A: 1 B: 2
  failure:               FAIL: 408 failures out of  408 runs in 52.837ms
    testing.NotEqualError: Expected equal values but they are non-equal. A: 1 B: 2

1 error and 2 failure out of 3 tests (0.691s)

Unit tests are a good starting point for testing small units of your program. Pure functions are deterministic and are thus preferable for tests over non-deterministic tests using actors. You are limited in what you can do though, since all called functions must be pure.

Flaky tests

Flaky tests are those that have different outcomes during different runs, i.e. they are not deterministic. To combat these, acton test will per default attempt to run tests multiple times to ensure that the result is the same. It runs as many test iterations as possible for at least 50ms. If a test is flaky, this will be displayed in the test output.

Source:

import random
import testing

def _test_flaky():
    i = random.randint(0, 2)
    if i == 0:
        return
    elif i == 1:
        testing.assertEqual(1, 2)
    else:
        raise ValueError("Random failure")

Run:

acton test

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.017 s
  Final compilation step
   Finished final compilation step in   0.453 s

Tests - module example:
  flaky:                 FLAKY FAIL: 565 failures out of 1140 runs in 50.043ms
    testing.NotEqualError: Expected equal values but they are non-equal. A: 1 B: 2

1 out of 1 tests failed (0.625s)

Performance testing

It is also possible to run tests in a performance mode, which uses the same basic test definitions (so you can run your tests both as logic test and for performance purposes) but alters the way in which the tests are run. In performance mode, only a single test will be run at a time unlike the normal mode in which many tests are typically run concurrently.

To get good numbers in performance mode, it's good if test functions run for at least a couple of milliseconds. With very short tess, very small differences lead to very large percentage difference.

Source:

import testing

def _test_simple():
    a = 0
    for i in range(99999):
        a += i

Run:

acton test perf

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.016 s
  Final compilation step
   Finished final compilation step in   0.451 s

Tests - module example:
  simple:                OK:  3.21ms            Avg:  4.20ms             5.11ms             106 runs in 1005.261ms

All 1 tests passed (1.571s)

(note that the output is rather wide, scroll horizontally to see the full output)

Performance comparisons

When running in performance mode you can record a snapshot of performance using acton test perf --record. A perf_data file is written to disk with the stored performance data. Subsequent test runs will read this file and show a comparison. The difference is displayed as a percentage increase or decrease in time.

Source:

import testing

def _test_simple():
    a = 0
    for i in range(99999):
        a += i

Run:

acton test perf --record
acton test perf

Output:

Building project in /home/user/foo
  Compiling example.act for release
   Finished compilation in   0.017 s
  Final compilation step
   Finished final compilation step in   0.452 s

Tests - module example:
  simple:                OK:  3.25ms            Avg:  4.16ms             7.38ms             122 runs in 1006.002ms

All 1 tests passed (1.565s)

Building project in /home/user/foo
  Compiling example.act for release
   Already up to date, in    0.000 s
  Final compilation step
   Finished final compilation step in   0.116 s

Tests - module example:
  simple:                OK:  3.23ms -0.50%     Avg:  4.17ms +0.19%      6.35ms -13.91%     119 runs in 1001.375ms

All 1 tests passed (1.215s)

(note that the output is rather wide, scroll horizontally to see the full output)

Compilation

Acton is a compiled language and as such, outputs binary executables. It is possible to influence the compilation process in various ways.

Optimized for native CPU features

The default target is somewhat conservative to ensure a reasonable amount of compatibility. On Linux, the default target is GNU Libc version 2.27 which makes it possible to run Acton programs on Ubuntu 18.04 and similar old operating systems. Similarly, a generic x86_64 CPU is assumed which means that newer extra CPU instruction sets are not used.

To compile an executable optimized for the local computer, use --target native. In many cases it can lead to a significant faster program, often running 30% to 100% faster.

Statically linked executables using musl for portability

On Linux, executable programs can be statically linked using the Musl C library, which maximizes portability as there are no runtime dependencies at all.

To compile an executable optimized for portability using musl on x86_64, use --target x86_64-linux-musl.

A default compiled program is dynamically linked with GNU libc & friends

$ actonc helloworld.act
Building file helloworld.act
  Compiling helloworld.act for release
   Finished compilation in   0.013 s
  Final compilation step
   Finished final compilation step in   0.224 s
$ ldd helloworld
        linux-vdso.so.1 (0x00007fff2975b000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f11f472a000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f11f4725000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f11f4544000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f11f453f000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f11f4827000)
$

A program linked statically towards Musl has no run time dependencies:

$ actonc helloworld.act --target x86_64-linux-musl
Building file helloworld.act
  Compiling helloworld.act for release
   Finished compilation in   0.013 s
  Final compilation step
   Finished final compilation step in   0.224 s
$ ldd helloworld
        not a dynamic executable
$

Although untested, static linking with musl should work on other CPU architectures.

MacOS does not support static compilation.

Cross-compilation

Acton supports cross-compilation, which means that it is possible to run develop on one computer, say a Linux computer with an x86-64 CPU but build an executable binary that can run on a MacOS computer.

Here's such an example. We can see how per default, the output is an ELF binary for x86-64. By setting the --target argument, actonc will instead produce an executable for a Mac.

$ actonc --quiet helloworld.act
$ file helloworld
helloworld: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.0.0, with debug_info, not stripped
$ actonc --quiet helloworld.act --target x86_64-macos-none
$ file helloworld
helloworld: Mach-O 64-bit x86_64 executable, flags:<NOUNDEFS|DYLDLINK|TWOLEVEL|PIE>

It is not only possible to compile for other operating systems, but also for other CPU architectures. For example, use --target aarch64-macos-any to produce a binary executable for an Apple M1/M2 CPU.

Prebuilt libraries

Acton ships with prebuilt libraries for the local platforms default target, i.e. if you install Acton on a x86-64 Linux machine, it will have libraries prebuilt for x86_64-linux-gnu.2.27. The default target uses these prebuilt libraries which results in a fast build:

$ actonc helloworld.act
Building file helloworld.act
  Compiling helloworld.act for release
   Finished compilation in   0.013 s
  Final compilation step
   Finished final compilation step in   0.224 s
$

When targeting something that is not the default target, the entire Acton system, including builtins, the run time system, the standard library and external library dependencies is built from source and can take a significant amount of time. The build process is highly parallelized and cached. For example, on an AMD 5950X with 16 cores / 32 threads, it takes around 7 seconds to do a complete rebuild for a small Acton program as can be seen here:

$ actonc helloworld.act --target aarch64-macos-none
Building file helloworld.act
  Compiling helloworld.act for release
   Finished compilation in   0.012 s
  Final compilation step
   Finished final compilation step in   6.847 s
$

Build cache

In an Acton project, there is a build cache, is is stored in a directory called build-cache in the project directory. The cache is always used for the project local files. If a non-default --target is being used, the built output of the Acton system is also stored in the cache, which means that it is only the first time around that it is slow. Any subsequent build is going to use the cache and run very fast. Like in this example, where the first invocation takes 6.120 seconds and the second one runs in 0.068 seconds.

$ actonc new hello
Created project hello
Enter your new project directory with:
  cd hello
Compile:
  actonc build
Run:
  ./out/bin/hello

Initialized empty Git repository in /home/kll/hello/.git/
$ cd hello/
$ actonc build --target native
Building project in /home/kll/hello
  Compiling hello.act for release
   Finished compilation in   0.012 s
  Final compilation step
   Finished final compilation step in   6.120 s
$ actonc build --target native
Building project in /home/kll/hello
  Compiling hello.act for release
   Already up to date, in    0.000 s
  Final compilation step
   Finished final compilation step in   0.068 s
$

When compiling standalone .act files, there is no project and thus no persistent cache, so using a custom --target will always incur a penalty.

Package Management

acton offers integrated package management to declare dependencies on other Acton packages and automatically download them from their sources on the Internet.

The guiding principle behind Actons package management is to strive for determinism, robustness and safety. This is primarily achieved by only resolving dependencies at design time. That is, it is the developer of a particular Acton package that determines its exact dependencies and not whomever might be downloading and building said package. The very identity of a package at a particular point in time, which can be thought of as the version of a package, is the hash of its content. This is the foundation of Actons package management. Each dependency has a hash of its content. A URL is just one place from which this particular version of a package can be downloaded. The hash of packages is determined and recorded at design time. Anyone pulling down and building dependencies will have the hash verified to ensure a deterministic build.

There is no central package repository, instead dependencies are defined as URLs from which the dependency package can be downloaded. This is typically a tar.gz file from GitHub, GitLab or similar source hosting site. Again, the very identity of a version of a package is the conten hash. The URL is only from where to get it.

Acton is statically compiled, all dependencies are fetched and included at compile time. There are no run time dependencies.

Add Dependency

Add a dependency to your project by using acton pkg add and providing the URL to the project and a local reference name.

In this case we add the example foo package as a dependency.

acton pkg add https://github.com/actonlang/foo/archive/refs/heads/main.zip foo

This will fetch the dependency and add it to the build.act.json file of your local project, resulting in something like:

{
    "dependencies": {
        "foo": {
            "url": "https://github.com/actonlang/foo/archive/refs/heads/main.zip",
            "hash": "1220cd47344f8a1e7fe86741c7b0257a63567b4c17ad583bddf690eedd672032abdd"
        }
    },
    "zig_dependencies": {}
}

Note

It is possible to edit build.act.json by hand, but adding dependencies, which requires filling in the 'hash' field requires computing the hash which is somewhat tricky.

The foo package provides a single foo module with a foo function (that appropriately returns foo). We can now access it from our main actor:

import foo

actor main(env):
    print(foo.foo())
    env.exit(0)

Local Dependencies

It is possible to use dependencies available via a local file system path by setting the path attribute. Edit build.act.json and add or modify an existing dependency. Set the path attribute to a relative path on the local file system, e.g.:

{
    "dependencies": {
        "foo": {
            "url": "https://github.com/actonlang/foo/archive/refs/heads/main.zip",
            "hash": "1220cd47344f8a1e7fe86741c7b0257a63567b4c17ad583bddf690eedd672032abdd",
            "path": "../foo"
        },
        "local_lib": {
            "path": "deps/local_lib"
        }
    },
    "zig_dependencies": {}
}

Note

Setting a local path will take priority over a remote url. This can be useful to fork a library and make local modifications to it before submitting them back upstream.

Remove Dependency

You can remove a dependency from your project with acton pkg remove:

acton pkg remove foo

Fetch Dependencies / Enable Airplane Mode

You can fetch all the dependencies of a project by using acton fetch. It will download the dependencies specified in build.act.json to the cache.

acton fetch enables you to work offline (a.k.a airplane mode).

C / C++ / Zig dependencies

Much like dependencies on other Acton packages, an Acton project can depend on a Zig package which could be a C / C++ or Zig library, as long as it has a build.zig file.

  • acton zig-pkg add URL NAME --artifact X --artifact Y
    • list the libraries you want to link with as artifacts
  • acton zig-pkg remove NAME

Example

acton zig-pkg add https://github.com/allyourcodebase/zlib/archive/refs/tags/1.3.1.tar.gz zlib --artifacts z
{
    "dependencies": {},
    "zig_dependencies": {
        "zlib": {
            "url": "https://github.com/allyourcodebase/zlib/archive/refs/tags/1.3.1.tar.gz",
            "hash": "122034ab2a12adf8016ffa76e48b4be3245ffd305193edba4d83058adbcfa749c107",
            "artifacts": [
                "z"
            ]
        }
    }
}

Security and Trust (or the lack thereof)

Working with Zig / C / C++

Acton has C ABI compatibility which makes it trivial to call C functions and fairly simply to call Zig and C++ using C wrapping functions. If you want to integrate a library written in one of these languages, this page is for you.

Regardless of the foreign language used, we need to consider a few things:

  • memory allocation, it must play well with the Acton GC
    • You can allocate memory via classic malloc or via Acton GC malloc
      • acton_malloc - normal malloc
      • acton_malloc_atomic for allocations that are guaranteed to not contain pointers
    • Better safe than sorry, use the GC-malloc when in doubt
    • Always use Acton GC malloc for actor and class attributes and similar
      • object and actor instances are garbage collected by the GC and there is no destructor function, so if you would have used class malloc there is no good place for the free
    • Within pure functions, you can use class malloc - be sure to free the allocations before return of the function, even for error paths
  • thread safety, the Acton RTS is threaded and actors are concurrently executed by different threads
    • in general, we strive to only keep data per actor and since an actor executes sequentially, we do not need thread safety measures like locks - just make sure you don't try to share data between actors "under the hood"
    • libraries must not use global variables though
  • asynchronous I/O, the Acton RTS performs asynchronous I/O and any other library that performs I/O need to conform to this model

Integrating a C library (zlib)

This is a guide to integrating C libraries in Acton code. We will use the zlib compression library, written in C, to build an Acton module that supports zlib compression and decompression.

We will only focus on the inflate and deflate functions in zlib. They are pure functions (meaning they only take some input and return some output, they do not have any side effects like writing to some shared state), that makes them easier to integrate than anything that does I/O. While zlib does expose functions to interact with files, we don't want to reimplement file related functionality since we already have this supported by the Acton stdlib.

Create new project

Let's start by making a new Acton project, let's call it acton-zlib. New projects are created with an example "Hello world" app. Let's remove it and start from scratch.

acton new acton-zlib
cd acton-zlib
rm src/*

Acton's low level build system - the Zig build system

The Acton compiler parses .act source code, runs through all its compilation passes with type checking, CPS conversion, lambda lifting etc and finally produces C code. Internally, Acton then uses the Zig build system to compile the generated C code to libraries and finally binary executables.

To add a C library dependency, it first needs to be buildable using the Zig build system, which means that it needs a build.zig file, the config file for the Zig build, somewhat similar to the CMakeLists.txt of CMake. Some projects have already adopted a build.zig in the upstream repo, like PCRE2 and the Boehm-Demers-Weiser GC (both of which are used by Acton). In some cases, there are forks of projects with build.zig added. Otherwise you will need to write one for yourself, which is usually simpler than it might first seem.

Add the zlib C library as a Zig dependency

In the case of zlib, there is already a repo available with a build.zig for zlib. Navigate to the Tags page, find 1.3.1 and the link to the source files, i.e. https://github.com/allyourcodebase/zlib/archive/refs/tags/1.3.1.tar.gz.

Add it to our acton-zlib project:

acton zig-pkg add https://github.com/allyourcodebase/zlib/archive/refs/tags/1.3.1.tar.gz zlib --artifact z

Note the --artifact z which is provided to instruct which library to link with. Headers from the zlib library, like zlib.h, will now become visible to C files in our project and the z library will be linked in with our executables. The easiest way to discover what the artifacts are called is by inspecting the build.zig file of the package. This particular zlib build.zig starts like this:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const upstream = b.dependency("zlib", .{});
    const lib = b.addStaticLibrary(.{
        .name = "z",
        .target = b.standardTargetOptions(.{}),
        .optimize = b.standardOptimizeOption(.{}),
    });
    lib.linkLibC();
    lib.addCSourceFiles(.{
        .root = upstream.path(""),
        .files = &.{
            "adler32.c",
            "crc32.c",
...

It is the .name argument to addStaticLibrary that tells us the name of the artifact. Zig packages might expose multiple such artifacts, as is the case for mbedtls.

acton zig-pkg add will fetch the package from the provided URL and save the hash sum to build.act.json, resulting in:

{
    "dependencies": {},
    "zig_dependencies": {
        "zlib": {
            "url": "https://github.com/allyourcodebase/zlib/archive/refs/tags/1.3.1.tar.gz",
            "hash": "122034ab2a12adf8016ffa76e48b4be3245ffd305193edba4d83058adbcfa749c107",
            "artifacts": [
                "z"
            ]
        }
    }
}

Create zlib.act Acton module

Next up we need to create the Acton zlib module. Open src/zlib.act and add a compress and decompress function:

pure def compress(data: bytes) -> bytes:
    NotImplemented

pure def decompress(data: bytes) -> bytes:
    NotImplemented

The NotImplemented statement tells the compiler that the implementation is not written in Acton but rather external. When there is a .ext.c file, the compiler expects it to contain the implementations for the NotImplemented functions. Also note the explicit types. Normally the Acton compiler can infer types, but since there is no Acton code here, only C code, there is nothing to infer from.

Now create src/zlib.ext.c which is where we will do the actual implementation of these functions. We need to add a __ext_init__ function, which runs on module load by the Acton RTS, which must always exist. There is nothing to do in particular for zlib so let's just create an empty function, like so:

void zlibQ___ext_init__() {}

Next, we need to fill in the C functions that map to the Acton functions compress and decompress. By invoking acton build we can get the compiler to generate a skeleton for these. We will also get a large error message, since there is no actual implementation:

user@host$ acton build
... some large error message

Ignore the error and instead check the content of out/types/zlib.c and we will find the C functions we need, commented out:

#include "rts/common.h"
#include "out/types/zlib.h"
#include "src/zlib.ext.c"
B_bytes zlibQ_compress (B_bytes data);
/*
B_bytes zlibQ_compress (B_bytes data) {
    // NotImplemented
}
*/
B_bytes zlibQ_decompress (B_bytes data);
/*
B_bytes zlibQ_decompress (B_bytes data) {
    // NotImplemented
}
*/
int zlibQ_done$ = 0;
void zlibQ___init__ () {
    if (zlibQ_done$) return;
    zlibQ_done$ = 1;
    zlibQ___ext_init__ ();
}

Copy the commented-out skeleton into our own src/zlib.ext.c. Just in order to get something that compiles, let's just quickly let the functions return the input data. Since both input and output are bytes, this should now compile (and work at run time).

B_bytes zlibQ_compress (B_bytes data) {
    return data;
}
B_bytes zlibQ_decompress (B_bytes data) {
    return data;
}
user@host:~/acton-zlib$ acton build
Building project in /Users/user/acton-zlib
  Compiling zlib.act for release
   Finished compilation in   0.005 s
  Compiling test_zlib.act for release
   Finished compilation in   0.019 s
  Final compilation step
user@host:~/acton-zlib$

Add a test module

Before we implement the body of the compress and decompress functions, we can write a small test module which will tell us when we've succeeded. We use some pre-known test data (which we could get from another language implementation):

import testing
import zlib

def _test_roundtrip():
    for x in range(100):
        i = "hello".encode()
        c = zlib.compress(i)
        d = zlib.decompress(c)
        testing.assertEqual(i, d)

def _test_compress():
    for x in range(100):
        i = "hello".encode()
        c = zlib.compress(i)
        testing.assertEqual(c, b'x\x9c\xcbH\xcd\xc9\xc9\x07')

def _test_decompress():
    for x in range(1000):
        c = b'x\x9c\xcbH\xcd\xc9\xc9\x07'
        d = zlib.decompress(c)
        testing.assertEqual(d, b'hello')

Note how we run a few test iterations to get slightly better timing measurements for performance testing. Run the test with acton test:

user@host:~/acton-zlib$ acton test

Tests - module test_zlib:
  decompress:            FAIL:  195 runs in 50.728ms
    testing.NotEqualError: Expected equal values but they are non-equal. A: b'x\x9c\xcbH\xcd\xc9\xc9\x07' B: b'hello'
  compress:              FAIL:  197 runs in 50.886ms
    testing.NotEqualError: Expected equal values but they are non-equal. A: b'hello' B: b'x\x9c\xcbH\xcd\xc9\xc9\x07'
  roundtrip:             OK:  226 runs in 50.890ms

2 out of 3 tests failed (26.354s)

user@host:~/acton-zlib$ 

As expected, the roundtrip test goes through, since we just return the input data while the compress and decompress tests fail.

Implement the compress function

Now let's fill in the rest of the owl. Below is the body of the zlibQ_compress function. The bulk of this code is not particularly interesting to this guide as it has more to do with standard C usage of zlib, but a few things are worth noting.

B_bytes zlibQ_compress(B_bytes data) {
    if (data->nbytes == 0) {
        return data;
    }

    // Prepare the zlib stream
    int ret;
    z_stream stream;
    memset(&stream, 0, sizeof(stream));
    ret = deflateInit(&stream, Z_DEFAULT_COMPRESSION);
    if (ret != Z_OK) {
        $RAISE((B_BaseException)$NEW(B_ValueError, to$str("Unable to compress data, init error: %d", ret)));
    }

    // Set the input data
    stream.avail_in = data->nbytes;
    stream.next_in = (Bytef*)data->str;

    // Allocate the output buffer using Acton's malloc
    size_t output_size = deflateBound(&stream, data->nbytes);
    Bytef* output_buffer = (Bytef*)acton_malloc_atomic(output_size);
    stream.avail_out = output_size;
    stream.next_out = output_buffer;

    // Perform the deflate operation
    ret = deflate(&stream, Z_FINISH);
    if (ret != Z_STREAM_END) {
        $RAISE((B_BaseException)$NEW(B_ValueError, $FORMAT("Unable to compress data, error: %d", ret)));
    }

    // Clean up
    deflateEnd(&stream);

    return actBytesFromCStringNoCopy(output_buffer);
}

Memory management is always top of mind when writing C, as it the case here. We can allocate memory via the Acton GC-heap malloc or just plain malloc() (the non-GC heap, to be explicit). Since zlibQ_compress is pure, we have no state leaking out of the function other than via its return value. All return values must be allocated on the Acton GC heap, so we know we must use acton_malloc for any value that we return. Any other local variables within the function can use classic malloc, as long as we make sure to explicitly free it up. For class or actor methods, any allocation for class or actor attributes must be performed using the Acton GC malloc, since there is no destructor or similar where a free can be inserted, so using classic malloc would be bound to leak. Also note that in this particular case, we know that the returned bytes value itself is not going to contain any pointers, so by using acton_malloc_atomic we can get a chunk of memory that will not be internally scanned by the GC, which saves a bit of time and thus improves GC performance. If we allocate structs that do carry pointers, they must use the normal acton_malloc().

actBytesFromCStringNoCopy(output_buffer) takes the buffer (already allocated via acton_malloc_atomic()) and wraps it up as a boxed value of the type B_bytes that we return.

Also note how we convert Zlib errors to Acton exceptions where necessary.

Running the test, the compress test now passes while roundtrip has stopped working (since decompress is not implemented yet):

user@host:~/acton-zlib$ acton test
Tests - module test_zlib:
  decompress:            FAIL:  158 runs in 50.175ms
    testing.NotEqualError: Expected equal values but they are non-equal. A: b'x\x9c\xcbH\xcd\xc9\xc9\x07' B: b'hello'
  compress:              OK:  167 runs in 50.225ms
  roundtrip:             FAIL:  147 runs in 50.266ms
    testing.NotEqualError: Expected equal values but they are non-equal. A: b'hello' B: b'x\x9c\xcbH\xcd\xc9\xc9\x07'

2 out of 3 tests failed (0.941s)

user@host:~/acton-zlib$ 

Implement the decompress function

Much like the compress function, the decompress function mostly relates to how zlib itself and its interface works. We use the same wrappers and transform errors to exceptions.

B_bytes zlibQ_decompress(B_bytes data) {
    if (data->nbytes == 0) {
        return data;
    }

    // Prepare the zlib stream
    int ret;
    z_stream stream;
    memset(&stream, 0, sizeof(stream));

    ret = inflateInit(&stream);
    if (ret != Z_OK) {
        $RAISE((B_BaseException)$NEW(B_ValueError, $FORMAT("Unable to decompress data, init error: %d", ret)));
    }

    // Set the input data
    stream.avail_in = data->nbytes;
    stream.next_in = (Bytef*)data->str;

    // Allocate the output buffer using Acton's malloc
    size_t output_size = 2 * data->nbytes; // Initial output buffer size
    Bytef* output_buffer = (Bytef*)acton_malloc_atomic(output_size);
    memset(output_buffer, 0, output_size);
    stream.avail_out = output_size;
    stream.next_out = output_buffer;

    // Perform the inflate operation, increasing the output buffer size if needed
    do {
        ret = inflate(&stream, Z_NO_FLUSH);
        if (ret == Z_BUF_ERROR) {
            // Increase the output buffer size and continue decompressing
            size_t new_output_size = output_size * 2;
            output_buffer = (Bytef*)acton_realloc(output_buffer, new_output_size);
            stream.avail_out = new_output_size - stream.total_out;
            stream.next_out = output_buffer + stream.total_out;
        } else if (ret != Z_OK) {
            $RAISE((B_BaseException)$NEW(B_ValueError, $FORMAT("Unable to decompress data, error: %d", ret)));
        }
    } while (ret == Z_BUF_ERROR);

    // Clean up
    inflateEnd(&stream);

    return actBytesFromCStringNoCopy(output_buffer);
}

Final test

user@host:~/acton-zlib$ acton test

Tests - module test_zlib:
  decompress:            OK:   42 runs in 51.065ms
  compress:              OK:   25 runs in 50.032ms
  roundtrip:             OK:   24 runs in 50.053ms

All 3 tests passed (0.738s)

user@host:~/acton-zlib$ 

And with that, we're done! A simple wrapper around zlib, which is also available on GitHub if you want to study it further.

Run Time System

The Acton Run Time System is what sets up the environment in which an Acton program runs. It performs bootstrapping of the root actor. The worker threads that carry out actual execution of actor continuations are part of the RTS. It is the RTS that handles scheduling of actors and the timer queue. All I/O is handled between modules in the standard library in conjunction with the RTS.

Arguments

It is possible to configure the RTS through a number of arguments. All arguments to the RTS start with --rts-. Use --rts-help to see a list of all arguments:

$ actonc examples/helloworld.act
Building file examples/helloworld.act
  Compiling helloworld.act for release
   Finished compilation in   0.012 s
  Final compilation step
   Finished final compilation step in   0.198 s
$ examples/helloworld --rts-help
The Acton RTS reads and consumes the following options and arguments. All
other parameters are passed verbatim to the Acton application. Option
arguments can be passed either with --rts-option=ARG or --rts-option ARG

  --rts-debug                       RTS debug, requires program to be compiled with --dev
  --rts-ddb-host=HOST               DDB hostname
  --rts-ddb-port=PORT               DDB port [32000]
  --rts-ddb-replication=FACTOR      DDB replication factor [3]
  --rts-node-id=ID                  RTS node ID
  --rts-rack-id=RACK                RTS rack ID
  --rts-dc-id=DATACENTER            RTS datacenter ID
  --rts-host=RTSHOST                RTS hostname
  --rts-help                        Show this help
  --rts-mon-log-path=PATH           Path to RTS mon stats log
  --rts-mon-log-period=PERIOD       Periodicity of writing RTS mon stats log entry
  --rts-mon-on-exit                 Print RTS mon stats to stdout on exit
  --rts-mon-socket-path=PATH        Path to unix socket to expose RTS mon stats
  --rts-no-bt                       Disable automatic backtrace
  --rts-log-path=PATH               Path to RTS log
  --rts-log-stderr                  Log to stderr in addition to log file
  --rts-verbose                     Enable verbose RTS output
  --rts-wthreads=COUNT              Number of worker threads [#CPU cores]

$

Worker threads

Per default, the RTS starts as many worker threads as there are CPU threads available, although at least 4. This is optimized for server style workloads where it is presumed that the Acton program is the sole program consuming considerable resources. When there are 4 and more CPU threads available, the worker threads are pinned to each respective CPU thread.

It is possible to specify the number of worker threads with --rts-wthreads=COUNT.

Actor method continuations run to completion, which is why it is wise not to set this value too low. Per default a minimum of 4 threads are started even when there are fewer CPU threads available, which means the operating system will switch between the threads inducing context switching overhead.