Skip to main content

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 fn with at most ms milliseconds. A tighter deadline may already be in effect from an enclosing span, in which case that one preempts this limit. Use the label and the attribution field on CancellationError to 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:

PropertyDescription
err.cause"timeout" for a time-budget breach
err.attributionThe label of the span whose deadline fired (undefined for an unlabelled span)
err.spansAll 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:

PropertyDescription
ce.timeRemainingMilliseconds 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.stepBudgetSpenttrue 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:

PropertyDefaultDescription
ce.iterationLimit1 024Maximum iterations of a looping control structure (Loop, Comprehension, While, FixedPoint) and of collection iteration
ce.recursionLimit1 024Maximum 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 by Loop, Factorial, Sum and Product; 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 AbortSignal is 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.