Skip to content

evaluateAsync stops yielding under N, Add, Evaluate and other lazy heads without an async handler #392

Closed

Description

@enumeratio

Environment: @cortex-js/compute-engine 0.142.0 and main (f67e60d), Node 24 and Chromium

Sum yields to the event loop under evaluateAsync and honours an abort signal. Wrap it in N,
Add or Evaluate and it runs to the end synchronously:

const sum = ["Sum", ["Divide", 1, ["Power", "k", 2]], ["Limits", "k", 1, 400000]];
const ac = new AbortController();
setTimeout(() => ac.abort(), 50);
await ce.box(["N", sum]).evaluateAsync({ signal: ac.signal });
expression, aborted after 50 ms outcome event-loop ticks (10 ms timer)
Sum(…), numericApproximation: true CancellationError after 61 ms 3
N(Sum(…)) finishes after 4.8 s 0
Add(Sum(…), 1), numericApproximation: true finishes after 4.6 s 0
Evaluate(Sum(…)), numericApproximation: true finishes after 5.3 s 0

The cause seems general. These heads are lazy and have only a sync evaluate, so the async path
falls back to it, and everything below them evaluates synchronously. On main, 143 lazy operators
have no evaluateAsync; the 13 that have one are Block, If, Loop, Which, And, Or,
Implies, Nand, Nor, List, Set, Sum and Product.

Ask:

  1. Give N an evaluateAsync that forwards to its operand's evaluateAsync({ numericApproximation: true, signal }), rounding as the sync handler does for N(x, n).
  2. The same for the lazy heads that only evaluate their operands and combine them (Add,
    Multiply, Evaluate, ReleaseHold, function application and so on). Perhaps a default async
    handler for a lazy head that evaluates its operands, rather than a twin for each.

A test that doesn't count timer ticks: run N(Sum(…)) under evaluateAsync, abort after about 50 ms,
and expect a prompt CancellationError. It only passes when the evaluation yields.

Our use: we run evaluations in workers that write a progress heartbeat on a timer, and re-run a job
whose heartbeat stops. That needs a long evaluation to yield. We'd be glad to send the PR for N, and
for the general handler if you'd take it.

Activity

  1. arnog commented on Oct 8, 2026

    @arnog
    Member

    Thanks for the precise report and the table. Fixed on main: N, Add, Multiply, Evaluate and ReleaseHold now have an evaluateAsync handler that awaits their held operands, so your N(Sum(…)), Add(Sum(…), 1) and Evaluate(Sum(…)) cases reject with a CancellationError about 50 ms after the abort. N(x, p) rounds as before; two forms stay synchronous for now because they raise the engine's precision for the computation, which cannot span an await while other evaluations run: p above the engine precision minus the guard digits (p ≥ 17 at the default precision), and the goal form N(x, [p, a]).

    On the general handler: the engine already classifies the lazy operators that demand every held operand, but a default "await the operands, then run the sync handler" is not safe for lazy operators that read the structure of their operand (Numerator, Interpret, and N itself before this change), so it needs an opt-in flag per operator. That is tracked in ROADMAP, together with the application of a user function, whose body statements are still evaluated synchronously. A PR for either is welcome.

  2. added a commit that references this issue on Oct 9, 2026
  3. arnog commented on Oct 9, 2026

    @arnog
    Member

    Second part landed on main (commit 4ce5a90, from #423 by @enumeratio):

    • Equal, NotEqual, Less, LessEqual (and so Greater and GreaterEqual, which canonicalize to them) have an evaluateAsync handler that awaits each operand in order, with the same early stop as evaluate() (a chain stops at its first False pair) and the same exact re-read of a near tie under .N(). Less(Sum(1/k^2, k, 1, 400000), 2) aborted after 50 ms now rejects with a CancellationError at about 50 ms.
    • A user function's body statements run with evaluateStatementsAsync, and its arguments are awaited, so f(400000) with f(n) = Σ 1/k² yields and aborts too, on the operator-definition route and on the function-value route. Two cases stay synchronous: an application of a literal whose body is already suspended on the same engine (a recursive body, or a second concurrent call), and an application with a free symbol in an argument.
    • A lazy operator can declare evaluatesOperands: true when its handler demands and evaluates every held operand and reads nothing else of it; the asynchronous route then awaits the operands and runs the handler on the values. IdenticallyEqual sets it.

    Still open, tracked in ROADMAP.md: N(x, p) with p above the engine precision minus the guard digits (p ≥ 17 at the default precision) and the goal form N(x, [p, a]), which raise the engine's precision for the computation; and the remaining lazy operators with a synchronous handler only, which have to be audited one by one for the flag or given a twin. The issue stays open for those: a PR for them is welcome, and we are waiting on one before closing.

  4. arnog commented on Oct 12, 2026

    @arnog
    Member

    Third part landed on main (commits 023b725 and 03b267f): N(x, p) with p above the engine precision minus the guard digits, and the goal form N(x, [p, a]), now yield under evaluateAsync and honour the abort signal. N(Sum(1/k^2, k, 1, 400000), 30) and N(…, [10, 5]) aborted after 50 ms reject with a CancellationError at about 50 ms, where they ran to the end (4 s and 10 s).

    How: the precision of the big decimals is one value for the whole module, so it cannot stay raised across an await. Instead the window of a N(x, p) call, the working precision and the requested digits, travels with the asynchronous evaluation like its effects registry and context stack, and every synchronous slice of the evaluation installs it when it starts and restores the previous values when it returns. So two evaluations at different precisions interleave on one engine and each computes with its own digits, a handler that reads options.precision sees the digits of its own call, and nothing of a window is left on the engine while an evaluation is suspended. The values are those of the synchronous handler, including a nested N(y, q) inside a wider window, and a constant such as Pi is still not cached at the raised precision.

    For a user evaluateAsync handler there is one rule to know: pass your options on to the operands you evaluate (x.evaluateAsync(options), or a copy that keeps the private fields). An operand evaluation started with options of your own still inherits the window in force when it starts, but a handler that awaits before it evaluates its operand starts that evaluation outside the window.

    What is left of the ask is the audit of the lazy operators that still have only a synchronous handler. It is done, in ROADMAP.md under "Some lazy heads still run synchronously under evaluateAsync": the operators that read the structure of an operand, transform it symbolically, or hand it to a synchronous solver or quadrature stay synchronous by design, since an await between operands would not interrupt their long step; a smaller group (Assign, Pipe, When, Match, FlatMap, Timing, WithRandomSeed, HoldValues) could take a twin but needs a design each. Also still synchronous: an application of a function literal whose body is already suspended on the same engine, and one with a free symbol in an argument.

    All of this will be in the next release. I am closing the issue, since the reported cases and the two forms of N are done; a PR for any operator of that last group is welcome, as a new issue or against the roadmap entry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

featureNew feature or enhancement

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions