The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  4. Chapter 4: Functions
  5. Chapter 5: Control Flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and Pattern Matching
  9. Chapter 9: Classes and Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

Chapter 20: Memory Safety in Web Apps

This chapter collects, bug class by bug class, what Loup does to keep a web app correct: which mistakes the compiler refuses, which ones the runtime catches, and which defences are still planned. Most of the examples are mistakes you will actually make, shown with the message the Loup 1.25.1 toolchain prints and then fixed.

The big picture

Risk Loup 1.25 today Mechanism
Use-after-free, double free Prevented ARC semantics in every mode; no manual free
Missing value used as a value Compile-time error Option[T]; None is not a string
Out-of-range index Run-time IndexError Bounds-checked indexing
Wrong type through Any Partly: as? checks, as does not yet Checked casts
A callee changing data you lent it Compile-time error reads is the default verb
Hidden mutation at a call site Compile-time error edits: / takes: acknowledgements
Mutating method on shared object Compile-time error modifier + safe {}
Using a value after giving it away Compile-time error takes, unique let moves
Check-then-act race across await Compile-time error atomic {} refuses await
Leaked resources on error paths Your code try / finally
XSS through templates Your code escape filter by hand (auto-escaping ⚠ planned)
Integer overflow Not caught: wraps Specified as checked; not implemented
CSP, SSR state integrity, worker isolation, typed JS boundary ⚠ Planned No browser runtime ships yet

All listings in this chapter use @mode(web.compiled) and run under loup run, which executes a main() the way a compiled module's entry is run (see Chapter 2).

Memory: ARC semantics, not a garbage collector

Every Loup mode presents the same memory model: class instances are reference-counted, and an object is released when the last reference to it goes away (Language Reference §1-8.1.1; §1-2.6, footnote 1). The web modes are no exception. On the Wasm target, Loup values are host-managed references (externref) into the runtime's own heap. They are not Wasm GC objects; compiling to Wasm GC and Exception Handling is a future target (Language Reference §3-1).

What this gives you without any annotation:

  • No free, so no use-after-free and no double free.
  • Structs, enums, tuples and built collections are copied on assignment, so a function cannot keep a pointer into your list and change it later.
  • An object's lifetime ends at a predictable point, which is when del x runs its finalize() method.

The one leak ARC allows is a reference cycle: two objects that point at each other keep each other alive. The designed answer is a weak back-reference (pub weak var parent: Node?).

⚠ Planned. weak fields compile in both web modes today, but the Language Reference disagrees with itself about them on the web: the feature matrix (§1-2.6) allows weak in web.compiled, while §3-1.2 forbids it there. Avoid parent pointers in web code until this is settled, or break cycles yourself by clearing the field.

Null safety with Option

Loup has no null. A value that may be missing has type Option[T], and a plain string can never be None:

This code does not compile
@mode(web.compiled)

func main(): void {
    let userName: string = None;
    println("Hello, " + userName);
}
main.lpweb:4:5: error[E0001]: cannot assign 'None' to binding 'userName' of type 'string'

Say in the type that the value can be absent, and the compiler makes every reader deal with that case, either with a default (??) or with a match:

@mode(web.compiled)

func displayName(user: Option[string]): string {
    return user ?? "guest";
}

func main(): void {
    let signedIn: Option[string] = Some("ann");
    let anonymous: Option[string] = None;
    println(displayName(signedIn));
    println(displayName(anonymous));
    match anonymous {
        Some(name) => println("hello " + name),
        None => println("please sign in"),
    }
}

Listing 20-1: Handling a missing user with Option

ann
guest
please sign in

unwrap() is still available when you are certain, and it is checked: on a None it throws Uncaught UnwrapError: called `unwrap()` on a None value instead of reading garbage.

Bounds-checked indexing

Every index is checked. Reading past the end of a list is a run-time error, not a read of whatever memory follows:

This code throws at run time
@mode(web.compiled)

func main(): void {
    let slides = ["intro", "demo", "outro"];
    let current = 3;
    println(slides[current]);
}
Uncaught IndexError: list index 3 out of range (len 3)
  at main (main.lpweb:6:13)

In a UI, a stale index (a slide counter that ran one past the end, a selection from a list that has since shrunk) is an ordinary event, not a bug that should end the program. Turn the lookup into an Option and handle the missing case:

@mode(web.compiled)

func slideAt(slides: list[string], i: int): Option[string] {
    if (i < 0 or i >= slides.len()) {
        return None;
    }
    return Some(slides[i]);
}

func main(): void {
    let slides = ["intro", "demo", "outro"];
    let current = 3;
    match slideAt(slides, current) {
        Some(title) => println("showing " + title),
        None => println("no slide " + str(current)),
    }
    println(slideAt(slides, 1) ?? "end of deck");
    println(slideAt(slides, 3) ?? "end of deck");
}

Listing 20-2: A bounds-checked lookup that returns Option

no slide 3
demo
end of deck

(slides.get(i) throws IndexError too; it does not return an Option.)

Integer overflow wraps today

The Language Reference specifies checked integer arithmetic. Loup 1.25 does not implement the check: int arithmetic wraps around silently, in the web modes and everywhere else.

This code does not do what it should
@mode(web.compiled)

func main(): void {
    let big = 9223372036854775807;
    println(big + 1);
}
-9223372036854775808

If a value can approach the int range (byte counters, money in the smallest unit, IDs from a server), check the bound before the arithmetic.

Type confusion through Any

In web.compiled, Any is allowed but costs you static checking; the compiler warns with W_ANY_IN_AOT_MODE (§1-2.4.2). A form field arrives as Any, and the obvious code casts it with as:

This code throws at run time
@mode(web.compiled)

func quantity(raw: Any): int {
    return raw as int;
}

func main(): void {
    let total = quantity("3") + 1;
    println(total);
}
main.lpweb:3:20: warning: W_ANY_IN_AOT_MODE: use of `Any` in web.compiled mode prevents AOT specialisation — consider a static type if performance is critical
main.lpweb:3:20: warning: W_ANY_IN_AOT_MODE: use of `Any` in web.compiled mode prevents AOT specialisation — consider a static type if performance is critical
Uncaught RuntimeError: internal interpreter limitation: type error: Add expects same numeric type, got Str("3") + Int(1)

The Language Reference says expr as T throws TypeError at the cast when the value is not a T (§1-4.3). Stage0 does not check: the string "3" passes through a function declared to return int, and the program fails later, at the +, with an internal error. This is exactly the bug class Any invites. Use as?, which returns Option[T] and does check, and handle the None:

@mode(web.compiled)

func quantity(raw: Any): int {
    match raw as? int {
        Some(n) => return n,
        None => return 0,
    }
}

func main(): void {
    println(quantity(3) + 1);
    println(quantity("3") + 1);
}
4
1
main.lpweb:3:20: warning: W_ANY_IN_AOT_MODE: use of `Any` in web.compiled mode prevents AOT specialisation — consider a static type if performance is critical
main.lpweb:3:20: warning: W_ANY_IN_AOT_MODE: use of `Any` in web.compiled mode prevents AOT specialisation — consider a static type if performance is critical

Better still, give the parameter a concrete type at the boundary and parse once there. The --warnings-as-errors=W_ANY_IN_AOT_MODE switch described in §1-2.4.2 does not exist in the 1.25.1 loup run (error: unknown flag).

Cross-site scripting

An html fence does not escape inserted values in Loup 1.25 (Chapter 14 covers the details and the planned auto-escaping):

This code does not do what it should
@mode(web.compiled)

func main(): void {
    let comment = "<img src=x onerror=alert(1)>";
    let t = ```(html):(none)
    <li class="comment">{$ comment $}</li>
    ```;
    println(t.render({}));
}
<li class="comment"><img src=x onerror=alert(1)></li>

A user who posts that comment runs script in every reader's browser. Escape every untrusted value yourself:

@mode(web.compiled)

func main(): void {
    let comment = "<img src=x onerror=alert(1)>";
    let t = ```(html):(none)
    <li class="comment">{$ comment | escape $}</li>
    ```;
    println(t.render({}));
}

Listing 20-3: Escaping a user comment

<li class="comment">&lt;img src=x onerror=alert(1)&gt;</li>

escape is enough for text and for quoted attribute values. It does not make a javascript: URL safe, and nothing makes a value safe inside <script> or <style>; Listing 14-9 shows a scheme check for links.

Ownership verbs: who may change what

A parameter's verb says what the function may do with the argument (Language Reference §3-2.4.9). The verbs work the same in every mode:

Verb The callee may Call site writes
reads (default) look at it f(x)
edits change it in place f(edits: x)
takes keep it; the caller loses it f(takes: x)

reads is the default

A parameter with no verb is reads, so a function that only meant to count items cannot quietly change the caller's list:

This code does not compile
@mode(web.compiled)

func countTags(tags: list[string]): int {
    tags.push("seen");
    return tags.len();
}

func main(): void {
    var tags = ["news"];
    println(countTags(tags));
}
main.lpweb:4:5: 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 both fixes. If the function should not change the list, remove the push. If changing it is the point, say so with edits.

The call site acknowledges edits and takes

Declaring edits is half of the contract. The caller must also write the verb, so that a reader of the call can see the argument may change:

This code does not compile
@mode(web.compiled)

func addTag(edits tags: list[string], tag: string): void {
    tags.push(tag);
}

func main(): void {
    var tags = ["news"];
    addTag(tags, "sport");
    println(tags);
}
main.lpweb:9:12: error[E_VERB_REQUIRED]: E_VERB_REQUIRED: parameter 'tags' requires 'edits' verb acknowledgment at call site
@mode(web.compiled)

func addTag(edits tags: list[string], tag: string): void {
    tags.push(tag);
}

func countTags(tags: list[string]): int {
    return tags.len();
}

func main(): void {
    var tags = ["news"];
    addTag(edits: tags, tag = "sport");
    println(tags);
    println(countTags(tags));
}

Listing 20-4: edits at the declaration and at the call

["news", "sport"]
2

Mutating methods: modifier and safe

A method that writes a var field of this must be declared modifier:

This code does not compile
@mode(web.compiled)

class Counter {
    pub var clicks: int = 0;
    pub func click(): void {
        this.clicks = this.clicks + 1;
    }
}

func main(): void {
    let c = Counter();
    c.click();
    println(c.clicks);
}
main.lpweb:6:9: error[E_MODIFIER_REQUIRED]: E_MODIFIER_REQUIRED: writing to `var` field 'clicks' of `self` in method 'click' of class 'Counter' requires the method to be declared as `modifier`

Adding modifier moves the question to the caller. A class instance is a shared, reference-counted object, so calling a mutating method on it must happen in a safe context, where you take responsibility for there being no other mutation in flight:

This code does not compile
@mode(web.compiled)

class Counter {
    pub var clicks: int = 0;
    pub modifier func click(): void {
        this.clicks = this.clicks + 1;
    }
}

func main(): void {
    let c = Counter();
    c.click();
    println(c.clicks);
}
main.lpweb:12:5: error[E_SAFE_CONTEXT_REQUIRED]: E_SAFE_CONTEXT_REQUIRED: `modifier` method 'click' called on ARC receiver of type 'Counter' outside `safe {}` or `safe func` context — wrap the call in `safe { ... }` or mark the enclosing function as `safe func`
@mode(web.compiled)

class Counter {
    pub var clicks: int = 0;
    pub modifier func click(): void {
        this.clicks = this.clicks + 1;
    }
}

func main(): void {
    let c = Counter();
    safe {
        c.click();
        c.click();
    }
    println(c.clicks);
}

Listing 20-5: A modifier method called inside safe {}

2

The same rules combine for a function that updates an object passed with edits. In Stage0, writing a field through an edits parameter also needs the function to be a safe func (the Language Reference §3-2.4.9 says edits implies it), and a safe func is in turn called from a safe context:

@mode(web.compiled)

class Cart {
    pub var items: list[string] = [];
    pub var count: int = 0;
}

safe func addItem(edits cart: Cart, item: string): void {
    cart.items.push(item);
    cart.count = cart.count + 1;
}

func summary(cart: Cart): string {
    return str(cart.count) + " items";
}

func main(): void {
    let cart = Cart();
    safe {
        addItem(edits: cart, item = "tea");
        addItem(edits: cart, item = "milk");
    }
    println(summary(cart));
    println(cart.items);
}

Listing 20-6: A shopping cart updated through edits

2 items
["tea", "milk"]

summary takes the cart with the default reads, and a reader can tell from its signature alone that rendering the summary cannot change the cart.

takes: handing a value over

takes transfers the argument to the callee. After the call, the caller's binding is gone, which stops code from using an upload, a session or a message that now belongs to someone else:

This code does not compile
@mode(web.compiled)

class Upload {
    pub var name: string;
    new(name: string) { this.name = name; }
}

func submit(takes u: Upload): string {
    return "sent " + u.name;
}

func main(): void {
    let u = Upload("photo.png");
    println(submit(takes: u));
    println(u.name);
}
main.lpweb:15:13: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'u' was consumed by a `takes` parameter and cannot be used again

Read what you still need before you hand the value over:

@mode(web.compiled)

class Upload {
    pub var name: string;
    new(name: string) { this.name = name; }
}

func submit(takes u: Upload): string {
    return "sent " + u.name;
}

func main(): void {
    let u = Upload("photo.png");
    let label = u.name;
    println(submit(takes: u));
    println(label);
}
sent photo.png
photo.png

unique let: one owner at a time

A unique let binding has a single owner. Assigning it to another binding moves the value, and the old name can no longer be used:

This code does not compile
@mode(web.compiled)

class Token {
    pub let id: int;
}

func main(): void {
    unique let session = Token(id = 7);
    let handed = session;
    println(session.id);
}
main.lpweb:10:13: error[E_USE_AFTER_MOVE]: E_USE_AFTER_MOVE: binding 'session' was moved and cannot be used again — `unique class` instances and move-only structs have single ownership

Use the binding that now owns the value (println(handed.id); prints 7).

The stronger form, a unique class whose every instance is move-only, is not available on the web. The feature matrix (§1-2.6) lists it for every mode, but Stage0 accepts it only in host.native and freestanding:

This code does not compile
@mode(web.compiled)

unique class Session {
    pub let id: int;
    new(id: int) { this.id = id; }
}

func main(): void {
    let s = Session(1);
    println(s.id);
}
main.lpweb:3:1: error[E_UNIQUE_CLASS_ARC_BINDING]: E_UNIQUE_CLASS_ARC_BINDING: `unique class 'Session'` is only valid in `@mode(native)` or `@mode(freestanding)`

(The message still uses the retired name native; the mode is host.native.) In web code, use an ordinary class and unique let at the bindings that need single ownership.

Two checks the Language Reference specifies are not performed yet: E_BORROW_ESCAPE (a borrowed reference stored somewhere that outlives the borrow) and E_SAFE_ALIAS_MUTABLE. Code that would trip them compiles today.

Cleaning up on every path

A subscription, a timer or an open connection must be released even when the code between acquiring and releasing it throws. try / finally guarantees that:

@mode(web.compiled)

class Subscription {
    pub let topic: string;
    pub var open: bool = true;
    new(topic: string) {
        this.topic = topic;
        println("subscribe " + topic);
    }
    pub modifier func close(): void {
        this.open = false;
        println("unsubscribe " + this.topic);
    }
}

safe func render(topic: string): void {
    let sub = Subscription(topic);
    try {
        println("render " + topic);
        if (topic == "bad") {
            throw ValueError("render failed for " + topic);
        }
    } finally {
        sub.close();
    }
}

func main(): void {
    safe {
        render("prices");
        try {
            render("bad");
        } catch (ValueError as e) {
            println("caught: " + e.message);
        }
    }
}

Listing 20-7: Releasing a subscription with try / finally

subscribe prices
render prices
unsubscribe prices
subscribe bad
render bad
unsubscribe bad
caught: render failed for bad

The finally block runs on the normal path and on the exception path.

Check-then-act: atomic {}

On the browser's single thread, tasks interleave only where one of them suspends at an await. A check followed by an action is a race if an await sits between them: another task can change the state in that gap. An atomic {} block is a region the compiler proves has no suspension point (runtime §2-1.7):

This code does not compile
@mode(web.compiled)

class Stock {
    pub var left: int = 1;
    pub modifier func take(): void { this.left = this.left - 1; }
}

async func paymentOk(): Task[bool] {
    return true;
}

async func main(): Task[void] {
    let s = Stock();
    safe {
        atomic {
            if (s.left > 0) {
                let paid = await paymentOk();
                s.take();
            }
        }
    }
    println(s.left);
}
main.lpweb:17:28: error[E_ATOMIC_AWAIT]: E_ATOMIC_AWAIT: await expression is not allowed inside atomic {} block

Do the waiting first, then check and act inside the block with nothing that can suspend:

@mode(web.compiled)

class Stock {
    pub var left: int = 1;
    pub modifier func take(): void { this.left = this.left - 1; }
}

async func paymentOk(): Task[bool] {
    return true;
}

async func buy(edits s: Stock): Task[string] {
    let paid = await paymentOk();
    var result = "sold out";
    safe {
        atomic {
            if (paid and s.left > 0) {
                s.take();
                result = "reserved";
            }
        }
    }
    return result;
}

async func main(): Task[void] {
    let s = Stock();
    println(await buy(edits: s));
    println(await buy(edits: s));
}

Listing 20-8: Await outside, check and act inside atomic {}

reserved
sold out

On the interpreter, async functions currently run eagerly and in order, so no other task can actually slip into the gap yet; the atomic {} check is static and holds regardless of how the scheduler runs (Chapter 15).

Planned web defences

The remaining defences in the design belong to the browser runtime, which Loup 1.25 does not ship. They are described in the chapters listed, all marked ⚠ Planned:

  • Context-aware auto-escaping, SafeHtml, Url, the <script>/<style> prohibition (Language Reference §1-16.6): Chapter 14.
  • Effect cleanup and component lifetimes (effect returning a cleanup closure, signals owned by components; runtime §2-4.1): Chapter 16.
  • Shadow-DOM style isolation for Web Components (runtime §2-4.10): Chapter 17.
  • Typed JS interop (extern js, @jsexport with struct schemas checked at the boundary; runtime §2-3.1–§2-3.3) and Sendable messages to Web Workers (ScriptWorker, WorkerChannel[T]; runtime §2-1.10): Chapter 18.
  • SSR state serialization that escapes < and > in the embedded JSON, the optional stateIntegrity SHA-256 check during hydration, and a strict Content Security Policy with 'wasm-unsafe-eval' and no 'unsafe-inline' (runtime §2-4.7): Chapter 19.

A checklist for today

  1. Pipe every untrusted value in an html fence through | escape; check URL schemes yourself; never interpolate into <script> or <style>.
  2. Give possibly-missing values an Option type and handle None.
  3. Treat an index from the outside world as untrusted: check it, or return an Option.
  4. Avoid Any; where you must accept it, narrow with as?, not as.
  5. Leave parameters reads unless the function must change them; mark the ones that must edits, and hand values over with takes.
  6. Release resources in finally.
  7. Keep await out of check-then-act sequences; wrap them in atomic {}.
  8. Check bounds before arithmetic that could leave the int range: overflow wraps today.

Summary

  • Every Loup mode, including the web modes, presents ARC semantics. Wasm values are runtime-managed references, not Wasm GC objects.
  • Option replaces null, indexing is bounds-checked, and as? narrows Any safely. Plain as does not check yet, and int overflow wraps.
  • The verbs reads (default), edits and takes, the call-site acknowledgements, modifier / safe, and unique let turn aliasing and use-after-hand-over mistakes into compile errors: E_READS_MUTATION, E_VERB_REQUIRED, E_MODIFIER_REQUIRED, E_SAFE_CONTEXT_REQUIRED, E_USE_AFTER_TAKE, E_USE_AFTER_MOVE. unique class is not available in the web modes.
  • try / finally releases resources on every path; atomic {} keeps await out of a check-then-act region (E_ATOMIC_AWAIT).
  • html fences do not escape yet: use | escape. The browser-runtime defences (auto-escaping, CSP, SSR state integrity, worker Sendable checks, typed JS interop) are planned.