The Loup Scripting Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: Variables and Data Types
  3. Chapter 3: Functions and Closures
  4. Chapter 4: Control Flow
  5. Chapter 5: Collections
  6. Chapter 6: Structs
  7. Chapter 7: Enums and Pattern Matching
  8. Chapter 8: Classes and Objects
  9. Chapter 9: Interfaces and Generics
  10. Chapter 10: Error Handling
  11. Chapter 11: Modules and Packages
  12. Chapter 12: Strings, Interpolation, and Template Fences
  13. Chapter 13: Concurrency
  14. Chapter 14: Reflection and Dynamic Compilation
  15. Chapter 15: Reactive UI
  16. Chapter 16: Testing
  17. Chapter 17: JavaScript Host Interop
  18. Chapter 18: Building Script Tools
  19. Chapter 19: Memory Safety and Resource Management

Chapter 19: Memory Safety and Resource Management

What you'll learn: how long values and objects live in a Loup script, how the ownership verbs (reads, edits, takes, shares) and modifier/safe control who may change what, what unique adds, how to release files and other resources on time with with, try/finally and del, and which of these features the 1.25 toolchain does not fully implement yet.

You never free memory in a Loup script. Every mode, scripts included, uses the same memory model (Language Reference §1-8.1.1): a class instance lives as long as something refers to it, with the semantics of automatic reference counting (ARC), while structs, enums, tuples and collections are values that are copied. There is no null, no pointer arithmetic, and every index is checked.

That still leaves plenty of ways for a script to go wrong: a function that quietly changes a list its caller still uses, an object used after it was handed off, a lock file left behind when a step throws. This chapter is about those, and about the tools Loup gives you for them.

The Safety Story at a Glance

Concern Loup's answer In Loup 1.25
Use after free An object lives while it is referenced ✓
Null Option[T]; there is no null ✓ checked at compile time
Out-of-range index, missing key IndexError, KeyError at run time ✓
Integer overflow NumericOverflowError ✓ for int8…int32; ⚠ int / int64 wrap
A callee changing your data Parameters are read-only unless declared edits; call sites acknowledge edits:, takes:, shares: ✓
Using a value after handing it off takes, unique let ✓
Unsafe mutation of shared objects modifier methods, safe {}, safe func ✓ (the alias checks inside safe are not implemented yet)
Leaked files, locks, temp files with + Closeable / Scoped[T], try/finally, del + finalize() ✓; ⚠ defer does not run, finalize() runs only on del
Reference cycles weak fields ⚠ accepted, but weak references are never cleared
HTML injection the escape filter in template fences ⚠ html fences do not escape automatically
Check-then-act races in async code atomic {} ✓ checked at compile time

The ⚠ rows are explained where they come up.

Values and References

Collections, structs, enums and tuples are values: assigning one to another binding, or storing it, makes an independent copy. Class instances are references: two bindings can name the same object and see each other's changes.

@mode(host.scripted)

struct Point {
    x: int;
    var y: int;
}

class Account {
    pub let owner: string;
    pub var balance: int = 0;

    new(owner: string) {
        this.owner = owner;
    }
}

# Collections are values: assignment copies.
var original = ["draft"];
var copy = original;
copy.push("final");
println(original);
println(copy);

# Structs are values too.
var p = Point(x = 1, y = 2);
var q = p;
q.y = 20;
println(i"p.y = {$ p.y $}, q.y = {$ q.y $}");

# Class instances are references: both names see one object.
let mine = Account("ada");
let alsoMine = mine;
safe {
    alsoMine.balance = 50;
}
println(i"mine.balance = {$ mine.balance $}");

Listing 19-1: Values are copied; class instances are shared

["draft"]
["draft", "final"]
p.y = 2, q.y = 20
mine.balance = 50

The safe { … } around the write to alsoMine.balance is required: writing a field of a shared object is a mutation the compiler wants to see marked. "Mutating Objects: modifier and safe" below explains why.

Because only class instances are shared, most aliasing questions in Loup are about classes, and about the collections you pass to functions.

No Null: Option[T]

A value of type string is always a string. A function that may have nothing to return says so with Option[string], and the compiler makes the caller deal with None:

This code does not compile
@mode(host.scripted)

## The value after `key=` in a line such as `user=ada`, if the key matches.
func valueFor(line: string, key: string): Option[string] {
    let prefix = key + "=";
    if (line.startsWith(prefix)) {
        return Some(line.substring(prefix.length(), line.length()));
    }
    return None;
}

let user: string = valueFor("user=ada", "user");
println(user);
main.lpscr:12:1: error[E0001]: cannot assign 'Option[string]' to binding 'user' of type 'string'; unwrap the Option before use

Supply a default with ??, or handle both cases with match:

@mode(host.scripted)

let user: string = valueFor("user=ada", "user") ?? "anonymous";
println(user);

match valueFor("port=8080", "host") {
    Some(host) => println(i"host is {$ host $}"),
    None => println("no host configured"),
}

Listing 19-2: Handling an Option with ?? and match

ada
no host configured

opt.unwrap() also gets the value out, but throws UnwrapError on None; use it only where None would be a bug. Chapters 7 and 10 cover Option and Result in depth.

Checked Indexing and Missing Keys

Every index is checked. Reading past the end of a list throws IndexError (Chapter 1 showed one), and reading a missing key with d[key] throws KeyError:

This code throws at run time
@mode(host.scripted)

let settings: dict[string, string] = {"host": "localhost", "port": "8080"};
println(settings["host"]);
println(settings["user"]);
localhost
Uncaught KeyError: key 'user' not found in dict
  at <main> (main.lpscr:5:9)

When a key may legitimately be absent, use get, which returns an Option:

@mode(host.scripted)

let settings: dict[string, string] = {"host": "localhost", "port": "8080"};
println(settings["host"]);
println(settings.get("user") ?? "anonymous");
localhost
anonymous

The subscript is for keys that must be there; get is for keys that might not be. For lists, xs.get(i) is the same as xs[i] and throws too (§1-4.1.5), so check i < xs.length() first when an index can be out of range.

Integer Overflow

The sized integer types int8, int16, int32 (and their unsigned twins) check every operation. A result that does not fit throws NumericOverflowError (§1-4.1.1):

This code throws at run time
@mode(host.scripted)

let samples: int32 = 2000000000;
let doubled: int32 = samples * 2;
println(doubled);
Uncaught NumericOverflowError: arithmetic overflow: 4000000000 does not fit int32
  at <main> (main.lpscr:4:22)

The Language Reference says int (64-bit) is checked the same way. In Loup 1.25 it is not: int and int64 arithmetic wraps around silently. This listing is wrong, and nothing tells you:

This code does not do what it should
@mode(host.scripted)

let bytesPerBlock = 4096;
let blocks = 3000000000000000;
let total = bytesPerBlock * blocks;
println(i"total bytes: {$ total $}");
total bytes: -6158744073709551616

Until int overflow is checked, guard the arithmetic yourself where inputs can be large. The spec's checkedAdd-style helpers are not available yet either, so write the check:

@mode(host.scripted)

const INT_MAX = 9223372036854775807;

## a * b for non-negative a and b, or None if the product does not fit in an int.
func checkedMul(a: int, b: int): Option[int] {
    if (a != 0 and b > INT_MAX // a) {
        return None;
    }
    return Some(a * b);
}

let bytesPerBlock = 4096;
match checkedMul(bytesPerBlock, 3000000000000000) {
    Some(total) => println(i"total bytes: {$ total $}"),
    None => println("too large: the disk size does not fit in an int"),
}
match checkedMul(bytesPerBlock, 1000) {
    Some(total) => println(i"total bytes: {$ total $}"),
    None => println("too large"),
}

Listing 19-3: Guarding a multiplication that could overflow int

too large: the disk size does not fit in an int
total bytes: 4096000

The Ownership Verbs

A function parameter says what the function will do with the argument (Language Reference §3-2.4.9). The verb goes before the parameter name:

Verb Meaning Call site
reads (the default) looks at the value, does not change it f(x)
edits changes the caller's value in place f(edits: x)
takes takes ownership; the caller may not use the value again f(takes: x)
shares keeps a reference after the call returns f(shares: x)

The verbs work in every mode. In a script they are not about freeing memory; they make every change to your data visible at the call site.

reads is the default

A parameter without a verb is read-only. A function that tries to change it does not compile:

This code does not compile
@mode(host.scripted)

func addDefaultTags(tags: list[string]): void {
    if (not tags.contains("untriaged")) {
        tags.push("untriaged");
    }
}

var tags = ["bug"];
addDefaultTags(tags);
println(tags);
main.lpscr:5:9: error[E_READS_MUTATION]: E_READS_MUTATION: parameter 'tags' is a `reads` borrow (a parameter without a verb is read-only, §3-2.4.9) and cannot be mutated (`push` called on it); declare it `edits` and pass `edits: …` to change the caller's value, or copy it into a local first

The message offers two fixes, and they mean different things. If the function's job is to change the caller's list, declare the parameter edits. Do only that, and the call site is rejected next:

This code does not compile
@mode(host.scripted)

func addDefaultTags(edits tags: list[string]): void {
    if (not tags.contains("untriaged")) {
        tags.push("untriaged");
    }
}

var tags = ["bug"];
addDefaultTags(tags);
println(tags);
main.lpscr:10:16: error[E_VERB_REQUIRED]: E_VERB_REQUIRED: parameter 'tags' requires 'edits' verb acknowledgment at call site

The caller has to agree, in writing, that its list will change:

@mode(host.scripted)

func addDefaultTags(edits tags: list[string]): void {
    if (not tags.contains("untriaged")) {
        tags.push("untriaged");
    }
}

## Reads only: the parameter needs no verb.
func summary(tags: list[string]): string {
    return tags.join(", ");
}

var tags = ["bug"];
addDefaultTags(edits: tags);
println(summary(tags));

Listing 19-4: An edits parameter and its call-site acknowledgment

bug, untriaged

The acknowledgment must name the same verb as the declaration. addDefaultTags(takes: tags) here is E_VERB_MISMATCH: call-site verb 'takes' does not match parameter 'tags' declared as 'edits'.

The other fix — "copy it into a local first" — is right when the caller's list should stay as it was. Assigning a list copies it, so the function can change its copy and return it:

@mode(host.scripted)

## Returns the tags with the defaults added; the caller's list is untouched.
func withDefaultTags(tags: list[string]): list[string] {
    var result = tags;
    if (not result.contains("untriaged")) {
        result.push("untriaged");
    }
    return result;
}

let tags = ["bug"];
let labelled = withDefaultTags(tags);
println(tags);
println(labelled);
["bug"]
["bug", "untriaged"]

takes: handing a value off

A takes parameter consumes the argument: after the call, the caller's binding is gone. Use it when the callee becomes the owner — a report handed to a mailer, a job handed to a queue:

This code does not compile
@mode(host.scripted)

class Report {
    pub let title: string;
    pub var lines: list[string] = [];

    new(title: string) {
        this.title = title;
    }
}

## Hands the report to the mailer; the caller must not touch it afterwards.
func send(takes report: Report): void {
    println(i"sending '{$ report.title $}' ({$ report.lines.length() $} lines)");
}

let weekly = Report("Weekly status");
send(takes: weekly);
println(weekly.title);
main.lpscr:19:9: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'weekly' was consumed by a `takes` parameter and cannot be used again

If you still need something from the value, take it before the hand-off:

@mode(host.scripted)

let weekly = Report("Weekly status");
let title = weekly.title;
send(takes: weekly);
println(i"sent: {$ title $}");

Listing 19-5: Reading what you need before a takes call

sending 'Weekly status' (0 lines)
sent: Weekly status

The compiler also catches a hand-off inside a loop, where the second iteration would reuse a value the first one gave away:

This code does not compile
@mode(host.scripted)

class Report {
    pub let title: string;
    new(title: string) { this.title = title; }
}

func send(takes report: Report): void {
    println(i"sending '{$ report.title $}'");
}

let draft = Report("Draft");
for (recipient in ["ada", "grace"]) {
    send(takes: draft);
}
main.lpscr:14:17: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'draft' was consumed by a `takes` parameter in a previous iteration of the loop and cannot be used again

Give each iteration its own value:

@mode(host.scripted)

for (recipient in ["ada", "grace"]) {
    let copy = Report(i"Draft for {$ recipient $}");
    send(takes: copy);
}
sending 'Draft for ada'
sending 'Draft for grace'

A method can consume its own receiver: write the verb before func. A takes func close() makes the object unusable once it is closed:

This code does not compile
@mode(host.scripted)

class Connection {
    pub let host: string;
    var sent: int = 0;

    new(host: string) {
        this.host = host;
    }

    pub modifier func send(message: string): void {
        this.sent = this.sent + 1;
        println(i"-> {$ this.host $}: {$ message $}");
    }

    ## Closing consumes the connection.
    pub takes func close(): int {
        println(i"closing {$ this.host $}");
        return this.sent;
    }
}

let conn = Connection("db.local");
safe {
    conn.send("BEGIN");
    conn.send("COMMIT");
}
let count = conn.close();
safe {
    conn.send("ROLLBACK");
}
main.lpscr:30:5: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'conn' was consumed by a `takes` parameter and cannot be used again

Sending on a closed connection is a run-time error in most languages; here it does not compile. Remove the late send, and the program runs:

@mode(host.scripted)

let conn = Connection("db.local");
safe {
    conn.send("BEGIN");
    conn.send("COMMIT");
}
let count = conn.close();
println(i"{$ count $} messages sent");

Listing 19-6: A takes receiver makes "use after close" a compile error

-> db.local: BEGIN
-> db.local: COMMIT
closing db.local
2 messages sent

shares: keeping a reference

A shares parameter tells the caller that the callee will keep the object after the call returns — a registry, a cache, an observer list. The call site acknowledges it with shares:; leaving it out is E_VERB_REQUIRED, as with edits:

@mode(host.scripted)

class Counter {
    pub var hits: int = 0;

    pub modifier func bump(): void {
        this.hits = this.hits + 1;
    }
}

class Registry {
    var counters: list[Counter] = [];

    pub modifier func add(shares c: Counter): void {
        this.counters.push(c);
    }

    pub func total(): int {
        var t = 0;
        for (c in this.counters) {
            t = t + c.hits;
        }
        return t;
    }
}

let registry = Registry();
let pageViews = Counter();
safe {
    registry.add(shares: pageViews);
    pageViews.bump();
    pageViews.bump();
}
println(i"registry total: {$ registry.total() $}");

Listing 19-7: shares — the registry keeps the counter

registry total: 2

The registry and pageViews refer to the same counter, so the bumps are visible through both.

Mutating Objects: modifier and safe

Because class instances are shared, Loup asks for two marks wherever one is changed (§3-2.4.11, §3-2.4.12):

  1. A method that writes one of the object's var fields is declared modifier.
  2. A call to a modifier method, or a direct write to a pub var field, happens inside a safe context: a safe { … } block or a safe func.

Here is a rate limiter written the way you would write it in Python or TypeScript:

This code does not compile
@mode(host.scripted)

class RateLimiter {
    pub let limit: int;
    var used: int = 0;

    new(limit: int) {
        this.limit = limit;
    }

    pub func tryAcquire(): bool {
        if (this.used >= this.limit) {
            return false;
        }
        this.used = this.used + 1;
        return true;
    }
}
main.lpscr:15:9: error[E_MODIFIER_REQUIRED]: E_MODIFIER_REQUIRED: writing to `var` field 'used' of `self` in method 'tryAcquire' of class 'RateLimiter' requires the method to be declared as `modifier`

tryAcquire changes the limiter, so its signature must say so. Add modifier and call it:

This code does not compile
@mode(host.scripted)

class RateLimiter {
    pub let limit: int;
    var used: int = 0;

    new(limit: int) {
        this.limit = limit;
    }

    pub modifier func tryAcquire(): bool {
        if (this.used >= this.limit) {
            return false;
        }
        this.used = this.used + 1;
        return true;
    }
}

let limiter = RateLimiter(2);
for (request in ["a", "b", "c"]) {
    println(i"{$ request $}: {$ limiter.tryAcquire() $}");
}
main.lpscr:22:33: error[E_SAFE_CONTEXT_REQUIRED]: E_SAFE_CONTEXT_REQUIRED: `modifier` method 'tryAcquire' called on ARC receiver of type 'RateLimiter' outside `safe {}` or `safe func` context — wrap the call in `safe { ... }` or mark the enclosing function as `safe func`

Mark the call site with safe { … }, or give a whole function a safe context with safe func:

@mode(host.scripted)

let limiter = RateLimiter(2);
for (request in ["a", "b", "c"]) {
    safe {
        println(i"{$ request $}: {$ limiter.tryAcquire() $}");
    }
}

## A `safe func` provides the safe context for its whole body.
safe func drain(edits l: RateLimiter): int {
    var granted = 0;
    while (l.tryAcquire()) {
        granted = granted + 1;
    }
    return granted;
}

let fresh = RateLimiter(5);
safe {
    let granted = drain(edits: fresh);
    println(i"granted {$ granted $} more");
}

Listing 19-8: A modifier method called from safe {} and from a safe func

a: true
b: true
c: false
granted 5 more

A safe func is itself called from a safe context, the way an async func is awaited: the requirement travels up to the caller until a safe { … } block anchors it.

Structs do not use modifier: a struct is a value, so changing your copy cannot affect anyone else.

⚠ Planned. Inside a safe context the Language Reference also requires the compiler to reject a mutation while another reference to the same object is live (E_SAFE_ALIAS_MUTABLE), and to reject passing one value as two edits arguments (E_EDITS_ALIAS). Loup 1.25 does not check either yet, and it does not yet reject a safe func called outside a safe context. The spec also says a function with an edits parameter is implicitly a safe func; the 1.25 compiler still asks you to write safe func, as Listing 19-8 does.

unique: One Owner at a Time

unique let declares a binding that is the only reference to its object. Assigning it to another binding moves the object, and the old name can no longer be used (§3-2.4.7):

This code does not compile
@mode(host.scripted)

class Upload {
    pub let path: string;
    new(path: string) {
        this.path = path;
    }
}

unique let pending = Upload("photos.zip");
let active = pending;
println(i"uploading {$ pending.path $}");
main.lpscr:12:24: error[E_USE_AFTER_MOVE]: E_USE_AFTER_MOVE: binding 'pending' was moved and cannot be used again — `unique class` instances and move-only structs have single ownership

After the move, use the new name:

@mode(host.scripted)

unique let pending = Upload("photos.zip");
let active = pending;
println(i"uploading {$ active.path $}");
uploading photos.zip

A unique class makes every instance of a type uniquely owned. That is a native feature: in a script the declaration itself is rejected.

This code does not compile
@mode(host.scripted)

unique class FileLock {
    pub let path: string;
    new(path: string) {
        this.path = path;
    }
}

let lock = FileLock("build.lock");
println(lock.path);
main.lpscr:3:1: error[E_UNIQUE_CLASS_ARC_BINDING]: E_UNIQUE_CLASS_ARC_BINDING: `unique class 'FileLock'` is only valid in `@mode(native)` or `@mode(freestanding)`

(The message still uses the retired name native; the mode is host.native.) In a script, declare an ordinary class and put unique on the binding instead — unique let lock = FileLock("build.lock");. If the type really must never be shared, the file belongs in host.native; The Loup Native Book covers unique class, consume, borrow and lifetimes.

Reference Cycles and weak

Under ARC, two objects that refer to each other keep each other alive. The usual case is a tree whose children point back to their parent. Make the back-pointer weak: a weak field does not keep its object alive, and it is always an Option (§1-8.6).

This code does not compile
@mode(host.scripted)

class Folder {
    pub let name: string;
    pub var children: list[Folder] = [];
    pub weak var parent: Folder?;

    new(name: string) {
        this.name = name;
    }

    pub func path(): string {
        return match this.parent {
            Some(p) => p.path() + "/" + this.name,
            None => this.name,
        };
    }
}

let home = Folder("home");
let docs = Folder("docs");
safe {
    docs.parent = home;
    home.children.push(docs);
}
println(docs.path());
main.lpscr:23:5: error[E0001]: cannot assign 'Folder' to 'Option[Folder]'

Folder? is Option[Folder], so the compiler wants an Option. (The Language Reference says assigning a plain object to a weak field wraps it in Some automatically; the 1.25 compiler does not.) Wrap it:

@mode(host.scripted)

class Folder {
    pub let name: string;
    pub var children: list[Folder] = [];
    pub weak var parent: Folder?;

    new(name: string) {
        this.name = name;
    }

    pub func path(): string {
        return match this.parent {
            Some(p) => p.path() + "/" + this.name,
            None => this.name,
        };
    }
}

let home = Folder("home");
let docs = Folder("docs");
safe {
    docs.parent = Some(home);
    home.children.push(docs);
}
println(docs.path());

Listing 19-9: A tree with a weak parent link

home/docs

⚠ Not yet effective. In Loup 1.25 a weak reference is never cleared: loup run keeps reading Some(…) from a weak field even after the last strong reference to the object is gone, and native builds currently store weak fields as strong references. Write weak where the design calls for it — it documents ownership and will behave as specified once release is implemented — but do not write code that waits for a weak field to become None.

Closures capture outer variables by reference: a closure sees later changes to a var it uses, and assigning one needs a nonlocal or global declaration (Chapter 3). Capture lists such as [weak self] are for native builds; loup run rejects them in every mode ("capture lists are only allowed in @compiled files targeting --target native").

Releasing Resources

Memory takes care of itself; files, lock files, sockets and child processes do not. The rule is the same as in Python or C#: release a resource in code that runs on every path out of the block, including when an exception is thrown.

try/finally

The most basic tool is finally, which runs whether the try block finishes, returns or throws:

@mode(host.scripted)

from io.fs import (remove);

## Runs `step` while holding a lock file, and always removes the lock.
func withLockFile(path: string, step: fn(): void): void {
    if (exists(path)) {
        throw RuntimeError(i"{$ path $} exists: another run is in progress");
    }
    writeText(path, "locked\n");
    try {
        step();
    } finally {
        remove(path);
    }
}

withLockFile("deploy.lock", func(): void {
    println("deploying...");
});
println(i"lock left behind: {$ exists("deploy.lock") $}");

try {
    withLockFile("deploy.lock", func(): void {
        throw RuntimeError("upload failed");
    });
} catch (RuntimeError as e) {
    println(i"failed: {$ e.message $}");
}
println(i"lock left behind: {$ exists("deploy.lock") $}");

Listing 19-10: A lock file removed on success and on failure

deploying...
lock left behind: false
failed: upload failed
lock left behind: false

The Language Reference (§1-6.5) also has defer expr; and defer { … }, which run when the enclosing block exits, in reverse order. In Loup 1.25, defer does not run. The compiler accepts and type-checks it, and loup run silently skips the deferred action:

This code does not do what it should
@mode(host.scripted)

func publish(): void {
    println("acquire lock");
    defer println("release lock");
    println("publishing");
}

publish();
println("done");
acquire lock
publishing
done

Under the specification, release lock would print before done. Until defer is implemented, write the cleanup in a finally block, as Listing 19-10 does, or use with.

with and Closeable

When a resource is an object, let the object clean up after itself. Chapter 4 introduced with; this section looks at how a class takes part in it. A with statement (§1-6.6) binds a resource for the duration of a block and calls its exit hook on every way out of the block. The simplest way to get the hook is to implement the prelude interface Closeable, whose one required method is close().

Having a close() method is not enough on its own:

This code does not compile
@mode(host.scripted)

## Collects lines in memory and writes them to a file when closed.
class LineWriter {
    let path: string;
    var lines: list[string] = [];

    new(path: string) {
        this.path = path;
    }

    pub modifier func write(line: string): void {
        this.lines.push(line);
    }

    pub func close(): void {
        writeText(this.path, this.lines.join("\n") + "\n");
    }
}

with (out = LineWriter("report.txt")) {
    safe {
        out.write("total: 3");
    }
}
main.lpscr:21:1: error[E0001]: E_WITH_NOT_SCOPED: type 'LineWriter' does not implement Scoped[T] — a `with` resource must provide scope_enter() and scope_exit(error) (Closeable and Releasable do)
main.lpscr:21:1: error[E0001]: E_WITH_NOT_SCOPED: type 'LineWriter' does not implement Scoped[T] — a `with` resource must provide scope_enter() and scope_exit(error) (Closeable and Releasable do)
main.lpscr:21:1: error[E0001]: E_WITH_NOT_SCOPED: type 'LineWriter' does not implement Scoped[T] — a `with` resource must provide scope_enter() and scope_exit(error) (Closeable and Releasable do)

(The 1.25 compiler prints this diagnostic three times.) with looks for the Scoped protocol, and a class gets it by declaring implements Closeable:

@mode(host.scripted)

## Collects lines in memory and writes them to a file when closed.
class LineWriter implements Closeable {
    let path: string;
    var lines: list[string] = [];

    new(path: string) {
        this.path = path;
    }

    pub modifier func write(line: string): void {
        this.lines.push(line);
    }

    pub func close(): void {
        writeText(this.path, this.lines.join("\n") + "\n");
        println(i"wrote {$ this.lines.length() $} line(s) to {$ this.path $}");
    }
}

with (out = LineWriter("report.txt")) {
    safe {
        out.write("total: 3");
        out.write("errors: 0");
    }
}
print(readText("report.txt"));

try {
    with (out = LineWriter("partial.txt")) {
        safe {
            out.write("step 1 done");
        }
        throw RuntimeError("step 2 failed");
    }
} catch (RuntimeError as e) {
    println(i"caught: {$ e.message $}");
}
print(readText("partial.txt"));

Listing 19-11: A Closeable resource is closed when the block ends, even when it throws

wrote 2 line(s) to report.txt
total: 3
errors: 0
wrote 1 line(s) to partial.txt
caught: step 2 failed
step 1 done

The binding is written with (name = expression), without let. Several resources can share one with: with (a = …, b = …) { … } enters them left to right and closes them right to left.

Custom Scoped[T] resources

Closeable is a convenience over the general protocol, Scoped[T] (§1-11.2). A Scoped[T] class has two methods: scope_enter() runs when the block starts and returns the value bound by with — which need not be the object itself — and scope_exit(error) runs when it ends, receiving the exception if there was one. Returning false from scope_exit lets that exception continue; true suppresses it.

@mode(host.scripted)

from io.fs import (remove);

## A scratch file that exists only for the duration of a `with` block.
class ScratchFile implements Scoped[string] {
    let path: string;

    new(path: string) {
        this.path = path;
    }

    ## Creates the file; the `with` binding receives its path.
    pub func scope_enter(): string {
        writeText(this.path, "");
        return this.path;
    }

    ## Removes the file. Returning false lets an exception continue.
    pub func scope_exit(error: Option[Any]): bool {
        remove(this.path);
        return false;
    }
}

with (path = ScratchFile("sort-input.tmp")) {
    writeText(path, "pear\napple\nfig\n");
    let lines = readText(path).trim().split("\n");
    lines.sort(func(a: string, b: string) => a.compareTo(b));
    println(lines);
}
println(i"scratch file still there: {$ exists("sort-input.tmp") $}");

Listing 19-12: A Scoped[string] that hands the block a path and deletes the file afterwards

["apple", "fig", "pear"]
scratch file still there: false

del and finalize()

Some resources do not fit a block: a subscription that lives until some later event, a cache entry you drop by hand. A class can declare func finalize(): void, and del name; calls it immediately and ends the binding (§1-6.11, §1-8.9):

@mode(host.scripted)

var listeners: list[string] = [];

## Registers interest in a topic until it is finalized.
class Subscription {
    pub let topic: string;

    new(topic: string) {
        this.topic = topic;
        listeners.push(topic);
    }

    func finalize(): void {
        listeners.remove(listeners.indexOf(this.topic));
        println(i"unsubscribed from {$ this.topic $}");
    }
}

let logins = Subscription("user.login");
let errors = Subscription("app.error");
println(listeners);

del logins;
println(listeners);

Listing 19-13: del runs finalize() at a point you choose

["user.login", "app.error"]
unsubscribed from user.login
["app.error"]

After del, the name is uninitialized, and the compiler rejects any later use:

This code does not compile
@mode(host.scripted)

var listeners: list[string] = [];

## Registers interest in a topic until it is finalized.
class Subscription {
    pub let topic: string;

    new(topic: string) {
        this.topic = topic;
        listeners.push(topic);
    }

    func finalize(): void {
        listeners.remove(listeners.indexOf(this.topic));
        println(i"unsubscribed from {$ this.topic $}");
    }
}

let logins = Subscription("user.login");
del logins;
println(logins.topic);
main.lpscr:22:9: error[E_USE_AFTER_DEL]: E_USE_AFTER_DEL: use of binding 'logins' after `del` — the binding is uninitialized once it has been passed to `del` (§1-6.11)

Read what you need before the del, as with takes.

⚠ finalize() runs only on del today. Under the ARC rules of §1-8.1.1, finalize() also runs when the last reference to an object goes away: at the end of a scope, on reassignment, at program exit. In Loup 1.25, loup run calls finalize() only for del — in Listing 19-13, the app.error subscription is never finalized. Do not rely on finalize() for cleanup that must happen; use with, try/finally or an explicit del. The compiler also does not yet warn when a finalizable object goes out of scope without del, or reject del on a value that has no finalize() (E_DEL_NOT_FINALIZABLE).

Escaping Untrusted Text in HTML

A script that writes HTML from data it did not create — comments, file names, the output of another tool — must escape that data. The Language Reference makes html template fences escape every insertion automatically (§1-16). Loup 1.25 does not: an html fence inserts the text as it is.

This code does not do what it should
@mode(host.scripted)

let comment = "<script>alert('hi')</script>";
let page = ```(html):(none)
<p class="comment">{$ comment $}</p>
```;
println(page.render({}));
<p class="comment"><script>alert('hi')</script></p>

Anyone who opens that page runs the commenter's script. Until fences escape by default, apply the escape filter to every insertion of untrusted text:

@mode(host.scripted)

let comment = "<script>alert('hi')</script>";
let page = ```(html):(none)
<p class="comment">{$ comment | escape $}</p>
```;
println(page.render({}));

Listing 19-14: The escape filter makes untrusted text safe to embed

<p class="comment">&lt;script&gt;alert(&#x27;hi&#x27;)&lt;/script&gt;</p>

Chapter 12 covers template fences and filters.

atomic {}: No Suspension in a Check-Then-Act

In async code, every await is a point where other tasks may run. A check followed by an action with an await between them can act on stale information. An atomic { … } block (runtime volume §2-1.7) is a region with no suspension points, and the compiler enforces that:

This code does not compile
@mode(host.scripted)

var cache: dict[string, string] = {};

async func fetchProfile(user: string): Task[string] {
    return i"profile of {$ user $}";
}

async func profileFor(user: string): Task[string] {
    atomic {
        if (not cache.contains(user)) {
            cache[user] = await fetchProfile(user);
        }
        return cache[user];
    }
}

println(await profileFor("ada"));
main.lpscr:12:27: error[E_ATOMIC_AWAIT]: E_ATOMIC_AWAIT: await expression is not allowed inside atomic {} block

Do the waiting outside the block, then check again and act inside it:

@mode(host.scripted)

var cache: dict[string, string] = {};

async func fetchProfile(user: string): Task[string] {
    return i"profile of {$ user $}";
}

async func profileFor(user: string): Task[string] {
    let known = cache.get(user);
    if (known.is_some()) {
        return known.unwrap();
    }
    let fetched = await fetchProfile(user);
    atomic {
        # Check again: another task may have filled the entry while we waited.
        if (not cache.contains(user)) {
            cache[user] = fetched;
        }
    }
    return cache[user];
}

println(await profileFor("ada"));
println(await profileFor("ada"));

Listing 19-15: Awaiting outside an atomic block, checking and updating inside it

profile of ada
profile of ada

atomic {} is not a lock; it works because a script's tasks take turns on one thread. In Loup 1.25, async functions run eagerly and in order (Chapter 13), so no other task can interleave yet; writing the code this way keeps it correct when they can.

Features That Belong to Other Modes

Some memory features exist only where there is a native ARC runtime or real hardware. In a script they are compile errors:

Feature In host.scripted
unique class E_UNIQUE_CLASS_ARC_BINDING (shown above)
consume x (turn a unique binding into an ordinary reference) E_CONSUME_NOT_AVAILABLE: only host.native and host.dual
Capture lists [weak self], [unowned self] rejected by loup run in every mode
asm { … } E_ASM_NOT_COMPILED

For example:

This code does not compile
@mode(host.scripted)

func pause(): void {
    asm { "nop" }
}
main.lpscr:4:5: error[E_ASM_NOT_COMPILED]: E_ASM_NOT_COMPILED: `asm { }` is only valid in a compiled mode (`web.compiled`, `host.native`, `freestanding`); the module mode is `host.scripted` (§3-3.7)

⚠ Planned. arc let and arc var, which declare a binding as an explicit ARC reference (§1-6.2), are not implemented in any mode: the parser rejects arc let b = Box(); with error[E0003]: expected ';'.

shared class (a class whose instances may cross threads) is available in scripts, and the compiler checks its rule that every field is Shareable: a string field, for example, is E_SHARED_FIELD_NOT_SHAREABLE. Constructing one with shared Stats() is not supported by loup run yet (internal interpreter limitation: unsupported instruction: NewSharedObject). Chapter 13 and the Native Book cover sharing between threads.

A Checklist for Scripts

Before you hand a script to someone else:

  1. Functions that can find nothing return Option[T]; each unwrap() is a deliberate "this cannot be None".
  2. Dictionary lookups that may miss use get(key) ?? default, not d[key].
  3. Arithmetic on large int values is guarded: in Loup 1.25, int overflow wraps silently.
  4. Functions that change their arguments declare edits; functions that keep them declare shares; functions that consume them declare takes.
  5. Methods that change their object are modifier, and their callers use safe.
  6. Every file, lock or temp file is released in with, try/finally or an explicit del — never in defer or an unprompted finalize() in 1.25.
  7. Every insertion of untrusted text into HTML uses | escape.

Where the Native Book Goes Further

A script never frees memory itself, so it needs only the verbs, unique let, modifier/safe, weak and the resource tools in this chapter. In host.native, ownership also controls cost: borrow and excl borrow remove reference-count traffic, lifetimes (lifetime ~name, borrow[~name], unowned[~name]) prove that a reference does not outlive its target, and unique class and consume give single ownership to a whole type. See The Loup Native Book.

Summary

  • Every mode has ARC semantics: class instances are shared references, while collections, structs, enums and tuples are values copied on assignment.
  • Option[T] replaces null; indexing throws IndexError and a missing key KeyError; get returns an Option.
  • Sized integers throw NumericOverflowError on overflow, but in Loup 1.25 int and int64 wrap — guard large arithmetic yourself.
  • Parameters are read-only by default (E_READS_MUTATION). edits, takes and shares must be acknowledged at the call site (E_VERB_REQUIRED, E_VERB_MISMATCH), and a value passed with takes: cannot be used again (E_USE_AFTER_TAKE), including on a method receiver.
  • A method that writes a var field is modifier (E_MODIFIER_REQUIRED), and calls to it need safe {} or a safe func (E_SAFE_CONTEXT_REQUIRED).
  • unique let moves on assignment (E_USE_AFTER_MOVE); unique class, consume, capture lists and asm are not available in scripts, and arc let is not implemented anywhere.
  • weak fields are typed T? and assigned with Some(…); in 1.25 they are never cleared.
  • Release resources with try/finally, with and Closeable or Scoped[T], or del with finalize(). In Loup 1.25, defer does not run and finalize() runs only on del.
  • html fences do not escape automatically yet: use | escape on untrusted text.
  • atomic {} rejects await inside a check-then-act region (E_ATOMIC_AWAIT).