Skip to content

Commit db73cad

Browse files
XicyUmut Akkayalahma
authored
Add StackGuard for improved stack handling support (#1566)
Co-authored-by: Umut Akkaya <[email protected]> Co-authored-by: Marko Lahma <[email protected]>
1 parent b1df4cb commit db73cad

File tree

6 files changed

+186
-14
lines changed

6 files changed

+186
-14
lines changed

Jint.Tests/Runtime/EngineLimitTests.cs

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
#if !NETFRAMEWORK
22

33
using System.Text;
4+
using Jint.Runtime;
45

56
namespace Jint.Tests.Runtime;
67

78
public class EngineLimitTests
89
{
10+
11+
#if RELEASE
12+
const int FunctionNestingCount = 960;
13+
#else
14+
const int FunctionNestingCount = 520;
15+
#endif
16+
917
[Fact]
1018
public void ShouldAllowReasonableCallStackDepth()
1119
{
@@ -15,24 +23,61 @@ public void ShouldAllowReasonableCallStackDepth()
1523
return;
1624
}
1725

18-
#if RELEASE
19-
const int FunctionNestingCount = 960;
20-
#else
21-
const int FunctionNestingCount = 520;
22-
#endif
26+
var script = GenerateCallTree(FunctionNestingCount);
2327

24-
// generate call tree
28+
var engine = new Engine();
29+
engine.Execute(script);
30+
Assert.Equal(123, engine.Evaluate("func1(123);").AsNumber());
31+
Assert.Equal(FunctionNestingCount, engine.Evaluate("x").AsNumber());
32+
}
33+
34+
[Fact]
35+
public void ShouldNotStackoverflowWhenStackGuardEnable()
36+
{
37+
// Can be more than actual dotnet stacktrace count, It does not hit stackoverflow anymore.
38+
int functionNestingCount = FunctionNestingCount * 2;
39+
40+
var script = GenerateCallTree(functionNestingCount);
41+
42+
var engine = new Engine(option => option.Constraints.MaxExecutionStackCount = functionNestingCount);
43+
engine.Execute(script);
44+
Assert.Equal(123, engine.Evaluate("func1(123);").AsNumber());
45+
Assert.Equal(functionNestingCount, engine.Evaluate("x").AsNumber());
46+
}
47+
48+
[Fact]
49+
public void ShouldThrowJavascriptExceptionWhenStackGuardExceed()
50+
{
51+
// Can be more than actual dotnet stacktrace count, It does not hit stackoverflow anymore.
52+
int functionNestingCount = FunctionNestingCount * 2;
53+
54+
var script = GenerateCallTree(functionNestingCount);
55+
56+
var engine = new Engine(option => option.Constraints.MaxExecutionStackCount = 500);
57+
try
58+
{
59+
engine.Execute(script);
60+
engine.Evaluate("func1(123);");
61+
}
62+
catch (JavaScriptException jsException)
63+
{
64+
Assert.Equal("Maximum call stack size exceeded", jsException.Message);
65+
}
66+
}
67+
68+
private string GenerateCallTree(int functionNestingCount)
69+
{
2570
var sb = new StringBuilder();
26-
sb.AppendLine("var x = 10;");
71+
sb.AppendLine("var x = 1;");
2772
sb.AppendLine();
28-
for (var i = 1; i <= FunctionNestingCount; ++i)
73+
for (var i = 1; i <= functionNestingCount; ++i)
2974
{
3075
sb.Append("function func").Append(i).Append("(func").Append(i).AppendLine("Param) {");
3176
sb.Append(" ");
32-
if (i != FunctionNestingCount)
77+
if (i != functionNestingCount)
3378
{
3479
// just to create a bit more nesting add some constructs
35-
sb.Append("return x++ > 1 ? func").Append(i + 1).Append("(func").Append(i).AppendLine("Param): undefined;");
80+
sb.Append("return x++ >= 1 ? func").Append(i + 1).Append("(func").Append(i).AppendLine("Param): undefined;");
3681
}
3782
else
3883
{
@@ -43,10 +88,7 @@ public void ShouldAllowReasonableCallStackDepth()
4388
sb.AppendLine("}");
4489
sb.AppendLine();
4590
}
46-
47-
var engine = new Engine();
48-
engine.Execute(sb.ToString());
49-
Assert.Equal(123, engine.Evaluate("func1(123);").AsNumber());
91+
return sb.ToString();
5092
}
5193
}
5294

Jint.Tests/Runtime/EngineTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1883,6 +1883,31 @@ public void DateShouldParseToString()
18831883
");
18841884
}
18851885

1886+
1887+
[Fact]
1888+
public void ShouldThrowErrorWhenMaxExecutionStackCountLimitExceeded()
1889+
{
1890+
new Engine(options => options.Constraints.MaxExecutionStackCount = 1000)
1891+
.SetValue("assert", new Action<bool>(Assert.True))
1892+
.Evaluate(@"
1893+
var count = 0;
1894+
function recurse() {
1895+
count++;
1896+
recurse();
1897+
return null; // ensure no tail recursion
1898+
}
1899+
try {
1900+
count = 0;
1901+
recurse();
1902+
assert(false);
1903+
} catch(err) {
1904+
assert(count >= 1000);
1905+
}
1906+
");
1907+
1908+
}
1909+
1910+
18861911
[Fact]
18871912
public void LocaleNumberShouldUseLocalCulture()
18881913
{

Jint/Engine.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public sealed partial class Engine : IDisposable
6363
internal ConditionalWeakTable<object, ObjectInstance>? _objectWrapperCache;
6464

6565
internal readonly JintCallStack CallStack;
66+
internal readonly StackGuard _stackGuard;
6667

6768
// needed in initial engine setup, for example CLR function construction
6869
internal Intrinsics _originalIntrinsics = null!;
@@ -129,6 +130,7 @@ public Engine(Action<Engine, Options> options)
129130
Options.Apply(this);
130131

131132
CallStack = new JintCallStack(Options.Constraints.MaxRecursionDepth >= 0);
133+
_stackGuard = new StackGuard(this);
132134
}
133135

134136
private void Reset()

Jint/Options.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Jint.Runtime.Debugger;
1010
using Jint.Runtime.Descriptors;
1111
using Jint.Runtime.Modules;
12+
using Jint.Runtime.CallStack;
1213

1314
namespace Jint
1415
{
@@ -385,6 +386,16 @@ public class ConstraintOptions
385386
/// </summary>
386387
public int MaxRecursionDepth { get; set; } = -1;
387388

389+
/// <summary>
390+
/// Maximum recursion stack count, defaults to -1 (as-is dotnet stacktrace).
391+
/// </summary>
392+
/// <remarks>
393+
/// Chrome and V8 based engines (ClearScript) that can handle 13955.
394+
/// When set to a different value except -1, it can reduce slight performance/stack trace readability drawback. (after hitting the engine's own limit),
395+
/// When max stack size to be exceeded, Engine throws an exception <see cref="JavaScriptException">.
396+
/// </remarks>
397+
public int MaxExecutionStackCount { get; set; } = StackGuard.Disabled;
398+
388399
/// <summary>
389400
/// Maximum time a Regex is allowed to run, defaults to 10 seconds.
390401
/// </summary>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
// https://github.com/dotnet/runtime/blob/a0964f9e3793cb36cc01d66c14a61e89ada5e7da/src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/StackGuard.cs
5+
6+
using System.Runtime.CompilerServices;
7+
using System.Threading;
8+
9+
namespace Jint.Runtime.CallStack;
10+
11+
internal sealed class StackGuard
12+
{
13+
public const int Disabled = -1;
14+
15+
private readonly Engine _engine;
16+
17+
public StackGuard(Engine engine)
18+
{
19+
_engine = engine;
20+
}
21+
22+
public bool TryEnterOnCurrentStack()
23+
{
24+
if (_engine.Options.Constraints.MaxExecutionStackCount == Disabled)
25+
{
26+
return true;
27+
}
28+
29+
#if NETFRAMEWORK || NETSTANDARD2_0
30+
try
31+
{
32+
RuntimeHelpers.EnsureSufficientExecutionStack();
33+
return true;
34+
}
35+
catch (InsufficientExecutionStackException)
36+
{
37+
}
38+
#else
39+
if (RuntimeHelpers.TryEnsureSufficientExecutionStack())
40+
{
41+
return true;
42+
}
43+
#endif
44+
45+
if (_engine.CallStack.Count > _engine.Options.Constraints.MaxExecutionStackCount)
46+
{
47+
ExceptionHelper.ThrowRangeError(_engine.Realm, "Maximum call stack size exceeded");
48+
}
49+
50+
return false;
51+
}
52+
53+
public TR RunOnEmptyStack<T1, TR>(Func<T1, TR> action, T1 arg1)
54+
{
55+
#if NETFRAMEWORK || NETSTANDARD2_0
56+
return RunOnEmptyStackCore(static s =>
57+
{
58+
var t = (Tuple<Func<T1, TR>, T1>) s;
59+
return t.Item1(t.Item2);
60+
}, Tuple.Create(action, arg1));
61+
#else
62+
// Prefer ValueTuple when available to reduce dependencies on Tuple
63+
return RunOnEmptyStackCore(static s =>
64+
{
65+
var t = ((Func<T1, TR>, T1)) s;
66+
return t.Item1(t.Item2);
67+
}, (action, arg1));
68+
#endif
69+
70+
}
71+
72+
private R RunOnEmptyStackCore<R>(Func<object, R> action, object state)
73+
{
74+
// Using default scheduler rather than picking up the current scheduler.
75+
Task<R> task = Task.Factory.StartNew((Func<object?, R>) action, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
76+
77+
// Avoid AsyncWaitHandle lazy allocation of ManualResetEvent in the rare case we finish quickly.
78+
if (!task.IsCompleted)
79+
{
80+
// Task.Wait has the potential of inlining the task's execution on the current thread; avoid this.
81+
((IAsyncResult) task).AsyncWaitHandle.WaitOne();
82+
}
83+
84+
// Using awaiter here to propagate original exception
85+
return task.GetAwaiter().GetResult();
86+
}
87+
}

Jint/Runtime/Interpreter/Expressions/JintCallExpression.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ static bool CanSpread(Node? e)
7878

7979
protected override object EvaluateInternal(EvaluationContext context)
8080
{
81+
if (!context.Engine._stackGuard.TryEnterOnCurrentStack())
82+
{
83+
return context.Engine._stackGuard.RunOnEmptyStack(EvaluateInternal, context);
84+
}
85+
8186
if (_calleeExpression._expression.Type == Nodes.Super)
8287
{
8388
return SuperCall(context);

0 commit comments

Comments
 (0)