Resource limits
Adversarial or accidental input should degrade into a catchable error, not exhaust the machine. Four bounds do that.
| variable | bounds | default |
|---|---|---|
ECKO_MAX_DEPTH | call and recursion depth | 2000 |
ECKO_MAX_PARSE_DEPTH | nesting the parser accepts | 128 |
ECKO_MAX_STEPS | loop and step budget | unlimited (opt in) |
ECKO_MAX_ALLOC | bytes in one value built from a count, or one piece read from a stream | 256 MiB |
Recursion
maximum call depth exceeded - a function may be calling itself
without a base case (raise ECKO_MAX_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_MAX_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.
Step budget
ECKO_MAX_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_MAX_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_MAX_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_MAX_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_ARCHIVE_MAX_UNPACKED, below.
Concurrency and IO bounds
Not resource limits in the same sense, but the same instinct - see Environment variables:
ECKO_MAX_TASKS(256) - running async tasksECKO_MAX_PARALLEL(all cores) -pmapand tool-round workersECKO_HTTP_MAX_BODY(10 MiB),ECKO_MAX_WS_CONNS(1024)ECKO_PKG_MAX_BYTES(50 MiB),ECKO_PKG_MAX_UNPACKED(200 MiB)ECKO_ARCHIVE_MAX_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 }, 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.