diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0f9de..8b6b3fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4bcaac2..e08377a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs new file mode 100644 index 0000000..cc69a88 --- /dev/null +++ b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs @@ -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( + "
before
")).ConfigureAwait(false); + + Assert.AreEqual("before", document.QuerySelector("#result").TextContent); + Assert.Throws(() => 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 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(() => 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(); + 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("

first

")).ConfigureAwait(false); + var second = await secondContext.OpenAsync(request => request.Content("

second

")).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( + "

before

")).ConfigureAwait(false); + Assert.AreEqual("configured", document.QuerySelector("p").TextContent); + Assert.AreSame(created, context.GetService().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(); + 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(_ => 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(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(); + } + } + } + } +} diff --git a/src/AngleSharp.Js/Dom/Worker.cs b/src/AngleSharp.Js/Dom/Worker.cs index 8a23c79..854a31a 100644 --- a/src/AngleSharp.Js/Dom/Worker.cs +++ b/src/AngleSharp.Js/Dom/Worker.cs @@ -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) + { + workerConfig = workerConfig.With(service); + } + } + _workerContext = BrowsingContext.New(workerConfig); Enqueue(_workerLoop, TaskPriority.Critical, () => diff --git a/src/AngleSharp.Js/EngineCreator.cs b/src/AngleSharp.Js/EngineCreator.cs new file mode 100644 index 0000000..11f99a7 --- /dev/null +++ b/src/AngleSharp.Js/EngineCreator.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Js +{ + using AngleSharp.Dom; + + /// + /// Creates a window's Jint engine. Register this service with the configuration + /// to customize engine creation before page scripts run. + /// + /// The window whose engine is being created. + /// Options containing the DOM wrapper, module loader, and stack guard. + /// A fresh engine constructed with the supplied options. + /// + /// DOM bindings are installed after this delegate returns. Configure the engine + /// and host globals here; access DOM objects and run page scripts afterwards. + /// + public delegate Jint.Engine EngineCreator(IWindow window, Jint.Options options); +} diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index d9ef5a2..24a9856 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -37,21 +37,17 @@ sealed class EngineInstance public EngineInstance(IWindow window, IDictionary assignments, IEnumerable 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(); + _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();