Execution Constraints
Some innocuous-looking expressions can take a very long time — or forever — to evaluate: a sum over a huge range, a number-theory function applied to a large integer, a limit that fails to converge, or a loop that never terminates.
To keep evaluation responsive, the Compute Engine offers two kinds of bounds: a time budget, expressed as a span, and iteration/recursion budgets, expressed as counts.
Time Budgets: Spans
A time budget is a span: a region of code you run with an explicit
deadline. Everything evaluated inside the span shares one deadline; when it is
exceeded, the evaluation in progress throws a CancellationError with
cause: "timeout".
import { ComputeEngine, CancellationError } from "@cortex-js/compute-engine";
const ce = new ComputeEngine();
try {
ce.withTimeLimit({ ms: 500, label: "my-app:eval" }, () =>
ce.parse("\\sum_{i=1}^{100000000} \\sqrt{i}").evaluate()
);
} catch (err) {
if (err instanceof CancellationError && err.cause === "timeout")
console.log("Evaluation exceeded its time budget");
}
withTimeLimit(limit, fn) runs fn and returns its value. The limit is
either a number of milliseconds, or an object { ms, label }. The object form
is preferred for new code: the label reads before the callback, and it is
what lets you attribute a timeout later (see below).
The contract is at most ms milliseconds:
Runs
fnwith at mostmsmilliseconds. A tighter deadline may already be in effect from an enclosing span, in which case that one preempts this limit. Use thelabeland theattributionfield onCancellationErrorto determine which limit fired.
Work performed outside any span is not time-bounded. A consumer that wants a blanket bound wraps its entry point in a single span — one line at the boundary — rather than relying on an implicit global limit.
Nesting: the tighter deadline wins
Spans nest. When you enter a span inside another, the effective deadline is the minimum of the two — a span can only shorten the budget, never extend it. This means an inner span can never defeat a bound its caller set:
ce.withTimeLimit({ ms: 100, label: "outer" }, () =>
// Even though this asks for 5000ms, the enclosing 100ms deadline preempts it.
ce.withTimeLimit({ ms: 5000, label: "inner" }, () =>
ce.parse("\\sum_{i=1}^{100000000} \\sqrt{i}").evaluate()
)
);
Labels are for attribution only, never for control: a label cannot buy a span more time than its enclosing budget allows.
Attribution: whose budget expired?
When several spans are active, a CancellationError tells you which one owned
the deadline that fired:
| Property | Description |
|---|---|
err.cause | "timeout" for a time-budget breach |
err.attribution | The label of the span whose deadline fired (undefined for an unlabelled span) |
err.spans | All active span labels, outermost first |
attribution answers the practical question "was this my budget or my
caller's?" — compare it directly against the label you passed. Because
nesting takes the tighter deadline, the span that fires is the tighter one:
try {
ce.withTimeLimit({ ms: 100, label: "outer" }, () =>
ce.withTimeLimit({ ms: 5000, label: "inner" }, () =>
ce.parse("\\sum_{i=1}^{100000000} \\sqrt{i}").evaluate()
)
);
} catch (err) {
if (err instanceof CancellationError) {
console.log(err.attribution); // ➔ "outer" (the tighter, enclosing budget)
console.log(err.spans); // ➔ ["outer", "inner"]
}
}
Here the outer 100ms budget preempts the inner 5000ms request, so
attribution is "outer". Had the inner span been the tighter one,
attribution would be "inner". Code catching the error inside the inner
span can therefore tell "my own sub-budget expired — degrade gracefully" from
"my caller's budget expired — propagate".
The numeric form withTimeLimit(500, fn) stays valid and produces an
unlabelled span: any timeout it owns has attribution: undefined and does
not appear in spans. Supply a label only when you can act on it.
Step budgets
ce.withStepBudget({ steps, label }, fn) runs fn with at most steps steps
of engine work. A step is one of the engine's cancellation checks. The count
for one computation on one engine state is the same on every machine, so a
step budget stops at the same point whatever the speed or the load of the
machine. It is an opaque unit, not a cost, and it is not comparable across
engine versions: tune the budget by measuring, and keep a withTimeLimit span
outside it. A spent budget throws a CancellationError with
cause: "step-budget" and the span's label as its attribution.
Reading the state of the current span
The engine checks its deadline only inside its own work (evaluation, canonicalization). Two read-only properties tell your own code where the current span stands:
| Property | Description |
|---|---|
ce.timeRemaining | Milliseconds left before the earliest deadline of the active spans, 0 or less once it has passed. Infinity when no active span has a time limit (outside any span, or in a step budget with no time limit around it). A spent step budget does not change it. |
ce.stepBudgetSpent | true when a step budget of the current span, or of an enclosing one, is spent. Always false outside any span. |
Both properties describe the span that is active in the current synchronous
code. Code that runs after an await is outside the span, even when the
await is inside the withTimeLimit callback: it reads Infinity and
false.
A loop of your own that runs inside a span, and does work the engine does not
see (walking expressions, building a list), reads ce.timeRemaining to stop
on time:
ce.withTimeLimit({ ms: 50, label: "my-app:layout" }, () => {
for (const item of items) {
if (ce.timeRemaining <= 0) break;
layOut(item);
}
});
When a budget is spent, code between the throw and the end of the span can
catch the CancellationError and return a value anyway. That value is
partial. Before you store a value computed in the span in a cache, read
ce.stepBudgetSpent, so that the cache does not keep a partial value.
ce.timeLimit has been removed
Earlier versions armed a global, implicit deadline around every
evaluate()/simplify() through the ce.timeLimit property (default 2000ms).
Its scope was never well-defined — "the current evaluation" is not a region you
can point at — and a span silently overrode it with no indication in the API.
ce.timeLimit was deprecated in the previous minor release and is now
removed. There is no implicit deadline anymore: an evaluate() outside a
span runs unbounded, and withTimeLimit() spans are the only way to arm one.
If you relied on the old 2000ms ambient default, wrap the work you want
bounded in a span:
// Before (no longer compiles)
ce.timeLimit = 500;
const r = expr.evaluate();
// After
const r = ce.withTimeLimit({ ms: 500, label: "my-app:eval" }, () =>
expr.evaluate()
);
For a blanket bound, this is a single wrap at your application's entry point.
Numerical integration is special. Monte Carlo integration (NIntegrate,
or N() on an Integrate expression) does not throw when it runs out of time.
Instead it returns the estimate computed from the samples taken so far, with a
correspondingly larger error bound: a partial estimate is more useful than no
answer.
Iteration and Recursion Budgets
Time is not the only way a computation can run away. Two count-based budgets bound structural growth independently of the clock:
| Property | Default | Description |
|---|---|---|
ce.iterationLimit | 1 024 | Maximum iterations of a looping control structure (Loop, Comprehension, While, FixedPoint) and of collection iteration |
ce.recursionLimit | 1 024 | Maximum depth of user-function recursion |
When the iteration limit is exceeded, a CancellationError with cause
"iteration-limit-exceeded" is thrown:
ce.iterationLimit = 10_000;
// A loop over a billion elements: aborts after 10,000 iterations
ce.box(["Loop", ["Add", "i", 1], ["Element", "i", ["Range", 1, 1e9]]]).evaluate();
// ➔ throws CancellationError, cause: "iteration-limit-exceeded"
When user-function recursion exceeds ce.recursionLimit, a CancellationError
with cause "recursion-depth-exceeded" is thrown instead.
Setting ce.iterationLimit to 0 or a negative value disables the iteration
limit; a span (if any) still applies.
The built-in symbolic integrator and the variation-of-parameters step of
DSolve each have a fixed budget of 100,000 steps. The budget counts each
step of the search, the simplification rules tried included, so the result is
the same on every machine. When the budget is spent, the result is the
unevaluated Integrate or DSolve expression; no error is thrown. A
withTimeLimit() span of the caller still applies, and its expiry reaches the
caller.
An Epsil program has no iteration limit. The first time an engine runs a
program (executeEpsil, or the epsil command), the default limit is removed,
and it stays removed for every later evaluation on that engine, Epsil or not,
so that a lazy collection a program returns can be read in full. A limit you
assign to ce.iterationLimit yourself is kept, and it applies to Epsil
programs too. A host that shares one engine between Epsil and other evaluation,
and wants the cap for the latter, assigns ce.iterationLimit after the program
has run.
Asynchronous Evaluation
For long-running computations, evaluateAsync() evaluates without blocking the
event loop and accepts an AbortSignal so a caller can request cancellation:
const controller = new AbortController();
// Request cancellation if the evaluation is still running after one second
setTimeout(() => controller.abort("user-canceled"), 1000);
try {
const result = await expr.evaluateAsync({ signal: controller.signal });
console.log(result.toString());
} catch (err) {
if (err instanceof CancellationError) console.log("Canceled:", err.cause);
}
Be aware of what async cancellation can and cannot do today:
- Cooperative, not preemptive. An
abort()is honored only at the points where the engine checks the signal. Today those checks are reached byLoop,Factorial,SumandProduct; other operators run their synchronous evaluation to completion and cannot be interrupted by a signal. - A synchronous tight loop blocks delivery. JavaScript is
single-threaded. If an uninterruptible operator is spinning, the
abort()callback cannot even run until it yields, so cancellation may be delayed indefinitely or never observed for that call. - Not a runaway guard. For untrusted or genuinely unbounded work, an
AbortSignalis not sufficient protection. Bounding such work reliably requires isolating it in a Web Worker or a separate process that you can terminate from the outside.
For most work, a time span (withTimeLimit) and the iteration/recursion
budgets are the appropriate bounds; reach for async cancellation when you need
a user-facing "cancel" affordance on the interruptible operators above.