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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- Fixed background thread leaks when worker construction is rejected
- Fixed canonical DOM node identity and concrete prototypes through `DomSameObject` getters (#140)
- Added `JsScriptingOptions.EngineCreator` to create a configured Jint engine before any page script runs

# 1.1.0

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ var config = Configuration.Default
});
```

Register an `EngineCreator` service to configure Jint before inline page scripts run:

```cs
using Jint;
using AngleSharp.Js;

var config = Configuration.Default
.With(new EngineCreator((window, options) => new Engine(options
.MaxStatements(100_000)
.TimeoutInterval(TimeSpan.FromSeconds(5))
.LimitMemory(16 * 1024 * 1024))))
.WithJs();
```

The factory runs once for each window and must return a fresh engine using the supplied options.
Those options contain AngleSharp's DOM module loader, object wrapper, and call stack guard.
DOM bindings are installed after the factory returns. Configure the engine and host globals
in the factory, then access DOM objects and evaluate page scripts through the document.
Jint controls the scope and reset behavior of its constraints.
Any state captured by the service remains owned by the host.
Hosts sharing one configuration across windows must make
their captured state safe for that use.

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

```cs
Expand Down
165 changes: 165 additions & 0 deletions src/AngleSharp.Js.Tests/EngineConfigurationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
namespace AngleSharp.Js.Tests
{
using AngleSharp.Dom;
using AngleSharp.Io;
using AngleSharp.Js.Tests.Mocks;
using AngleSharp.Scripting;
using Jint;
using Jint.Runtime;
using NUnit.Framework;
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

public class EngineConfigurationTests
{
[Test]
public async Task ConstraintsAreInstalledBeforeInlineScriptsRun()
{
var config = Configuration.Default
.With(new EngineCreator((window, options) => new Engine(options.MaxStatements(64))))
.WithJs()
.WithEventLoop();

using (var context = BrowsingContext.New(config))
{
var document = await context.OpenAsync(request => request.Content(
"<div id='result'>before</div><script>for (var i = 0; i < 1000; i++) {} " +
"document.getElementById('result').textContent = 'after';</script>")).ConfigureAwait(false);

Assert.AreEqual("before", document.QuerySelector("#result").TextContent);
Assert.Throws<StatementsCountOverflowException>(() => document.ExecuteScript(
"for (var i = 0; i < 1000; i++) {}"));
Assert.AreEqual("before", document.ExecuteScript("document.getElementById('result').textContent"));
}
}

[Test]
public async Task CreatorServiceCanBeCreatedForEachBrowsingContext()
{
var services = 0;
Func<IBrowsingContext, EngineCreator> createService = _ =>
{
services++;
var limit = services == 1 ? 64 : 2000;
return (window, options) => new Engine(options.MaxStatements(limit));
};
var config = Configuration.Default.With(createService).WithJs().WithEventLoop();

using (var first = BrowsingContext.New(config))
using (var second = BrowsingContext.New(config))
{
var firstDocument = await first.OpenNewAsync().ConfigureAwait(false);
var secondDocument = await second.OpenNewAsync().ConfigureAwait(false);
Assert.Throws<StatementsCountOverflowException>(() => firstDocument.ExecuteScript(
"for (var i = 0; i < 1000; i++) {}"));
Assert.AreEqual(100, secondDocument.ExecuteScript("var i = 0; for (; i < 100; i++) {} i;"));
Assert.AreEqual(2, services);
}
}

[Test]
public async Task CreatorRunsOncePerWindowAndRetainsDomWrapping()
{
var calls = 0;
var windows = new ConcurrentQueue<IWindow>();
var config = Configuration.Default.With(new EngineCreator((window, options) =>
{
calls++;
windows.Enqueue(window);
return new Engine(options.MaxStatements(1000));
})).WithJs().WithEventLoop();

using (var firstContext = BrowsingContext.New(config))
using (var secondContext = BrowsingContext.New(config))
{
var first = await firstContext.OpenAsync(request => request.Content("<p>first</p>")).ConfigureAwait(false);
var second = await secondContext.OpenAsync(request => request.Content("<p>second</p>")).ConfigureAwait(false);

Assert.AreEqual("first", first.ExecuteScript("document.querySelector('p').textContent"));
Assert.AreEqual("first", first.ExecuteScript("document.querySelector('p').textContent"));
Assert.AreEqual("second", second.ExecuteScript("document.querySelector('p').textContent"));
Assert.AreEqual(2, calls);
CollectionAssert.AreEqual(new[] { first.DefaultView, second.DefaultView }, windows.ToArray());
}
}

[Test]
public async Task CreatorReturnsTheEngineUsedForInlineScriptsAndDomBindings()
{
Engine created = null;
var config = Configuration.Default.With(new EngineCreator((window, options) =>
{
created = new Engine(options.Configure(engine => engine.SetValue("hostValue", "configured")));
return created;
})).WithJs().WithEventLoop();

using (var context = BrowsingContext.New(config))
{
var document = await context.OpenAsync(request => request.Content(
"<p>before</p><script>document.querySelector('p').textContent = hostValue;</script>")).ConfigureAwait(false);
Assert.AreEqual("configured", document.QuerySelector("p").TextContent);
Assert.AreSame(created, context.GetService<JsScriptingService>().GetOrCreateJint(document));
created.SetValue("hostNode", document.QuerySelector("p"));
Assert.AreEqual(true, document.ExecuteScript("hostNode === document.querySelector('p')"));
}
}

[TestCase(false)]
[TestCase(true)]
public async Task WorkersInheritTheEngineCreatorService(Boolean contextFactory)
{
var services = 0;
var windows = new ConcurrentQueue<IWindow>();
EngineCreator CreateEngine(String hostValue) => (window, options) =>
{
windows.Enqueue(window);
return new Engine(options.MaxStatements(256)
.Configure(engine => engine.SetValue("hostValue", hostValue)));
};
Object registration = contextFactory
? (Object)new Func<IBrowsingContext, EngineCreator>(_ => CreateEngine("context-" + Interlocked.Increment(ref services)))
: CreateEngine("configured");
var config = Configuration.Default
.With(registration)
.WithJs()
.WithEventLoop()
.With(new DelayedRequester(0, "self.__host = hostValue; for (var i = 0; i < 10000; i++) {} self.__after = true;"))
.WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true });

using (var context = BrowsingContext.New(config))
{
var document = await context.OpenNewAsync().ConfigureAwait(false);
Assert.AreEqual(contextFactory ? "context-1" : "configured", document.ExecuteScript("hostValue"));
var worker = new Dom.Worker(document.DefaultView, "https://example.com/worker.js");

try
{
for (var retries = 200; retries > 0 && !worker.IsInitialized && worker.StartupError is null; retries--)
{
await Task.Delay(10).ConfigureAwait(false);
}

Assert.IsInstanceOf<StatementsCountOverflowException>(worker.StartupError);
Assert.AreEqual(contextFactory ? "context-2" : "configured", worker.EvaluateInWorker("self.__host"));
Assert.AreEqual("undefined", worker.EvaluateInWorker("typeof self.__after"));
var createdWindows = windows.ToArray();
Assert.AreEqual(2, createdWindows.Length);
Assert.AreSame(document.DefaultView, createdWindows[0]);
Assert.AreNotSame(document.DefaultView, createdWindows[1]);
Assert.AreNotSame(context, createdWindows[1].Document.Context);
Assert.AreSame(createdWindows[1], createdWindows[1].Document.DefaultView);
if (contextFactory)
{
Assert.AreEqual(2, services);
}
}
finally
{
worker.Terminate();
}
}
}
}
}
9 changes: 9 additions & 0 deletions src/AngleSharp.Js/Dom/Worker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ public Worker(IWindow window, String source)
var workerConfig = Configuration.Default
.With(_scripting)
.WithOnly(_workerLoop);

foreach (var service in parentContext.OriginalServices)
{
if (service is EngineCreator || service is Func<IBrowsingContext, EngineCreator>)
{
workerConfig = workerConfig.With(service);
}
}

_workerContext = BrowsingContext.New(workerConfig);

Enqueue(_workerLoop, TaskPriority.Critical, () =>
Expand Down
17 changes: 17 additions & 0 deletions src/AngleSharp.Js/EngineCreator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace AngleSharp.Js
{
using AngleSharp.Dom;

/// <summary>
/// Creates a window's Jint engine. Register this service with the configuration
/// to customize engine creation before page scripts run.
/// </summary>
/// <param name="window">The window whose engine is being created.</param>
/// <param name="options">Options containing the DOM wrapper, module loader, and stack guard.</param>
/// <returns>A fresh engine constructed with the supplied options.</returns>
/// <remarks>
/// DOM bindings are installed after this delegate returns. Configure the engine
/// and host globals here; access DOM objects and run page scripts afterwards.
/// </remarks>
public delegate Jint.Engine EngineCreator(IWindow window, Jint.Options options);
}
26 changes: 11 additions & 15 deletions src/AngleSharp.Js/EngineInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,17 @@ sealed class EngineInstance
public EngineInstance(IWindow window, IDictionary<String, Object> assignments, IEnumerable<Assembly> libs, JsScriptingOptions options)
{
_importMap = new JsImportMap();

_engine = new Engine((o) =>
{
o.EnableModules(new JsModuleLoader(this, window.Document, false));
// The handler answers out of the caches assigned right below, which only exist
// once this constructor returns. Jint wraps nothing while it is configuring
// itself, so that is safe - and Engine.Options is internal, so registering the
// handler afterwards is not an option.
o.SetWrapObjectHandler(WrapObject);
// 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;
});
var engineOptions = new Options();
engineOptions.EnableModules(new JsModuleLoader(this, window.Document, false));
// The handler uses the caches initialized below. The creator must return the
// engine before DOM objects are wrapped or page scripts are evaluated.
engineOptions.SetWrapObjectHandler(WrapObject);
// Left alone, the JS call stack is the native one, and a runaway recursion
// takes the process down with an uncatchable StackOverflowException.
engineOptions.Constraints.MaxExecutionStackCount = options.MaxCallStackDepth > 0 ? options.MaxCallStackDepth : StackGuardDisabled;
var creator = window.Document.Context.GetService<EngineCreator>();
_engine = (creator is null ? new Engine(engineOptions) : creator.Invoke(window, engineOptions))
?? throw new InvalidOperationException("The engine creator must return a Jint engine.");
_libs = new LibrarySet(libs);
_prototypes = new PrototypeCache(_engine, _libs);
_references = new ReferenceCache();
Expand Down
Loading