Resource limits

Adversarial or accidental input should degrade into a catchable error, not exhaust the machine. Four bounds do that.

variableboundsdefault
ECKO_LIMIT_DEPTHcall and recursion depth2000
ECKO_LIMIT_PARSE_DEPTHnesting the parser accepts128
ECKO_LIMIT_STEPSloop and step budgetunlimited (opt in)
ECKO_LIMIT_ALLOCbytes in one value built from a count, or one piece read from a stream256 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 tasks
  • ECKO_LIMIT_PARALLEL (all cores) - pmap and tool-round workers
  • ECKO_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.archive extraction and std.zlib decompression

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.