Limits
Every session runs under a budget. Limits are charged during evaluation rather than checked afterwards, so a runaway script is stopped rather than reported once it has already cost you the memory.
The execution budget
ExecutionLimits is a record; take a profile and override what you need.
| Limit | Default | What it caps |
|---|---|---|
Timeout |
30s | wall clock for the whole execution |
ParserTimeout |
5s | wall clock for parsing alone |
MaxCommands |
10,000 | commands executed |
MaxLoopIterations |
10,000 | iterations of any single loop |
MaxTotalLoopIterations |
1,000,000 | loop iterations across the script |
MaxFunctionDepth |
100 | shell function recursion |
MaxNestingDepth |
100 | nesting of compound commands and expansions |
MaxInputBytes |
10 MB | script size |
MaxOutputBytes |
10 MB | bytes retained on stdout or stderr |
MaxWorkUnits |
100,000,000 | abstract work shared by parser, interpreter and builtins |
MaxParserFuel |
10,000,000 | tokens and productions the parser may consume |
MaxBraceExpansionItems |
10,000 | expansions from one brace expansion |
MaxGlobMatches |
10,000 | paths one glob may match |
ExecutionLimits.Default mirrors upstream's defaults; ExecutionLimits.Strict tightens them for
genuinely untrusted input.
var limits = ExecutionLimits.Default with
{
Timeout = TimeSpan.FromSeconds(120),
MaxCommands = 100_000,
MaxOutputBytes = 4_000_000,
};
var bash = Bash.CreateBuilder().WithLimits(limits).Build();
That particular profile is roughly what Curiosity Workspace gives Sudo: a legitimate command there can walk a whole configuration tree, so it gets more room than the default profile for untrusted input, and the wall clock is what actually bounds a runaway loop.
The Python budget
PythonOptions carries the interpreter's own caps:
| Option | Default | What it caps |
|---|---|---|
MaxInstructions |
20,000,000 | bytecode instructions per invocation |
MaxRecursionDepth |
200 | Python call depth |
MaxDirectoryDepth |
64 | how far a tree walk descends |
MaxDirectoryDepth defaults to the same value the shell's filesystem will let a script create, so
over that filesystem it can never be reached and costs nothing. Raise the two together, or a walk
will refuse to enter a directory the shell was allowed to make.
Filesystem quotas
Separate from the execution budget, and covered in The virtual filesystem: total bytes, file count and path depth.
Truncation and failure
Two different things happen when a budget runs out, and the difference is deliberate.
Output is truncated, and says so. ExecResult carries StdoutTruncated and StderrTruncated,
so a caller can tell "this is the whole answer" from "this is the first 4 MB of it".
Everything else raises. A wall clock that expires, a command count that is exhausted, a filesystem quota that is full: each ends the execution rather than returning what it managed to do. A traversal that quietly stopped part-way would report a subset as though it were the whole, and a caller acting on that is worse off than one that got an error.
Never widen a limit to make something pass
A limit that is in the way is telling you the script is doing more work than you budgeted for. Raise it because you decided the budget was wrong, not because a case failed.
Cancellation
Every entry point takes a CancellationToken, threaded explicitly rather than captured. That is the
right lever for a request-scoped host: the limits bound a script that misbehaves, and the token
cancels one whose caller went away.
var result = await bash.ExecAsync(script, httpContext.RequestAborted);
Read next
- The sandbox model — the properties these limits back up.
- Quick Start — handling a non-zero exit.