Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Released on ?.

- Fixed `StackOverflowException` from deeply recursive scripts (#75)
- Added `JsScriptingOptions` to tune the engine via `WithJs` (#75)
- Fixed usage of document ready state (#87) @Sebbs128
- Fixed `HasChildNodes` is now exposed as a method to DOM (#106) @arekdygas
- Updated to use AngleSharp v1
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ var config = Configuration.Default
.WithJs(); // from AngleSharp.Js
```

This will register a scripting engine for JS files. The JS parsing options and more could be set with parameters of the `WithJs` method.
This will register a scripting engine for JS files. The engine can be tuned by passing a `JsScriptingOptions` instance to `WithJs`:

```cs
var config = Configuration.Default
.WithJs(new JsScriptingOptions
{
// how deep a script may recurse before the engine reports
// "Maximum call stack size exceeded" (10000 by default)
MaxCallStackDepth = 5000,
});
```

You can also use this part with a console for logging. The call for this is `WithConsoleLogger`, e.g.,

Expand Down
12 changes: 11 additions & 1 deletion docs/general/01-Basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,17 @@ var config = Configuration.Default
.WithJs(); // from AngleSharp.Js
```

This will register a scripting engine for JS files. The JS parsing options and more could be set with parameters of the `WithJs` method.
This will register a scripting engine for JS files. The engine can be tuned by passing a `JsScriptingOptions` instance to `WithJs`:

```cs
var config = Configuration.Default
.WithJs(new JsScriptingOptions
{
// how deep a script may recurse before the engine reports
// "Maximum call stack size exceeded" (10000 by default)
MaxCallStackDepth = 5000,
});
```

You can also use this part with a console for logging. The call for this is `WithConsoleLogger`, e.g.,

Expand Down
80 changes: 80 additions & 0 deletions src/AngleSharp.Js.Tests/StackGuardTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
namespace AngleSharp.Js.Tests
{
using AngleSharp.Dom;
using Jint.Runtime;
using NUnit.Framework;
using System;
using System.Threading.Tasks;

public class StackGuardTests
{
// A recursion that never ends. Jint implements no tail calls, so this really does
// grow the call stack. Without a stack guard it exhausts the native one, and the
// resulting StackOverflowException takes the whole process down - which is why the
// tests below cannot assert anything weaker than "we got here at all".
private const String RunawayRecursion = "function boom() { return boom(); } boom();";

// Deeper than a 1 MB stack holds, so the engine has to keep going on a fresh one
// instead of reporting the depth as an error.
private const String DeepRecursion = "function depth(n) { return n === 0 ? 0 : 1 + depth(n - 1); } depth(1500);";

[Test]
public async Task RunawayRecursionInPageScriptDoesNotEscapeOpenAsync()
{
var config = Configuration.Default
.WithJs()
.WithEventLoop();

var content = $"<!doctype html><script>{RunawayRecursion}</script>";
var document = await BrowsingContext.New(config).OpenAsync(m => m.Content(content));

Assert.IsNotNull(document);
}

[Test]
public async Task RunawayRecursionReportsMaximumCallStackSizeExceeded()
{
// A small limit keeps the failure quick and independent of how much native
// stack the test runner happens to have left.
var config = Configuration.Default
.WithJs(new JsScriptingOptions { MaxCallStackDepth = 200 })
.WithEventLoop();

var document = await BrowsingContext.New(config).OpenNewAsync();
var error = Assert.Throws<JavaScriptException>(() => document.ExecuteScript(RunawayRecursion));

Assert.AreEqual("Maximum call stack size exceeded", error.Message);
}

[Test]
public async Task DeepButFiniteRecursionStillSucceeds()
{
var config = Configuration.Default
.WithJs()
.WithEventLoop();

var document = await BrowsingContext.New(config).OpenNewAsync();
var result = document.ExecuteScript(DeepRecursion);

Assert.AreEqual(1500.0, result);
}

[Test]
public async Task EditingTheOptionsAfterwardsLeavesTheServiceAlone()
{
var options = new JsScriptingOptions();
var config = Configuration.Default
.WithJs(options)
.WithEventLoop();

// The engine is only built once a document asks for it, so an edit landing
// in between must not be the one deciding how that document behaves.
options.MaxCallStackDepth = 200;

var document = await BrowsingContext.New(config).OpenNewAsync();
var result = document.ExecuteScript(DeepRecursion);

Assert.AreEqual(1500.0, result);
}
}
}
14 changes: 11 additions & 3 deletions src/AngleSharp.Js/EngineInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ sealed class EngineInstance
{
#region Fields

// Jint's StackGuard.Disabled, which is internal.
private const Int32 StackGuardDisabled = -1;

private readonly Engine _engine;
private readonly PrototypeCache _prototypes;
private readonly ReferenceCache _references;
Expand All @@ -27,13 +30,18 @@ sealed class EngineInstance

#region ctor

public EngineInstance(IWindow window, IDictionary<String, Object> assignments, IEnumerable<Assembly> libs)
public EngineInstance(IWindow window, IDictionary<String, Object> assignments, IEnumerable<Assembly> libs, JsScriptingOptions options)
{
_importMap = new JsImportMap();

_engine = new Engine((options) =>
_engine = new Engine((o) =>
{
options.EnableModules(new JsModuleLoader(this, window.Document, false));
o.EnableModules(new JsModuleLoader(this, window.Document, false));
// Left alone, the JS call stack is the native one, and a script recursing
// deeper than it holds takes the whole process down - a StackOverflowException
// cannot be caught. Guarded, the engine continues on a fresh stack and finally
// reports an ordinary "Maximum call stack size exceeded" error instead.
o.Constraints.MaxExecutionStackCount = options.MaxCallStackDepth > 0 ? options.MaxCallStackDepth : StackGuardDisabled;
});
_libs = libs;
_prototypes = new PrototypeCache(_engine, libs);
Expand Down
15 changes: 13 additions & 2 deletions src/AngleSharp.Js/JsConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,20 @@ public static IConfiguration WithEventLoop(this IConfiguration configuration, Fu
/// </summary>
/// <param name="configuration">The configuration to use.</param>
/// <returns>The new configuration.</returns>
public static IConfiguration WithJs(this IConfiguration configuration)
public static IConfiguration WithJs(this IConfiguration configuration) =>
configuration.WithJs(new JsScriptingOptions());

/// <summary>
/// Sets scripting to true, registers the JavaScript engine with the
/// given options and returns a new configuration with the scripting
/// service and possible auxiliary services, if not yet registered.
/// </summary>
/// <param name="configuration">The configuration to use.</param>
/// <param name="options">The options tuning the engine.</param>
/// <returns>The new configuration.</returns>
public static IConfiguration WithJs(this IConfiguration configuration, JsScriptingOptions options)
{
var service = new JsScriptingService();
var service = new JsScriptingService(options);
var observer = new EventAttributeObserver(service);
var handler = new JsNavigationHandler(service);

Expand Down
19 changes: 18 additions & 1 deletion src/AngleSharp.Js/JsEventLoop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ namespace AngleSharp.Js
/// </summary>
public sealed class JsEventLoop : IEventLoop, IDisposable
{
// Scripts run on this thread, and the JS call stack is the native one. The usual
// 1 MB holds roughly a thousand JavaScript frames, well short of what a browser
// offers, and every frame beyond it costs the engine a hop onto a fresh stack.
// The size is reserved address space rather than memory, but a 32 bit process has
// little of it to spare when it runs many loops, so only a 64 bit one is enlarged;
// zero leaves the thread with the default of the process.
private static readonly Int32 DefaultMaxStackSize = IntPtr.Size == 8 ? 16 * 1024 * 1024 : 0;

private readonly Dictionary<TaskPriority, Queue<LoopEntry>> _queues = new Dictionary<TaskPriority, Queue<LoopEntry>>();
private readonly Object _lockObj = new Object();
private CancellationTokenSource _cts;
Expand All @@ -19,8 +27,17 @@ public sealed class JsEventLoop : IEventLoop, IDisposable
/// Creates a new event loop thread.
/// </summary>
public JsEventLoop()
: this(DefaultMaxStackSize)
{
}

/// <summary>
/// Creates a new event loop thread with the given stack size.
/// </summary>
/// <param name="maxStackSize">The stack size of the thread running the scripts.</param>
public JsEventLoop(Int32 maxStackSize)
{
var thread = new Thread(Runner)
var thread = new Thread(Runner, maxStackSize)
{
IsBackground = true,
Name = "AngleSharpEventLoop",
Expand Down
28 changes: 28 additions & 0 deletions src/AngleSharp.Js/JsScriptingOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace AngleSharp.Js
{
using System;

/// <summary>
/// Options tuning the JavaScript engine.
/// </summary>
public sealed class JsScriptingOptions
{
/// <summary>
/// Gets or sets the JavaScript call stack depth that has to be supported
/// before the engine gives up with a "Maximum call stack size exceeded"
/// error. Defaults to 10000, which is roughly what browsers allow. Values
/// of zero or less remove the limit - the engine is then bounded by the
/// native stack alone, so a runaway recursion terminates the process with
/// an uncatchable StackOverflowException.
/// </summary>
public Int32 MaxCallStackDepth { get; set; } = 10000;

// An engine is built per window, long after the options were handed over, so
// reading them then would let a later edit of the caller's object decide how
// the next document behaves. The service takes this copy instead.
internal JsScriptingOptions Clone() => new JsScriptingOptions
{
MaxCallStackDepth = MaxCallStackDepth,
};
}
}
16 changes: 14 additions & 2 deletions src/AngleSharp.Js/JsScriptingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,30 @@ public class JsScriptingService : IScriptingService

private readonly ConditionalWeakTable<IWindow, EngineInstance> _contexts;
private readonly Dictionary<String, Object> _external;
private readonly JsScriptingOptions _options;

#endregion

#region ctor

/// <summary>
/// Creates a new JavaScript engine.
/// Creates a new JavaScript engine using the default options.
/// </summary>
public JsScriptingService()
: this(new JsScriptingOptions())
{
}

/// <summary>
/// Creates a new JavaScript engine using the given options. The options
/// are copied, so that editing them afterwards leaves this engine alone.
/// </summary>
/// <param name="options">The options tuning the engine.</param>
public JsScriptingService(JsScriptingOptions options)
{
_contexts = new ConditionalWeakTable<IWindow, EngineInstance>();
_external = new Dictionary<String, Object>();
_options = (options ?? throw new ArgumentNullException(nameof(options))).Clone();
}

#endregion
Expand Down Expand Up @@ -113,7 +125,7 @@ internal EngineInstance GetOrCreateInstance(IDocument document)
if (!_contexts.TryGetValue(objectContext, out var instance))
{
var libs = GetAssemblies(document.Context).ToArray();
instance = new EngineInstance(objectContext, _external, libs);
instance = new EngineInstance(objectContext, _external, libs, _options);
_contexts.Add(objectContext, instance);
}

Expand Down
Loading