Resource limits
Adversarial or accidental input should degrade into a catchable error, not exhaust the machine. Four bounds do that.
| variable | bounds | default |
|---|---|---|
ECKO_LIMIT_DEPTH | call and recursion depth | 2000 |
ECKO_LIMIT_PARSE_DEPTH | nesting the parser accepts | 128 |
ECKO_LIMIT_STEPS | loop and step budget | unlimited (opt in) |
ECKO_LIMIT_ALLOC | bytes in one value built from a count, or one piece read from a stream | 256 MiB |
Recursion
recursion limit exceeded (2000 calls deep) - a function may be calling
itself without a base case (raise ECKO_LIMIT_DEPTH if this is intended)
The message names the likely cause, because the likely cause is a missing base case. Genuinely deep recursion is legitimate, and raising the limit is the right answer there - but a runaway recursion is far more common, and a stack overflow that kills the process gives you nothing to debug.
Parse depth
ECKO_LIMIT_PARSE_DEPTH (default 128) bounds nesting at parse time, which matters for input you did not write. Deeply nested JSON is a standard way to make a recursive-descent parser blow the stack; here it is a catchable error before evaluation starts.
The bound is on the expression tree's height, whatever shape produced it, so chains count as nesting: every operator in a + b + c, every stage in x |> f |> g, every call in m.a().b() and every arm of an else if chain adds a level. Brackets do not; ((x)) is as deep as x.
128 is far beyond anything hand-written - the longest chain in any shipped Ecko program is 54 terms. If you hit it on your own source, the code is telling you something; a generated program that genuinely needs more can raise the variable.
Data nesting
ECKO_LIMIT_PARSE_DEPTH bounds nesting in source. The values a program builds while it runs are not bounded, and almost nothing you do to them is bounded either: rendering, comparing, json.encode and releasing walk nesting iteratively, so depth costs memory and nothing else. The exception is below.
mut x = []
for i in 0..50000 {
x = [x]
}
print(len(string(x)))
Printing that used to kill the process - "fatal runtime error: stack overflow", exit 134, and a try/catch around it made no difference, because a stack overflow is not an error a program can catch. It prints the whole value now, at any depth.
The parsers that turn foreign bytes into values bound themselves, so untrusted input cannot build one of these: json.decode, yaml.parse and toml.parse all refuse deep nesting and say so. It takes a program that means to.
yaml.stringify and toml.stringify are the exception: they refuse a value nested more than 1000 levels deep, with a catchable { kind: "limit", limit: 1000 } error. Neither format's parser reads back anything close to that depth.
Every thread that runs your code gets the same stack as the rest of the program: an HTTP or WebSocket handler, an async task, a pmap worker and a tool call. What works at the top level works in any of them.
Releasing a value has no depth limit either. When the last reference to a deeply nested list, map, struct, cell, secret or closure goes away, its contents are freed in a loop rather than one stack frame per level, so a chain tens of millions of levels deep is released like any other value. Before, dropping one nested past about eight million levels aborted the process the same way printing did.
The one shape that still releases a level at a time is a chain that runs through async or contracted functions, tasks or channels: each link holding the next only by way of one of those. Data does not end up built that way by accident.
Step budget
ECKO_LIMIT_STEPS is opt-in and unlimited by default, because a step cap on ordinary work would be a surprise. Set it where a program processes untrusted input or runs unattended:
ECKO_LIMIT_STEPS=100000000 ecko handler.ecko
An infinite loop then becomes an error you can catch, log and recover from, instead of a process that has to be killed.
Values built from a count
"-" * n, str.repeat, the pad family, random.bytes and io.read_exact all size an allocation from a number the program supplies, and a number the allocator cannot satisfy is not an error but an abort: the process prints "memory allocation of N bytes failed" and exits 134, with nothing for try to catch. ECKO_LIMIT_ALLOC (default 256 MiB) bounds them, and a count past it raises a kind: "limit" error carrying limit (the ceiling in bytes) and env (the variable that raises it), so a program can report both without scraping the message.
io.read_exact: the result would be 999999999999 bytes, over the
268435456 byte limit (ECKO_LIMIT_ALLOC)
io.read_exact is the one to remember: a length-prefixed protocol hands it the peer's own number.
The same cap bounds one piece read from a stream, which is where it does the most work. A io.read_until or a line whose delimiter never arrives stops at the cap rather than growing until the process dies, and the bytes it read stay readable. fs.read on a whole file and a buffered HTTP body are refused above it too, and both errors name the streaming form to use instead.
A stream is how you handle something bigger than this cap. The limit is on each piece, never on the total, so a 40 GB file is fine as long as you never ask for all of it at once.
Two related bounds have their own homes. Turning a range into a list is capped at 10,000,000 elements (list(0..30000000) raises and names the alternative), and a range past that cap prints as its literal rather than the list it can never be. Decompression stops at ECKO_LIMIT_UNPACKED, below.
Concurrency and IO bounds
Not resource limits in the same sense, but the same instinct - see Environment variables:
ECKO_LIMIT_TASKS(256) - running async tasksECKO_LIMIT_PARALLEL(all cores) -pmapand tool-round workersECKO_HTTP_MAX_BODY(10 MiB),ECKO_HTTP_MAX_WS_CONNS(1024)ECKO_PKG_MAX_BYTES(50 MiB),ECKO_PKG_MAX_UNPACKED(200 MiB)ECKO_LIMIT_UNPACKED(1 GiB) -std.archiveextraction andstd.zlibdecompression
The AI budget
ECKO_AI_MAX_CALLS is the same idea applied to spending, and the one to set in production - see Token budgeting. Exhausting it raises { kind: "budget", calls, max, scope }, which is catchable like any other operational failure.
Catching a limit
try {
risky_parse(untrusted)
} catch (e) {
match get(e, "kind") {
"budget" => degrade()
_ => error(e)
}
}
The point of a limit that raises rather than aborts is that a server can survive one bad request. See Error handling.