Skip to content

Check lambda bodies in the safety checker - #41

Open
wtholliday wants to merge 1 commit into
mainfrom
safety-check-lambda-bodies
Open

Check lambda bodies in the safety checker#41
wtholliday wants to merge 1 commit into
mainfrom
safety-check-lambda-bodies

Conversation

@wtholliday

Copy link
Copy Markdown
Collaborator

Fixes #39.

SafetyChecker::check_expr had no Expr::Lambda arm, so lambdas fell into the catch-all and their bodies were never visited — array bounds, divisor non-zero, and require clauses at call sites were all skipped inside one. Since safety here is entirely static, nothing was checking at all: an out-of-bounds store through a lambda parameter was silent on every backend.

What changed

New check_lambda_body helper binds the parameters and checks the body:

  • Param types come from the annotation when present, otherwise from the solved Type::Func domain of the lambda expression (lambda params are usually unannotated), so u32 params still get min = 0.
  • Params shadow captured variables of the same name; other captures keep the constraints they have at the definition site.
  • Checker state is saved/restored around the body, then invalidate_assigned runs — a lambda that writes a captured variable conservatively drops what was known about it, since the write happens at an unknown call time.

Expr::Call checks an immediately-invoked lambda literal against its actual argument intervals (inheriting the arguments' symbolic len bounds), so (|i| arr[i])(2) is still accepted while (|i| arr[i])(1000) is rejected.

A lambda that is stored in a variable, passed to a higher-order function, or nested is checked with unconstrained parameters — the same rule as a function body — so it has to guard itself:

var g = (|i| if i >= 0 && i < arr.len { arr[i] } else { -1 })   // accepted
var g = (|i| arr[i])                                            // rejected

Relating call-site arguments back to a lambda bound to a variable (var g = (|i| arr[i]); g(2)) is deliberately not attempted: it needs the binding to be provably unreassigned and non-escaping, which is a design decision rather than part of closing the hole.

Tests

All four repros from #39 now fail --check with the same diagnostics the identical non-lambda code produces.

  • 12 unit tests in safety_checker.rs (unconstrained/guarded index, write, divisor, capture, shadowing, direct call safe/unsafe, nested, capture invalidation, u32 param).
  • 8 golden tests in tests/cases/lambdas/ — 6 rejection cases (read, write, divide, require, nested, HOF) and 2 acceptance cases.

cargo test --workspace passes: 369 lib + 4 golden (jit/vm/asm/stack) + 21 lsp. No existing test changed behavior. cargo fmt --check is clean for the touched file.

Unrelated bug found while writing the tests

A lambda that captures an array misbehaves on every non-JIT backend, independent of this change (the safety checker only emits diagnostics):

main {
    var arr: [i32; 4]
    arr[2] = 11
    var g = (|x| arr[2] + x)
    print(g(1))
}

JIT prints 12; the VM panics at src/vm.rs:1283 (attempt to subtract with overflow, from self.ip - 1 inside the out-of-bounds-access panic message); asm and stack exit 139. That is why lambda_guarded_ok.lyte is --check-only, with a comment saying so. Worth its own issue.

🤖 Generated with Claude Code

`check_expr` had no `Expr::Lambda` arm, so lambdas fell into the catch-all
and their bodies were never visited: array bounds, divisor non-zero, and
`require` clauses at call sites were all skipped inside one. Since safety
here is entirely static, that meant nothing was checking at all — an
out-of-bounds store through a lambda parameter was silent on every backend.

Add `check_lambda_body`, which binds the parameters and checks the body:

  * Param types come from the annotation when present, otherwise from the
    solved `Type::Func` domain of the lambda expression, so `u32` params
    still get `min = 0`.
  * Params shadow captured variables of the same name; other captures keep
    the constraints they have at the definition site.
  * State is saved/restored around the body, then `invalidate_assigned`
    runs — a lambda that writes a captured variable conservatively drops
    what we knew about it, since the write happens at an unknown call time.

`Expr::Call` checks an immediately-invoked lambda literal against its
actual argument intervals (inheriting the arguments' symbolic `len`
bounds), so `(|i| arr[i])(2)` is still accepted while `(|i| arr[i])(1000)`
is rejected. A lambda that is stored, passed to a higher-order function,
or nested is checked with unconstrained parameters — the same rule as a
function body — so it has to guard itself.

Fixes #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Safety checker never descends into lambda bodies

1 participant