diff --git a/SPEC.md b/SPEC.md index 55b577e8..5090b77a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1938,8 +1938,12 @@ ilo help ai -- compact AI spec to stdout (= contents of ai. ilo serv -- long-lived JSON request/response loop ilo --max-ast-depth N -- cap parser nesting at N (default 256; protects `ilo serv` and other untrusted-source paths from DoS payloads, raises ILO-P103) +ilo --max-runtime SECS -- cap wall-clock runtime at SECS (default 60; 0 disables; raises ILO-R016) +ilo --max-output-bytes BYTES -- cap stdout output at BYTES (default ~100 MB; 0 disables; raises ILO-R017) ``` +**Production-safety guards (`ILO-R016`, `ILO-R017`).** `ilo run` caps wall-clock runtime at 60 s and stdout output at ~100 MB by default. A runaway loop (missing increment, recursion with no base case) aborts with `ILO-R016` once the time budget hits, instead of burning CPU forever; a `prnt` loop without termination aborts with `ILO-R017` once the byte budget hits, instead of filling the agent transcript with megabytes of garbage. Both guards write a structured diagnostic to stderr and exit 1. Defaults are well above any legitimate program (real agent tasks finish under 10 s and produce kilobytes); raise with `--max-runtime SECS` / `--max-output-bytes BYTES`, set either to `0` to disable. The guards were installed by the mandelbrot persona report (2026-05-20) which spun in an infinite loop and wrote 165 MB of stdout before the harness intervened. + **Verb-noun aliases.** `ilo run ` is an exact alias for the bare positional `ilo ` - same dispatch, same engine selection, same arg handling. `ilo build -o ` is an alias for `ilo compile -o `. Both exist to match the toolchain conventions used by `cargo`, `go`, and `zero` so agents and humans can guess the command name without consulting the help text. The bare positional forms remain fully supported for backwards compatibility; nothing has been removed. **`ilo check`.** Standalone verifier invocation: lex, parse, resolve imports, and run the type verifier without proceeding to bytecode compilation or execution. Exit code 0 means the program is well-typed and verifier-clean; exit code 1 means at least one diagnostic was emitted on stderr. The output mode follows the global flags (`--json` for NDJSON diagnostics, `--text` for plain text, `--ansi` for coloured output; auto-detected when omitted - JSON when stderr is not a TTY, ANSI otherwise). `ilo check` works on both files and inline code; on a syntactically-broken input it still reports the parse error rather than crashing, which is important for editor and agent loops that may feed in half-written programs. diff --git a/ai.txt b/ai.txt index da2e527d..26eff215 100644 --- a/ai.txt +++ b/ai.txt @@ -16,6 +16,6 @@ TOOLS (EXTERNAL CALLS): tool "" > timeou IMPORTS: Split programs across files with `use`: use "path/to/file.ilo" -- import all declarations use "path/to/file.ilo" [name1 name2] -- import only named declarations All imported declarations merge into a flat shared namespace - no qualification, no `mod::fn` syntax. The verifier catches name collisions. -- math.ilo dbl n:n>n; *n 2 half n:n>n; /n 2 -- main.ilo use "math.ilo" run n:n>n; dbl! half n [Rules] Path is relative to the importing file's directory Transitive: if `a.ilo` uses `b.ilo`, `b.ilo`'s declarations are visible to `main.ilo` when it uses `a.ilo` Circular imports are an error (`ILO-P018`) Scoped import with unknown name: `ILO-P019` `use` in inline code (no file context): `ILO-P017` [Error codes] `ILO-P017`=File not found or `use` in inline mode `ILO-P018`=Circular import detected `ILO-P019`=Name in `[...]` list not declared in the imported file ERROR HANDLING: `R ok err` return type. Call then match: get-user uid;?{^e:^+"Lookup failed: "e;~d:use d} Compensate/rollback inline: charge pid amt;?{^e:release rid;^+"Payment failed: "e;~cid:continue} [Auto-Unwrap `!`] `func! args` calls `func` and auto-unwraps the Result: if `~v` (Ok), returns `v`; if `^e` (Err), immediately returns `^e` from the enclosing function. inner x:n>R n t;~x outer x:n>R n t;d=inner! x;~d Equivalent to `r=inner x;?r{~v:v;^e:^e}` but in 1 token instead of 12. Rules: The called function must return `R` or `O` (else verifier error ILO-T025) The enclosing function must return `R` (or `O` for Optional callees) (else verifier error ILO-T026) `!` goes after the function name, before args: `get! url` not `get url!` Zero-arg: `fetch!()` [Panic-Unwrap `!!`] `func!! args` is symmetric in shape with `!`, but on the failure path it aborts the program with a runtime diagnostic and exit code 1 instead of propagating. There is no enclosing-return-type constraint, so persona code can use it from `main>t`, `main>n`, or any non-Result / non-Optional context. main>t;rdl!! "input.txt" -- read file, abort with diagnostic if missing main>n;v=num!! "42";v -- parse number, abort on parse error main>n;m=mset mmap "k" 7;mget!! m "k" -- get value or abort if key missing On `^e` (Err) the program writes `panic-unwrap: ` to stderr and exits 1. On `O nil` the program writes `panic-unwrap: expected value, got nil`. On `~v` (Ok) or non-nil Optional, the inner value is extracted, identical to `!`. Rules: The called function must return `R` or `O` (else verifier error ILO-T025) **No constraint on the enclosing function's return type** - this is the difference from `!` `!!` goes after the function name, before args: `rdl!! path` not `rdl path!!` Zero-arg: `fetch!!()` Use `!` when the caller wants to react to the Err (compensate, retry, log). Use `!!` when the failure is a programming or environmental error the caller has no way to recover from - typical in short scripts, glue code, and main entry points. PATTERNS (FOR LLM GENERATORS): [Bind-first pattern] Always bind complex expressions to variables before using them in operators. Operators only accept atoms and nested operators as operands - not function calls. -- DON'T: *n fac -n 1 (fac is an operand of *, not a call) -- DO: r=fac -n 1;*n r (bind call result, then use in operator) [Recursion template] >;;...;;combine 1. **Guard**: base case returns early - `<=n 1 1` (or `<=n 1{1}`) 2. **Bind**: bind recursive call results - `r=fac -n 1` 3. **Combine**: use bound results in final expression - `*n r` [Factorial] fac n:n>n;<=n 1 1;r=fac -n 1;*n r `<=n 1 1` - braceless guard: if n <= 1, return 1 `r=fac -n 1` - recursive call with prefix subtract as argument `*n r` - multiply n by result [Fibonacci] fib n:n>n;<=n 1 n;a=fib -n 1;b=fib -n 2;+a b `<=n 1 n` - braceless guard: return n for 0 and 1 `a=fib -n 1;b=fib -n 2` - two recursive calls, each with prefix arg `+a b` - add results [Tail-call optimisation] ilo guarantees that **tail calls do not consume host-stack frames**. A function that recurses only in tail position can run to arbitrary depth — the runtime trampolines the call by rebinding parameters in place rather than pushing a frame. The manifesto's "Constrained" rule (every feature must pay for itself in tokens) vetoed adding a `loop` keyword. Instead, tail-recursive accumulator patterns are the canonical idiom for iteration beyond what `@` foreach covers, and the TCO guarantee makes them safe at any depth. A call is in **tail position** when its return value is the function's return value: the last statement of the body, the expression of a `ret` statement, an arm of a tail-position `?` match, or the body of a braceless guard. Calls inside `@` foreach, `@` range, `wh` loops, or as operands of further computation are NOT in tail position. -- Tail-recursive countdown — runs to arbitrary depth. count-down n:n>n;=n 0 0;count-down -n 1 -- Tail-recursive accumulator — sums a list without growing the host stack. sum-acc xs:L n acc:n>n;empty=len xs;=empty 0 acc;sum-acc tl xs +acc hd xs Constraints on the tail-call peephole: The callee must be a direct user-defined function name (not a FnRef in scope, not a closure, not a builtin, not a tool). The call must have no auto-unwrap (`!` / `!!`) — those forms inspect the result before deciding whether to propagate. These constraints leave the common shapes (recursive accumulators, state machines, mutual recursion via direct names) covered. Other shapes still recurse the host stack as before; for deep recursion through non-tail-eligible shapes, restructure into an accumulator. Tree interpreter and bytecode VM (`--vm`) support shipped in 0.12.x; the VM emits `OP_TAILCALL` for tail-position user-fn calls and reuses the current call frame instead of pushing a new one, so depth is bounded only by available heap. Cranelift (`--jit`, AOT) gains matching `return_call` lowering in a subsequent PR; until then, deep tail-recursion under the JIT/AOT path recurses the host stack and is bounded by it. [Multi-statement bodies] Semicolons separate statements. Last expression is the return value. f x:n>n;a=*x 2;b=+a 1;*b b -- (x*2 + 1)^2 Bodies may also be written across multiple newline-separated lines, indented under the signature. The parser stays inside the same function body while it sees an open bracket (`[`, `(`, `{`) or a pipe operator continuation. This makes long literals and multi-line conditional pipelines readable without semicolons: f x:n>n a=*x 2 b=+a 1 *b b g>L n [10, 20, 30, 40, 50, 60, 70, 80] Statement separation reverts to standard rules once brackets close. A blank line ends the current declaration. Windows CRLF (`\r\n`) is normalised to `\n` before lexing, so files edited on Windows parse identically to Unix-line-ending files. [Multi-function files] Functions in a file are separated by **newlines**. The parser strips all newlines, so the token stream is flat. After parsing each function body, the parser uses the next newline-delimited boundary to start the next declaration. A non-last function body's **final expression must not be a bare variable reference (`Ref`) or a function call**, because the parser greedily reads following tokens as additional call arguments. Safe endings prevent this: Binary operator=`+n 0`, `*x 1`=✓=fixed arity - no greedy loop Index access=`xs.0`, `rec.field`=✓=returns `Expr::Index`, not `Ref` Match block=`?v{…}`=✓=ends with `}` ForEach block=`@x xs{…}`=✓=ends with `}` Parenthesised expr=`(x>>f>>g)`=✓=ends with `)` Record constructor=`point x:1 y:2`=✓=parses as `Expr::Record`, not `Ref` Text/number literal=`"ok"`, `42`=✓=literal, not `Ref` Bare variable (`Ref`)=`n`, `result`=✗=greedy loop fires Bare function call=`len xs`, `f a`=✗=greedy loop fires The **last function in a file** can end with anything - greedy parsing stops at EOF. -- Non-last functions: end with a binary expression digs n:n>n;t=str n;l=len t;+l 0 -- +l 0 = l (binary, safe) clmp n:n lo:n hi:n>n;n hi hi;+n 0 -- +n 0 = n (binary, safe; `clamp` is a builtin) -- Last function: bare call is fine sz xs:L n>n;len xs -- EOF - greedy loop stops naturally To use a pipe chain in a non-last function, wrap it in parentheses: dbl-inc x:n>n;(x>>dbl>>inc) -- parens prevent >> from consuming next function's name inc-sq x:n>n;x>>inc>>sq -- last function - no parens needed [DO / DON'T] -- DON'T: fac n:n>n;<=n 1 1;*n fac -n 1 -- ↑ *n sees fac as an atom operand, not a call -- DO: fac n:n>n;<=n 1 1;r=fac -n 1;*n r -- ↑ bind-first: call result goes into r, then *n r works -- DON'T: +fac -n 1 fac -n 2 -- ↑ + takes two operands; fac is just an atom ref -- DO: a=fac -n 1;b=fac -n 2;+a b -- ↑ bind both calls, then combine -ERROR DIAGNOSTICS: ilo verifies programs before execution and reports errors with stable codes, source context, and suggestions. [Error codes] Every error has a stable `ILO-` code. The letter is the namespace - the phase that raised the diagnostic - so agents and tools can route on prefix without parsing the message. Numeric ranges are reserved per namespace with generous gaps, so future codes slot in cleanly and the contract is forward-compatible. `ILO-L000-099`=L=Lexer / tokenisation=active `ILO-P100-199`=P=Parser / syntax=active `ILO-N200-299`=N=Names / resolution=reserved `ILO-I300-399`=I=Imports=reserved `ILO-T400-499`=T=Types=active `ILO-V500-599`=V=Verifier (post-type checks)=reserved `ILO-R600-699`=R=Runtime=active `ILO-D700-799`=D=Deprecation warnings=reserved `ILO-E800-899`=E=Engine-specific limitations=reserved `ILO-S900-999`=S=Skill / spec system=reserved **Historical codes.** ilo shipped with flat numbering inside each namespace - `ILO-L001`, `ILO-P001`, `ILO-T001`, `ILO-R001`, `ILO-W001`, all starting at 001. Those codes remain valid forever. The hundreds-block allocation above applies to new codes from now on, and a cross-engine regression test asserts every emitted code lives in a documented range. **Reserved namespaces.** `N`, `I`, `V`, `D`, `E`, `S` carry no codes today. They are forward declarations so the first code in each category slots into its own range without conflicting with the active namespaces. `D` is earmarked for deprecation warnings: when a feature is scheduled for removal it emits an `ILO-D7xx` warning at compile time without failing the build. Use `--explain` to see a detailed explanation: ilo --explain ILO-T004 [Source context] Errors point at the relevant source location with a caret: error[ILO-T005]: undefined function 'foo' (called with 1 args) --> 1:9 1 | f x:n>n;foo x = note: in function 'f' = suggestion: did you mean 'f'? Parser, verifier, and runtime errors all show source spans. The verifier uses the enclosing statement span as the best available location for expression-level errors. [Suggestions] The verifier provides context-aware hints: **Did you mean?** - Levenshtein-based suggestions for undefined variables, functions, fields, and types **Type conversion** - suggests `str` for n→t, `num` for t→n **Missing arms** - lists uncovered match patterns with types **Arity** - shows expected parameter signature [Error output formats] --ansi / -a ANSI colour (default for TTY) --text / -t Plain text (no colour) --json / -j JSON (default for piped output) --no-hints / -nh Suppress idiomatic hints --silent / -s Suppress program stdout (mainly for --bench; see below) NO_COLOR=1 Disable colour (same as --text) **`--silent` / `-s`.** Suppresses the program's own stdout (`prnt`, `prnv`, `jprn`, etc.) for the duration of execution. Designed for `ilo --bench`: combined with `--json` it lets agent harnesses (e.g. persona cost rollup) consume the bench JSON envelope on stdout without it being drowned in the benchmarked function's own output. Stderr is never silenced, so genuine errors still surface. Diagnostic output (including the bench JSON envelope and the human-readable bench summary block) is always emitted on stdout regardless of `--silent` — the flag only redirects program-level prints. Unix only (no-op on Windows for the program-stdout half; bench output still reaches stdout there). JSON error output follows a structured schema with `severity`, `code`, `message`, `labels` (with spans), `notes`, and `suggestion` fields. Runtime errors raised from the Cranelift JIT (opt-in via `--jit`) populate `labels` with the source span of the failing operation, matching tree and VM behaviour. Span coverage threads through every JIT runtime helper (unwrap, panic-unwrap, list-get, slice, index, jpth, mget, record-field strict access, builtin dispatch, dynamic call); AOT-compiled binaries inherit the same coverage. Pre-v0.11.6 builds surfaced `{"labels":[]}` for these shapes - if you see an empty labels array on a runtime error, the binary is out of date. AOT binaries also install an async-signal-safe handler in `ilo_aot_init` that catches fatal signals (SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGABRT) and writes a single JSON line on stderr identifying the signal before the process terminates with the conventional 128+signo exit code. The diagnostic uses `ILO-R015` (AOT runtime fault). Without the handler, a hard fault inside compiled native code would leave the process with raw signal exit (e.g. 139 for SIGSEGV) and no diagnostic — agents driving ilo couldn't distinguish a clean non-zero exit from a hard fault. A SIGSEGV from an AOT binary is always a bug in ilo (codegen or runtime helper); file an issue with the source program and the JSON line. AOT binaries also install an async-signal-safe handler in `ilo_aot_init` that catches fatal signals (SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGABRT) and writes a single JSON line on stderr identifying the signal before the process terminates with the conventional 128+signo exit code. The diagnostic uses `ILO-R015` (AOT runtime fault). Without the handler, a hard fault inside compiled native code would leave the process with raw signal exit (e.g. 139 for SIGSEGV) and no diagnostic — agents driving ilo couldn't distinguish a clean non-zero exit from a hard fault. A SIGSEGV from an AOT binary is always a bug in ilo (codegen or runtime helper); file an issue with the source program and the JSON line. [Top-level program output] For a program whose entry function returns a Result, the `~`/`^` wrapper is split across streams and exit codes so shell callers do not have to strip a prefix: `~v` (Ok)=`v` (bare)=-=0 `^e` (Err)=-=`^e`=1 any non-Result=`v`=-=0 In `--json` mode the value is always wrapped (`{"schemaVersion": 1, "ok": v}` / `{"schemaVersion": 1, "error": {...}}`) and emitted to stdout; exit codes match the plain-mode table. The `schemaVersion` field was added in 0.12.1 to every CLI `--json` envelope (`run`, `graph`, `--ast`, `serv`, `tools --json`, `spec --json`) so agents can route on a single field across every command. See `JSON_OUTPUT.md` for the full audit table. `Display` on `Value::Ok` / `Value::Err` still renders `~v` / `^e` in every other context (nested values, `prnt`, REPL prompts, error messages, debug output) - only the top-level program-return print path is split. The contract applies uniformly to in-process runners (`ilo prog.ilo`, `--vm`, `--jit`) and to AOT-compiled standalone binaries from `ilo compile`. Both strip the top-level `~`/`^` wrapper on stdout, route `^e` to stderr, and use the same exit codes - output is byte-for-byte identical across every backend. **Auto-echo suppression for `prnt` + status sentinel.** When the entry function has at least one *unconditional top-level* `prnt` call AND the tail expression is a bare wrapped string literal (`~"text"` or `^"text"`), the top-level auto-echo is suppressed. The wrapped literal is treated as a status sentinel rather than a value the caller wants captured. Without this rule, a function shaped like `m>R t t;prnt "report";~"ok"` emits `report\nok\n` on stdout and shell callers piping the output have to strip the trailing `ok`. The rule does NOT fire when (a) there is no `prnt` in the body — `m>R t t;~"ok"` still prints `ok` because the wrapped literal IS the program's output (the `cli-tasks-save-ok.ilo` pattern); (b) the `prnt` is nested inside a guard, loop, or match arm — those are conditional and the `prnt` may never run; (c) the tail is `~v` where `v` is a binding or call — that's a real return value. `^"text"` errors still go to stderr with exit 1; the suppression rule never silently swallows an Err. Pinned by `tests/regression_tilde_str_noecho.rs` and `examples/tilde-str-noecho.ilo`. [Idiomatic hints] After successful execution, ilo scans the source for non-canonical forms and emits hints to stderr: hint: `==` → `=` saves 1 char (both mean equality in ilo) hint: `length` → `len` (canonical short form) Builtin alias hints appear at most once per program (the first long-form name found). In JSON mode, hints appear as `{"hints":["..."]}` on stderr. Suppress with `--no-hints` / `-nh`. [CLI invocation] ilo 'code' [args...] -- inline program; default-runs the entry function ilo program.ilo [func] [args] -- if `func` is omitted and the file declares exactly one function, that function runs automatically ilo run program.ilo [func] [a] -- verb form; same dispatch as the bare positional ilo check program.ilo [--json] [--strict] -- run the verifier without executing (exit 0 = clean; --strict treats warnings as exit-code errors) ilo build program.ilo -o out -- AOT compile to a standalone binary (alias for `compile`) ilo program.ilo --ast -- print parsed AST as JSON and exit ilo --explain ILO-T004 -- print error explanation and exit ilo help ai -- compact AI spec to stdout (= contents of ai.txt) ilo serv -- long-lived JSON request/response loop ilo --max-ast-depth N -- cap parser nesting at N (default 256; protects `ilo serv` and other untrusted-source paths from DoS payloads, raises ILO-P103) **Verb-noun aliases.** `ilo run ` is an exact alias for the bare positional `ilo ` - same dispatch, same engine selection, same arg handling. `ilo build -o ` is an alias for `ilo compile -o `. Both exist to match the toolchain conventions used by `cargo`, `go`, and `zero` so agents and humans can guess the command name without consulting the help text. The bare positional forms remain fully supported for backwards compatibility; nothing has been removed. **`ilo check`.** Standalone verifier invocation: lex, parse, resolve imports, and run the type verifier without proceeding to bytecode compilation or execution. Exit code 0 means the program is well-typed and verifier-clean; exit code 1 means at least one diagnostic was emitted on stderr. The output mode follows the global flags (`--json` for NDJSON diagnostics, `--text` for plain text, `--ansi` for coloured output; auto-detected when omitted - JSON when stderr is not a TTY, ANSI otherwise). `ilo check` works on both files and inline code; on a syntactically-broken input it still reports the parse error rather than crashing, which is important for editor and agent loops that may feed in half-written programs. **`ilo check --strict`.** Treats every warning-severity diagnostic (ILO-T032 bare `fmt`, ILO-T033 bare `mset` / `+=` / `mdel`, ILO-W002 `@x (jpar! …){…}` steering to `jpar-list!`, future warning codes) as a hard exit-code failure. The diagnostic stream itself is unchanged: warnings still emit with `severity: "warning"` in the JSON output, so editor integrations that route by severity stay correct. Only the exit code is elevated. CI harnesses that gate merges on `ilo check` should use `--strict` so warnings can't slip through silently; for interactive use, the default (warnings-are-advisory) is the right behaviour. **Default-run.** Inline programs (`ilo 'code'`) and single-function files run their entry function with the remaining CLI args; no explicit function name needed. Multi-function files auto-pick a function called `main` when no positional func arg is supplied. The same heuristic applies to the explicit engine flags - `--vm` and `--jit` both auto-pick `main` on multi-fn files, matching the default-engine behaviour. With no `main` declared, supply a function-name argument. **AOT entry-pick.** `ilo compile file.ilo -o out` (alias `ilo build`) follows the same entry-pick rules as the in-process engines: a single user-defined function is used directly; on multi-function files the entry is `main` if defined, otherwise the explicit positional `func` arg (`ilo compile file.ilo -o out run`); otherwise the compile fails with `ILO-E801` and exits 1 without writing a binary. AOT does not fall back to "first declared function" - that historical default produced binaries that called the wrong entry symbol and SIGSEGV'd at runtime. **Default engine.** The bytecode register VM is the default execution path. It supports every opcode (closures with Phase 2 capture, listview windows, fused len-of-filter, every modern shape), and avoids the JIT compile-and-bail cost paid by the pre-v0.11.9 Cranelift-first default whenever a program touched an opcode the JIT couldn't handle. Cranelift JIT is opt-in via `--jit`; on opt-in, the JIT runs hot numeric loops and falls back to the VM on bailout. Phase 2 captures run natively on every public backend - VM, JIT, and AOT (`ilo compile`); AOT embeds the postcard `CompiledProgram` blob into the binary's `.rodata` so dispatch helpers can re-enter the VM on user-fn callbacks the same way the in-process runners do. For long-running workloads where the JIT pays for itself, opt in explicitly; for most agent workloads the VM is the right default. **Tree-walker is internal-only.** The tree-walking interpreter is no longer user-selectable: `--run-tree` and its `--run` alias were removed from the public CLI in 0.12.1 (they now error with the unknown-flag guard). The interpreter stays in-tree as the dispatch target for HOF / regex / fmt-variadic / IO / sleep / ct / rsrt / closure-bind-ctx shapes the VM and Cranelift haven't lifted natively yet - the VM bails to it transparently for the ops listed by `is_tree_bridge_eligible` (`rgx`, `rgxall`, `rgxall1`, `rgxall-multi`, `rgxsub`, `fmt`, `fmt2`, `rd`, `rdb`, `rdjl`, `rdin`, `rdinl`, `sleep`, `lsd`, `walk`, `glob`, `dirname`, `basename`, `pathjoin`, `fsize`, `mtime`, `isfile`, `isdir`, `run`, `env-all`, `jkeys`, `tz-offset`, `ct` 2-arg and 3-arg, `rsrt` 2-arg and 3-arg, `dur-parse`, `dur-fmt`, and the closure-bind ctx variants of `map`/`flt`/`fld`/`srt`). Cross-engine parity for those shapes is pinned by `tests/regression_builtin_bridge.rs` and `tests/regression_tree_bridge_invariants.rs`. 0.13.0+ is on track for a hard drop once the bridge consumers are lifted natively and the shared runtime types (`Value`, `MapKey`, `RuntimeError`, math helpers) are extracted from `src/interpreter/` to a non-engine module. **Subcommand dispatch.** The first positional argument is interpreted as a function name when it has the shape of an ilo identifier - `[a-z][a-z0-9]*(-[a-z0-9]+)*` - so `ilo file.ilo list-orders` routes to the `list-orders` function. Args that don't match the ident shape (file paths like `/tmp/data.json`, numbers, sigils, bracketed lists, anything with a `.` or `/`) route to `main` (or the entry function) as a positional CLI arg instead. Trailing dashes (`foo-`), doubled dashes (`foo--bar`), and negative numbers (`-1`) are not idents and pass through as data. **Unknown `--flag` guard.** Any token in the positional tail matching the clean long-flag shape `--word` or `--word-with-dashes` that isn't a recognised flag is rejected upfront with `error: unrecognised flag '--'. Use 'ilo --help' for valid flags. To pass it as a literal arg, separate with '--' first.` and exit 1. This prevents `ilo main.ilo --engine tree` from silently consuming `--engine` as a positional arg (which used to surface as misleading `ILO-R012 no functions defined` or `ILO-R004 main: expected N args, got N+1`). To pass a hyphen-prefixed token through as literal data, place the `--` separator first: `ilo main.ilo -- --foo`. Anything after the first `--` is data. Tokens with `=` (`--key=val`), trailing or doubled dashes (`--foo-`, `--foo--bar`), and negative numbers (`-1`) are not clean flag shapes and pass through unchanged. **Text-typed params.** When the entry function declares a parameter of type `t`, the CLI passes the raw arg through without numeric coercion. `ilo 'f x:t>t;x' 42` returns the string `"42"`, not the number 42. **Exit codes.** A program returning `Value::Err` (or `^reason` from the entry function) exits with code 1 and prints the err payload on stderr. `~v` (Ok) and any non-Result return value exit 0. Verifier and parser errors exit 2. **List args from the CLI.** Comma-separated args become `L n` or `L t` automatically: `ilo 'f xs:L n>n;sum xs' 1,2,3`. +ERROR DIAGNOSTICS: ilo verifies programs before execution and reports errors with stable codes, source context, and suggestions. [Error codes] Every error has a stable `ILO-` code. The letter is the namespace - the phase that raised the diagnostic - so agents and tools can route on prefix without parsing the message. Numeric ranges are reserved per namespace with generous gaps, so future codes slot in cleanly and the contract is forward-compatible. `ILO-L000-099`=L=Lexer / tokenisation=active `ILO-P100-199`=P=Parser / syntax=active `ILO-N200-299`=N=Names / resolution=reserved `ILO-I300-399`=I=Imports=reserved `ILO-T400-499`=T=Types=active `ILO-V500-599`=V=Verifier (post-type checks)=reserved `ILO-R600-699`=R=Runtime=active `ILO-D700-799`=D=Deprecation warnings=reserved `ILO-E800-899`=E=Engine-specific limitations=reserved `ILO-S900-999`=S=Skill / spec system=reserved **Historical codes.** ilo shipped with flat numbering inside each namespace - `ILO-L001`, `ILO-P001`, `ILO-T001`, `ILO-R001`, `ILO-W001`, all starting at 001. Those codes remain valid forever. The hundreds-block allocation above applies to new codes from now on, and a cross-engine regression test asserts every emitted code lives in a documented range. **Reserved namespaces.** `N`, `I`, `V`, `D`, `E`, `S` carry no codes today. They are forward declarations so the first code in each category slots into its own range without conflicting with the active namespaces. `D` is earmarked for deprecation warnings: when a feature is scheduled for removal it emits an `ILO-D7xx` warning at compile time without failing the build. Use `--explain` to see a detailed explanation: ilo --explain ILO-T004 [Source context] Errors point at the relevant source location with a caret: error[ILO-T005]: undefined function 'foo' (called with 1 args) --> 1:9 1 | f x:n>n;foo x = note: in function 'f' = suggestion: did you mean 'f'? Parser, verifier, and runtime errors all show source spans. The verifier uses the enclosing statement span as the best available location for expression-level errors. [Suggestions] The verifier provides context-aware hints: **Did you mean?** - Levenshtein-based suggestions for undefined variables, functions, fields, and types **Type conversion** - suggests `str` for n→t, `num` for t→n **Missing arms** - lists uncovered match patterns with types **Arity** - shows expected parameter signature [Error output formats] --ansi / -a ANSI colour (default for TTY) --text / -t Plain text (no colour) --json / -j JSON (default for piped output) --no-hints / -nh Suppress idiomatic hints --silent / -s Suppress program stdout (mainly for --bench; see below) NO_COLOR=1 Disable colour (same as --text) **`--silent` / `-s`.** Suppresses the program's own stdout (`prnt`, `prnv`, `jprn`, etc.) for the duration of execution. Designed for `ilo --bench`: combined with `--json` it lets agent harnesses (e.g. persona cost rollup) consume the bench JSON envelope on stdout without it being drowned in the benchmarked function's own output. Stderr is never silenced, so genuine errors still surface. Diagnostic output (including the bench JSON envelope and the human-readable bench summary block) is always emitted on stdout regardless of `--silent` — the flag only redirects program-level prints. Unix only (no-op on Windows for the program-stdout half; bench output still reaches stdout there). JSON error output follows a structured schema with `severity`, `code`, `message`, `labels` (with spans), `notes`, and `suggestion` fields. Runtime errors raised from the Cranelift JIT (opt-in via `--jit`) populate `labels` with the source span of the failing operation, matching tree and VM behaviour. Span coverage threads through every JIT runtime helper (unwrap, panic-unwrap, list-get, slice, index, jpth, mget, record-field strict access, builtin dispatch, dynamic call); AOT-compiled binaries inherit the same coverage. Pre-v0.11.6 builds surfaced `{"labels":[]}` for these shapes - if you see an empty labels array on a runtime error, the binary is out of date. AOT binaries also install an async-signal-safe handler in `ilo_aot_init` that catches fatal signals (SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGABRT) and writes a single JSON line on stderr identifying the signal before the process terminates with the conventional 128+signo exit code. The diagnostic uses `ILO-R015` (AOT runtime fault). Without the handler, a hard fault inside compiled native code would leave the process with raw signal exit (e.g. 139 for SIGSEGV) and no diagnostic — agents driving ilo couldn't distinguish a clean non-zero exit from a hard fault. A SIGSEGV from an AOT binary is always a bug in ilo (codegen or runtime helper); file an issue with the source program and the JSON line. AOT binaries also install an async-signal-safe handler in `ilo_aot_init` that catches fatal signals (SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGABRT) and writes a single JSON line on stderr identifying the signal before the process terminates with the conventional 128+signo exit code. The diagnostic uses `ILO-R015` (AOT runtime fault). Without the handler, a hard fault inside compiled native code would leave the process with raw signal exit (e.g. 139 for SIGSEGV) and no diagnostic — agents driving ilo couldn't distinguish a clean non-zero exit from a hard fault. A SIGSEGV from an AOT binary is always a bug in ilo (codegen or runtime helper); file an issue with the source program and the JSON line. [Top-level program output] For a program whose entry function returns a Result, the `~`/`^` wrapper is split across streams and exit codes so shell callers do not have to strip a prefix: `~v` (Ok)=`v` (bare)=-=0 `^e` (Err)=-=`^e`=1 any non-Result=`v`=-=0 In `--json` mode the value is always wrapped (`{"schemaVersion": 1, "ok": v}` / `{"schemaVersion": 1, "error": {...}}`) and emitted to stdout; exit codes match the plain-mode table. The `schemaVersion` field was added in 0.12.1 to every CLI `--json` envelope (`run`, `graph`, `--ast`, `serv`, `tools --json`, `spec --json`) so agents can route on a single field across every command. See `JSON_OUTPUT.md` for the full audit table. `Display` on `Value::Ok` / `Value::Err` still renders `~v` / `^e` in every other context (nested values, `prnt`, REPL prompts, error messages, debug output) - only the top-level program-return print path is split. The contract applies uniformly to in-process runners (`ilo prog.ilo`, `--vm`, `--jit`) and to AOT-compiled standalone binaries from `ilo compile`. Both strip the top-level `~`/`^` wrapper on stdout, route `^e` to stderr, and use the same exit codes - output is byte-for-byte identical across every backend. **Auto-echo suppression for `prnt` + status sentinel.** When the entry function has at least one *unconditional top-level* `prnt` call AND the tail expression is a bare wrapped string literal (`~"text"` or `^"text"`), the top-level auto-echo is suppressed. The wrapped literal is treated as a status sentinel rather than a value the caller wants captured. Without this rule, a function shaped like `m>R t t;prnt "report";~"ok"` emits `report\nok\n` on stdout and shell callers piping the output have to strip the trailing `ok`. The rule does NOT fire when (a) there is no `prnt` in the body — `m>R t t;~"ok"` still prints `ok` because the wrapped literal IS the program's output (the `cli-tasks-save-ok.ilo` pattern); (b) the `prnt` is nested inside a guard, loop, or match arm — those are conditional and the `prnt` may never run; (c) the tail is `~v` where `v` is a binding or call — that's a real return value. `^"text"` errors still go to stderr with exit 1; the suppression rule never silently swallows an Err. Pinned by `tests/regression_tilde_str_noecho.rs` and `examples/tilde-str-noecho.ilo`. [Idiomatic hints] After successful execution, ilo scans the source for non-canonical forms and emits hints to stderr: hint: `==` → `=` saves 1 char (both mean equality in ilo) hint: `length` → `len` (canonical short form) Builtin alias hints appear at most once per program (the first long-form name found). In JSON mode, hints appear as `{"hints":["..."]}` on stderr. Suppress with `--no-hints` / `-nh`. [CLI invocation] ilo 'code' [args...] -- inline program; default-runs the entry function ilo program.ilo [func] [args] -- if `func` is omitted and the file declares exactly one function, that function runs automatically ilo run program.ilo [func] [a] -- verb form; same dispatch as the bare positional ilo check program.ilo [--json] [--strict] -- run the verifier without executing (exit 0 = clean; --strict treats warnings as exit-code errors) ilo build program.ilo -o out -- AOT compile to a standalone binary (alias for `compile`) ilo program.ilo --ast -- print parsed AST as JSON and exit ilo --explain ILO-T004 -- print error explanation and exit ilo help ai -- compact AI spec to stdout (= contents of ai.txt) ilo serv -- long-lived JSON request/response loop ilo --max-ast-depth N -- cap parser nesting at N (default 256; protects `ilo serv` and other untrusted-source paths from DoS payloads, raises ILO-P103) ilo --max-runtime SECS -- cap wall-clock runtime at SECS (default 60; 0 disables; raises ILO-R016) ilo --max-output-bytes BYTES -- cap stdout output at BYTES (default ~100 MB; 0 disables; raises ILO-R017) **Production-safety guards (`ILO-R016`, `ILO-R017`).** `ilo run` caps wall-clock runtime at 60 s and stdout output at ~100 MB by default. A runaway loop (missing increment, recursion with no base case) aborts with `ILO-R016` once the time budget hits, instead of burning CPU forever; a `prnt` loop without termination aborts with `ILO-R017` once the byte budget hits, instead of filling the agent transcript with megabytes of garbage. Both guards write a structured diagnostic to stderr and exit 1. Defaults are well above any legitimate program (real agent tasks finish under 10 s and produce kilobytes); raise with `--max-runtime SECS` / `--max-output-bytes BYTES`, set either to `0` to disable. The guards were installed by the mandelbrot persona report (2026-05-20) which spun in an infinite loop and wrote 165 MB of stdout before the harness intervened. **Verb-noun aliases.** `ilo run ` is an exact alias for the bare positional `ilo ` - same dispatch, same engine selection, same arg handling. `ilo build -o ` is an alias for `ilo compile -o `. Both exist to match the toolchain conventions used by `cargo`, `go`, and `zero` so agents and humans can guess the command name without consulting the help text. The bare positional forms remain fully supported for backwards compatibility; nothing has been removed. **`ilo check`.** Standalone verifier invocation: lex, parse, resolve imports, and run the type verifier without proceeding to bytecode compilation or execution. Exit code 0 means the program is well-typed and verifier-clean; exit code 1 means at least one diagnostic was emitted on stderr. The output mode follows the global flags (`--json` for NDJSON diagnostics, `--text` for plain text, `--ansi` for coloured output; auto-detected when omitted - JSON when stderr is not a TTY, ANSI otherwise). `ilo check` works on both files and inline code; on a syntactically-broken input it still reports the parse error rather than crashing, which is important for editor and agent loops that may feed in half-written programs. **`ilo check --strict`.** Treats every warning-severity diagnostic (ILO-T032 bare `fmt`, ILO-T033 bare `mset` / `+=` / `mdel`, ILO-W002 `@x (jpar! …){…}` steering to `jpar-list!`, future warning codes) as a hard exit-code failure. The diagnostic stream itself is unchanged: warnings still emit with `severity: "warning"` in the JSON output, so editor integrations that route by severity stay correct. Only the exit code is elevated. CI harnesses that gate merges on `ilo check` should use `--strict` so warnings can't slip through silently; for interactive use, the default (warnings-are-advisory) is the right behaviour. **Default-run.** Inline programs (`ilo 'code'`) and single-function files run their entry function with the remaining CLI args; no explicit function name needed. Multi-function files auto-pick a function called `main` when no positional func arg is supplied. The same heuristic applies to the explicit engine flags - `--vm` and `--jit` both auto-pick `main` on multi-fn files, matching the default-engine behaviour. With no `main` declared, supply a function-name argument. **AOT entry-pick.** `ilo compile file.ilo -o out` (alias `ilo build`) follows the same entry-pick rules as the in-process engines: a single user-defined function is used directly; on multi-function files the entry is `main` if defined, otherwise the explicit positional `func` arg (`ilo compile file.ilo -o out run`); otherwise the compile fails with `ILO-E801` and exits 1 without writing a binary. AOT does not fall back to "first declared function" - that historical default produced binaries that called the wrong entry symbol and SIGSEGV'd at runtime. **Default engine.** The bytecode register VM is the default execution path. It supports every opcode (closures with Phase 2 capture, listview windows, fused len-of-filter, every modern shape), and avoids the JIT compile-and-bail cost paid by the pre-v0.11.9 Cranelift-first default whenever a program touched an opcode the JIT couldn't handle. Cranelift JIT is opt-in via `--jit`; on opt-in, the JIT runs hot numeric loops and falls back to the VM on bailout. Phase 2 captures run natively on every public backend - VM, JIT, and AOT (`ilo compile`); AOT embeds the postcard `CompiledProgram` blob into the binary's `.rodata` so dispatch helpers can re-enter the VM on user-fn callbacks the same way the in-process runners do. For long-running workloads where the JIT pays for itself, opt in explicitly; for most agent workloads the VM is the right default. **Tree-walker is internal-only.** The tree-walking interpreter is no longer user-selectable: `--run-tree` and its `--run` alias were removed from the public CLI in 0.12.1 (they now error with the unknown-flag guard). The interpreter stays in-tree as the dispatch target for HOF / regex / fmt-variadic / IO / sleep / ct / rsrt / closure-bind-ctx shapes the VM and Cranelift haven't lifted natively yet - the VM bails to it transparently for the ops listed by `is_tree_bridge_eligible` (`rgx`, `rgxall`, `rgxall1`, `rgxall-multi`, `rgxsub`, `fmt`, `fmt2`, `rd`, `rdb`, `rdjl`, `rdin`, `rdinl`, `sleep`, `lsd`, `walk`, `glob`, `dirname`, `basename`, `pathjoin`, `fsize`, `mtime`, `isfile`, `isdir`, `run`, `env-all`, `jkeys`, `tz-offset`, `ct` 2-arg and 3-arg, `rsrt` 2-arg and 3-arg, `dur-parse`, `dur-fmt`, and the closure-bind ctx variants of `map`/`flt`/`fld`/`srt`). Cross-engine parity for those shapes is pinned by `tests/regression_builtin_bridge.rs` and `tests/regression_tree_bridge_invariants.rs`. 0.13.0+ is on track for a hard drop once the bridge consumers are lifted natively and the shared runtime types (`Value`, `MapKey`, `RuntimeError`, math helpers) are extracted from `src/interpreter/` to a non-engine module. **Subcommand dispatch.** The first positional argument is interpreted as a function name when it has the shape of an ilo identifier - `[a-z][a-z0-9]*(-[a-z0-9]+)*` - so `ilo file.ilo list-orders` routes to the `list-orders` function. Args that don't match the ident shape (file paths like `/tmp/data.json`, numbers, sigils, bracketed lists, anything with a `.` or `/`) route to `main` (or the entry function) as a positional CLI arg instead. Trailing dashes (`foo-`), doubled dashes (`foo--bar`), and negative numbers (`-1`) are not idents and pass through as data. **Unknown `--flag` guard.** Any token in the positional tail matching the clean long-flag shape `--word` or `--word-with-dashes` that isn't a recognised flag is rejected upfront with `error: unrecognised flag '--'. Use 'ilo --help' for valid flags. To pass it as a literal arg, separate with '--' first.` and exit 1. This prevents `ilo main.ilo --engine tree` from silently consuming `--engine` as a positional arg (which used to surface as misleading `ILO-R012 no functions defined` or `ILO-R004 main: expected N args, got N+1`). To pass a hyphen-prefixed token through as literal data, place the `--` separator first: `ilo main.ilo -- --foo`. Anything after the first `--` is data. Tokens with `=` (`--key=val`), trailing or doubled dashes (`--foo-`, `--foo--bar`), and negative numbers (`-1`) are not clean flag shapes and pass through unchanged. **Text-typed params.** When the entry function declares a parameter of type `t`, the CLI passes the raw arg through without numeric coercion. `ilo 'f x:t>t;x' 42` returns the string `"42"`, not the number 42. **Exit codes.** A program returning `Value::Err` (or `^reason` from the entry function) exits with code 1 and prints the err payload on stderr. `~v` (Ok) and any non-Result return value exit 0. Verifier and parser errors exit 2. **List args from the CLI.** Comma-separated args become `L n` or `L t` automatically: `ilo 'f xs:L n>n;sum xs' 1,2,3`. FORMATTER: Dense output is the default - newlines are for humans, not agents. No flag needed for dense format: ilo 'code' Dense wire format (default) ilo 'code' --dense / -d Same, explicit ilo 'code' --expanded / -e Expanded human format (for code review) [Dense format] Single line per declaration, minimal whitespace. Operators glue to first operand: cls sp:n>t;>=sp 1000{"gold"};>=sp 500{"silver"};"bronze" [Expanded format] Multi-line with 2-space indentation. Operators spaced from operands: cls sp:n > t >= sp 1000 { "gold" } >= sp 500 { "silver" } "bronze" Dense format is canonical - `dense(parse(dense(parse(src)))) == dense(parse(src))`. COMPLETE EXAMPLE: tool get-user"Retrieve user by ID" uid:t>R profile t timeout:5,retry:2 tool send-email"Send an email" to:t subject:t body:t>R _ t timeout:10,retry:1 type profile{id:t;name:t;email:t;verified:b} ntf uid:t msg:t>R _ t;get-user uid;?{^e:^+"Lookup failed: "e;~d:!d.verified{^"Email not verified"};send-email d.email "Notification" msg;?{^e:^+"Send failed: "e;~_:~_}} [Recursive Example] Factorial and Fibonacci as standalone functions: fac n:n>n;<=n 1 1;r=fac -n 1;*n r fib n:n>n;<=n 1 n;a=fib -n 1;b=fib -n 2;+a b diff --git a/examples/runtime-guard.ilo b/examples/runtime-guard.ilo new file mode 100644 index 00000000..19e5ccb3 --- /dev/null +++ b/examples/runtime-guard.ilo @@ -0,0 +1,17 @@ +-- runtime-guard: `ilo run` caps wall-clock at 60 s (default) and stdout at +-- ~100 MB (default). Override with `--max-runtime SECS` and +-- `--max-output-bytes BYTES`. Set either to 0 to disable. +-- +-- Hitting the runtime cap aborts with ILO-R016; hitting the output cap +-- aborts with ILO-R017. Both are pure safety nets, a well-behaved program +-- never sees them. The mandelbrot persona run that surfaced this guard +-- missed a loop increment and produced 165 MB of stdout in an infinite +-- loop before the harness killed it; the cap turns that into a structured +-- error the agent can learn from instead. + +triangle n:n>n;s=0;i=0;wh n;r=triangle 10;r + +-- run: main +-- out: 45 diff --git a/skills/ilo/ilo-agent.md b/skills/ilo/ilo-agent.md index b897b349..c4a0a165 100644 --- a/skills/ilo/ilo-agent.md +++ b/skills/ilo/ilo-agent.md @@ -54,6 +54,10 @@ AOT-compiled binaries (`ilo compile`) follow the same contract byte-for-byte. Parser nesting is capped at 256 by default — guards `ilo serv` and any other context that compiles untrusted source against `((((...((1+1))))...))` DoS payloads that would otherwise blow the parser stack. Hand-written ilo rarely exceeds depth 10. Override with `--max-ast-depth N` on `ilo`, `ilo run`, `ilo check`, `ilo build`, or `ilo serv` when a real program needs more. Hitting the cap surfaces as `ILO-P103`. +## Runtime + output caps + +`ilo run` caps wall-clock runtime at 60 s and stdout output at ~100 MB by default. A runaway loop aborts with `ILO-R016` (time) or `ILO-R017` (output) instead of spinning forever or filling the transcript with megabytes of garbage. Override with `--max-runtime SECS` and `--max-output-bytes BYTES`; set either to `0` to disable. If you hit either code, check loop variables increment and recursion has a base case — that's the cause 95% of the time. + ## Branching Failures / repair: `ilo-edit-loop`. Runnable patterns: `ilo-examples`. Tools: `ilo-tools`. Engine pick: `ilo-engines`. diff --git a/skills/ilo/ilo-errors.md b/skills/ilo/ilo-errors.md index e968fe52..4dab0c25 100644 --- a/skills/ilo/ilo-errors.md +++ b/skills/ilo/ilo-errors.md @@ -37,6 +37,8 @@ description: Use this when reading ILO-XXXX error codes or fixing failures. List - **R001 div-by-zero** - guard with `=b 0 ^"..."`. - **R004 wrong main arity** - CLI args don't match signature. - **R012 no functions defined** - typo'd flag swallowed as positional. +- **R016 wall-clock runtime exceeded** - `ilo run` killed the program at the 60 s (default) budget. Almost always an infinite loop - check loop variables increment and recursion has a base case. Raise with `--max-runtime SECS` if legitimate. +- **R017 stdout output exceeded** - `ilo run` killed the program at the ~100 MB (default) stdout budget. Usually a `prnt` call inside an unbounded loop. Raise with `--max-output-bytes BYTES` if legitimate. - **R020 file not found** - check path or `env "HOME"`. - **R030 http error** - non-2xx or network. Match `^e`. - **R040 json parse error** - bad `jpar` input. Match `^e`. diff --git a/src/cli/args.rs b/src/cli/args.rs index 41399bb7..30dedb8e 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -60,6 +60,20 @@ pub struct Global { /// program needs deeper nesting. #[arg(long = "max-ast-depth", global = true)] pub max_ast_depth: Option, + + /// Wall-clock budget for `ilo run` in seconds. Default 60. Set to 0 to + /// disable. A runaway loop (missing increment, recursion with no base + /// case) aborts with `ILO-R016` once the budget is hit instead of + /// burning CPU and producing megabytes of useless stdout. + #[arg(long = "max-runtime", global = true)] + pub max_runtime: Option, + + /// Maximum stdout bytes for `ilo run`. Default ~100 MB. Set to 0 to + /// disable. A loop calling `prnt` without termination aborts with + /// `ILO-R017` once the budget is hit, instead of filling the agent + /// transcript with garbage. + #[arg(long = "max-output-bytes", global = true)] + pub max_output_bytes: Option, } #[derive(Subcommand, Debug)] @@ -915,6 +929,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; // In test environment stderr is typically not a TTY → should return Json. // We can't reliably test the TTY branch, but we can test that explicit_json @@ -939,6 +955,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; assert!(g.explicit_json()); assert_eq!(g.output_mode(), OutputMode::Json); @@ -953,6 +971,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; assert!(!g.explicit_json()); assert_eq!(g.output_mode(), OutputMode::Text); @@ -967,6 +987,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; assert!(!g.explicit_json()); assert_eq!(g.output_mode(), OutputMode::Ansi); diff --git a/src/diagnostic/registry.rs b/src/diagnostic/registry.rs index 43eea0ab..b511754e 100644 --- a/src/diagnostic/registry.rs +++ b/src/diagnostic/registry.rs @@ -1491,6 +1491,59 @@ A hard fault from an AOT binary is always a bug in ilo itself — either a codegen issue in the Cranelift AOT backend, or a missing runtime check that the other engines apply. Please file an issue with the source program and the JSON diagnostic. +"#, + }, + ErrorEntry { + code: "ILO-R016", + short: "wall-clock runtime budget exceeded", + long: r#"## ILO-R016: wall-clock runtime budget exceeded + +`ilo run` aborted because the program ran for longer than the +configured wall-clock budget (default 60 s). The watchdog thread +fires this when `elapsed > --max-runtime SECS`, writes a structured +diagnostic to stderr, and exits with code 1. + +By far the most common cause is an infinite loop: a `wh` body that +doesn't update its loop variable, or a recursion with no base case. +The mandelbrot persona run that surfaced this guard missed a +`col=col+1` increment and would have spun forever - the cap turns +that into a clear signal the agent can act on. + +Override with `--max-runtime N` (seconds; 0 disables) when a +legitimate program needs longer. Long-running batch jobs and +training loops are the normal reason to bump or disable it. + +``` +ilo --max-runtime 300 main.ilo -- allow 5 minutes +ilo --max-runtime 0 main.ilo -- disable the cap +``` +"#, + }, + ErrorEntry { + code: "ILO-R017", + short: "stdout output budget exceeded", + long: r#"## ILO-R017: stdout output budget exceeded + +`ilo run` aborted because the program wrote more bytes to stdout +than the configured budget (default ~100 MB). Every `prnt` call in +every engine (tree, VM, Cranelift JIT) charges its output against +the budget; when the total exceeds `--max-output-bytes`, the next +write triggers a structured diagnostic to stderr and exits 1. + +The most common cause is a loop calling `prnt` without termination +or without backing off: an unbounded `wh` body, a recursion with +no base case, or a missing increment on the loop variable. The +budget keeps a runaway from filling disk or the agent transcript +with megabytes of useless output before anyone notices. + +Override with `--max-output-bytes N` (bytes; 0 disables) when a +legitimate program produces a lot of output - typically structured +data dumps, log replay, or a code-generation pipeline. + +``` +ilo --max-output-bytes 1073741824 main.ilo -- raise to 1 GB +ilo --max-output-bytes 0 main.ilo -- disable the cap +``` "#, }, ErrorEntry { diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index bb45f72b..ec16d150 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -5406,7 +5406,11 @@ fn call_function(env: &mut Env, name: &str, args: Vec) -> Result { .into_iter() .next() .expect("prnt: arity=1 guaranteed by caller"); - println!("{v}"); + let s = format!("{v}"); + // +1 for the trailing newline `println!` adds. Charging it keeps the + // byte budget honest against a `wh true{prnt 0}` runaway. + crate::runtime_guard::record_output(s.len() + 1); + println!("{s}"); return Ok(v); } if builtin == Some(Builtin::Jdmp) && args.len() == 1 { diff --git a/src/lib.rs b/src/lib.rs index 9b2c93f6..edec3339 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ pub mod rng; pub mod interpreter; pub mod lexer; pub mod parser; +pub mod runtime_guard; pub mod tools; pub mod verify; pub mod vm; diff --git a/src/main.rs b/src/main.rs index d4ea93a4..30957308 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2431,6 +2431,32 @@ fn load_dotenv() { load_env_file(".env"); } +/// Install the production-safety guards for an `ilo run`. Reads +/// `--max-runtime` / `--max-output-bytes` off the global flags (with the +/// defaults from `runtime_guard`) and arms the watchdog plus the output +/// counter. Called from both the explicit `Cmd::Run` arm and the bare +/// positional dispatch — both ultimately execute user code. +/// +/// A mandelbrot persona run (2026-05-20) missed a `col=col+1` loop +/// increment, produced 165 MB of stdout in an infinite loop before the +/// harness killed it, and the agent had no useful signal to learn from. +/// This installs the cap so the next runaway aborts with `ILO-R016` / +/// `ILO-R017` and a hint pointing at the cause. +fn install_runtime_guard(global: &cli::Global, mode: OutputMode) { + let secs = global + .max_runtime + .unwrap_or(ilo::runtime_guard::DEFAULT_MAX_RUNTIME_SECS); + let bytes = global + .max_output_bytes + .unwrap_or(ilo::runtime_guard::DEFAULT_MAX_OUTPUT_BYTES); + let abort_mode = if matches!(mode, OutputMode::Json) { + ilo::runtime_guard::AbortMode::Json + } else { + ilo::runtime_guard::AbortMode::Text + }; + ilo::runtime_guard::install(std::time::Duration::from_secs(secs), bytes, abort_mode); +} + fn main() { load_dotenv(); @@ -2556,6 +2582,8 @@ fn main() { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }, args: raw_args, }, @@ -2715,6 +2743,7 @@ fn dispatch_cli(cli: cli::Cli, bare_has_bin: bool) -> i32 { let explicit_json = cli.global.explicit_json(); let no_hints = cli.global.no_hints; let silent = cli.global.silent; + install_runtime_guard(&cli.global, mode); dispatch_run(r, mode, explicit_json, no_hints, silent) } None => { @@ -2727,6 +2756,10 @@ fn dispatch_cli(cli: cli::Cli, bare_has_bin: bool) -> i32 { full.extend(cli.args); full }; + // Bare-positional dispatch also executes programs (the legacy + // `ilo ''` and `ilo file.ilo` shapes), so install the guard + // here too. + install_runtime_guard(&cli.global, cli.global.output_mode()); dispatch_bare_args(args, &cli.global) } } @@ -8246,6 +8279,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }, args: vec!["f>n;1".to_string()], }; @@ -8265,6 +8300,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args(vec!["ilo".to_string(), "-ai".to_string()], &global); assert_eq!(code, 0); @@ -8281,6 +8318,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec!["ilo".to_string(), "help".to_string(), "lang".to_string()], @@ -8298,6 +8337,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec!["ilo".to_string(), "help".to_string(), "ai".to_string()], @@ -8315,6 +8356,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args(vec!["ilo".to_string(), "-h".to_string()], &global); assert_eq!(code, 0); @@ -8331,6 +8374,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; // ILO-T001 is a known error code let code = dispatch_bare_args( @@ -8353,6 +8398,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8374,6 +8421,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; // --explain without a code argument → error exit let code = dispatch_bare_args(vec!["ilo".to_string(), "--explain".to_string()], &global); @@ -8391,6 +8440,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args(vec!["ilo".to_string(), "--version".to_string()], &global); assert_eq!(code, 0); @@ -8405,6 +8456,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args(vec!["ilo".to_string(), "-V".to_string()], &global); assert_eq!(code, 0); @@ -8419,6 +8472,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args(vec!["ilo".to_string(), "-v".to_string()], &global); assert_eq!(code, 0); @@ -8435,6 +8490,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8456,6 +8513,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8486,6 +8545,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec!["ilo".to_string(), "-e".to_string(), "f>n;42".to_string()], @@ -8503,6 +8564,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; // -e with empty code string should fail let code = dispatch_bare_args( @@ -8523,6 +8586,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; // bench mode: requires a func name in rest let code = dispatch_bare_args( @@ -8549,6 +8614,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8571,6 +8638,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8597,6 +8666,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8620,6 +8691,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8641,6 +8714,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8664,6 +8739,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8685,6 +8762,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8708,6 +8787,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8733,6 +8814,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8756,6 +8839,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args( vec![ @@ -8781,6 +8866,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; // Just runs a simple program; tests that global.ansi overrides detected mode let code = dispatch_bare_args(vec!["ilo".to_string(), "f>n;42".to_string()], &global); @@ -8796,6 +8883,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args(vec!["ilo".to_string(), "f>n;42".to_string()], &global); assert_eq!(code, 0); @@ -8810,6 +8899,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; let code = dispatch_bare_args(vec!["ilo".to_string(), "f>n;42".to_string()], &global); assert_eq!(code, 0); @@ -9619,6 +9710,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }, args: vec![], }; @@ -9637,6 +9730,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }, args: vec![], }; @@ -9658,6 +9753,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }, args: vec![], }; @@ -9678,6 +9775,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }, args: vec![], }; @@ -9706,6 +9805,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }, args: vec![], }; @@ -10054,6 +10155,8 @@ mod tests { no_hints: false, silent: false, max_ast_depth: None, + max_runtime: None, + max_output_bytes: None, }; // rest has first arg = "double" which matches a function name let code = dispatch_bare_args( diff --git a/src/runtime_guard.rs b/src/runtime_guard.rs new file mode 100644 index 00000000..408ae090 --- /dev/null +++ b/src/runtime_guard.rs @@ -0,0 +1,185 @@ +//! Production-safety guards for `ilo run`. +//! +//! A persona run (mandelbrot, 2026-05-20) missed a `col=col+1` loop increment +//! and spun in an infinite loop, producing 165 MB of stdout before the harness +//! killed the process. The agent had no useful signal to learn from - the +//! transcript was just a wall of dots. This module exists so the next runaway +//! program aborts cleanly with a diagnostic (`ILO-R016` for wall-clock, +//! `ILO-R017` for stdout bytes) that names the budget, the override flag, and +//! a hint about the most likely cause. +//! +//! ## Surface +//! +//! - [`install`] - call once from `fn main` after parsing `--max-runtime` / +//! `--max-output-bytes`. Spawns the watchdog thread. +//! - [`record_output`] - call at every print site in every engine (tree, VM, +//! the small set of native `println!`s in `prnt`/result-print paths). +//! Increments a process-wide byte counter; aborts via [`abort_with`] if the +//! total exceeds the budget. +//! - [`abort_with`] - write a structured diagnostic to stderr (JSON line in +//! non-TTY contexts, plain text otherwise) and `process::exit(1)`. Async- +//! signal-safe enough for the watchdog thread. +//! +//! The defaults (60 s, ~100 MB) are high enough that no legitimate program is +//! bothered, low enough that a runaway loop gets killed inside a single agent +//! turn. +//! +//! Both budgets are off when [`install`] is not called - the library still +//! works as a library; only `ilo run` opts in. + +use std::io::Write; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::time::Duration; + +/// Default wall-clock budget for `ilo run`: 60 s. Anything longer and the +/// agent has almost certainly produced a runaway loop. CLI override: +/// `--max-runtime ` (0 disables). +pub const DEFAULT_MAX_RUNTIME_SECS: u64 = 60; + +/// Default stdout budget for `ilo run`: ~100 MB (100 * 1024 * 1024 bytes). +/// The mandelbrot persona produced 165 MB of dots before the harness +/// noticed - 100 MB stops that runaway long before it fills disk or hits +/// the agent transcript limit. CLI override: `--max-output-bytes ` +/// (0 disables). +pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 100 * 1024 * 1024; + +/// 0 = guard not installed / disabled. +static MAX_RUNTIME_MS: AtomicU64 = AtomicU64::new(0); +static MAX_OUTPUT_BYTES: AtomicU64 = AtomicU64::new(0); +static OUTPUT_BYTES_USED: AtomicU64 = AtomicU64::new(0); +static OUTPUT_MODE: AtomicUsize = AtomicUsize::new(0); // 0 ansi/text, 1 json +static ABORTED: AtomicBool = AtomicBool::new(false); + +/// Output-mode hint for [`abort_with`]. We can't depend on `OutputMode` here +/// without a circular import, so we mirror the relevant bit (json vs text). +#[derive(Copy, Clone)] +pub enum AbortMode { + Text, + Json, +} + +/// Install the guard. `max_runtime` of 0 disables the wall-clock cap; +/// `max_output_bytes` of 0 disables the output cap. +/// +/// Call once from `fn main` per `ilo run`. Safe to no-op (zero / zero) so +/// non-run subcommands skip the watchdog entirely. +pub fn install(max_runtime: Duration, max_output_bytes: u64, mode: AbortMode) { + let ms = max_runtime.as_millis().min(u64::MAX as u128) as u64; + MAX_RUNTIME_MS.store(ms, Ordering::Relaxed); + MAX_OUTPUT_BYTES.store(max_output_bytes, Ordering::Relaxed); + OUTPUT_BYTES_USED.store(0, Ordering::Relaxed); + OUTPUT_MODE.store( + match mode { + AbortMode::Text => 0, + AbortMode::Json => 1, + }, + Ordering::Relaxed, + ); + ABORTED.store(false, Ordering::Relaxed); + + if ms > 0 { + std::thread::Builder::new() + .name("ilo-runtime-watchdog".to_string()) + .spawn(move || { + let start = std::time::Instant::now(); + loop { + if ABORTED.load(Ordering::Relaxed) { + return; + } + let elapsed_ms = start.elapsed().as_millis() as u64; + if elapsed_ms >= ms { + abort_with( + "ILO-R016", + &format!( + "wall-clock runtime exceeded {} ms (--max-runtime {})", + ms, + ms / 1000 + ), + "infinite loop is the most common cause - check loop variables increment, recursion has a base case, or pass `--max-runtime N` if a legitimate program needs longer.", + ); + } + // 100 ms granularity keeps the kill latency tight without + // burning a core spinning. + std::thread::sleep(Duration::from_millis(100)); + } + }) + .expect("spawn watchdog thread"); + } +} + +/// Record `n` bytes written to stdout. Aborts via [`abort_with`] if the +/// total exceeds the configured budget. Cheap when no budget is set +/// (one relaxed atomic load). +pub fn record_output(n: usize) { + let cap = MAX_OUTPUT_BYTES.load(Ordering::Relaxed); + if cap == 0 { + return; + } + let total = OUTPUT_BYTES_USED.fetch_add(n as u64, Ordering::Relaxed) + n as u64; + if total > cap { + abort_with( + "ILO-R017", + &format!("stdout output exceeded {cap} bytes (--max-output-bytes)"), + "a loop printing without a break or increment is the most common cause - check `prnt` calls inside `wh`/`fa` bodies. raise the cap with `--max-output-bytes N` if a legitimate program needs more.", + ); + } +} + +/// Write a structured diagnostic to stderr and exit the process with code 1. +/// +/// Idempotent: the first caller wins; concurrent callers (e.g. watchdog +/// firing the same instant a print-site overflows) silently return so the +/// stderr output stays a single coherent message. +pub fn abort_with(code: &str, message: &str, hint: &str) -> ! { + if ABORTED.swap(true, Ordering::SeqCst) { + // Another thread is already shutting down - park forever, the + // first caller will exit() us. + loop { + std::thread::sleep(Duration::from_secs(60)); + } + } + let stderr = std::io::stderr(); + let mut h = stderr.lock(); + if OUTPUT_MODE.load(Ordering::Relaxed) == 1 { + let json = serde_json::json!({ + "error": { + "code": code, + "message": message, + "hint": hint, + } + }); + let _ = writeln!(h, "{json}"); + } else { + let _ = writeln!(h, "error[{code}]: {message}"); + let _ = writeln!(h, " hint: {hint}"); + } + let _ = h.flush(); + std::process::exit(1); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_output_noop_when_uninstalled() { + // Clean slate + MAX_OUTPUT_BYTES.store(0, Ordering::Relaxed); + OUTPUT_BYTES_USED.store(0, Ordering::Relaxed); + record_output(1_000_000); + assert_eq!(OUTPUT_BYTES_USED.load(Ordering::Relaxed), 0); + } + + #[test] + fn record_output_accumulates_under_budget() { + MAX_OUTPUT_BYTES.store(1024, Ordering::Relaxed); + OUTPUT_BYTES_USED.store(0, Ordering::Relaxed); + ABORTED.store(false, Ordering::Relaxed); + record_output(100); + record_output(200); + assert_eq!(OUTPUT_BYTES_USED.load(Ordering::Relaxed), 300); + // Reset so other tests don't see this state. + MAX_OUTPUT_BYTES.store(0, Ordering::Relaxed); + OUTPUT_BYTES_USED.store(0, Ordering::Relaxed); + } +} diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 073c4824..58900ea4 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -8590,7 +8590,11 @@ impl<'a> VM<'a> { let a = ((inst >> 16) & 0xFF) as usize + base; let b = ((inst >> 8) & 0xFF) as usize + base; let v = reg!(b); - println!("{}", v.to_value()); + let s = format!("{}", v.to_value()); + // +1 for the newline. Keeps the --max-output-bytes budget + // honest against a runaway `wh true{prnt 0}` loop. + crate::runtime_guard::record_output(s.len() + 1); + println!("{s}"); // passthrough: same heap value now lives in two regs, bump RC v.clone_rc(); reg_set!(a, v); @@ -18729,7 +18733,10 @@ pub(crate) extern "C" fn jit_mdel(map: u64, key: u64) -> u64 { #[unsafe(no_mangle)] pub(crate) extern "C" fn jit_prt(v: u64) -> u64 { let nv = NanVal(v); - println!("{}", nv.to_value()); + let s = format!("{}", nv.to_value()); + // +1 for the newline. Keeps --max-output-bytes honest for JIT'd loops. + crate::runtime_guard::record_output(s.len() + 1); + println!("{s}"); // passthrough — clone_rc for heap values nv.clone_rc(); v diff --git a/tests/runtime_guard.rs b/tests/runtime_guard.rs new file mode 100644 index 00000000..592716f2 --- /dev/null +++ b/tests/runtime_guard.rs @@ -0,0 +1,154 @@ +//! Integration tests for the `ilo run` production-safety guards. +//! +//! Origin: mandelbrot persona (2026-05-20) ran an infinite loop and produced +//! 165 MB of stdout before the harness killed it. These tests pin the +//! `--max-runtime` (`ILO-R016`) and `--max-output-bytes` (`ILO-R017`) guards +//! across every engine the bare-positional dispatch can pick. +//! +//! Each test uses a subprocess because the guard calls `process::exit(1)` +//! after writing a structured diagnostic to stderr - there is no graceful +//! return path, by design (the alternative is to thread a cancellation +//! token through every engine's eval loop, which is a much bigger change +//! for a guard that only fires on already-broken programs). + +use std::process::Command; +use std::time::{Duration, Instant}; + +fn ilo() -> Command { + Command::new(env!("CARGO_BIN_EXE_ilo")) +} + +/// Run ilo with the given args; return (success, stdout, stderr, wall-clock). +fn run_args(args: &[&str]) -> (bool, String, String, Duration) { + let start = Instant::now(); + let out = ilo() + .args(args) + .output() + .unwrap_or_else(|e| panic!("failed to spawn ilo: {e}")); + let elapsed = start.elapsed(); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + (out.status.success(), stdout, stderr, elapsed) +} + +// ── --max-runtime ───────────────────────────────────────────────────────────── + +/// Default engine (VM): infinite loop without output aborts on the runtime +/// budget with ILO-R016. We pass `--max-runtime 1` so the test finishes +/// quickly; the watchdog polls at 100 ms granularity, so the real wall +/// clock should be under ~1.5 s with plenty of slack for CI. +#[test] +fn infinite_loop_default_engine_aborts_on_max_runtime() { + let (ok, _stdout, stderr, elapsed) = run_args(&[ + "--max-runtime", + "1", + "--json", + "main>n;n=0;wh true{n=+n 1};n", + ]); + assert!(!ok, "should abort with non-zero exit"); + assert!( + stderr.contains("ILO-R016"), + "expected ILO-R016 in stderr, got: {stderr}" + ); + assert!( + stderr.contains("--max-runtime"), + "diagnostic should name the override flag, got: {stderr}" + ); + assert!( + elapsed < Duration::from_secs(5), + "watchdog should kill within ~1.5 s, took {elapsed:?}" + ); +} + +/// JIT engine path. Without explicit cap on this build, infinite loops +/// would otherwise spin forever. Note we pin to the cranelift feature so +/// this only runs on feature-enabled builds (the default). +#[cfg(feature = "cranelift")] +#[test] +fn infinite_loop_jit_engine_aborts_on_max_runtime() { + let (ok, _stdout, stderr, elapsed) = run_args(&[ + "--max-runtime", + "1", + "--json", + "main>n;n=0;wh true{n=+n 1};n", + "--jit", + ]); + assert!(!ok); + assert!(stderr.contains("ILO-R016"), "stderr: {stderr}"); + assert!(elapsed < Duration::from_secs(5), "elapsed={elapsed:?}"); +} + +// ── --max-output-bytes ──────────────────────────────────────────────────────── + +/// Default engine (VM): a loop printing without termination aborts with +/// ILO-R017 once the byte budget is exhausted. Using 200 bytes so the +/// test takes microseconds. +/// +/// The body returns `0` after the (unreachable) loop so `>n` typechecks - +/// the loop itself spins forever in practice, but the verifier doesn't +/// know that. +#[test] +fn runaway_prnt_loop_default_engine_aborts_on_max_output_bytes() { + let (ok, stdout, stderr, _elapsed) = run_args(&[ + "--max-output-bytes", + "200", + "--json", + "main>n;n=0;wh true{prnt n;n=+n 1};0", + ]); + assert!(!ok, "should abort with non-zero exit"); + assert!( + stderr.contains("ILO-R017"), + "expected ILO-R017 in stderr, got: {stderr}" + ); + assert!( + stderr.contains("--max-output-bytes"), + "diagnostic should name the override flag, got: {stderr}" + ); + // stdout should have a bit of content (the partial print before the + // overflow) but be capped well below the runaway baseline. The exact + // amount depends on the timing of the overflow check; we just assert + // it's bounded. + assert!( + stdout.len() < 4096, + "stdout should be capped, got {} bytes", + stdout.len() + ); +} + +/// JIT engine path. Calls go through `jit_prt` which also accounts. +#[cfg(feature = "cranelift")] +#[test] +fn runaway_prnt_loop_jit_engine_aborts_on_max_output_bytes() { + let (ok, _stdout, stderr, _elapsed) = run_args(&[ + "--max-output-bytes", + "200", + "--json", + "main>n;n=0;wh true{prnt n;n=+n 1};0", + "--jit", + ]); + assert!(!ok); + assert!(stderr.contains("ILO-R017"), "stderr: {stderr}"); +} + +// ── happy path: well-behaved programs are unaffected ────────────────────────── + +/// A well-behaved program well under both budgets runs to completion. +/// Guards a future change from accidentally tightening the cap for normal +/// use. +#[test] +fn well_behaved_program_unaffected_by_default_guards() { + let (ok, stdout, stderr, _elapsed) = run_args(&["main>n;prnt 42;0"]); + assert!(ok, "stderr: {stderr}"); + assert!(stdout.contains("42"), "stdout: {stdout}"); +} + +/// Setting `--max-runtime 0` disables the wall-clock cap entirely. Useful +/// for batch/training runs that the operator already knows are long. We +/// can't actually verify "ran forever" so we settle for "ran a tight loop +/// 100k times under 10 s with no abort". +#[test] +fn max_runtime_zero_disables_guard() { + let (ok, _stdout, stderr, _elapsed) = + run_args(&["--max-runtime", "0", "main>n;n=0;wh