Run JavaScript you don’t trust from C# with OneEcma: a fresh engine per script, a step budget, an allocation budget, a wall-clock deadline, a call-depth limit and cancellation, each ending in a report that says what stopped the run.
using RecursiveAI.OneEcma; using RecursiveAI.OneEcma.Diagnostics; // One engine per untrusted script, with every limit set. static ScriptEngine NewSandbox() => new(new ScriptEngineOptions { Limits = new EngineLimits { Fuel = 5_000_000, // steps: calls, loop back-edges, RegExp backtracking MaxAllocatedBytes = 16 << 20, // 16 MiB requested over the run WallClockMs = 1_000, // one second MaxCallDepth = 500, // a catchable RangeError past this MaxStringLength = 1 << 20, // longer strings throw RangeError }, }); static string Run(string source, string name, CancellationToken token = default) { var engine = NewSandbox(); try { return "ok: " + engine.ToDisplayString(engine.Evaluate(source, name, token)); } catch (JsLimitException e) // a limit ended the run; script could not catch it { return $"limit {e.Kind}:\n" + DiagnosticRenderer.Render(e.Report!); } catch (JsException e) // the script threw { return "error:\n" + DiagnosticRenderer.Render(e.Report!); } } Console.WriteLine(Run("Array.from({ length: 5 }, (_, i) => i * i).join(',')", "ok.js")); Console.WriteLine(Run("for (;;) {}", "spin.js")); Console.WriteLine(Run("const parts = [];\nwhile (true) parts.push('x'.repeat(1000));", "hog.js")); Console.WriteLine(Run("/(a+)+$/.test('a'.repeat(40) + 'b')", "redos.js")); Console.WriteLine(Run("function down(n) { return down(n + 1); }\ntry { down(0); } catch (e) { `caught ${e.name}: ${e.message}` }", "deep.js")); Console.WriteLine(Run("typeof require + ' ' + typeof process + ' ' + typeof fetch + ' ' + typeof globalThis.console", "reach.js"));
ok: 0,1,4,9,16 limit Fuel: error[Limit]: Execution exceeded the step limit of 5000000 steps --> spin.js:1:1 | 1 | for (;;) {} | ^^^^^^^^^^^ | = stack: <top-level> (spin.js:1) limit Memory: error[Limit]: Execution exceeded the allocation limit of 16777216 bytes --> hog.js:2:25 | 2 | while (true) parts.push('x'.repeat(1000)); | ^^^^^^^^^^ | = stack: <top-level> (hog.js:2) limit Fuel: error[Limit]: Execution exceeded the step limit of 5000000 steps --> redos.js:1:1 | 1 | /(a+)+$/.test('a'.repeat(40) + 'b') | ^^^^^^^^^^^^^ | = stack: <top-level> (redos.js:1) ok: caught RangeError: Maximum call stack size exceeded ok: undefined undefined undefined undefined
The catastrophic regular expression is stopped by fuel like any loop, because OneEcma’s own RegExp engine charges every backtracking step. The last line shows what a script can reach on its own: no require, process, fetch or console. It has the language and its built-ins, and whatever you put on the global object.
A deadline and cancellation
Fuel counts work; a wall-clock limit puts a deadline on the run as a whole. Cancellation works on every entry point that computes:
var timed = new ScriptEngine(new ScriptEngineOptions { Limits = new EngineLimits { WallClockMs = 200 } }); try { timed.Evaluate("const end = Date.now() + 5000;\nwhile (Date.now() < end) {}", "clock.js"); } catch (JsLimitException e) { Console.WriteLine($"limit {e.Kind}:\n" + DiagnosticRenderer.Render(e.Report!)); } using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); var slow = new ScriptEngine(); // no limits but the token try { slow.Evaluate("let i = 0; while (true) i++;", "cancel.js", cts.Token); } catch (OperationCanceledException e) // cancellation is an OperationCanceledException { var limit = (JsLimitException)e.InnerException!; Console.WriteLine($"cancelled: {limit.Kind}, {e.Message}, report: {limit.Report is not null}"); }
limit WallClock: error[Limit]: Execution exceeded the wall-clock limit of 200 ms --> clock.js:2:1 | 2 | while (Date.now() < end) {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = stack: <top-level> (clock.js:2) cancelled: Cancelled, Execution was cancelled, report: False
The limits
| Limit | Default | When it’s hit |
|---|---|---|
Fuel | 4,000,000,000 steps | JsLimitException, kind Fuel |
MaxAllocatedBytes | 2 GiB | JsLimitException, kind Memory. It counts bytes requested over the run, not live bytes. |
WallClockMs | none | JsLimitException, kind WallClock |
MaxCallDepth | 10,000 | a RangeError the script can catch |
MaxNativeDepth | 120 | JsLimitException. It counts re-entries from built-ins into script, such as sort comparators, getters and proxy traps. |
MaxStringLength | 536,870,888 | a RangeError, “Invalid string length” |
a CancellationToken | none | OperationCanceledException wrapping a JsLimitException of kind Cancelled, with no report in 1.0.0 |
Good to know
- The wall clock and the cancellation token are polled every 4,096 steps, so a stop lands within one such chunk of the deadline.
- Parsing and execution use explicit stacks or depth counters, so deeply nested source can’t overflow the .NET stack.
- A limit bounds the script, not your .NET code: the time and cost of a host function are yours to bound.
- Use a fresh engine per untrusted script, as above. Creating one costs about 98 µs and 272 KB (benchmarks).
- The full contract, including how built-ins are charged, is in Runtime, limits and the host.