# Data Races Are a Compile Error Now: GALA in July (0.63 -> 0.72)

Go's race detector is very good at what it does. What it does is watch a program
run and report races it actually observed.

That last part is the whole problem. `go test -race` finds the race your test
happened to schedule, on the interleaving it happened to get, on the machine it
happened to run on. It is a sampling instrument pointed at a nondeterministic
process. The races it misses are not exotic ones — they are the ordinary ones
that needed a slightly different timing to show up, which is also a fair
description of the races that reach production.

Seven releases have landed since [the last of these
posts](https://martianov.dev/gala-june-2026-bind-also), 0.63 through 0.72.2. The
one that changes how the language feels is this: **a value that isn't safe to
share across goroutines is now a compile error.** Not a warning, not a lint, not
a runtime sampler. A build failure, with a caret pointing at the capture.

---

## The rule

A data race needs two goroutines touching the same memory with at least one
writing. The cleanest way to make that impossible is to guarantee the value is
**deeply immutable**: if nobody can ever mutate it, any number of goroutines can
read it concurrently with no synchronisation and no race.

So GALA's rule for crossing a goroutine boundary is exactly one sentence:

> Only deeply-immutable values may cross.

The interesting question is not whether that rule is sound — it obviously is —
but whether a language can adopt it without making concurrent code miserable to
write. In most languages this rule would be intolerable, because most values in
most programs are mutable.

GALA can afford it because the language already pushed everything the other way:
`val` over `var`, `collection_immutable` over `collection_mutable`,
`Option`/`Try`/`Either` over `nil` and naked error pairs, immutable struct fields
by default. Most values a well-written GALA program shares are *already*
shareable. The check is therefore silent in the common case. It only speaks up
when you try to send something genuinely mutable.

That's the design bet: the safety check is cheap because the language spent
three years making immutability the path of least resistance.

---

## What it looks like when it fires

A concurrency boundary is a parameter typed `Sendable[F]`. `concurrent.Future`
uses one; so does `FutureOn`; so can your own library. Here is a boundary and a
closure that captures a `var`:

```gala
package main

func run(body Sendable[func() int]) int = body()

func main() {
    var counter = 0
    Println(run(() => counter + 1))
}
```

That program does not compile:

```
error[GALA-E0037]: closure crossing a concurrency boundary captures reassignable var "counter" — a data race, because the enclosing scope may reassign it while the goroutine runs
  --> var_capture.gala:7:23
  |
7 |     Println(run(() => counter + 1))
  |                       ^^^^^^^ snapshot it into an immutable `val` before the boundary
  |
  = hint: snapshot it into an immutable `val` before the boundary (e.g. `val counter = counter` outside the closure) so the goroutine captures a stable copy
```

The closure never ran. No scheduler had to cooperate. There is no flaky-test
phase where this reproduces on CI one time in forty.

Four shapes trigger `GALA-E0037`: capturing a reassignable `var`; using a `val`
*whole* when its type isn't deeply immutable (a `collection_mutable` value, a
struct with a `var` field, a Go-interop handle, a bare slice/map/pointer);
reading a field path that passes through a `var` field; and capturing a bare
`func` value, whose type says nothing about what *it* closed over.

That last one matters more than it looks. A `func() int` parameter is an opaque
promise — you cannot see its captures, so you cannot vouch for them. Typing the
parameter `Sendable[func() int]` is how the guarantee propagates: the caller
vouches, and the callee may forward it into a further boundary.

---

## What counts as shareable

The predicate is deep, transitive, and structural. It looks through wrappers,
collections, tuples, struct fields, and sealed variants until it hits a
primitive or something it must reject.

Shareable: the value primitives; `string`; every `collection_immutable`
structure (`Array`, `List`, `HashMap`, `HashSet`, `TreeMap`, `TreeSet`) when
their type arguments are; `Option`/`Try`/`Either` and tuples when theirs are;
immutable structs and sealed types all the way down.

Not shareable: anything from `collection_mutable`, structs with a `var` field,
raw Go slices, maps and pointers, and opaque Go-interop references.

Which means this compiles, and every capture in it is checked:

```gala
val xs = ArrayOf(10, 20, 30)
val f = Future(xs.Size())
Println(f.Get())
```

`Future(expr)` is by-name thunk sugar — it desugars to `Future(() => expr)`, so
`expr` *is* the async closure, and its captures get the same treatment as an
explicit lambda.

---

## Field sensitivity, and the limit that's actually left

A whole-value check would be annoying enough to route around. If sharing a
struct required the struct to be immutable in its entirety, every `Future` would
open with a stanza of snapshot locals.

So the check is **field-access-sensitive**. It records *how* the closure uses
each capture, and a capture read only through immutable field paths is accepted
even when the struct as a whole is not shareable:

```gala
struct AppModel(team string, statuses Array[int], var attempts int)

val model = AppModel("qa", ArrayOf(1, 2, 3), 0)

// ACCEPTED — reads only the immutable `team` / `statuses` fields, even though
// AppModel is unshareable as a whole (it has a `var attempts` field).
Future(() => project(model.team, model.statuses))

// REJECTED — reads through the `var` field.
Future(() => model.attempts + 1)
```

Calling a method on a field you just read works too. The receiver's own type
decides, so the common shapes real code writes are accepted:

```gala
// ACCEPTED — `team` is a `string`, unconditionally shareable.
Future(() => model.team.Size())

// REJECTED — the receiver field's own type is mutable.
Future(() => model.buf.Size())    // buf: collection_mutable.Array[int]
```

That last part is newer than the rest of this post. When I first wrote it up,
`model.team.Size()` was rejected — a method call ended the field-path run and
collapsed the whole capture, so a `string` that could never race got refused. It
was sound but wrong-feeling, and it landed on a shape you write constantly
(`model.name.Size()`, `model.items.Map(...)`). It's fixed in 0.72.1: a trailing
method call on a field path defers to the leaf's shareability. Indexing and
chained calls stay conservative and still mark the capture whole.

Which is roughly what you want from a check this young — the sound-but-annoying
direction is the recoverable one, and it got recovered in a point release.

One more scope limit worth stating plainly: this is **not** a general race
detector. It guards declared `Sendable[F]` boundaries. Reach around them — hand
a pointer to hand-written Go, share state through a Go library — and you are
back to `go test -race`, exactly as before. It makes the safe path checkable, it
does not make the unsafe path impossible.

---

## The rest of the month

**Structured concurrency and cancellation.** `Future` gained real cancellation
with scope semantics: linear combinators inherit their parent's token, so a
derived stage shares its scope, while a fresh root opens a new one. Cancellation
is API-level — there's no body-token escape hatch to misuse.

**Stack traces name your `.gala` lines.** A panic now reports GALA source
positions, not positions in generated Go:

```
panic: runtime error: integer divide by zero
main.divide(...)
	foo.gala:4
main.main()
	foo.gala:8
```

This rides Go's own `//line` mechanism — the transpiler stamps each statement
with its originating GALA line, which the Go compiler and runtime already honor.
No source-map file, no runtime cost. Imported GALA packages map too, so a single
trace can span library and application source with each frame pointing at real
`.gala` code. It's approximate inside constructs that lower to several Go
statements or to an IIFE (`match`, `if`-expressions, `bind`/`also`) — right
file, line possibly off by a few.

**Diagnostics got a frame.** Errors now render Rust/Elm style, with a source
excerpt, a caret span, an inline label, and a hint:

```
error[GALA-E0035]: bare Go builtin "len(...)" is not part of GALA's surface
  --> bare_len.gala:4:13
  |
4 |     val n = len(s)
  |             ^^^ use `.Size()`
  |
  = hint: use `.Size()` (logical size — characters for strings) or `.ByteSize()` (raw bytes) instead of `len(...)`
```

**Guaranteed tail-call optimisation.** A self-tail-recursive function with an
if-expression body compiles to a loop, so deep recursion runs in constant stack
space. This:

```gala
func factorial(n int, acc int) int = if (n <= 0) acc else factorial(n - 1, n * acc)
```

becomes exactly this Go:

```go
func factorial(n int, acc int) int {
	for {
		if n <= 0 {
			return acc
		} else {
			_tmp_1 := n - 1
			_tmp_2 := n * acc
			n, acc = _tmp_1, _tmp_2
			continue
		}
	}
}
```

Note the temporaries: both arguments are evaluated against the *old* parameter
values before either is reassigned, which is what makes `factorial(n-1, n*acc)`
mean what it says. The scope is deliberately narrow — plain functions (no
receiver), expression bodies, genuine self-tail-calls. Non-tail recursion like
`n * factorial(n-1)` is left alone rather than silently mis-transformed.

**`use`, and closing the escape hatches.** Scoped resource binding arrived, and
bare `defer`, bare `go`, and bare Go builtins were removed from GALA's surface —
routed through `use`, the `concurrent` package, and `go_interop` / `go_builtins`
respectively. Which is the same instinct as E0037: if the language offers a
checked path, the unchecked one shouldn't be the one that's easier to type.

**A `subprocess` package**, with a goroutine-safe `Process` handle — async line
reads, async stdin writes, kill-after-timeout, all returning `Future`s.

---

## Where this leaves things

The pitch for GALA has been "the type system Go is missing." Sum types,
exhaustive matching, `Option` instead of `nil` — a compiler that rejects the
programs you didn't mean to write.

Concurrency safety is that same pitch aimed at the bug class Go developers
actually lose days to. Go's answer to data races is a runtime tool you remember
to run. GALA's answer is that the program doesn't build. Not because GALA is
cleverer, but because it spent its immutability budget early and can now cash it
in for something a mutable-by-default language can't buy at any price.

The check is young, and it guards declared boundaries rather than every
goroutine — reach around it and you are back to `go test -race`. Within those
boundaries it is still a different category of guarantee than sampling a
schedule and hoping.

`gala.fyi` has the reference for
[concurrency safety](https://gala.fyi/features/concurrency-safety/) and the full
[GALA-E0037](https://gala.fyi/docs/errors/gala-e0037/) writeup, or you can try
the language without installing anything in the
[playground](https://gala-playground.fly.dev).
