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 xruns itsfinalize()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.
weakfields compile in both web modes today, but the Language Reference disagrees with itself about them on the web: the feature matrix (§1-2.6) allowsweakinweb.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:
@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:
@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.
@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:
@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):
@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"><img src=x onerror=alert(1)></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:
@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:
@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:
@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:
@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:
@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:
@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:
@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):
@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 (
effectreturning 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,@jsexportwith struct schemas checked at the boundary; runtime §2-3.1–§2-3.3) andSendablemessages to Web Workers (ScriptWorker,WorkerChannel[T]; runtime §2-1.10): Chapter 18. - SSR state serialization that escapes
<and>in the embedded JSON, the optionalstateIntegritySHA-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
- Pipe every untrusted value in an
htmlfence through| escape; check URL schemes yourself; never interpolate into<script>or<style>. - Give possibly-missing values an
Optiontype and handleNone. - Treat an index from the outside world as untrusted: check it, or return an
Option. - Avoid
Any; where you must accept it, narrow withas?, notas. - Leave parameters
readsunless the function must change them; mark the ones that mustedits, and hand values over withtakes. - Release resources in
finally. - Keep
awaitout of check-then-act sequences; wrap them inatomic {}. - Check bounds before arithmetic that could leave the
intrange: overflow wraps today.
Summary
- Every Loup mode, including the web modes, presents ARC semantics. Wasm values are runtime-managed references, not Wasm GC objects.
Optionreplacesnull, indexing is bounds-checked, andas?narrowsAnysafely. Plainasdoes not check yet, andintoverflow wraps.- The verbs
reads(default),editsandtakes, the call-site acknowledgements,modifier/safe, andunique letturn 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 classis not available in the web modes. try/finallyreleases resources on every path;atomic {}keepsawaitout of a check-then-act region (E_ATOMIC_AWAIT).htmlfences do not escape yet: use| escape. The browser-runtime defences (auto-escaping, CSP, SSR state integrity, workerSendablechecks, typed JS interop) are planned.