Resource limits

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

variableboundsdefault
ECKO_MAX_DEPTHcall and recursion depth2000
ECKO_MAX_PARSE_DEPTHnesting the parser accepts128
ECKO_MAX_STEPSloop and step budgetunlimited (opt in)
ECKO_MAX_ALLOCbytes in one value built from a count, or one piece read from a stream256 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 tasks
  • ECKO_MAX_PARALLEL (all cores) - pmap and tool-round workers
  • ECKO_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.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 }, 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.