From e0a8a9fa2276bb2fbc8975bc5155c1ea8bd3c71d Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 15 Jul 2026 11:44:09 +0300 Subject: [PATCH] Keep the memory limit exact instead of amortizing it in tight loops The tight-loop constraint amortization (#2672) checks the Time, Cancellation and MemoryLimit constraints once every 64 statements. That is sound for a clock or a cancellation token - a check reads external state it does not consume - but allocation is irreversible and unbounded per statement: a single iteration can allocate arbitrarily much (e.g. `s += s` doubling), so a function-body tight loop can overshoot the configured cap, up to a real OutOfMemoryException, before the next 64-statement check fires. v4.12.0 had no while/do-while tight lane and checked memory every statement. Move MemoryLimitConstraint into the exact set so memory-limited engines check it per statement again (as before the tight lane), preserving the limit as a hard-ish bound for sandboxing untrusted code. Time and cancellation stay amortized, so the common timeout-only / unconstrained engine keeps the tight-lane fast path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XM8rSnn8j66kDCTaP2exNK --- Jint/Engine.Constraints.cs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/Jint/Engine.Constraints.cs b/Jint/Engine.Constraints.cs index fd6143f9f..5fec993fd 100644 --- a/Jint/Engine.Constraints.cs +++ b/Jint/Engine.Constraints.cs @@ -10,14 +10,24 @@ public partial class Engine private readonly record struct ConstraintPartition(Constraint[] Exact, Constraint[] Amortized); /// - /// Splits the registered constraints by required check frequency. The built-in time, - /// cancellation and memory-limit constraints only observe external state (a timer, a token, - /// an allocation counter), so checking them every N statements is semantically equivalent to + /// Splits the registered constraints by required check frequency. The built-in time and + /// cancellation constraints only observe external state that a check reads without consuming + /// (a timer, a token), so checking them every N statements is semantically equivalent to /// checking per statement — only the detection latency is bounded instead of immediate (the /// same reasoning bulk built-ins apply via ). Everything - /// else stays exact: counts statements, so its call - /// frequency IS its semantics, and user-derived constraints may depend on being called once - /// per statement — silently amortizing them would be a breaking behavior change. + /// else stays exact: + /// + /// counts statements, so its call frequency IS its + /// semantics. + /// reads an allocation counter, but unlike a clock + /// that only advances, allocation between checks is irreversible and unbounded per statement + /// (a single iteration can allocate arbitrarily much, e.g. exponential string growth), so + /// amortizing it would let the process overshoot the configured cap — potentially to a real + /// OutOfMemoryException — before the next check. Keeping it exact preserves the memory bound + /// as a hard-ish guarantee for sandboxing untrusted code (matching pre-tight-lane behavior). + /// User-derived constraints may depend on being called once per statement — silently + /// amortizing them would be a breaking behavior change. + /// /// private static ConstraintPartition PartitionConstraints(Constraint[] constraints) { @@ -30,8 +40,8 @@ private static ConstraintPartition PartitionConstraints(Constraint[] constraints var amortized = new List(constraints.Length); foreach (var constraint in constraints) { - // All three types are sealed, so the checks cannot match a user-derived subclass. - if (constraint is TimeConstraint or CancellationConstraint or MemoryLimitConstraint) + // Both types are sealed, so the check cannot match a user-derived subclass. + if (constraint is TimeConstraint or CancellationConstraint) { amortized.Add(constraint); }